diff --git a/AGENTS.md b/AGENTS.md index db4c375d..8cc237e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,10 +52,25 @@ them, the milestone plan, and the certification checklist. frame, every HTTP response is parsed with a zod schema before use. 8. **Secrets never leave SecretStorage.** No API keys in settings, logs, telemetry, tests, or fixtures. The pasted Model API key is never passed to - any child process: the Muse Code CLI signs in on its own, and the - extension only checks that its credential file exists and when it last - changed, never its contents. Log through the `LogOutputChannel`; never - `console.log` in the host. + any child process: the Muse Code CLI signs in on its own. + - **The CLI's credential file.** The extension reads only its structure + (`src/core/backends/musecode/credentialFile.ts`): the schema version, + which providers are named (only `meta` speaks for the sign-in), each + one's `storage` lane, and whether `meta` has an `api_key` or + `access_token` entry (the parse replaces the value with `true`). It + also reads the file's size and modification time. Never a token + value. + - **When the structure cannot say**, the CLI answers `account/read` + (PLAN.md D26). Its `label` (an e-mail address) and `avatarUrl` are + never kept, logged or shown. + - **Text the CLI chose.** A `loginCompleted` message, `muse serve` or + `muse skills` stderr and an MSP error message never reach the log as + sent: they can name a path under the user's profile or an account, + and the redactor catches only keys. Log a protocol word through + `wireWordForLog`, an MSP failure through `failureForLog`, stderr + through `stderrForLog`, or fixed words. + - **Logging.** Log through the `LogOutputChannel`; never `console.log` + in the host. 9. **Dependencies are deliberate.** Before adding one: check peer ranges against the pins in `PLAN.md` §2 D3 (`npm info peerDependencies`), check `npm audit`, pin the exact version (`.npmrc` enforces `save-exact`), diff --git a/CHANGELOG.md b/CHANGELOG.md index 3510c31b..bf6b2fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,122 @@ happened, not what was planned; superseded entries are kept. and third-party-notices checks cover the new bundle, `npm run cycles` follows it, and the `.vsix` ships it. +### Fixed + +- **Signing out of Muse Code finishes, and a signed-out CLI no longer reads + as signed in** (PLAN.md D26). + - **The cause.** `muse logout` rewrites the CLI's `auth.json` with no + sign-in in it; it never deletes the file. Muse Code 1.3.0 and 1.4.0 do + this on every OS. + - **What went wrong.** The extension took the file being there for a + sign-in. After any sign-out it went on choosing Muse Code, and the panel + stayed on "Sign-out is in progress or credentials remain" until a new + browser sign-in. + - **Reading the file.** The extension now reads only its structure: the + schema version, which providers it names, the storage lane of each, and + whether Muse Code's own (`meta`) holds a key in a shape Muse Code was + seen writing (`api_key`, `access_token`). Every value, the token + included, is dropped as the file is parsed. Another entry alone, such + as the one Muse Code's bundled Slack connector reads, or a `meta` entry + in any other shape, is not taken for a Muse sign-in. + - **Asking Muse Code.** When the structure cannot say, the extension asks + Muse Code itself (`account/read` on a short-lived host). On macOS that + covers every file but the empty one a sign-out leaves, since no one has + seen what Muse Code 1.4.0 does there with a file holding the sign-in. + The extension keeps the answer until the file changes, or until you + sign in, sign out or choose **Check again**, which always asks afresh; + pressing it twice asks once. On macOS it asks only when you act: a + click in the panel, or the **Sign Out** or **Diagnostics** command. + - **Switching backends.** Whenever the panel moves conversations to the + other backend (Muse Code signed in after all, a pasted key while Muse + Code is signed out, an install that found Muse Code signed in, a + changed `museSpark.backend`), the + conversation running on the old backend ends first, as a completed + browser sign-in already did. Before, a Cancel pressed just after the + browser approved could switch to Muse Code while a Model API + conversation kept running. A restart that finishes late no longer + forgets a backend signed in on meanwhile, and a save of the sign-out + state that fails late no longer keeps conversations shut after a later + save succeeded. A restart that fails late, or an install's error + report, no longer replaces a newer state with its error. + - **Old answers.** An answer Muse Code gives to a question the extension + had already dropped (after Cancel, a sign-out or **Check again**) + reaches no one, and a slower, older check never replaces what a newer + one showed. While a browser sign-in shows its code, a check leaves the + code where it is. + - **Signing out.** Sign-out uses Muse Code's own `account/logout` and + confirms it with `account/read`. Only when Muse Code still reads signed + in afterwards does it open `muse logout` in a terminal. With + `META_API_KEY` set, a sign-out no longer opens that terminal every + time. A sign-out never decides on an earlier answer from Muse Code, so + a Keychain sign-in made since is signed out too. + - **Check again after a sign-out.** A sign-out that must wait for the + terminal, or for `META_API_KEY` to go, now offers **Check again**, + which its message asks for. +- **Browser sign-in waits as long as the code lives, and ends at once when + Muse Code ends it.** + - **The code's lifetime.** A code lasts ten minutes (captured on + 1.4.0-R4302.1). The panel now waits that long and says when the code + expired. Before, it gave up after five minutes and cancelled a code + that could still be approved. + - **Other endings.** A denied code and a sign-in Muse Code could not save + each have a message of their own. An ending Muse Code has not been + seen to send is shown in its own word. + - **Cancel.** It works at once, even when Muse Code stops answering, and + the code leaves the panel at once. If the browser approved just + before, the panel follows what Muse Code saved instead of saying the + sign-in was cancelled. On macOS, where an approval may reach only the + Keychain, a Cancel after the code was shown asks Muse Code afresh, + and so does every click that asks it (Diagnostics included), unless + it already asked during that same click. + - **Success.** It is taken from Muse Code's `account/read` turning + signed in, or from a new credential file it does not contradict. When + Muse Code could not say who was signed in before the flow, its own + `granted`, borne out by `account/read`, counts too, so a Keychain + sign-in that leaves the file as it was is seen. + - **A re-sign-in to the same account** after a sign-out Muse Code could + not finish is recognized from Muse Code's own `granted`, even when + only the macOS Keychain changed; before, it waited out the limit. + - **With `museSpark.backend` set to the Model API,** the panel no longer + asks Muse Code about its sign-in at all, so a slow answer cannot keep a + stored key waiting. + - **A host that exits.** The sign-in fails at once instead of waiting + out the eleven-minute limit, unless the credential file changed first: + then the sign-in went through. A change Muse Code already called + signed out (another Muse process signing out) still counts as signed + out. + - **Sign-out and closing the window** no longer wait on Muse Code's + answer to a sign-in that had just finished or failed. Closing the + window cancels the sign-in and closes its host and every short-lived + account host, and a sign-in click still checking the CLI then starts + nothing. A check that answers after a sign-out no longer holds the + sign-out gate again. +- **A macOS `auth.json` copied to Windows or Linux is named** as the reason + Muse Code cannot start, instead of a host that exits at every message. + That covers an empty version-2 file too: Muse Code 1.4.0 refuses it on + Windows and on Linux, as it refuses a pointer and a Keychain entry in a + version-1 file. With `META_API_KEY` set Muse Code starts with any of + them. The log names that state without the file's path; the panel still + shows it. +- **The CLI's terminals get `museSpark.environmentVariables`.** The + terminals for `muse logout`, MCP sign-in and **Open in Terminal** now run + with them, as `muse serve` does, so with `XDG_CONFIG_HOME` moved they use + the same config home. +- **Muse Spark: Diagnostics** describes the CLI's credential file by its + structure and gives the CLI's sign-in state. On macOS it also says whether + the login Keychain holds Muse Code's item, looked up by attribute only, + which reads no secret and shows no prompt. Its question to Muse Code + about the sign-in may still show the Keychain's prompt, as Muse Code + reads the Keychain to answer. +- **The log keeps no text Muse Code chose.** Its messages can name a folder + in your profile or your e-mail address, and the log's redaction catches + only keys. How a browser sign-in ended is logged in fixed words; an MSP + error by its kind and code; a sign-in state or reason only when shaped + like a protocol word; and what `muse serve` and `muse skills` write to + stderr as fixed words for the lines Muse Code was seen writing (an + unsupported credential file, an unreadable Keychain item, a failed + model-catalog fetch), otherwise by its length alone. + ## [0.9.1] - 2026-09-27 Works with Muse Code 1.4.0, now Meta's stable release, which its launcher diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd743055..d89337d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,10 @@ Use this order for a candidate branch: `docs/certification/m43.md` shows one. Frames several test files use go in one helper, trimmed but otherwise as captured (`test/unit/helpers/m46Capture.ts`), and the fake CLI in `test/e2e` - answers in the same shapes. + answers in the same shapes. The CLI's credential file is the same: its + captured shapes, with placeholders for every secret, are in + `test/unit/helpers/credentialShapes.ts`, and a test that needs a shape + nobody captured says so in a comment. - Update `CHANGELOG.md` (Keep a Changelog, under `Unreleased`), the README where behaviour changed, and `docs/PRIVACY.md` when anything new leaves the machine. diff --git a/PLAN.md b/PLAN.md index 7b5fc52d..8a27360b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -88,7 +88,7 @@ Therefore: | --------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Windows | `%LOCALAPPDATA%\Programs\muse` (added to the user PATH) | `muse.cmd` → `powershell.exe -NoProfile -ExecutionPolicy Bypass -File .muse-launcher.ps1 ` | `muse-bin-.exe` (~415 MB), `.muse-version`, `.muse-release-info.json`, `.muse-channel` | `%USERPROFILE%\.config\muse\auth.json` | | Linux (Kubuntu 26.04) | `~/.local/bin` (`MUSE_INSTALL_DIR` overrides; PATH added to shell rc files) | `muse` bash launcher (33 KB; needs bash, not sh) | `muse-bin-` (~314 MB), `.muse-version`, `.muse-release-info.json` | `$XDG_CONFIG_HOME/muse/auth.json` or `~/.config/muse/auth.json` (`MUSE_AUTH_PATH` overrides) | -| macOS 15.7 (Mac mini) | `~/.local/bin` (PATH added to `~/.zshrc`) | same bash launcher | same | same as Linux | +| macOS 15.7 (Mac mini) | `~/.local/bin` (PATH added to `~/.zshrc`) | same bash launcher | same | as Linux, a token-free pointer; the token in the login Keychain (D26) | Constraints and decisions: @@ -165,8 +165,9 @@ deprecated). Webview controls are hand-built on VS Code CSS theme variables. - API keys live only in `vscode.SecretStorage`; never in settings, logs, or telemetry. (The rest of this row is superseded by the D1 amendment, §9: since M7 the stored key is never passed to `muse serve` or any other child - process; the CLI signs in on its own and the extension only checks that its - credential file exists.) + process; the CLI signs in on its own and the extension reads only the + structure of its credential file, asking the CLI when that cannot say, + D26 amendment.) - Webview: strict CSP with per-load nonce, `localResourceRoots` limited to the bundled `dist/webview`, no remote scripts, no `eval`. Every inbound message is parsed with a zod schema; unknown shapes are logged and dropped. @@ -842,6 +843,165 @@ by a release after a Muse Code version is verified to fix it. Showing an action that fails is worse than hiding one that would work. `docs/certification/release-0.9.1.md`. +**Amendment 2026-09-27: the CLI's sign-in is read, not assumed** +(`fix/cli-sign-in-detection`; evidence in `docs/certification/sign-in-detection.md`). + +- **The finding.** `muse logout` rewrites `auth.json` as + `{"schema_version": 1, "providers": {}}` and never deletes it. This was + seen on 1.3.0 Linux and on 1.4.0 Windows and Linux, in isolated homes. +- **What it broke.** "Signed in" had been "the file exists". So after any + sign-out the auto backend stayed on Muse Code, and M55's logout hold + waited for a deletion that never came. +- **macOS.** A sign-in is a login Keychain item (`ai.meta.dev.credentials`, + account `meta`), and the file is a token-free pointer + (`schema_version: 2`, `storage: "keychain"`). +- **The structural read (`src/core/backends/musecode/credentialFile.ts`).** + Only four facts are kept: the schema version, which providers are + named, each provider's `storage` lane, and whether `meta` carries + `api_key` or `access_token` (the parse replaces a key's value with + `true`). The parse drops every other field. Only `providers.meta` speaks + for the sign-in: 1.4.0-R4302.1's bundled Slack connector reads its own + `providers.slack_connector` from the same file (PR #49 review). It + decides: + - no file → signed out, with no process; + - the empty version-1 file a sign-out leaves → signed out, on every OS; + - off macOS, a `meta` entry in a captured inline shape (no `storage`, + and `api_key` or `access_token`) → signed in; + - a `meta` entry in any other shape (another `storage`, or no captured + key), or other providers alone → asked of the CLI; + - on macOS, a version-1 file holding the credential and an empty + version-2 file → asked of the CLI: nobody captured whether 1.4.0 reads + or migrates them there (review round 4); + - off macOS, any version-2 file (the empty one included) or a `meta` + whose storage is the Keychain → a named error. `muse serve` exits 3 + at startup with each of the three on Windows and on Linux + ("unsupported auth schema version 2"; "keychain item for meta is + unreadable"), captured 2026-09-27 on 1.4.0-R4302.1 (Windows 11 and the + Kubuntu VM). With `META_API_KEY` set it starts with each of the three + and answers `envKey` on both, so the key wins over those files; macOS + was not probed with the key. +- **Asking the CLI.** A Keychain pointer on macOS, any other file on macOS + but the sign-out's, or a file the read cannot place, is asked of the + CLI: `account/read` on a short-lived `experimentalApi` host. + - The answer is kept until the file's size or modification time changes, + or until a sign-out, a device sign-in or Check again forgets it. Cancel + only leaves an unanswered probe behind, so what it shows next asks + nothing new; a sign-out that cancels a sign-in forgets the answer too, + so it never skips `account/logout` on a stale `signedOut`. Presses of + Check again while one runs join it: one host, one question. + - On macOS the CLI is asked only on a user action (Check again, sign-in, + sign-out, the Sign Out and Diagnostics commands), so a Keychain prompt + follows a click. + - `unknown` counts as signed in, as before; a turn's `authRequired` + still corrects it. +- **Sign-out.** It uses MSP `account/logout` on a short-lived host (the + chat host has no `experimentalApi`, and sign-out stops it first). The + result is confirmed by `account/read`. When that does not confirm it + (`META_API_KEY` makes it answer `envKey`), the sign-in is read afresh; + only a sign-in still there opens the terminal `muse logout`, with + `museSpark.environmentVariables`, confirmed later by the file or the CLI. + A sign-out that keeps the hold is published as `error`, the state the + panel offers Check again in. A refresh that answers after a sign-out + ended leaves the hold as that sign-out left it. +- **M55's device flow signs in on:** + - `account/read` turning `accountLogin` on the open host; + - or a new file that `account/read` does not contradict. + + This supersedes M55's "accept only after a new credential-file + modification". `account/changed` is not relied on: neither a terminal + `muse logout` nor an expired code fired it. + +- **How it ends otherwise (PR #49 review, live capture).** Every ending + was captured on 1.4.0-R4302.1 in throwaway homes (`test/fixtures/msp/`): + `expired`, 600 s after `loginStart`, with a message; `cancelled`; + `granted`, `denied` and `failed`, from Approve and Deny clicked on the + device page, and an approval whose file could not be written. + - **`granted`.** It came 205 ms after the file was written and + `account/read` said `accountLogin`; it never ends the flow or counts + on its own, and `account/read` or the file decides. + - **`denied` and `failed`.** Each ends the flow with its own message. + No ending's message is logged: it is free text the CLI chose (`failed`'s + names the credential file's path), so the log names each captured + ending in fixed words (Codex on `886af682`). + - **Every other word.** It ends the flow at once and is shown as Muse + Code sent it (AGENTS.md rule 13); the log keeps the word only in the + shape of a protocol word, never its message. + - **With no first `account/read`,** a sign-in from before would pass for + a new one, so `accountLogin` alone does not count: a file written + since the flow began does, and so does the host's `granted` borne out + by `account/read` saying `accountLogin` (a Keychain sign-in may leave + the file as it was; review round 4). That captured success counts + whatever came before, so a same-account re-sign-in on macOS, which may + change only the Keychain while the logout hold keeps the old sign-in, + is seen too (Codex on `328efb52`). + - **Forced Model API.** With `museSpark.backend` set to `modelApi`, the + choice asks the CLI nothing (`isCliSignInConsulted`), in the gate and + in host admission. + - **Cancel after approval.** When the file changed since the flow began, + its structure decides, not the click. With the hold on or a Model API + session, the code leaves the panel at once, before the refresh. + - **A host that exits** fails the flow at once (`connection.closed`), + unless the credential file changed since the flow began: then it + signed in. A write that an answered `account/read` already called + signed out (another Muse process's sign-out) stays refuted, for an + exit and for a later `account/read` left unanswered alike. +- **The backstop.** The extension waits 11 minutes, past the code's + lifetime, so Muse Code's `expired` ends an unapproved code. The old + 5 minutes cancelled codes that were still live. +- **A CLI that stops answering.** Cancel and the host's ending are noticed + at once even while `account/read` is unanswered. `loginCancel` is + bounded at 2 s before the host is closed anyway. A sign-out never waits + on a question a finished or failed sign-in asks, and a refresh begun + before a sign-out ended cannot publish after it. The window closing + cancels the sign-in, waits for it, and closes every short-lived account + host before the backends stop; a click still in its pre-flight questions + then starts nothing (a flag `stopSignIn` sets, checked by `signIn` and + the device flow's join). +- **Switching backends (Codex on `1ae3604f`).** Every state that could + sign in on another backend than conversations run on is published + through one helper, `AuthService.publishSelection`: every refresh (Check + again, a Cancel that still landed a sign-in, a failed sign-in's refresh, + a device sign-in's confirmation, CLI discovery after an install), a + pasted key, and a failed install. It opens a new admission generation, + shows `checking`, ends the running backend's conversations + (`restartBackend(true)`) and only then publishes. `liveBackend` is the + backend of the last signed-in state, cleared by any restart that ended + the conversations (a sign-out included), so none is ended twice. +- **Stale answers (Codex on `886af682`).** A probe that Cancel, a sign-out + or Check again left behind gives its late answer to no caller, the ones + that joined it included: they look again and get the newer answer. Every + refresh takes a ticket as it starts and publishes only if nothing newer + has; while the browser sign-in waits, a refresh leaves its code on + screen. +- **The log.** Text the CLI chose is never logged as sent, since the log + channel's redactor catches only key-shaped strings (Codex on + `886af682`): + - the unsupported-file message names the credential file's full path, so + the log says so in fixed words and the panel keeps the path; + - a sign-in ending, an `account/read` state and an `authRequired` reason + are logged only in the shape of a protocol word (`wireWordForLog`), + captured endings in fixed words; + - an MSP failure is logged by its kind and code (`failureForLog`), and + `muse serve` or `muse skills` stderr by the lines 1.4.0-R4302.1 was + captured writing (an unsupported schema version, an unreadable + Keychain item, a failed model-catalog fetch), in fixed words; any other + line by its length (`stderrForLog`, + `src/core/backends/musecode/logText.ts`). +- **Where it is only as good as the schema.** `account/*` stays + experimental. +- **Owner steps.** A real sign-in remains an owner step on: + - macOS, for the pointer after a 1.4.0 login (and whether 1.4.0 reads a + version-1 file there); + - Linux, for the device-login file. + + The Windows success sequence, a declined code, a failed save and the + logout of an OAuth slot were captured on 2026-09-27 + (`scratchpad/cred-capture/capture2.mjs`). + +- **Not taken.** `TBH_CREDENTIAL_BACKEND=file` is not set. R4302.1 fixed + #38/#53 and the launcher updates itself; the README names the switch + only for someone stuck on R4161.1. + ### D27 — The audit: editing correctness (2026-09-23) Section D of the audit (D24): the Model API's file tools, Edit Review and @@ -5954,6 +6114,9 @@ never send the old prompt under the new key. Recovery from a held old CLI credential file may start an explicit browser device flow only when `META_API_KEY` is absent and the extension key was cleared; accept it only after a new credential-file modification is observed. +(Amended 2026-09-27, D26: accept it only once the CLI confirms the new +sign-in; `muse logout` never removes the file, so "remains" means the CLI +still reports a sign-in.) Sign-out must publish a gated state and start ending attached sessions before awaiting global state or SecretStorage. Other panels must not send a paid child follow-up or similar session action during that wait. A rejected @@ -6143,7 +6306,13 @@ showed `account/read` logged out, `loginStart {type:"deviceCode"}` returning `loginCompleted` cancellation notification. It did not capture a successful sign-in; success must be proved by a credential file change and the backend's refresh, not a guessed notification shape. The Model API key continues through -SecretStorage and is never given to `muse serve`. +SecretStorage and is never given to `muse serve`. (Amended 2026-09-27, D26: +`account/read`, whose shapes were captured on 2026-09-27, decides a +sign-in, with the file change as the second signal. The PR #49 captures +added `expired`, 600 s after `loginStart`, then `granted`, `denied` and +`failed` to the captured endings; `granted` came after both signals, so +it is not one of its own, except with no first `account/read`, where +`granted` borne out by `account/read` counts.) **Acceptance:** no installer or login starts without its button; installer command is fixed, shown before confirmation, and runs in a visible terminal; diff --git a/README.md b/README.md index ac89a2a9..5f063373 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,13 @@ harness:shots`) against a scripted session, so they match the build. directly and keeps the manual instructions available. Sign in, one of two ways: - **Sign in with your Meta account** shows an approval code in the panel. - Open its sign-in page in your browser and approve the code. **Cancel - sign-in** stops the temporary CLI sign-in process. Work is billed to your - Muse subscription. + Open its sign-in page in your browser and approve the code within ten + minutes, before it expires. **Cancel sign-in** stops the temporary CLI + sign-in process; if the browser approved just before, the panel + follows what Muse Code saved. If you deny the code, it expires, or Muse + Code cannot save the sign-in, the panel says so; an ending Muse Code + has not been seen to send is shown in its own word. Work is billed to + your Muse subscription. - **Use a Model API key** takes a key from dev.meta.ai (Meta's current keys start with `LLM_`; older ones look like `LLM||`), stores it in VS Code's secret storage and runs the Model API backend @@ -1225,7 +1229,7 @@ What stays in English: | --------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Muse Spark: Open in Sidebar | — | Focus the chat view in the activity bar | | Muse Spark: New Conversation | `Ctrl+N` (`Cmd+N`) when `enableNewConversationShortcut` is on, Muse focused | Clear the active panel to a new conversation, or open one where `preferredLocation` says | -| Muse Spark: Sign Out | — | Forget the stored Model API key and run `muse logout` when the CLI is signed in | +| Muse Spark: Sign Out | — | Forget the stored Model API key and sign the CLI out when it is signed in (its `account/logout`, else `muse logout`) | | Muse Spark: Open in Terminal | — | Run the Muse Code CLI's own interactive interface in a VS Code terminal at the workspace root | | Muse Spark: Create AGENTS.md | — | Write the rules file with `muse init` (or the same template without the CLI) and open it; an existing file is opened | | Muse Spark: Open Walkthrough | — | Open the four-step Get Started walkthrough | @@ -1236,7 +1240,7 @@ What stays in English: | Muse Spark: Toggle Thinking | `Ctrl+Alt+T` (macOS `Option+T`, Linux `Ctrl+Alt+O`), composer only | Turn reasoning on or off for this conversation. Claude Code uses `Alt+T`; on Windows that opens the Terminal menu, on GNOME `Ctrl+Alt+T` opens a terminal | | Muse Spark: Set Up Shell Sandbox | — | Windows: run Muse Code's one-time `muse sandbox windows setup` through a UAC prompt and report the result; elsewhere reports that no setup is needed | | Muse Spark: Show Logs | — | Open the "Muse Spark" log channel (keys redacted) | -| Muse Spark: Diagnostics | — | Write the versions, the backend and CLI facts, credential presence (as yes/no), the dictation state, the network posture and `muse config status` to the log and open it: what a bug report needs | +| Muse Spark: Diagnostics | — | Write the versions, the backend and CLI facts, credential facts, never a value, the dictation state, the network posture and `muse config status` to the log and open it: what a bug report needs | | Muse Spark: Manage Skills | — | Turn Muse Code's skills on or off (`muse skills enable`/`disable`), then offer to restart it so the change takes effect | | Muse Spark: Import Skills from Claude Code or Codex | — | Preview what `muse skills import` would copy, import it once you confirm, report what was imported, skipped or failed | | Muse Spark: Export Conversation | — | Save the conversation in front of you as Markdown where you choose, and open it | @@ -1301,7 +1305,7 @@ Bypass at once. | `modelApiSubagents` | `false` | [Paid](#paid-features): Model API child tasks, with a model-rate confirmation and a fresh four-request popup for every task | | `modelApiScheduledPrompts` | `false` | [Paid](#scheduled-prompts-model-api): a due prompt can run only after this machine-scoped gate and a separate confirmation of that occurrence's Model API token rates; never unattended | | `modelApiHooks` | `false` | Run Muse Code's hook commands on the Model API backend in a trusted workspace: your administrator's, yours and the project's. They run as you, outside the agent's sandbox, without the Model API key; review them with **Muse Spark: Hooks** first. Machine-scoped | -| `environmentVariables` | `[]` | `{ name, value }` pairs for the Muse Code process (an `XDG_CONFIG_HOME` here is where the extension looks for the CLI's sign-in and settings too). Never put API keys here; use Sign in. Changing it restarts the host | +| `environmentVariables` | `[]` | `{ name, value }` pairs for the Muse Code process and the terminals that run the CLI (Open in Terminal, MCP sign-in, `muse logout`); an `XDG_CONFIG_HOME` here is where the extension looks for the CLI's sign-in and settings too. Never put API keys here; use Sign in. Changing it restarts the host | The Model API backend's shell tool applies `terminal.integrated.env.*` the way VS Code's terminal does. A restart of Muse Code, for a setting, trust @@ -1471,18 +1475,59 @@ stopped and the next message resumes the same session. read and edit text and images up to 10 MiB and read PDFs up to 32 MB; the search tool skips files over 1 MiB. The agent can read part of a larger file with a shell command. -- **Sign-out does not finish, or the panel stays gated** — if `muse logout` - is still running or its terminal could not open, the panel stays gated and - tells you to finish logout. An inherited `META_API_KEY` remains outside the - extension: remove it from your environment or VS Code's configured - environment variables, then choose **Check again**. Browser approval - cannot override that key's billing priority. If VS Code reports that - sign-out protection could not be saved, finish `muse logout` and remove - `META_API_KEY` before reopening VS Code. If the old CLI credential file - remains, a fresh browser approval can replace it; the panel requires a new - file write before using that sign-in. If VS Code cannot delete the stored - Model API key, sign-out stops this window's backend and keeps it gated - until the key can be cleared. +- **Sign-out does not finish, or the panel stays gated** — sign-out asks + Muse Code to sign itself out (`account/logout`). Only when Muse Code + still reads signed in afterwards does it open `muse logout` in a + terminal, with `museSpark.environmentVariables`, so it signs out the + same config home. With `META_API_KEY` set, Muse Code cannot confirm the + logout itself; the extension then reads the sign-in again and opens no + terminal once it shows none. + - **Waiting for the terminal:** the panel stays gated, with **Check + again**, until `muse logout` has run. That command leaves + `~/.config/muse/auth.json` behind with no sign-in in it, and the + extension reads that as signed out. Choose **Check again** once the + terminal is done. If the terminal could not open, run `muse logout` + yourself. + - **An inherited `META_API_KEY`:** it stays outside the extension. Remove + it from your environment or VS Code's configured environment variables, + then choose **Check again**. Browser approval cannot override that key's + billing priority. + - **Sign-out protection could not be saved:** finish `muse logout` and + remove `META_API_KEY` before reopening VS Code. + - **The old CLI sign-in remains:** a fresh browser approval can replace + it. The panel uses that sign-in only after Muse Code confirms it. + - **The stored Model API key cannot be deleted:** sign-out stops this + window's backend and keeps it gated until the key can be cleared. +- **The panel says Muse Code cannot start because its sign-in file is in + the macOS format** — the `auth.json` it names came from a Mac: a + version-2 file, or one whose sign-in lives in the Keychain. Muse Code + 1.4.0 on Windows and on Linux exits at startup with such a file, even an + empty one. Move or rename the file, then sign in again. With + `META_API_KEY` set, Muse Code starts anyway and uses the key. +- **The panel shows signed in on macOS, but the first message asks you to + sign in** — on a Mac the token is in the login Keychain, and the + extension asks Muse Code about it only when you act: a click in the + panel (sign-in, sign-out, **Check again**), or the **Sign Out** or + **Diagnostics** command. That goes for any `auth.json` on a Mac but the + empty one a sign-out leaves, since what Muse Code 1.4.0 does there with + another file has not been seen. A Keychain prompt never appears just + because VS Code opened. Choose **Check again** to have Muse Code asked + afresh now. **Muse Spark: Diagnostics** says whether the Keychain item exists, + looked up without reading the secret; it also asks Muse Code for its + sign-in, and Muse Code may read the Keychain to answer, which can show + the Keychain's prompt. +- **Browser sign-in fails with "keychain write failed (internal error + -2147483648)" on Windows or Linux** — Muse Code 1.4.0-R4161.1 could not + save a sign-in there + ([#38](https://github.com/meta-models/muse-code-sdk/issues/38), + [#53](https://github.com/meta-models/muse-code-sdk/issues/53)). R4302.1 + fixed it, and Muse Code's launcher updates itself; 1.4.0-R4302.1 or later + needs nothing more (**Muse Spark: Diagnostics** shows the version). Only + if you are stuck on R4161.1: add `TBH_CREDENTIAL_BACKEND` with the value `file` to + `museSpark.environmentVariables` and to the terminal you sign in from. + That undocumented switch, which Meta's own SDK tests use, keeps the + sign-in in `auth.json`. Remove it after updating, and never set it on + macOS, where it hides a Keychain sign-in. - **Every shell command fails with `sandbox enforcement unavailable`** — Muse Code runs commands inside an OS sandbox that needs a one-time administrator setup on Windows. The panel offers it in a notification ("Set up now" diff --git a/SECURITY.md b/SECURITY.md index e9dec2f5..12ab00df 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,13 +30,25 @@ Only the latest release on the Visual Studio Marketplace receives fixes. - **Credentials.** A pasted Model API key lives only in VS Code's SecretStorage, is sent only to `api.meta.ai`, and is never passed to a child process, written to settings or logs, or shown in the panel. The - Muse Code CLI's own credential file is never read: the extension checks - only that it exists and when it last changed. In-panel sign-in runs Muse - Code's device-code flow in a temporary `muse serve` that owns no - conversation and is closed on success, cancel, timeout or error; the only - page it opens must be on `https://auth.meta.com`. The log channel redacts - key-shaped strings, in Meta's current `LLM_…` form and the older - `LLM||` one. + extension reads only the structure of the Muse Code CLI's own credential + file, never a token in it: the schema version, which providers it names + (only `meta` speaks for the sign-in), each one's storage lane (the macOS + Keychain), and whether `meta` has an `api_key` or `access_token` entry + (the parse keeps the fact, never the value). When that is not enough, it + asks the CLI (`account/read`) and discards the account label and avatar + address the answer carries. It never reads the Keychain's secret. + In-panel sign-in, that question and sign-out (`account/logout`) run in a + temporary `muse serve` that owns no conversation and is closed on + success, cancel, timeout, error or when the window closes, after which + no sign-in starts; a sign-in whose host exits fails at once unless the + credential file changed first, by a write no `account/read` answer + called signed out. The only page sign-in opens must be on + `https://auth.meta.com`. The log channel redacts key-shaped strings, in + Meta's current `LLM_…` form and the older `LLM||` one; it + cannot catch a path or an e-mail address, so free text Muse Code writes + (sign-in endings, MSP error messages, `muse serve` and `muse skills` + stderr) is logged in fixed words, by its kind, or by its length, never + as sent. - **Workspace trust.** In VS Code's Restricted Mode the agent loads no workspace rules, skills or memory, runs no shell commands, and the extension runs no `git` (a repository's `.git/config` can name programs diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 0742c294..978815e4 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -194,15 +194,60 @@ fields, never raw configuration or failed-command output. operating system's credential vault), never in settings files, logs or the workspace. It is sent only to `api.meta.ai` as a bearer token, and never passed to the Muse Code CLI or any other process. -- The Muse Code CLI's own sign-in lives in the CLI's credential file - (`~/.config/muse/auth.json`). The extension checks only whether the file - exists and when it last changed, never its contents, to decide which - sign-in path to offer and to confirm that a new sign-in wrote it. +- The Muse Code CLI keeps its own sign-in. On Windows and Linux it is in + the CLI's credential file (`~/.config/muse/auth.json`). On macOS the token + is in your login Keychain (item `ai.meta.dev.credentials`, account + `meta`), and `auth.json` only points to it. +- To tell whether the CLI is signed in, the extension reads only the + structure of `auth.json`: its schema version, which providers it names + (only Muse Code's own, `meta`, speaks for the sign-in), the storage lane + of each (whether one points to the Keychain), and whether `meta` has an + `api_key` or `access_token` entry, never what the entry holds. + - Every value in the file, the token included, is dropped while the file + is parsed. Nothing from it is stored, logged or passed on. + - When that structure cannot say, the extension asks the CLI itself + (`account/read` on a short-lived `muse serve` that owns no + conversation). On macOS that is every file but the empty one a + sign-out leaves. It keeps the answer until the file changes, or until + you sign in, sign out or choose **Check again**. + - When the file is in a form Muse Code cannot start with here, the panel + names the file's path; the log says so without the path. + - The CLI's answer carries your account's e-mail address as a label, and + the address of your account picture. The extension discards both + without logging or showing them. + - On macOS it asks only when you act, since the CLI may read the + Keychain to answer: a click in the panel (sign-in, sign-out, **Check + again**), or the **Sign Out** or **Diagnostics** command. + - It also uses the file's size and modification time, to notice a new + sign-in. +- When Muse Code ends a browser sign-in, the log says how, in fixed words + (the code expired, the sign-in was denied, saving failed), never the + message Muse Code sent with it: a failed save's message names a folder + in your profile, and any message could name one, or your e-mail address. +- Other text Muse Code writes reaches the log the same way: its error + messages by their kind and code, the state `account/read` reports and a + backend's sign-in reason only when shaped like a protocol word, and what + `muse serve` or `muse skills` writes to stderr as fixed words for the + lines Muse Code was seen writing (an unsupported credential file, an + unreadable Keychain item, a failed model-catalog fetch), otherwise only + its length. +- **Muse Spark: Diagnostics** on macOS looks up the Keychain item by its + attributes only, with `security find-generic-password` and no `-g` or + `-w`. That lookup reads no secret and shows no prompt, and the report + says whether the item is there. Diagnostics also asks the CLI for its + sign-in (`account/read`); on macOS the CLI may read the Keychain to + answer, which can show the Keychain's own prompt. - **Sign out** in the panel (the same action as `/logout` and **Muse Spark: - Sign Out**) deletes the pasted key from secret storage, runs `muse logout` - in a terminal when the CLI is signed in, and records in VS Code's - extension state (no credential) that you signed out, so an old CLI - credential cannot sign the window back in until you sign in again. + Sign Out**) does three things: + - It deletes the pasted key from secret storage. + - It signs the CLI out when the CLI is signed in, through the CLI's own + `account/logout` on a short-lived `muse serve`. If the CLI still reads + signed in afterwards, it runs `muse logout` in a terminal instead, with + your `museSpark.environmentVariables`, so the same config home is + signed out. + - It records in VS Code's extension state (no credential) that you signed + out, so an old CLI credential cannot sign the window back in until you + sign in again. ## What stays on your machine diff --git a/docs/certification/README.md b/docs/certification/README.md index 07b6adb2..c09b959e 100644 --- a/docs/certification/README.md +++ b/docs/certification/README.md @@ -72,3 +72,4 @@ The PNGs beside the records are that day's harness renders. - [0.9.1](release-0.9.1.md): Muse Code 1.4.0 on Windows: rename, fork and the sandbox warning limited for every version; known 1.4.0 schema fingerprints (PLAN.md D26 amendment) - [M57](m57.md): the Model API backend out of the activation bundle into `dist/modelApi.js`, the identity audit and the bundle-split gate (PLAN.md D6) - [M58](m58.md): a popup before every paid use: Allow once, Allow always in this workspace, or Deny (PLAN.md D48) +- [Sign-in detection](sign-in-detection.md): the CLI's sign-in read from its credential file's structure and confirmed by the CLI, sign-out through `account/logout`, and every way a browser sign-in ends (PLAN.md D26 amendment) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md new file mode 100644 index 00000000..339f619a --- /dev/null +++ b/docs/certification/sign-in-detection.md @@ -0,0 +1,1109 @@ +# Sign-in detection — the CLI's sign-in is read, not assumed (PLAN.md D26 amendment) + +Recorded 2026-09-27, branch `fix/cli-sign-in-detection`. + +## The finding + +The extension took "Muse Code CLI signed in" to mean "`auth.json` exists". + +- **What `muse logout` does.** It never deletes the file. It rewrites it + as the 44-byte `{"schema_version": 1, "providers": {}}`. +- **Where that was seen.** Muse Code 1.3.0 on Linux, and 1.4.0-R4302.1 on + Windows and Linux. Each run used an isolated home with a dummy stored + key and a dead proxy. +- **What it broke.** After any sign-out the auto backend kept choosing + Muse Code. A panel sign-out stayed on "Sign-out is in progress or + credentials remain" until a new browser sign-in. +- **Why no test caught it.** The unit test "does not reassert CLI sign-in + while terminal logout still has the credential file" modelled the logout + as deleting the file. +- **macOS.** A sign-in is a login Keychain item (`ai.meta.dev.credentials`, + account `meta`), and `auth.json` is a token-free pointer + (`schema_version: 2`, `storage: "keychain"`). + +The evidence is `scratchpad/muse-1.4.0-credentials.md`, with its probes +under `scratchpad/m140cred/`: + +- `probe-logout-iso*.json`: the file after `muse logout`, on three builds; +- `probe-account-logout-iso.json`: MSP `account/logout` returns + `{state:"loggedOut", credentialRequired:true}` in 10 ms, leaves the same + file, and fires `account/changed`. A terminal logout fired nothing + within 6 s; +- `probe-account-{win,mac,linux}.json`: the `account/read` shapes; +- the `account/read` states the code relies on, all captured with + `credentialRequired: true`: `accountLogin` and `loggedOut` + (`probe-account-*.json`, `probe-logout-iso*.json`), `apiKey` after + `muse auth set` (`probe-authset-*.json`), and `envKey` with `META_API_KEY` + set (`envkey-account-read.txt`). `credentialRequired: false` was never + seen, so no code gives it a meaning; +- `ptr-stderr.txt` and `ptr1-stderr.txt`: `muse serve` exits 3 on Windows + with a schema-2 pointer, and with a version-1 provider whose storage is + the Keychain; +- `probe-v2-serve.mjs` and its redacted output `probe-v2-serve.json` (the + third review round, below): `muse serve` exits 3 on an empty version-2 + file too, and starts with a version-2 file when `META_API_KEY` is set; +- the same probe, extended in the fourth review round, and its redacted + outputs `probe-v2-serve-win.json` (Windows 11) and + `probe-v2-serve-linux.json` (the Kubuntu VM): the three files named + `unsupportedHere`, each with and without a dummy `META_API_KEY`, on both + (below); +- the bundled Slack connector, `slack_connector.py` in the `muse-core` + plugin's cache (1.4.0-R4302.1), reads its own + `providers.slack_connector.bot_token` from the same `auth.json` (its + lines 18–20, 60 and 505): a provider in the file is not necessarily a + Muse sign-in. + +Every probe's trace shows 0 model attempts. No real sign-in or sign-out was +made, and no token was read. + +## What changed + +| Behaviour | Where | Tests | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth.json` is parsed for its structure only: schema version, which providers are named, each one’s `storage` lane, and whether `meta` has `api_key` or `access_token` (the parse replaces the value with `true`; review round 4). The schema drops every other field. Only `providers.meta` speaks for the sign-in; the bundled Slack connector’s entry does not (review round 3). A file over 64 KiB, a folder or a stat failure is not read. | `src/core/backends/musecode/credentialFile.ts` (`MUSE_CREDENTIAL_PROVIDER`), `readCredentialFile` in `src/host/auth/cliAccount.ts` | `credentialFile.test.ts` (the captured shapes, a Slack-only file, six malformed files); `cliAccount.test.ts`; `cliAccount.e2e.test.ts` | +| No file, or the empty version-1 file a sign-out leaves, is signed out without a process, on every OS. Off macOS, a `meta` entry in a captured inline shape (no `storage`; `api_key` or `access_token`) is signed in. A `meta` entry in any other shape, and other providers alone, are asked of the CLI (review round 4). | `CliAccount.signIn` | `cliAccount.test.ts`; `authService.test.ts` "AuthService over the CLI’s real credential file"; `cliAccount.e2e.test.ts` | +| A macOS pointer on macOS, any other file on macOS but the sign-out’s (a version-1 file holding the credential, an empty version-2 file: uncaptured there; review round 4), or a file the structure cannot place, is asked of the CLI (`account/read` on a short-lived `experimentalApi` host). The answer is kept per path, size and mtime. | `CliAccount.confirm`, `AccountHosts.probe` | `cliAccount.test.ts` (cache by mtime and by size, one shared question, a failed answer asked again on a click); `cliAccount.e2e.test.ts` (one probe) | +| On macOS the CLI is asked only on a user action (Check again, sign-in, sign-out, the Sign Out and Diagnostics commands), never on activation or panel open. | `CliAccount.confirm`; `AuthService.refresh(isUserAction)`, `checkAgain` | `cliAccount.test.ts`; `authService.test.ts` "when the CLI may be asked"; `conversationController.test.ts` | +| Cancel leaves an unanswered probe behind and keeps the remembered answer. A sign-out, a device sign-in and Check again forget the answer too, so a Keychain change that leaves the file as it was is seen (review round 3); a sign-out forgets it right after the Cancel it makes, before it decides on `account/logout`, and presses of Check again while one runs join it (review round 4). | `CliAccount.abandonProbe`, `forgetAnswers`; `AuthService.cancelSignIn`, `checkAgain`, `signInWithCli`, `logOutCli`; the controller’s `retryBackend` | `cliAccount.test.ts`; `authService.test.ts` (the real-file cases); `conversationController.test.ts` | +| A macOS file on Windows or Linux (any version 2, the empty one included, or a Keychain lane on `meta`) is a named error that gives the file’s path; `muse serve` exits 3 with each on both (captured, review round 4). The browser sign-in is refused with the same text instead of starting a host that exits 3. With `META_API_KEY` set, `muse serve` starts with each of the three on both, and the key wins. The log names the state without the path. | `AuthService.selectedSnapshot`, `signInWithCli`, `cliCredential`; `UI_TEXT.cliCredentialUnsupported` | `credentialFile.test.ts`; `authService.test.ts` "a credential file Muse Code cannot start with" | +| Sign-out goes through MSP `account/logout`, confirmed by `account/read`. When that does not confirm it, the sign-in is read afresh; only a sign-in still there opens `muse logout` in a terminal, which gets `museSpark.environmentVariables`. The logout hold ends once the file or the CLI shows no sign-in, even though the file stays. A sign-out that keeps the hold is published as `error`, so the panel offers the Check again its message asks for. | `logOutAccount`; `AuthService.performSignOut`, `logOutCli`, `refresh`; `terminalEnvironment`, `runCliInTerminal` | `accountHost.test.ts`; `authService.test.ts`; `launch.test.ts`; e2e | +| The device sign-in succeeds on either of two signals: `account/read` turning `accountLogin` while polling; a new file that `account/read` does not contradict. A CLI that cannot answer `account/read` falls back to the file. The captured `granted` comes after both, so it is no signal of its own, except that with no first `account/read` it counts when `account/read` says `accountLogin` (review round 4); `accountLogin` alone then does not. A host that exits after the file changed signed in. | `runDeviceSignIn`, `isSignedIn` | `deviceSignIn.test.ts` "how it ends"; `cliAccount.e2e.test.ts` (the granted sequence replayed) | +| `account/loginCompleted` ends the flow at once on any outcome but `granted`. The captured `expired`, `denied` and `failed` show their own messages; any other word is shown as Muse Code sent it (`signInEnded`). No ending’s message reaches the log: each captured ending is logged in fixed words, and an uncovered word only in the shape of a protocol word (Codex on `886af682`). A malformed ending keeps waiting. | `runDeviceSignIn` (`loggedEnding`); `AuthService` (`signInExpired`, `signInDenied`, `signInSaveFailed`, `signInEnded`) | `deviceSignIn.test.ts`; `authService.test.ts` "how Muse Code ends a browser sign-in"; `cliAccount.e2e.test.ts` (the captured frames replayed) | +| Cancel, the host’s ending and the host’s exit are noticed at once, even while an `account/read` goes unanswered: each poll and each wait races a stop signal, which `connection.closed` also trips. An exit fails the sign-in. `account/loginCancel` is bounded at 2 s, then the host is closed anyway. | `runDeviceSignIn` (`untilStopped`, `cancelLogin`); `MUSE_LOGIN_CANCEL_TIMEOUT_MS` | `deviceSignIn.test.ts` "a CLI that stops answering", "a host that exits"; `cliAccount.e2e.test.ts` (the fake leaves `account/read` unanswered, or exits) | +| The extension waits 11 minutes, past the captured 600 s code lifetime, so Muse Code’s own `expired` ends an unapproved code. Before, it cancelled at 5 minutes a code the browser could still approve. | `CREDENTIAL_POLL_TIMEOUT_MS` | `deviceSignIn.test.ts` "waits past the captured code lifetime" | +| A Cancel pressed while the pre-check reads the file is kept: the controller exists before that read, and an aborted flow never starts. A Cancel pressed after the credential file changed lets the file decide (review round 3). With the hold on or a Model API session, the code leaves the panel before the refresh that follows (review round 4). | `AuthService.signInWithCli`, `finishCancelledCliSignIn` | `authService.test.ts` "cancels the running device flow", "lets the credential file decide a Cancel pressed just after the browser approved" | +| Sign-out never waits on a question a finished or failed sign-in asks: the confirming `cliSignIn(true)` and each `refresh(true)` race the flow’s signal or the sign-out’s. A refresh begun before or during a sign-out cannot publish after it: the epoch moves as the sign-out starts and ends (review round 3). Nor can it put back a hold that sign-out released: it holds again only while a sign-out runs (review round 4). | `AuthService.confirmCliSignIn`, `refreshUnlessCancelled`, `finishFailedCliSignIn`, `performSignOut` | `authService.test.ts` "sign-in, sign-out and Cancel racing", "keeps the state a sign-out published …" | +| The window closing closes every short-lived account host through one `AbortController`, probes Cancel left behind included, then cancels the sign-in and waits for it, then stops the backends (review round 3). A click still in its pre-flight questions then starts nothing, and no later click opens a key prompt (a flag `stopSignIn` sets; review round 4). | `AccountHosts`; `AuthService.stopSignIn`; `lifecycle.shutdown` in `extension.ts` | `accountHost.test.ts` "AccountHosts"; `authService.test.ts` "cancels the browser sign-in and waits for it to end" | +| The account’s `label` (an e-mail address) and `avatarUrl` are dropped by the `account/read` parse and never logged. | `accountStateSchema` | `accountHost.test.ts` (the captured signed-in answer); `deviceSignIn.test.ts`; `cliAccount.e2e.test.ts` (the fake CLI sends a label) | +| Diagnostics names the file’s structure and the CLI’s sign-in. On macOS it adds the Keychain item’s presence (`security find-generic-password -s ai.meta.dev.credentials -a meta`, no `-g`/`-w`: exit 0 or 44). Its `account/read` may make the CLI read the Keychain, which can prompt. | `renderSupportReport`, `keychainItemPresence`, the Diagnostics command | `supportReport.test.ts`; `credentialFile.test.ts` | + +The fake CLI (`test/e2e/fake-muse/serve.mjs`) now echoes `experimentalApi`. +For a client that asked for it, it serves `account/read` from the +credential file under `XDG_CONFIG_HOME`, as captured (review round 4): a +`meta` holding `access_token` (a browser sign-in) is `accountLogin`, one +holding `api_key` alone (`muse auth set`) is `apiKey`, anything else +signed out. It serves `account/logout` by rewriting that file as the empty +one the CLI leaves. At startup, before `initialize`, it exits 3 with the +captured stderr on a file 1.4.0-R4302.1 refused: a schema version other +than 1 (1 or 2 on macOS), "unsupported auth schema version …", and off +macOS a version-1 `meta` in the Keychain lane, "keychain item for meta is +unreadable". It does not model `META_API_KEY`, with which the real CLI +starts. `installFakeCredential` writes the granted capture's file +structure (schema 1, `meta` with the captured keys, placeholders for every +value) instead of `{"fake": true}`. + +The fake also runs the device sign-in from the live captures below. It +reads `test/fixtures/msp/account-login-*.json` from `MUSE_FAKE_CAPTURES`: + +- `account/loginStart` answers as captured; +- `MUSE_FAKE_LOGIN_ENDING` sends, after `MUSE_FAKE_LOGIN_ENDING_MS`, the + notifications that capture received before its `loginCancel`. For + `granted` it first writes the file as the browser sign-in left it, then + sends `account/changed`, the ending and `account/changed` again; +- `account/loginCancel` sends the captured `cancelled` ending, then + `{cancelled: true}`, in the captured order; +- `MUSE_FAKE_ACCOUNT_READ=silentAfterStart` leaves `account/read` + unanswered once the flow has started; +- `MUSE_FAKE_LOGIN_EXIT_MS` exits that long after `loginStart`, a host + that dies mid-flow. + +The unit tests read the same files through +`test/unit/helpers/accountLoginCapture.ts`. The file shapes the tests use +are in `test/unit/helpers/credentialShapes.ts`: the `muse auth set` file +(schema 1, `meta` with `api_key` alone, `probe-authset-*.json`), the +device-login file (the granted capture) and the logout shell. Every +shape nobody captured (the third party's macOS pointer, the Slack-only +file, a file cut short) says so in a comment. + +Checked in the third review round and left as it was: + +- **`markAuthRequired`.** It publishes `signedOut` with the backend's own + `authRequired` reason, which asks for no Check again. The panel shows the + sign-in buttons that reason calls for, so C1's mismatch does not arise. +- **`META_API_KEY` before the file.** The W2 capture showed `muse serve` + starting with a version-2 file when the key is set, so the key still + wins over the file check. (Round 4 narrowed this claim to what was + captured, then captured the rest: Windows and Linux, all three files.) +- **`granted`.** Captured now, it still needs no handler: it comes after + the file and `account/read` already show the sign-in. (Round 4 found the + one case where it does: no first `account/read`; see below.) + +## Live capture of the device sign-in (PR #49 review) + +Codex's review of PR #49 raised two findings: + +- **P1.** The `loginCompleted` handlers were written from the schema's + words; only `cancelled` had been captured. +- **P2.** A poll that awaited `account/read` held up Cancel and the host's + ending for 30 s when the CLI stopped answering. + +The owner authorized real device sign-ins with his account for this +capture. + +- **How it was driven.** `scratchpad/cred-capture/capture.mjs` drove + `muse-bin-1.4.0-R4302.1.exe serve` over raw MSP NDJSON, with + `experimentalApi`, as the extension asks. + - The machine was Windows 11, `serverInfo` 1.4.0, build `aebe0c18`. + - It asked `account/read` every second and recorded every frame. Each + frame was redacted before it was written. +- **The throwaway home.** Each run had a new `mkdtemp` folder under + `%TEMP%`. + - It held `HOME`, `USERPROFILE`, every `XDG_*_HOME`, `APPDATA`, + `LOCALAPPDATA` and an empty workspace. `META_API_KEY` and + `TBH_CREDENTIAL_BACKEND` were removed. + - A run went on only when three checks held: the host's `museHome` was + in that folder, the trace said `config_root source="xdg"`, and + `account/read` said `loggedOut`. +- **Safety.** + - The owner's `auth.json` was only `stat`ed: 640 bytes and mtime + 2026-09-22 20:21:02Z, before and after every run. + - Each throwaway home was deleted and checked gone. + - No session started, and every trace counted 0 model attempts. + - No code was approved, so no token was issued or written. + +| Capture | 2026-09-27 (PDT) | Browser | Frames | What the wire did | +| --------------------- | ---------------- | ----------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| expired | 18:13–18:23 | none: the code was left | `test/fixtures/msp/account-login-expired.json` | `{outcome: "expired", message: "login failed: the request expired"}` came 600.5 s after `loginStart`'s answer, with no `account/changed`. `account/read` stayed `loggedOut`. A later `loginCancel` answered `{cancelled: false}`. | +| cancelled | 18:14–18:22 | none (see below) | `test/fixtures/msp/account-login-cancelled.json` | The run meant to approve the code, and sent `loginCancel` at its 482 s deadline. `{outcome: "cancelled"}`, with no message, arrived before the `{cancelled: true}` answer. The code was still live at 482 s. | +| a blocked config path | 18:22 | none | not kept | A regular file where the `muse` config folder goes. `serve` started, `account/read` said `loggedOut`, and `loginStart` answered as usual. The save failure this was set up for comes only after an approval. | + +- **`loginStart`.** It answers exactly `{verificationUrl, userCode}`, + with no expiry field. The URL is + `https://auth.meta.com/oauth/device/?code=`, and the code is + 4+4 letters. +- **`emittedAtMs`.** 1.4.0 adds a frame-level `emittedAtMs` to + notifications. The SDK's `Notification` type has it. +- **What the fixtures keep.** The first two polls, and the last one before + the ending. The code is replaced by its shape, `AAAA-AAAA`; no label, + e-mail or token was in any frame. + +In that first round `granted`, `denied` and `failed` could not be +captured: each needs a click on the device page in the owner's signed-in +Chrome, and the Chrome Control extension was disabled in that profile. +They were captured later the same evening (below). + +### The success, a denial and a failed save (review round 3) + +- **When and how.** 2026-09-27, 21:27–21:30 PDT (2026-09-28T04:27Z–04:30Z). + `scratchpad/cred-capture/capture2.mjs` drove the same build + (1.4.0-R4302.1, `serverInfo` 1.4.0, build `aebe0c18`, Windows 11) over + raw MSP NDJSON with `experimentalApi`, one run per outcome. The raw + redacted frames and each run's summary are in + `scratchpad/cred-capture/out/live2/`; the fixtures were made from them. +- **The throwaway home.** As before: a new `mkdtemp` folder per run, with + `HOME`, `USERPROFILE`, every `XDG_*_HOME`, `APPDATA`, `LOCALAPPDATA` and + an empty workspace inside it, and `META_API_KEY`, `TBH_CREDENTIAL_BACKEND` + and `MUSE_AUTH_PATH` removed. The trace confirmed + `config_root source="xdg"` before `loginStart`. Each home was deleted + and checked gone. +- **The browser.** The device page (`auth.meta.com/oauth/device/?code=…`) + in the owner's signed-in Chrome asked only "Approve Muse Code?", with the + code and two buttons, **Approve** and **Deny**. About 20 s after + `loginStart`, Approve was clicked for `granted` and `failed`, and Deny + for `denied`. The page then said "Muse Code was approved" or "… was + denied". +- **Safety.** Every trace counted 0 model attempts; no session started. + The owner's own `auth.json` was only `stat`ed (640 bytes, mtime + 2026-09-22T20:21:02.598Z) before, between and after the runs, unchanged + (`live2/real-auth-stat.txt`). The `granted` run signed out through + `account/logout` in the same home before the home was deleted; the trace + logged `credential.revoke` with outcome `revoked`. +- **Redaction.** The user code is its shape, `AAAA-AAAA`; `museHome` and + paths are under ``; the account's label and avatar address are + the stand-ins `someone@example.com` and `https://example.com/avatar.png`, + replaced by key before any frame was written. No token is in any frame. + +| Capture | 2026-09-27 (PDT) | Clicked | Frames | What the wire did | +| ------- | ----------------- | ------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| granted | 21:27:45–21:28:15 | Approve | `test/fixtures/msp/account-login-granted.json` | The file first appeared 20.43 s after `loginStart`'s answer (437 B). `account/changed {state: accountLogin, credentialRequired: true}`, with no label, came at 21.25 s, as the next poll's `account/read` also said `accountLogin`. The file was rewritten (1062 B: schema 1, `providers.meta` with `access_token`, `obtained_via: "device_code"`, `mechanism: "oauth"`, `api_key`, `api_base_url` and the account's name, e-mail and avatar address). `{outcome: "granted"}`, with no message, came at 21.46 s; a second `account/changed`, now with `label` and `avatarUrl`, 1 ms later. `account/logout` answered `{state: loggedOut, credentialRequired: true}` in 27 ms, and the file became the 44-byte `{"schema_version":1,"providers":{}}`. | +| denied | 21:29:03–21:29:28 | Deny | `test/fixtures/msp/account-login-denied.json` | `{outcome: "denied", message: "login failed: the request was denied"}` came 20.4 s after `loginStart`'s answer. No `account/changed`; `account/read` stayed `loggedOut`; no file was written. | +| failed | 21:29:35–21:30:00 | Approve | `test/fixtures/msp/account-login-failed.json` | A regular 49-byte file stood where `cfg\muse` goes. `{outcome: "failed", message: "login succeeded but saving failed: failed to write credential file at \cfg\muse: Cannot create a file when that file already exists. (os error 183)"}` came 20.5 s after `loginStart`'s answer. The message names a local path, which is the user's profile folder in real life. No `account/changed`; `account/read` stayed `loggedOut`; nothing was written; the trace shows no `credential.refresh`, so no key was minted. | + +- **`avatarUrl`.** `account/read` and `account/changed` carry an + `avatarUrl` the MSP schema does not list. The schema parse is lenient + and drops it, with the label. +- **What changed because of it.** `denied` and `failed` have their own + messages (`signInDenied`, `signInSaveFailed`), and `signInEnded` is left + for words never seen. The log kept `expired`'s and `denied`'s messages + only: `failed`'s names a path, so the log said "saving the credential + failed". (Since Codex's review of `886af682`, no message is logged: + every captured ending has fixed words.) `granted` kept no handler: it + comes after the file and `account/read` already show the sign-in. + +**What the wire showed that the code had wrong.** + +- **The code's lifetime.** A code lives 600 s. The extension gave up at + 5 minutes and cancelled a code the browser could still approve, so its + `expired` handler could never be reached. + - The backstop is now 11 minutes (`CREDENTIAL_POLL_TIMEOUT_MS`), checked + against the lifetime measured in the fixture. + - The panel now shows Muse Code's own `expired`. +- **The message.** `expired` carries one, as the schema says; `cancelled` + carries none. +- **`account/changed`.** It did not fire for an expired code either, so it + stays unused. +- **The order.** The ending precedes the `loginCancel` answer. + +## An empty version-2 file off macOS (review round 3, W2) + +The second review round read `{"schema_version":2,"providers":{}}` as +signed out on every OS. The nearest capture, `ptr-stderr.txt`, showed +Windows refusing a pointer on its version alone. So the file was given +to `muse serve` itself. + +- **How.** `scratchpad/m140cred/probe-v2-serve.mjs`, 2026-09-27 21:38 PDT + (2026-09-28T04:38Z), Windows 11, the installed + `muse-bin-1.4.0-R4302.1.exe serve`. Each case had a new `mkdtemp` home + (`HOME`, `USERPROFILE`, every `XDG_*_HOME`, `APPDATA`, `LOCALAPPDATA`), + holding exactly the file named; `META_API_KEY`, `TBH_CREDENTIAL_BACKEND` + and `MUSE_AUTH_PATH` were removed, and the network went to a dead proxy + (`127.0.0.1:9`). The probe sent `initialize` with `experimentalApi` and, + if the host started, one `account/read`, then closed it. No session, no + sign-in, 0 model attempts in every trace. The owner's `auth.json` was + only `stat`ed, unchanged; every home was removed. +- **The output.** `scratchpad/m140cred/probe-v2-serve.json`, redacted + (paths under ``, the dummy key as ``, the label + replaced). + +| Case | `META_API_KEY` | What `serve` did | +| ----------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `{"schema_version":2,"providers":{}}` | unset | exit 3 after 425 ms, before `initialize`: `compose serve model client: unsupported auth schema version 2 at …\auth.json` | +| a version-2 pointer (`storage: keychain`) | unset | the same exit 3 and message (the control case) | +| a version-2 pointer | a dummy value (no key) | started; `account/read` answered `{state: envKey, credentialRequired: true}` | +| `{"schema_version":2,"providers":{}}` | a dummy value | started; `account/read` answered `envKey` | + +- **The verdict.** Muse Code refuses any version-2 file off macOS on its + version, the empty one included. The structural read now names every + version-2 file off macOS as one Muse Code cannot start with. The verdict + was `keychainElsewhere`; it is `unsupportedHere` now, because an empty + file points to no Keychain. Its message, `cliCredentialUnsupported`, + says the file is in the macOS format, in every table. Linux was treated + like Windows without a probe; round 4 probed it (below). +- **The environment key.** With `META_API_KEY` set, `serve` starts with a + version-2 file and uses the key, so the key still bypasses the file + check (`AuthService.cliCredential`), as before. + +## The fourth review round (re-review of `990ee91e`) + +### Linux and `META_API_KEY` captured (W3) + +`unsupportedHere` on Linux, and `META_API_KEY` with a version-1 Keychain +lane, had never been run. `scratchpad/m140cred/probe-v2-serve.mjs` (SHA-256 +`5223F87A…FD26068`) was extended to six cases: the empty version-2 file, a +version-2 pointer and `{"schema_version":1,"providers":{"meta":{"storage":"keychain"}}}`, +each without and with a dummy `META_API_KEY` (`LLM_dummy-not-a-real-key`, +no real key anywhere). + +- **How.** `serve` only: `initialize` with `experimentalApi` and, if the + host started, one `account/read`. Each case had a new `mkdtemp` home + (`HOME`, `USERPROFILE`, every `XDG_*_HOME`, `APPDATA`, `LOCALAPPDATA`) + holding exactly the file named; `META_API_KEY`, `TBH_CREDENTIAL_BACKEND` + and `MUSE_AUTH_PATH` were removed and every proxy variable pointed at the + dead `127.0.0.1:9`. Every trace said `config_root source="xdg"`, and + every host that started reported its `museHome` inside the throwaway + home. No session, no sign-in, **0 model attempts** in every trace. +- **Windows.** Windows 11 (10.0.26200), Node 24.20.0, the installed + `muse-bin-1.4.0-R4302.1.exe`, 2026-09-28T05:55:27Z–05:55:43Z. The owner's + `auth.json` was only `stat`ed (640 bytes, unchanged). Output + `probe-v2-serve-win.json` (SHA-256 `0F2B543C…A3428D5E`). +- **Linux.** The Kubuntu VM (`10.10.11.212`, kernel 7.0.0-31-generic, + Node 24.18.0, `~/.local/bin/muse-bin-1.4.0-R4302.1`), reached with + `ssh -i ~/.ssh/muse_ext_ed25519`, 2026-09-28T05:55:53Z–05:55:54Z. The + probe ran from a `mktemp -d` folder that was removed afterwards; no + `/tmp/muse-w3-probe-*` or `/tmp/muse-v2-probe-*` was left, and the VM + has no `~/.config/muse` (checked before and after). Output + `probe-v2-serve-linux.json` (SHA-256 `5A3F1D84…3C5ADE63`). +- **Redaction.** Paths under ``, the home as `~`, the dummy key + as ``, the `envKey` label replaced. + +| Case | `META_API_KEY` | Windows 11 | Kubuntu | +| --------------------------------- | -------------- | ----------------------------------------------------------------------------------------- | --------------------------------------- | +| empty version 2 | unset | exit 3 (3128 ms), before `initialize`: "unsupported auth schema version 2 at …\auth.json" | exit 3 (138 ms), the same message | +| empty version 2 | dummy | started; `account/read` → `envKey` | started (`platformOs: linux`); `envKey` | +| version-2 pointer | unset | exit 3 (556 ms), the same message | exit 3 (134 ms), the same message | +| version-2 pointer | dummy | started; `envKey` | started; `envKey` | +| version 1, `meta` in the Keychain | unset | exit 3 (465 ms): "keychain item for meta is unreadable (internal error -2147483648)" | exit 3 (134 ms), the same message | +| version 1, `meta` in the Keychain | dummy | started; `envKey` | started; `envKey` | + +- **The Linux verdicts.** Linux does what Windows does, so every + version-2 file and a version-1 Keychain lane stay `unsupportedHere` there, + now from a capture (`credentialFile.test.ts` cites both outputs; drill BW + breaks the Linux side). +- **The environment key.** It wins over all three files on Windows and on + Linux, so `AuthService.cliCredential` keeps looking at no file while the + key is set. The docs' "the key still wins" now names both systems and + the three files; macOS was not probed with the key. + +### What changed in round 4 + +| Finding | Where | What | Tests | +| ------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | `AuthService.performSignOut` | `forgetCliAnswers()` right after the `cancelSignIn()` it makes: the logout decision asks the CLI afresh, so a stale remembered `signedOut` no longer skips `account/logout`. | `authService.test.ts` "asks the CLI afresh at sign-out, so a sign-in made since is signed out" | +| R2 | `runDeviceSignIn`, `isSignedIn` | The notification handler records `granted`. With no first `account/read`, `granted` together with `account/read` saying `accountLogin` is the sign-in; `granted` alone is not (neither with no answer nor with `envKey`). | `deviceSignIn.test.ts` "signs in on the captured granted and account/read when the first account/read went unanswered", "takes no sign-in from granted alone when …" | +| R3 | `AuthService.stopSignIn`, `signIn`, `joinDeviceSignIn` | A flag `stopSignIn` sets; `signIn` and the join return at once when it is set. Chosen over joining the window's signal to the flow's connect: a stopped click then never publishes `signingIn`, asks its pre-check, or opens a key prompt, where a signal would reach it only at the connect. | `authService.test.ts` "starts no sign-in once the window is closing, not even from a click in its pre-flight", "opens no key prompt and starts no device flow once the window is closing" | +| R4 | `AuthService.refresh` | A refresh that finds the epoch moved holds again only while a sign-out runs (`isSignOutRunning()`, read afresh after the awaits); otherwise it returns the snapshot and leaves the hold alone. | `authService.test.ts` "leaves the hold as a finished sign-out left it when a refresh answers late" | +| R5 | `AuthService.checkAgain` | Concurrent presses share one in-flight promise: one forget, one question, one host. | `authService.test.ts` "asks the CLI once however often Check again is pressed while it answers" | +| R6 | `AuthService.finishFailedCliSignIn` | Publishes `{status, detail, verificationUrl: undefined, userCode: undefined}` first, then, with the hold on or a Model API session, refreshes (and still sends the notice). | `authService.test.ts` "clears the code at once after Cancel with the logout hold on", "… with a Model API session" | +| R7 | `runDeviceSignIn` (`endedAs`) | A host that is gone while the credential file changed since the flow began has signed in (logged as such); with no change it still fails at once. | `deviceSignIn.test.ts` "signs in when the host exits after the credential file changed" | +| W1 | `credentialFileVerdict` | `inline` only for a `meta` with no `storage` and `api_key` or `access_token` (the parse keeps the key's presence as `true`, never its value). `meta: {}`, another `storage`, or no captured key: `unrecognized`. | `credentialFile.test.ts` "leaves a meta entry in any other shape to the CLI on %s", "takes either captured credential key alone …" | +| W2 | `credentialFileVerdict`; `constants.ts` | On macOS a version-1 file holding the credential and an empty version-2 file are `unrecognized` (asked on a user action; the passive estimate is `unknown`). The captured version-1 empty file stays signed out on every OS. The constants comment no longer says macOS writes version 1 with `TBH_CREDENTIAL_BACKEND=file` (the Mac wrote no file). | `credentialFile.test.ts` (the lines round 4 named, now "leaves a file holding the credential to the CLI on macOS" and the pointer case); `cliAccount.test.ts` "asks the CLI about %s on macOS, only on a user action", "still reads the file a sign-out leaves as signed out on macOS" | +| W3 | captures; `credentialFile.test.ts` | Above. | `credentialFile.test.ts` "names a macOS file Muse Code cannot start with on %s"; `authService.test.ts` "leaves the file to the CLI while META_API_KEY signs it in" | +| W4 | `AuthService.set`, `signInLine` | The unsupported-file state is logged as "the Muse Code credential file is in a format Muse Code cannot start with on this system"; the panel keeps the path. | `authService.test.ts` "logs no credential path for that state" | +| W5 | `test/e2e/fake-muse/serve.mjs`; `cliAccount.e2e.test.ts`; `deviceSignIn.test.ts` | The fake exits 3 with the captured stderr as above and answers `apiKey` / `accountLogin` by shape. The e2e's schema-9 file, which expected `signedIn`, became a synthetic `meta` in an uncaptured `storage` lane (asked of the CLI); the schema-9 file now proves a host that exits answers `unknown`. The unit test that sent `granted` before `accountLogin` is renamed "waits for account/read after a granted that comes before it"; "signs in on the poll that sees the account, before the captured granted follows" replays the captured order. | `cliAccount.e2e.test.ts` "answers unknown when the host exits at startup", "answers account/read about %s as captured", "reads a stored sign-in from the file alone off macOS" | + +Also in the working tree when this round began, and part of it: the e2e +Slack-only test asks with `signIn(true)`, so it holds on macOS too, and the +15 `package.nls*.json` tables describe `museSpark.environmentVariables` as +reaching the terminals that run the CLI (C6). + +## Drills + +Each drill broke one guard in the working tree and ran the named suites. +The original bytes were then written back from memory, never through git. +Every target's SHA-256 matched its pre-drill value, before and after all +seventeen (`scratchpad/cred-fix/drills.mjs`, `drills-result.json`). + +| Drill | What was broken | Suites | Result | +| ----- | ------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| A | The empty file a sign-out leaves read as a sign-in | credentialFile, cliAccount, authService | exit 1, 8 failed; first "reads the empty file a sign-out leaves as signed out, without starting the CLI" | +| B | A macOS Keychain pointer accepted off macOS | credentialFile, cliAccount | exit 1, 3 failed; first "names a macOS pointer on Windows without starting a host that would exit" | +| C | macOS asks the CLI without a user action | cliAccount | exit 1, 1 failed: "asks about a macOS Keychain pointer only on a user action" | +| D | The answer cached without the file's size and mtime | cliAccount, cliAccount.e2e | exit 1, 2 failed; first "asks about a malformed file off macOS at once, and keeps the answer until it changes" | +| E | A failed answer never asked again on a user action | cliAccount | exit 1, 1 failed: "keeps a failed answer for passive looks, and asks again on a user action" | +| F | Sign-out skips `account/logout` (terminal only) | authService | exit 1, 4 failed; first "clears the key, signs the CLI out through account/logout, and restarts" | +| G | The hold counts any credential file as a sign-in (the original bug) | authService | exit 1, 8 failed; first "keeps an environment-authenticated CLI gated until its key is removed" | +| H | A Keychain pointer off macOS not named in the gate | authService | exit 1, 1 failed: "names a macOS Keychain pointer on Windows or Linux instead of offering a dead sign-in" | +| I | Browser sign-in starts a host that would exit on that pointer | authService | exit 1, 1 failed: the same case | +| J | A Cancel during the pre-check is lost | authService | exit 1, 1 failed: "cancels the running device flow without changing credentials" | +| K | `denied`, `expired`, `failed` ignored (only `cancelled` ends) | deviceSignIn | exit 1, 3 failed; first "ends at once on denied, logs the host’s reason, and needs no loginCancel" | +| L | `granted` or a new file taken while `account/read` says signed out | deviceSignIn | exit 1, 2 failed; first "waits while the store does not show a granted sign-in yet" | +| M | `account/read` polling not a sign-in signal | deviceSignIn | exit 1, 1 failed: "notices a sign-in by polling account/read when no outcome arrives" | +| N | `account/logout` trusted without `account/read` | accountHost | exit 1, 4 failed; first "is false when account/logout is refused" | +| O | The account label kept by the `account/read` parse | accountHost | exit 1, 1 failed: "keeps the state and drops the label" | +| P | Check again not a user action | conversationController | exit 1, 1 failed: "delegates sign-in, sign-out, retry and external links" | +| Q | The Diagnostics Keychain line dropped | supportReport | exit 1, 1 failed: "describes the CLI’s sign-in by the file’s structure and the Keychain item" | + +Drills R to AA cover the PR #49 review fixes. They ran the same way, on +the final tree, from `scratchpad/cred-capture/drills.mjs` +(`drills-result.json`). After all ten, each target's SHA-256 matched its +pre-drill value. +"e2e" is `test/e2e/cliAccount.e2e.test.ts`, whose fake CLI replays the +captured frames. + +| Drill | What was broken | Suites | Result | +| ----- | -------------------------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | +| R | A poll waits for an unanswered `account/read` (the P2 race removed) | deviceSignIn, e2e | exit 1, 6 failed (4 unit, 2 e2e, each at its test timeout); first "ends at once on the host’s ending while account/read goes unanswered" | +| S | The wait between polls does not notice Cancel | deviceSignIn | exit 1, 1 failed: "notices Cancel during the wait between polls" | +| T | `loginCancel` waits the 30 s MSP deadline instead of 2 s | deviceSignIn | exit 1, 2 failed; first "closes the host after Cancel even when loginCancel is never answered" | +| U | The captured `expired` loses its own meaning | deviceSignIn, e2e | exit 1, 6 failed; first (e2e) "ends on the captured expired ending, shown the code as captured" | +| V | The captured `cancelled` loses its own meaning | deviceSignIn | exit 1, 1 failed: "treats the captured cancellation ending as terminal" | +| W | An ending no capture covers keeps the flow waiting (the old rule) | deviceSignIn | exit 1, 5 failed; first "ends at once on denied, which no capture covers, as the CLI named it" | +| X | The uncaptured `granted` ends the flow like any other word | deviceSignIn | exit 1, 2 failed; first "takes an uncaptured granted for nothing: account/read decides" | +| Y | Every ending shown with the `expired` text | authService | exit 1, 3 failed; first "ends an uncaptured denied sign-in at once, signed out with its reason" | +| Z | The extension gives up at 5 minutes, before the captured code lifetime | deviceSignIn | exit 1, 1 failed: "waits past the captured code lifetime, so Muse Code ends an unapproved code itself" | +| AA | An ending that arrives before `loginStart` answers is taken for Cancel | deviceSignIn | exit 1, 1 failed: "ends on the host’s ending while loginStart is unanswered, with no code shown" | +| AB | The pre-flight account probe awaited without the flow's signal (the review of PR #49) | `authService.test.ts` | exit 1, 1 failed: Cancel waited on a probe the CLI never answered | +| AC | Sign-out and the next sign-in rejoin a probe Cancel abandoned (the review of PR #49) | `cliAccount.test.ts` | exit 1, 1 failed: the second question waited on the unanswered probe | +| AD | A file change counted as a sign-in after a stop, while a poll went unanswered (the review of PR #49) | `deviceSignIn.test.ts` | exit 1, 1 failed: the expired code was reported as a sign-in | +| AE | `account/read`'s uncaptured `credentialRequired: false` read as a keyless sign-in (the review of PR #49) | `cliAccount.test.ts` | exit 1, 1 failed: a signed-out answer with the flag false became `signedIn` | +| AF | The device flow's signed-out guard gated on `credentialRequired` again (the review of PR #49) | `deviceSignIn.test.ts` | exit 1, 1 failed: a rewritten file counted as a sign-in under the uncaptured value | +| AG | An empty version-2 file read as a Keychain pointer off macOS (the review of PR #49) | `credentialFile.test.ts` | exit 1, 2 failed: a signed-out macOS file copied to Windows or Linux blocked browser sign-in | +| AH | `account/logout` confirmed on any answer but a stored sign-in (the review of PR #49) | `accountHost.test.ts` | exit 1, 2 failed: `envKey` and the uncaptured `credentialRequired: false` confirmed a logout | +| AI | A refresh begun before sign-out publishes its late answer (the review of PR #49) | `authService.test.ts` | exit 1, 1 failed: the signed-out state was overwritten with "Sign-out is in progress" | +| AJ | A sign-in cancelled by sign-out still announces its own cancellation | `authService.test.ts` | exit 1, 1 failed: "Sign-in cancelled" was shown during the sign-out | +| AK | The CLI's answer returned although the credential file was rewritten while it was asked (the review of PR #49) | `cliAccount.test.ts` | exit 1, 1 failed: a sign-out rewrite during the probe still read as signed in | +| AL | The CLI's remembered answer kept after a confirmed logout that left the file unchanged (the review of PR #49) | `authService.test.ts` | exit 1, 1 failed: a Keychain-style logout stayed signed in and kept the hold | + +Drills AM to BJ cover the third round of the PR #49 review (the races R1–R6, +the wire evidence W1–W2, the failure paths C1–C9). They ran the same way, on +the final tree, from `scratchpad/cred-capture/drills2.mjs` +(`drills2-result.json`): each target's SHA-256 matched its pre-drill value +after every drill and after all twenty-four. The fixes with no product guard +have no drill: W3 and C10 change test data and test timing only, C7 and C8 +docs and a comment, and W2's environment-key finding confirmed the code as it +was. + +| Drill | What was broken | Suites | Result | +| ----- | -------------------------------------------------------------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| AM | R1: a finished sign-in confirms the new sign-in (after a sign-out) without the flow’s signal | `authService.test.ts` | exit 1, 1 failed: "signs out without waiting on a finished sign-in confirming the sign-in after a sign-out" | +| AN | R1: a finished sign-in’s last `refresh(true)` without the flow’s signal | `authService.test.ts` | exit 1, 1 failed: "signs out without waiting on a finished sign-in refreshing after the sign-in" | +| AO | R2: Cancel forgets the remembered answer too (the old single hook) | `authService.test.ts` | exit 1, 2 failed; first "signs out without waiting on a pre-flight probe the CLI never answered (the review of PR #49)" | +| AP | R2: a failed sign-in’s refresh does not yield to a sign-out | `authService.test.ts` | exit 1, 1 failed: "signs out without waiting on the refresh a failed sign-in began" | +| AQ | R2: abandoning a probe drops the remembered answer too | `cliAccount.test.ts` | exit 1, 1 failed: "keeps a remembered answer when a probe is abandoned, and asks afresh once forgotten" | +| AR | R3: the sign-out epoch does not move as the sign-out ends | `authService.test.ts` | exit 1, 1 failed: "keeps the state a sign-out published when a refresh begun during it answers late" | +| AS | R4: Check again keeps the CLI’s remembered answers | `authService.test.ts` | exit 1, 1 failed: "asks the CLI afresh on Check again, even about a sign-in it confirmed" | +| AT | R4: the panel’s Check again is a plain `refresh(true)` again | `conversationController.test.ts` | exit 1, 1 failed: "delegates sign-in, sign-out, retry and external links" | +| AU | R5: with no first `account/read`, an existing `accountLogin` counts as a new sign-in | `deviceSignIn.test.ts` | exit 1, 1 failed: "when the first account/read goes unanswered, takes no sign-in from before the flow for a new one" | +| AV | R6: Cancel publishes signed out although the file changed since the flow began | `authService.test.ts` | exit 1, 1 failed: "lets the credential file decide a Cancel pressed just after the browser approved" | +| AW | W1: any provider in `auth.json` counts as the Muse sign-in | `credentialFile.test.ts`, `cliAccount.test.ts` | exit 1, 5 failed; first "asks the CLI about a file naming another provider alone" | +| AX | W1: the fake CLI counts any provider as a login | e2e | exit 1, 1 failed: "asks the CLI about a file naming another provider alone" | +| AY | W2: an empty version-2 file off macOS read as signed out | `credentialFile.test.ts` | exit 1, 2 failed; first "names a macOS file Muse Code cannot start with on win32" | +| AZ | C1: a sign-out that keeps the hold publishes `signedOut`, with no Check again | `authService.test.ts` | exit 1, 3 failed; first "ends the host and clears the key when the CLI logout terminal cannot open" | +| BA | C2: the device flow does not notice its host exiting | `deviceSignIn.test.ts`, e2e | exit 1, 3 failed; first "fails at once when the host exits during the flow" | +| BB | C3: an account host is not closed when the window closes | `accountHost.test.ts` | exit 1, 1 failed: "closes a probe still waiting when the window closes, and starts none afterwards" | +| BC | C3: closing the window does not wait for the cancelled sign-in | `authService.test.ts` | exit 1, 1 failed: "cancels the browser sign-in and waits for it to end" | +| BD | C4: every `loginCompleted` message logged as sent, the failed path included | `deviceSignIn.test.ts`, e2e | exit 1, 3 failed; first "ends on the captured failed, and logs no path" | +| BE | C4: the captured `denied` and `failed` lose their own meanings | `deviceSignIn.test.ts`, e2e | exit 1, 5 failed; first "ends on the captured denied, and logs no path" | +| BF | C4: the captured `denied` shown with the generic text | `authService.test.ts` | exit 1, 2 failed; first "ends the captured denied sign-in at once, signed out with its reason" | +| BG | C4: the generic ending promises a log line that is not written | `authService.test.ts` | exit 1, 1 failed: "ends an unknown sign-in at once, signed out with its reason" | +| BH | C5: an unconfirmed logout always opens `muse logout` in a terminal | `authService.test.ts` | exit 1, 1 failed: "opens no muse logout terminal once the file shows no sign-in after an unconfirmed logout" | +| BI | C6: a CLI terminal gets none of `museSpark.environmentVariables` | `launch.test.ts` | exit 1, 1 failed: "gives a CLI terminal the configured variables, one spelling each on Windows" | +| BJ | C9: a successful device sign-in keeps the CLI’s earlier answers | `authService.test.ts` | exit 1, 1 failed: "asks the CLI afresh after a browser sign-in, the file unchanged" | + +Drills BK to BZ cover the fourth round (the races R1–R7, the wire evidence +W1–W5). They ran the same way, on the final tree, from +`scratchpad/cred-capture/drills3.mjs` (SHA-256 `65B3AD09…1F90A53E`; +`drills3-result.json`, `drills3.log`): each drill replaced one exact +string, found exactly once, ran its suites, and wrote the original bytes +back from memory. Each target's SHA-256 matched its pre-drill value after +every drill and after all sixteen (`authService.ts` `6527bb5f…`, +`deviceSignIn.ts` `32c25770…`, `credentialFile.ts` `758947ea…`, +`serve.mjs` `d7c7c535…`). R3 has two drills because `signIn` and the +device flow's join each check the flag. The findings with no product guard +have no drill: the renamed unit test and the e2e test data (W5), and the +constants comment (W2). + +| Drill | What was broken | Suites | Result | +| ----- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| BK | R1: a sign-out decides on the answer the CLI gave before it (the one Cancel keeps) | `authService.test.ts` | exit 1, 1 failed: "asks the CLI afresh at sign-out, so a sign-in made since is signed out" | +| BL | R2: with no first `account/read`, `granted` and `accountLogin` do not count (only a new file) | `deviceSignIn.test.ts` | exit 1, 1 failed: "signs in on the captured granted and account/read when the first account/read went unanswered" | +| BM | R2: `granted` alone counts when there is no first `account/read` | `deviceSignIn.test.ts` | exit 1, 1 failed: "takes no sign-in from granted alone when account/read says envKey" | +| BN | R3: a click in its pre-flight starts a device flow after the window closed (the join ignores the flag) | `authService.test.ts` | exit 1, 1 failed: "starts no sign-in once the window is closing, not even from a click in its pre-flight" | +| BO | R3: a sign-in click after the window closed still runs (`signIn` ignores the flag) | `authService.test.ts` | exit 1, 1 failed: "opens no key prompt and starts no device flow once the window is closing" | +| BP | R4: a late refresh puts the hold back after a finished sign-out released it | `authService.test.ts` | exit 1, 1 failed: "leaves the hold as a finished sign-out left it when a refresh answers late" | +| BQ | R5: a second Check again forgets the first one’s probe and asks again | `authService.test.ts` | exit 1, 1 failed: "asks the CLI once however often Check again is pressed while it answers" | +| BR | R6: with the hold on or a Model API session, the code-less state is published only after the refresh | `authService.test.ts` | exit 1, 2 failed: "clears the code at once after Cancel with the logout hold on", "… with a Model API session" | +| BS | R7: a host that exits after writing the credential file fails the sign-in | `deviceSignIn.test.ts` | exit 1, 1 failed: "signs in when the host exits after the credential file changed" | +| BT | W1: any `meta` entry without the Keychain lane reads as holding the credential | `credentialFile.test.ts` | exit 1, 2 failed: "leaves a meta entry in any other shape to the CLI on win32", "… on linux" | +| BU | W2: a version-1 file holding the credential is settled as signed in on macOS | `credentialFile.test.ts`, `cliAccount.test.ts` | exit 1, 2 failed: "asks the CLI about a browser sign-in file on macOS, only on a user action", "leaves a file holding the credential to the CLI on macOS" | +| BV | W2: an empty version-2 file on macOS is settled as signed out | `credentialFile.test.ts`, `cliAccount.test.ts` | exit 1, 2 failed: "asks the CLI about an empty version-2 file on macOS, only on a user action", "reads a macOS Keychain pointer as needing the CLI on macOS" | +| BW | W3: Linux read as starting with a version-2 file (the verdict untied from the Linux capture) | `credentialFile.test.ts` | exit 1, 1 failed: "names a macOS file Muse Code cannot start with on linux" | +| BX | W4: the log line carries the unsupported-file message, credential path and all | `authService.test.ts` | exit 1, 1 failed: "logs no credential path for that state" | +| BY | W5: the fake CLI starts with a schema Muse Code exits 3 on | e2e | exit 1, 1 failed: "answers unknown when the host exits at startup" | +| BZ | W5: the fake CLI answers `accountLogin` for the `muse auth set` file | e2e | exit 1, 1 failed: "answers account/read about the muse auth set file as captured" | + +Drills CA to CR cover Codex's review of `886af682` and the sibling sweep +(below). They ran the same way, on the final tree, from +`scratchpad/cred-capture/drills4.mjs` (SHA-256 `35BBAB1F…C661667B`; +`drills4-result.json`, `drills4.log`). Each target's SHA-256 matched its +pre-drill value after every drill and after all eighteen: + +| File | SHA-256 (start) | +| --------------------------- | --------------- | +| `cliAccount.ts` | `139b3b77…` | +| `authService.ts` | `103e3310…` | +| `deviceSignIn.ts` | `76408a95…` | +| `logging.ts` | `7e475991…` | +| `accountHost.ts` | `b99cd09b…` | +| `logText.ts` | `ac2b096a…` | +| `museCodeBackendManager.ts` | `dc91fb30…` | +| `skillsCommands.ts` | `4b5096bf…` | +| `MuseCodeHost.ts` | `b0dc0c3b…` | + +"e2e (chat)" is `test/e2e/museCode.e2e.test.ts`. + +| Drill | What was broken | Suites | Result | +| ----- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| CA | P2-1: an abandoned probe gives its late answer to the caller that started it | `cliAccount.test.ts` | exit 1, 2 failed; first "asks afresh after an unanswered probe is abandoned, and gives its late answer to no one" | +| CB | P2-1 sibling: a caller that joined an abandoned probe gets its late answer | `cliAccount.test.ts` | exit 1, 1 failed: "gives the callers of a forgotten probe the newer answer, the one that joined it too" | +| CC | P2-1 sibling: an older refresh publishes over a newer one (no ticket) | `authService.test.ts` | exit 1, 1 failed: "never publishes an older refresh over a newer one" | +| CD | P2-1 sibling: a refresh while the device flow waits publishes over its code | `authService.test.ts` | exit 1, 1 failed: "keeps the device code on screen through a refresh while the flow waits" | +| CE | P2-2: a signed-out answer about a write is not remembered | `deviceSignIn.test.ts` | exit 1, 2 failed: "keeps the signed-out answer about a write when the host then exits", "… when account/read then cannot say" | +| CF | P2-2: a host exit counts any file change as a sign-in, the refuted write included | `deviceSignIn.test.ts` | exit 1, 1 failed: "keeps the signed-out answer about a write when the host then exits" | +| CG | P2-2 sibling: an unanswered `account/read` falls back to a file change an answer refuted | `deviceSignIn.test.ts` | exit 1, 1 failed: "keeps the signed-out answer about a write when account/read then cannot say" | +| CH | P2-3: the ending is logged with what the CLI sent, its message included | `deviceSignIn.test.ts` | exit 1, 7 failed; first "ends at once on the captured expired ending, logs it in fixed words, and sends no loginCancel" | +| CI | P2-3 sibling: an uncovered ending word is logged whatever its shape | `deviceSignIn.test.ts` | exit 1, 1 failed: "logs an uncovered ending word only in the shape of one" | +| CJ | P2-3 sibling: any text passes as a protocol word | `logText`, `cliAccount`, `accountHost`, `authService`, `deviceSignIn` tests | exit 1, 6 failed; first "logs an uncovered ending word only in the shape of one" | +| CK | P2-3 sibling: `CliAccount` logs the `account/read` state as sent | `cliAccount.test.ts` | exit 1, 1 failed: "logs the CLI’s state only in the shape of a protocol word" | +| CL | P2-3 sibling: an unconfirmed `account/logout` logs the state as sent | `accountHost.test.ts` | exit 1, 1 failed: "logs an unconfirming state only in the shape of a protocol word" | +| CM | P2-3 sibling: `markAuthRequired` logs the backend’s reason as sent | `authService.test.ts` | exit 1, 1 failed: "logs an authRequired reason only in the shape of a protocol word" | +| CN | P2-3 sibling: an MSP failure is logged by its message | `logText.test.ts`, `MuseCodeHost.test.ts` | exit 1, 2 failed: "names an MSP error by its kind and code, never its message", "logs a retried refusal and traces how long each command took" | +| CO | P2-3 sibling: a stderr line no capture covers is logged as written | `logText.test.ts`, `skillsCommands.test.ts`, e2e (chat) | exit 1, 4 failed; first "logs any other line by its length alone, one entry per line" | +| CP | P2-3 sibling: the backend manager logs `muse serve` stderr as written | e2e (chat) | exit 1, 2 failed: "reports a host that dies mid-turn and spawns a fresh one afterwards (drill)", "rejects when the binary will not start (drill)" | +| CQ | P2-3 sibling: a failed `muse skills` command logs its stderr as written | `skillsCommands.test.ts` | exit 1, 1 failed: "says so when the CLI is missing, the list fails or cannot be read" | +| CR | P2-3 sibling: a retried MSP refusal logs the CLI’s message | `MuseCodeHost.test.ts` | exit 1, 1 failed: "logs a retried refusal and traces how long each command took" | + +Drills CS to CY cover Codex's review of `1ae3604f` (below). They ran the +same way, on the final tree, from `scratchpad/cred-capture/drills5.mjs` +(`drills5-result.json`, `drills5.log`), all in `authService.test.ts`; +`authService.ts` matched its pre-drill SHA-256 after every drill. + +| Drill | What was broken | Result | +| ----- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| CS | The one helper publishes a switch without ending the running backend first | exit 1, 8 failed; first "ends the Model API conversation when a Cancel still lands a CLI sign-in" | +| CT | A refresh (Cancel, a failed sign-in, Check again, install) publishes past the helper | exit 1, 6 failed; first the same | +| CU | Key activation publishes past the helper | exit 1, 1 failed: "ends the Muse Code conversation when a pasted key moves conversations to the Model API" | +| CV | A failed install publishes past the helper | exit 1, 1 failed: "ends the Model API conversation when a failed install finds the CLI signed in" | +| CW | A restart that ended every conversation leaves the old backend recorded as running | exit 1, 1 failed: "ends conversations once: a sign-out leaves none for the next sign-in to end" | +| CX | A switch opens no new admission generation | exit 1, 1 failed: "ends the Model API conversation when a Cancel still lands a CLI sign-in" | +| CY | The backend conversations run on is not recorded as states publish | exit 1, 8 failed; first "ends the Model API conversation when a Cancel still lands a CLI sign-in" | + +Drills CZ to DB cover the macOS-only CI failure on `1ae3604f` (below). +They ran the same way on this Windows machine, from +`scratchpad/cred-capture/drills6.mjs` (`drills6-result.json`, +`drills6.log`), and each target matched its pre-drill SHA-256 afterwards. + +| Drill | What was broken | Suites | Result | +| ----- | ------------------------------------------------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| CZ | The granted e2e expects `inline` from every OS's reading, as before (the CI failure) | e2e | exit 1, 1 failed: "signs in on the captured granted sequence", `AssertionError: expected 'unrecognized' to be 'inline'` (`repro-ci.mjs`), as in CI | +| DA | The per-OS table drifts: macOS expected to read the file as holding the credential | `credentialFile.test.ts`, `cliAccount.test.ts`, e2e | exit 1, 3 failed; first "signs in on the captured granted sequence" | +| DB | The macOS branch regresses: a version-1 file holding the credential settled on macOS | `credentialFile.test.ts`, `cliAccount.test.ts`, e2e | exit 1, 5 failed; first "signs in on the captured granted sequence" | + +Drills DC to DE cover Codex's review of `19e74b07` (below), from +`scratchpad/cred-capture/drills7.mjs` (`drills7-result.json`, +`drills7.log`); each target matched its pre-drill SHA-256 afterwards. + +| Drill | What was broken | Suites | Result | +| ----- | --------------------------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------- | +| DC | A restart that ends conversations clears the running backend whatever was published meanwhile | `authService.test.ts` | exit 1, 1 failed: "keeps the backend a newer refresh signed in on when an older restart ends" | +| DD | An older logout-hold write that fails late marks the hold unsaved | `authService.test.ts` | exit 1, 1 failed: "keeps the latest logout-hold write’s outcome when an older one fails late" | +| DE | A probe about an older file version stays current when a newer one starts | `cliAccount.test.ts` | exit 1, 1 failed: "keeps the newer file version’s answer when an older probe answers late" | + +Drills DF to DH cover Codex's review of `86d63652` (below), from +`scratchpad/cred-capture/drills8.mjs` (`drills8-result.json`, +`drills8.log`), all in `authService.test.ts`; `authService.ts` matched its +pre-drill SHA-256 afterwards. + +| Drill | What was broken | Result | +| ----- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| DF | A switch whose restart fails late publishes its failure over a newer state | exit 1, 2 failed: "lets a newer refresh stand when an older switch’s restart fails late", "lets a sign-out stand …" | +| DG | A finished sign-in whose restart fails late publishes its failure over a newer state | exit 1, 1 failed: "lets a newer refresh stand when a finished sign-in’s restart fails late" | +| DH | A failed install publishes what it asked over a newer state | exit 1, 1 failed: "lets a newer state stand when a failed install’s question answers late" | + +Drills DI to DK cover Codex's review of `55b9e24c` (below), from +`scratchpad/cred-capture/drills9.mjs` (`drills9-result.json`, +`drills9.log`), all in `authService.test.ts`; `authService.ts` matched its +pre-drill SHA-256 afterwards. + +| Drill | What was broken | Result | +| ----- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| DI | A Cancel after the code, the file unchanged, is taken as nothing having happened (no refresh) | exit 1, 2 failed: "asks the CLI afresh on a Cancel after the code, when the Keychain alone changed", "reports the Cancel when the CLI, asked afresh, is still signed out" | +| DJ | A Cancel after the code refreshes on the CLI’s answer from before the flow | exit 1, 2 failed: the same two | +| DK | A Cancel as the file changed reads it passively (estimated on macOS, not asked) | exit 1, 1 failed: "asks the CLI about a file rewritten as Cancel was pressed, on macOS too" | + +## Codex on `328efb52`: forced Model API, and a same-account re-sign-in + +- **P2, forced Model API.** With `museSpark.backend` set to `modelApi`, + both the gate's refresh and host admission still asked the CLI for its + sign-in. On an ambiguous file that can mean a probe and `account/read`, + keeping a user with a stored key in `checking` until the MSP deadline. + - Now `isCliSignInConsulted(setting)` in `src/core/backendSelection.ts` + says the choice needs no CLI answer. `AuthService.choose` and + `readBackendChoice`, which host admission in `extension.ts` now uses, + ask nothing; the snapshot reports `hasCliSession: false`. + - The logout hold's checks after a sign-out still look at the CLI, as a + sign-out ends every credential. + - Drills DN (the gate) and DO (host admission). +- **P2, same-account re-sign-in.** With the logout hold keeping an + `accountLogin`, a macOS re-sign-in may replace only the Keychain item, + the pointer's mtime unchanged. The first and the later `account/read` + both said `accountLogin`, so after `granted` the flow waited until its + backstop and never lifted the hold. + - The captured success, `granted` borne out by `accountLogin`, now + counts whatever came before; `granted` alone still never does. + - Drill DP. +- **Drills** from `scratchpad/cred-capture/drills11.mjs` (each target + matched its SHA-256 afterwards): + - DN: exit 1, 1 failed: "selects from the stored key without asking the + CLI". + - DO: exit 1, 1 failed: "never asks for the CLI’s sign-in when the + backend is forced to the Model API". + - DP: exit 1, 1 failed: "signs in on granted and accountLogin when the + account was already signed in". +- **Checks.** `backendSelection`, `authService`, `deviceSignIn`, + `cliAccount` and the `cliAccount` e2e (207 tests), typecheck, lint, + l10n, jscpd and format; not the full gate (the machine was loaded). + +## Codex on `2a324d48`: a user action on macOS asks afresh + +**P2.** On macOS a sign-in or sign-out made elsewhere may change only the +Keychain, leaving the file's path, size and mtime as they were. +`CliAccount.confirm` reused a remembered definitive answer even for a +user action, so Diagnostics (and any other click) could report a stale +sign-in. + +- **Fixed in the class, not the call site.** On macOS a user action now + reuses a remembered answer only if it came from the same click: + younger than `MUSE_USER_ACTION_ANSWER_REUSE_MS`, 5 s. Otherwise it asks + `account/read` afresh, bounded as before. "Never reuse" was taken + literally at first and then bounded this way: a sign-out asks up to + three times and a refresh with the hold on four, and each question may + bring a Keychain prompt. Off macOS the file speaks for the sign-in, as + before. The answer's age is read on an injectable clock (`now`). +- **Every user action takes that path.** Diagnostics + (`cliAccount.signIn(true)`), Check again, Cancel, the sign-in button + (with the hold on, and after the code), sign-out and the Sign Out + command all ask with `isUserAction`. The browser sign-in's pre-check + stays passive on purpose: it only looks for a file the host cannot + start with, and must not prompt before the code is shown. +- **Drills.** From `scratchpad/cred-capture/drills10.mjs`, and each + target matched its SHA-256 afterwards: + - DL: macOS reuses any remembered answer. Exit 1, 1 failed: "on darwin, + asks a later user action afresh only on macOS, the file unchanged". + - DM: every question of one click asks again. Exit 1, 2 failed: the + same, and "asks about a macOS Keychain pointer only on a user action". +- **Checks.** `cliAccount`, `authService`, `supportReport` and the + `cliAccount` e2e (153 tests), typecheck, lint, l10n, jscpd and format; + not the full gate (the machine was loaded). + +## Codex on `55b9e24c`: a Cancel after a Keychain-only approval + +**P2.** On macOS an approval may update only the Keychain, the pointer +file's mtime unchanged. If Cancel won before the device host's next +`account/read`, `finishCancelledCliSignIn` reported `signedOut` and kept +the CLI's answer from before the flow, although the login had landed. + +- **Fixed.** A Cancel after the code was shown (`flow.isCodeShown`, set as + the code is published), with the file unchanged, forgets the CLI's + answers. `finishFailedCliSignIn` then shows the cancellation at once and + refreshes as a user action. On macOS that asks `account/read` afresh, a + question bounded by the account host's deadline and by the sign-out + signal. The refresh publishes through `publishSelection`'s guards, with + "Sign-in cancelled." as a notice (DI, DJ). A Cancel before any code + still ends as it did, with no question. +- **Sibling.** When the file had changed as Cancel was pressed, the + refresh was passive, so on macOS the new file was estimated (`unknown`), + not asked. A Cancel is a click, so it asks now (DK). +- **Swept, left as they were.** Other places where an unchanged file may + hide a Keychain change: + - the device flow's polls ask `account/read` on the same host (with no + first answer, `granted` counts); + - a sign-out, a completed sign-in and Check again forget the CLI's + answers; + - a passive refresh keeps the cached answer by design: on macOS the CLI + is asked only on a user action. +- **Checks run, and not the full gate** (the machine was still loaded): + `authService`, `cliAccount`, `conversationController` and the + `cliAccount` e2e (389 tests), typecheck, lint, l10n (0 problems), jscpd + (0 clones) and format all passed. The full gate is CI's three-OS run on + the pushed commit. + +## Codex on `86d63652`: error paths after an await + +**P2.** When `publishSelection`'s restart rejected late, its catch branch +published `signOutStopFailed` with a new ticket. That skipped the +`ticket < publishedTicket` and `isCurrent()` guards the success path uses, +so a stale attempt overwrote a newer state (a newer refresh, or a +sign-out) and closed admission. The previous sweep had covered success +paths only. + +- **Fixed.** The failure is published under the same guards, with the + refresh's own ticket (DF: a newer refresh and a sign-out). +- **Re-sweep: every catch, finally and error path after an await in + `AuthService`, `CliAccount`, `runDeviceSignIn` and `accountHost`.** + - **`confirmCliSignIn`.** A finished sign-in's restart that rejected + reached `finishFailedCliSignIn` and published `signInFailed` with a + fresh ticket, over a refresh published meanwhile. The restart is now + guarded: if anything was published after the flow's last own + publication, the newer state stands (DG). + - **`installFailed`**, the install's error path. It published the + selection it awaited even when a newer state was published while it + asked. It now takes a ticket before asking (DH). +- **Checked, left as they were.** + - `setLogoutHold`'s catch is guarded (DD). + - `stopBackendForSignOut`, `performSignOut`'s key-clear catch and + `logOutCli`'s terminal catch publish nothing; the one sign-out that + owns them publishes its own result. + - The `finally` blocks (`joinDeviceSignIn`, `awaitDeviceRunner`, + `signInWithCli`, `checkAgain`, `installMuseCode`, `signOut`, + `performSignOut`) reset single-writer or identity-guarded fields. + - `signInWithCli`'s catch for a failure before `confirmCliSignIn` runs + while the flow owns the panel, where no refresh publishes; a sign-out + is guarded by `isSigningOut`. + - `CliAccount.confirm`'s `finally` is identity-guarded, and a rejected + probe writes nothing. + - `runDeviceSignIn`'s error paths (`connect`, `cancelLogin`, `hostGone`, + `finally` closing its own session) touch only its own locals. + - `accountHost`'s catches (`onAccountHost`, `requestAccount`, + `connectAccountSession`) log and return a fallback, and share no + state. +- **Checks run, and not the full gate.** The machine was still loaded by + other worktrees' gates, so, as the coordinator asked, only the touched + suite (`authService.test.ts`, 103 tests), `npm run typecheck`, + `npm run lint`, `npm run check:l10n` (0 problems), + `npm run duplication` (0 clones) and `npm run format:check` ran. All + passed. The full gate is CI's three-OS run on the pushed commit. + +## Codex on `19e74b07`: shared state written after an await + +**P2.** `restartHosts(true)` cleared `liveBackend` after awaiting the +restart. A newer refresh could publish a signed-in state during that await +and record its backend; the late clear erased it, so a later switch +bypassed `isBackendSwitch` and left that backend's conversations running. + +- **Fixed with a guard.** Clearing before the await would leave + `liveBackend` empty after a failed restart, while conversations still + run. Instead, `set()` moves `liveVersion` each time it records a + backend; `restartHosts` captures it before the await and clears only if + it is unchanged (DC). +- **Siblings: every field in `AuthService`, `CliAccount` and + `runDeviceSignIn` written after an await.** + - `isLogoutPersistenceFailed`, set when a logout-hold write settles. An + older write that failed late marked the hold unsaved after newer + writes were saved, which shut admission. Only the latest write's + outcome is kept now (`holdWrites`; DD). + - `CliAccount.answered`. A probe about an older version of the file was + not abandoned when a probe for the newer version started (round 5's + abandon flag replaced the identity check), so its late answer could + overwrite the newer one. Starting a probe now abandons the one it + replaces (DE). +- **Checked, left as they were.** These are single-writer by construction: + - `deviceSignIn`, `installPromise` and `checkingAgain` are cleared after + their await, but every other caller joins the running promise, so none + writes meanwhile; + - `deviceAbort` and `isDeviceFlowWaiting` belong to the one device flow + `joinDeviceSignIn` allows; + - `isSigningOut` and the epoch's move at the end belong to the one + sign-out `signOut` joins; + - `signOutPromise` and `CliAccount.asking` are cleared only if still + theirs. + + `admissionGenerationValue` and `signOutEpoch` only increase, so no write + is lost. `snapshot` goes through the tickets and epochs, and + `runDeviceSignIn`'s `refutedWrite` is written by its one polling loop. + +- **The gate did not pass locally.** `npm run quality` ran four times on + the working tree (Windows 11, 2026-09-28, after drills DC to DE), and + each run **exited 1**. No failure was in a suite this pass touched. The + machine was loaded by other worktrees' gates (m67, m68 and m69, running + since about 03:00 with accessibility runs that looked hung, and m79's): + about 90 Chrome and 38 Node processes. Nobody could stop them. + - Run 1: `test:a11y`, two pages (`dark/tools`, `dark/approval`) without + a result after Chrome's network service crashed; 0 rules violated. + The unit tests passed (2,717). + - Run 2: `toolIo.test.ts` "kills a hook that exceeds its per-stream + output limit" (`isTimedOut` true). Run alone, it also failed once in + four. + - Run 3: that test again, and `mcpProcess.test.ts` "receives a real + server’s final response before its immediate exit closes stdio". + - Run 4 (after waiting 40 minutes for the other gates): 12 tests in + `processTree`, `mcpPool`, `mcpProcess`, `toolIo`, `shellQuote` and + `worktreeCommands`; 2,705 passed. + + What passed: the touched suites (`authService`, `cliAccount` and the + `cliAccount` e2e, 139 tests), `npm run typecheck`, `npm run lint`, + `npm run check:l10n` (0 problems), `npm run duplication` (0 clones) and + `npm run format:check`. The full gate for this commit is CI's three-OS + run on the pushed commit. + +## macOS CI on `1ae3604f`: expectations per OS + +CI failed on `macos-latest` only. Two e2e tests read a real credential file +on the host OS and expected `inline`: + +- `cliAccount.e2e.test.ts` "signs in on the captured granted sequence"; +- `museCode.e2e.test.ts` "spawns the configured binary … and sees the + credential file". + +On macOS that file is `unrecognized` since W2: no version-1 file holding +the credential was captured there, so the CLI is asked. The code was +right; the tests hard-coded the Windows and Linux reading. + +- **The table.** `capturedInlineVerdict(platform)` in + `test/unit/helpers/credentialShapes.ts` gives the verdict the captured + inline shapes get on each OS. `credentialFile.test.ts` pins it for all + three OSes against the read, so a wrong macOS expectation fails on any + runner. +- **The tests.** + - The granted e2e now reads the file the sign-in left as each OS reads + it (`SHIPPED_PLATFORMS`), so every runner checks macOS's branch. + - The chat e2e expects the host OS's entry. + - `cliAccount.test.ts` reads a real file as each OS does. +- **Reproduced here.** Drill CZ puts the old expectation back and fails on + Windows with CI's message (`repro-ci.mjs`). The Mac mini was not needed: + the branch is keyed on the platform argument, which the tests pass. +- **The gate.** `npm run quality` on the working tree (Windows 11, + 2026-09-28, after drills CZ to DB) exited 0 on its first run: + - 180 files passed and 2 skipped; 2,714 tests passed and 23 skipped; + statements 94.56 %; + - `check:l10n` 0 problems; jscpd 0 clones; + - `dist/extension.js` 442.6 KiB of 600; + - a11y 0 rules violated; audit 0 advisories; + - gitleaks no leaks; Semgrep 0 findings. + + macOS itself runs in CI after the push. + +## Codex on `1ae3604f`: one way to switch backends + +**P2.** With the Model API signed in, a Cancel pressed just after the +browser approved let the file decide: its refresh published Muse Code +signed in without a new admission generation or a restart, so the Model +API conversation kept running after the panel switched. + +- **Fixed.** One helper publishes every state that could sign in on + another backend than conversations run on: + `AuthService.publishSelection`. It opens a new admission generation, + shows `checking`, ends the running backend's conversations + (`restartBackend(true)`) and then publishes, if nothing newer has. A + stop that fails is `error` (`signOutStopFailed`). +- **Where it runs.** Every refresh goes through it (via `publishRefresh`), + and so do the paths that publish through a refresh: + - Check again; + - a Cancel that still landed a sign-in; + - a failed or timed-out sign-in with the hold on or a Model API session; + - a device sign-in's confirmation; + - CLI discovery after an install. Its own copy of this logic is gone; + - a changed `museSpark.backend`, whose refresh (after the extension's + own restart that keeps conversations) now ends the old backend's + conversations when the backend changes. + + So do the two paths that published a selection directly: key activation + and a failed install. + +- **Which backend is running.** `liveBackend` is the backend of the last + signed-in state published, and any restart that ends conversations + clears it (`restartHosts`). That covers a sign-out and a device + sign-in's own restart, so no conversation is ended twice. +- **Checked, left as they were.** These never publish a signed-in state, + so they cannot switch backends: the sign-out and a refresh during it + (`error`), Cancel's own state, the window closing, `markAuthRequired`, + `markBackendError`, and the device flow's code. The installer's + "keep the Model API signed in" state keeps the same backend. +- **Tests.** `authService.test.ts` "a switch of backends ends the running + one’s conversations first" (six cases), and the installer auto-switch + tests that already existed. + +## Codex on `886af682`, and the sibling sweep + +Codex reviewed `886af682` on PR #49 and raised three P2s. Each was fixed, +and every sibling of its class across `src/host/auth` and +`src/core/backends/musecode` (and the two places that log Muse Code's +stderr, `museCodeBackendManager.ts` and `skillsCommands.ts`) was fixed in +the same pass. + +**P2-1, stale answers.** `forgetAnswers()` and `abandonProbe()` cleared +`this.asking`, but the abandoned promise still returned its answer to +whoever awaited it, so a passive refresh could publish an older answer +after Check again's newer one. + +- **Fixed.** A probe carries `isAbandoned`; its starter and everyone who + joined it get `OBSOLETE` instead of the answer and look again, which + finds the newer remembered answer or joins the newer probe (bounded by + `MUSE_CREDENTIAL_READ_ATTEMPTS`). Test: `authService.test.ts` "publishes + no answer from a probe Check again left behind" (the finding's own + sequence over a real file), `cliAccount.test.ts`. +- **Siblings.** + - The joiners of an abandoned probe got its answer too (CB). + - Any two refreshes could publish out of order, for example a refresh + that read SecretStorage before a key was pasted. Every refresh now + takes a ticket as it starts and publishes only if nothing newer has + (CC). + - A refresh while the browser sign-in waited (a setting change) wiped + its code off the panel. The flow now owns the panel until the device + runner returns (CD). +- **Checked, left as they were.** `activateApiKey`, the install path and + `signIn` check the sign-out epoch after each await. `confirmCliSignIn` + races the flow's signal, and concurrent sign-outs join one. The device + flow reads the file before each `account/read`, so an answer is never + matched to a later write. +- **A cost of the fix.** A probe Cancel abandoned while the sign-in's + pre-check waited on it makes that orphaned caller look again, which can + start one more short-lived account host. None starts once the window + has closed, and the three read attempts bound it. + +**P2-2, a host exit read as success.** If a poll saw `loggedOut` for a +write another Muse process made and the host then exited, R7's exit +branch turned that same write into `signedIn`. + +- **Fixed.** A signed-out answer records the file's modification time as + a refuted write (read before the question, so an answer refutes only a + write it saw). Only a write no answer refuted counts. +- **Sibling.** A later `account/read` that could not answer (the host + going away rejects it) fell back to the same refuted write. It now uses + the same rule (CG). +- **Checked, left as they were.** `probeAccount` and `logOutAccount` read + a closed host as `unknown` or `unconfirmed`, never success. A Cancel + after a file change lets the file's structure decide. `stoppedEarly` + (an exit before the code) fails. `connectAccountSession` throws. + +**P2-3, free text in the log.** For `expired` and `denied` the outcome was +allowlisted, but the CLI's message was logged as sent: `clipForLog` only +shortens, and the redactor catches only key-shaped strings. + +- **Fixed.** Each captured ending is logged in fixed words, and the + schema no longer keeps the message. +- **Siblings,** with helpers `wireWordForLog` (`src/core/logging.ts`) and + `failureForLog` and `stderrForLog` + (`src/core/backends/musecode/logText.ts`): + - an uncovered ending word (CI); + - the `account/read` state in `CliAccount` (CK) and in `logOutAccount` + (CL); + - `markAuthRequired`'s reason (CM); + - the MSP error messages in three `MuseCodeHost` logs (a retried + refusal, `task/stopAll`, `approval/listPending`; CN, CR); + - `muse serve` stderr and its two close errors in the backend manager + (CO, CP); + - `muse skills` stderr (CQ). + + Stderr lines 1.4.0-R4302.1 was captured writing (an unsupported schema + version, an unreadable Keychain item, a failed model-catalog fetch) are + named in fixed words. Any other line is named by its length alone, so + the log no longer shows an unrecognized CLI error; the panel still shows + what it showed. + +- **Checked, left as they were.** + - `describeExit` is fixed words and an exit code. + - `MuseCodeHost`'s own dispatch exceptions (`String(error)`) come from + the extension's own code. + - The SDK's protocol-error text is its own, and logged by kind. + - `accountHost` logs an error's name only. + - The backend manager's spawn line (the CLI path the extension resolved) + and its `museHome` (a structured path the CLI reports) are M39's + deliberate facts, not free text. Both name the user's profile folder, + which is the owner's call. + +This pass added no UI string. + +## Not proved here + +A real sign-in is the owner's to give, and each of these needs one: + +- **A 1.4.0 macOS device login.** It should write the Keychain item and a + schema-2 pointer. The pointer's mtime should change on a same-account + re-login, which is the device flow's third signal. +- **Keychain prompts.** Whether one appears after a CLI update (a new + binary path), and what `account/read` says with the item present but the + Keychain locked (Remote-SSH into a Mac). +- **Linux R4302.1.** Whether a device-code login persists to the file as + it does on Windows. +- **macOS 1.4.0 and a version-1 or empty version-2 file.** Whether it + reads or migrates a version-1 file holding the credential, and what it + does with an empty version-2 file. The extension asks the CLI about both + there. +- **macOS and `META_API_KEY`.** The files the key was seen to rescue were + probed on Windows and Linux only. + +Captured since the first version of this list, on Windows R4302.1: the +success sequence (`granted` against `account/read`, `account/changed` and +the file), a declined code, a failed save, and the logout of an OAuth +login slot, which left the same empty file and logged +`credential.revoke` with outcome `revoked` (above). On Linux R4302.1 +(review round 4): `muse serve` refuses a version-2 file there as it does +on Windows, and a version-1 Keychain lane too, and starts with each while +`META_API_KEY` is set. + +## The gate + +Run on the working tree (Windows 11, 2026-09-27), before the commit: + +- `npm run test:unit` (after the size trims below): 177 files passed and 2 + skipped; 2,583 tests passed and 23 skipped; coverage thresholds met. +- `npm run typecheck`, `npm run lint`, `npm run format:check`, + `npm run check:l10n` (14 tables, 0 problems), `knip`, `npm run cycles` + and `npm run duplication` (0 clones): all exit 0. +- `npm run build`: **fails the D6 host budget**. `dist/extension.js` is + 602.7 KiB against 600 KiB; `main` was 596.8 KiB. The fix adds about + 6.0 KB minified: + - the account host helpers, about 2.1 KB; + - the CLI account check, about 1.6 KB; + - the AuthService changes, about 0.9 KB; + - the structural read, about 0.5 KB; + - four strings, about 0.5 KB; + - the constants, about 0.4 KB. + + Sharing one host lifecycle between the probe and the logout, a lookup + in place of a switch, one verdict accessor and plain words in + Diagnostics already took 0.85 KB off. The budget was not raised (AGENTS.md + rule 2): raising it, or a size cut elsewhere in the host bundle, is the + owner's decision. + +After the fix was joined with M57 (the Model API backend in its own bundle, +`dist/extension.js` down to about 425 KiB) and M58, `npm run quality` on +`2a4f0db` exited 0: + +- `check:l10n` 14 tables, 0 problems; jscpd 0 clones; +- vitest: 179 files passed and 2 skipped; 2,605 tests passed and 23 + skipped; statements 94.47 %; +- build: `dist/extension.js` 434.9 KiB of 600, `dist/modelApi.js` 297.2 KiB + of 400, the bundle-split check passed; +- a11y: 336 pages, 0 rules violated; +- gitleaks: no leaks; Semgrep: 287 rules on 416 files, 0 findings. + +The budget question above is settled by M57; no budget was raised. + +With the PR #49 review fixes, `npm run quality` on the working tree +(Windows 11, 2026-09-27, before the commit) exited 0: + +- `check:l10n` 14 tables, 0 problems; knip and dpdm clean; jscpd 0 clones; + PSScriptAnalyzer 0 findings; +- vitest: 179 files passed and 2 skipped; 2,619 tests passed and 23 + skipped; statements 94.47 %; +- build: `dist/extension.js` 434.8 KiB of 600, `dist/modelApi.js` + 297.1 KiB of 400; +- a11y: 336 pages, 0 rules violated; audit 0 advisories; +- gitleaks: no leaks; Semgrep: 287 rules on 416 files, 0 findings. + +gitleaks and Semgrep read tracked files only. The two new fixtures and +their helper were scanned on their own first (`gitleaks dir`: no leaks), +and again after the commit. + +With the third review round's fixes, `npm run quality` on the working tree +(Windows 11, 2026-09-27, after the drills, before the commit) exited 0: + +- `check:l10n` 14 tables, 93 manifest strings, 0 problems; knip and dpdm + clean; jscpd 0 clones; PSScriptAnalyzer 0 findings; +- vitest: 179 files passed and 2 skipped; 2,659 tests passed and 23 + skipped; statements 94.48 %; +- build: `dist/extension.js` 438.2 KiB of 600, `dist/modelApi.js` + 297.2 KiB of 400, the bundle-split check passed; +- a11y: 336 pages, 0 rules violated; audit 0 advisories; +- gitleaks: no leaks; Semgrep: 287 rules on 416 files, 0 findings. + +The three new fixtures (`account-login-granted.json`, `-denied.json`, +`-failed.json`) and `test/unit/helpers/credentialShapes.ts` were untracked +during that run, so they were scanned on their own (`gitleaks dir`: no +leaks). The label and avatar address in the granted fixture are the +stand-ins `someone@example.com` and `https://example.com/avatar.png`. + +With the fourth round's fixes, `npm run quality` on the working tree +(Windows 11, 2026-09-27, after drills BK to BZ, before the commit) exited +0: + +- format, lint (PSScriptAnalyzer 0 findings), all five typechecks; + `check:l10n` 14 tables, 93 manifest strings, 0 problems; knip and dpdm + clean; jscpd 0 clones; +- vitest: 179 files passed and 2 skipped; 2,685 tests passed and 23 + skipped; statements 94.53 %; +- build: `dist/extension.js` 440.3 KiB of 600, `dist/modelApi.js` + 297.2 KiB of 400, the bundle-split check passed; +- a11y: 336 pages, 0 rules violated; audit 0 advisories; +- gitleaks: no leaks; Semgrep: 287 rules on 416 files, 0 findings. + +`test/e2e/` on its own: 2 files passed (the 14 `cliAccount.e2e.test.ts` +tests among them) and the 2 opt-in live files skipped. This round added no +fixture; the probe outputs stay in the scratchpad. + +With Codex's review of `886af682` settled, `npm run quality` on the working +tree (Windows 11, 2026-09-28, after drills CA to CR, before the commit): + +- **First run: exit 1.** One test in a suite this pass did not touch, + `dictationHost.test.ts` "gives the helper its environment on top of the + host’s (M26)", hit vitest's 5 s timeout under the full parallel load. + Run alone three times, the suite passed each time (12 of 12, about + 3.5 s). +- **Second run: exit 0.** + - format, lint (PSScriptAnalyzer 0 findings), all five typechecks; + `check:l10n` 14 tables, 93 manifest strings, 0 problems; knip and dpdm + clean; jscpd 0 clones; + - vitest: 180 files passed and 2 skipped; 2,704 tests passed and 23 + skipped; statements 94.54 %; + - build: `dist/extension.js` 442.0 KiB of 600, `dist/modelApi.js` + 297.2 KiB of 400, the bundle-split check passed; + - a11y: 336 pages, 0 rules violated; audit 0 advisories; + - gitleaks: no leaks; Semgrep: 287 rules on 417 files, 0 findings. + +`logText.ts` and `logText.test.ts` were untracked during that run; they +hold no secret (synthetic paths and addresses only). + +With the backend switch settled (Codex on `1ae3604f`), `npm run quality` +on the working tree (Windows 11, 2026-09-28, after drills CS to CY, before +the commit) exited 0 on its first run: + +- format, lint (PSScriptAnalyzer 0 findings), all five typechecks; + `check:l10n` 14 tables, 0 problems; knip and dpdm clean; jscpd 0 clones; +- vitest: 180 files passed and 2 skipped; 2,710 tests passed and 23 + skipped; statements 94.56 %; +- build: `dist/extension.js` 442.6 KiB of 600, `dist/modelApi.js` + 297.2 KiB of 400, the bundle-split check passed; +- a11y: 336 pages, 0 rules violated; audit 0 advisories; +- gitleaks: no leaks; Semgrep: 287 rules on 417 files, 0 findings. diff --git a/l10n/ui.cs.json b/l10n/ui.cs.json index b6d7af09..d299c186 100644 --- a/l10n/ui.cs.json +++ b/l10n/ui.cs.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Klíč Model API začíná LLM_ (starší klíče mají tvar LLM|<číselné id>|).", "signInWaiting": "Čeká se na dokončení přihlášení v prohlížeči…", "signInTimedOut": "Přihlášení nebylo dokončeno včas. Zkuste to znovu.", + "signInExpired": "Platnost kódu vypršela dříve, než byl schválen. Přihlaste se znovu a získejte nový kód.", + "signInDenied": "Přihlášení jste v prohlížeči zamítli.", + "signInSaveFailed": "Muse Code se přihlásil, ale nepodařilo se mu uložit přihlašovací údaje.", + "signInEnded": "Přihlášení skončilo: {outcome}. Přihlaste se znovu a získejte nový kód.", + "cliCredentialUnsupported": "Muse Code nelze spustit: jeho soubor přihlášení {path} je ve formátu pro macOS, který Muse Code v tomto systému nedokáže přečíst. Přesuňte nebo přejmenujte tento soubor a pak se přihlaste znovu.", "hostExited": "Muse Code se neočekávaně zastavil", "hostStarting": "Spouští se Muse Code…", "hostRestartsOnSend": "Další zpráva ho znovu spustí a v této konverzaci se bude pokračovat.", diff --git a/l10n/ui.de.json b/l10n/ui.de.json index c311beee..15d6b898 100644 --- a/l10n/ui.de.json +++ b/l10n/ui.de.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Ein Model-API-Schlüssel beginnt mit LLM_ (ältere Schlüssel haben die Form LLM||).", "signInWaiting": "Warten auf den Abschluss der Anmeldung im Browser…", "signInTimedOut": "Die Anmeldung wurde nicht rechtzeitig abgeschlossen. Versuchen Sie es erneut.", + "signInExpired": "Der Code ist abgelaufen, bevor er bestätigt wurde. Melden Sie sich erneut an, um einen neuen Code zu erhalten.", + "signInDenied": "Sie haben die Anmeldung im Browser abgelehnt.", + "signInSaveFailed": "Muse Code hat sich angemeldet, konnte die Anmeldedaten aber nicht speichern.", + "signInEnded": "Anmeldung beendet: {outcome}. Melden Sie sich erneut an, um einen neuen Code zu erhalten.", + "cliCredentialUnsupported": "Muse Code kann nicht starten: Seine Anmeldedatei {path} liegt im macOS-Format vor, das Muse Code auf diesem System nicht lesen kann. Verschieben Sie diese Datei oder benennen Sie sie um, und melden Sie sich dann erneut an.", "hostExited": "Muse Code wurde unerwartet beendet", "hostStarting": "Muse Code wird gestartet…", "hostRestartsOnSend": "Die nächste Nachricht startet es neu und setzt diese Unterhaltung fort.", diff --git a/l10n/ui.es.json b/l10n/ui.es.json index 94004b00..3e600b67 100644 --- a/l10n/ui.es.json +++ b/l10n/ui.es.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Una clave de Model API empieza por LLM_ (las claves antiguas tienen la forma LLM||).", "signInWaiting": "Esperando a que termine el inicio de sesión en el explorador…", "signInTimedOut": "El inicio de sesión no se completó a tiempo. Vuelva a intentarlo.", + "signInExpired": "El código caducó antes de aprobarse. Vuelva a iniciar sesión para obtener un código nuevo.", + "signInDenied": "Rechazó el inicio de sesión en el navegador.", + "signInSaveFailed": "Muse Code inició sesión, pero no pudo guardar la credencial.", + "signInEnded": "El inicio de sesión terminó: {outcome}. Vuelva a iniciar sesión para obtener un código nuevo.", + "cliCredentialUnsupported": "Muse Code no puede iniciarse: su archivo de inicio de sesión {path} está en el formato de macOS, que Muse Code no puede leer en este sistema. Mueva o cambie el nombre de ese archivo y vuelva a iniciar sesión.", "hostExited": "Muse Code se detuvo inesperadamente", "hostStarting": "Iniciando Muse Code…", "hostRestartsOnSend": "El siguiente mensaje lo reinicia y continúa esta conversación.", diff --git a/l10n/ui.fr.json b/l10n/ui.fr.json index 9a95f36f..bc173da1 100644 --- a/l10n/ui.fr.json +++ b/l10n/ui.fr.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Une clé Model API commence par LLM_ (les anciennes clés ont la forme LLM||).", "signInWaiting": "En attente de la fin de la connexion dans le navigateur…", "signInTimedOut": "La connexion n’a pas abouti à temps. Réessayez.", + "signInExpired": "Le code a expiré avant d’être approuvé. Reconnectez-vous pour obtenir un nouveau code.", + "signInDenied": "Vous avez refusé la connexion dans le navigateur.", + "signInSaveFailed": "Muse Code s’est connecté, mais n’a pas pu enregistrer les identifiants.", + "signInEnded": "Connexion terminée : {outcome}. Reconnectez-vous pour obtenir un nouveau code.", + "cliCredentialUnsupported": "Muse Code ne peut pas démarrer : son fichier de connexion {path} est au format macOS, que Muse Code ne peut pas lire sur ce système. Déplacez ou renommez ce fichier, puis reconnectez-vous.", "hostExited": "Muse Code s’est arrêté de manière inattendue", "hostStarting": "Démarrage de Muse Code…", "hostRestartsOnSend": "Le prochain message le redémarre et poursuit cette conversation.", diff --git a/l10n/ui.hu.json b/l10n/ui.hu.json index 1be5b44f..6e63b8e7 100644 --- a/l10n/ui.hu.json +++ b/l10n/ui.hu.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "A Model API-kulcs LLM_ előtaggal kezdődik (a régebbi kulcsok formátuma LLM||).", "signInWaiting": "Várakozás a böngészős bejelentkezés befejezésére…", "signInTimedOut": "A bejelentkezés nem fejeződött be időben. Próbálja újra.", + "signInExpired": "A kód lejárt, mielőtt jóváhagyták volna. Jelentkezzen be újra, hogy új kódot kapjon.", + "signInDenied": "A bejelentkezést elutasította a böngészőben.", + "signInSaveFailed": "A Muse Code bejelentkezett, de nem tudta menteni a hitelesítő adatot.", + "signInEnded": "A bejelentkezés véget ért: {outcome}. Jelentkezzen be újra, hogy új kódot kapjon.", + "cliCredentialUnsupported": "A Muse Code nem tud elindulni: a bejelentkezési fájlja ({path}) macOS-formátumú, amelyet a Muse Code ezen a rendszeren nem tud beolvasni. Helyezze át vagy nevezze át ezt a fájlt, majd jelentkezzen be újra.", "hostExited": "A Muse Code váratlanul leállt", "hostStarting": "A Muse Code indítása…", "hostRestartsOnSend": "A következő üzenet újraindítja, és folytatja ezt a beszélgetést.", diff --git a/l10n/ui.it.json b/l10n/ui.it.json index 5edc0779..a82d0f21 100644 --- a/l10n/ui.it.json +++ b/l10n/ui.it.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Una chiave Model API inizia con LLM_ (le chiavi meno recenti hanno il formato LLM||).", "signInWaiting": "In attesa del completamento dell’accesso nel browser…", "signInTimedOut": "L’accesso non è stato completato in tempo. Riprova.", + "signInExpired": "Il codice è scaduto prima di essere approvato. Accedi di nuovo per ottenere un nuovo codice.", + "signInDenied": "Hai rifiutato l’accesso nel browser.", + "signInSaveFailed": "Muse Code ha eseguito l’accesso, ma non è riuscito a salvare la credenziale.", + "signInEnded": "L’accesso si è concluso: {outcome}. Accedi di nuovo per ottenere un nuovo codice.", + "cliCredentialUnsupported": "Muse Code non può avviarsi: il suo file di accesso {path} è nel formato di macOS, che Muse Code non può leggere su questo sistema. Sposta o rinomina il file, quindi accedi di nuovo.", "hostExited": "Muse Code si è arrestato in modo imprevisto", "hostStarting": "Avvio di Muse Code…", "hostRestartsOnSend": "Il prossimo messaggio lo riavvia e continua questa conversazione.", diff --git a/l10n/ui.ja.json b/l10n/ui.ja.json index 2c31b34f..be334b47 100644 --- a/l10n/ui.ja.json +++ b/l10n/ui.ja.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Model API キーは LLM_ で始まります(古いキーの形式は LLM|| です)。", "signInWaiting": "ブラウザーでのサインインが完了するのを待っています…", "signInTimedOut": "サインインが時間内に完了しませんでした。もう一度お試しください。", + "signInExpired": "承認される前にコードの有効期限が切れました。新しいコードを取得するには、もう一度サインインしてください。", + "signInDenied": "ブラウザーでサインインを拒否しました。", + "signInSaveFailed": "Muse Code はサインインしましたが、資格情報を保存できませんでした。", + "signInEnded": "サインインが終了しました: {outcome}。新しいコードを取得するには、もう一度サインインしてください。", + "cliCredentialUnsupported": "Muse Code を起動できません。サインイン ファイル {path} は macOS 形式のため、このシステムの Muse Code では読み取れません。このファイルを移動するか名前を変更してから、もう一度サインインしてください。", "hostExited": "Muse Code が予期せず停止しました", "hostStarting": "Muse Code を起動しています…", "hostRestartsOnSend": "次のメッセージで再起動し、この会話を続行します。", diff --git a/l10n/ui.ko.json b/l10n/ui.ko.json index f537a212..76764297 100644 --- a/l10n/ui.ko.json +++ b/l10n/ui.ko.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Model API 키는 LLM_로 시작합니다(이전 키의 형식은 LLM||입니다).", "signInWaiting": "브라우저 로그인이 완료되기를 기다리는 중…", "signInTimedOut": "로그인이 제시간에 완료되지 않았습니다. 다시 시도하세요.", + "signInExpired": "승인되기 전에 코드가 만료되었습니다. 새 코드를 받으려면 다시 로그인하세요.", + "signInDenied": "브라우저에서 로그인을 거부했습니다.", + "signInSaveFailed": "Muse Code가 로그인했지만 자격 증명을 저장하지 못했습니다.", + "signInEnded": "로그인이 종료되었습니다: {outcome}. 새 코드를 받으려면 다시 로그인하세요.", + "cliCredentialUnsupported": "Muse Code를 시작할 수 없습니다. 로그인 파일 {path}이(가) macOS 형식이라 이 시스템의 Muse Code가 읽을 수 없습니다. 이 파일을 옮기거나 이름을 바꾼 뒤 다시 로그인하세요.", "hostExited": "Muse Code가 예기치 않게 중지되었습니다", "hostStarting": "Muse Code를 시작하는 중…", "hostRestartsOnSend": "다음 메시지를 보내면 다시 시작되고 이 대화가 계속됩니다.", diff --git a/l10n/ui.pl.json b/l10n/ui.pl.json index c37d19bc..7e0ef057 100644 --- a/l10n/ui.pl.json +++ b/l10n/ui.pl.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Klucz Model API zaczyna się od LLM_ (starsze klucze mają postać LLM||).", "signInWaiting": "Oczekiwanie na zakończenie logowania w przeglądarce…", "signInTimedOut": "Logowanie nie zakończyło się na czas. Spróbuj ponownie.", + "signInExpired": "Kod wygasł, zanim został zatwierdzony. Zaloguj się ponownie, aby otrzymać nowy kod.", + "signInDenied": "Odrzucono logowanie w przeglądarce.", + "signInSaveFailed": "Muse Code zalogował się, ale nie mógł zapisać danych logowania.", + "signInEnded": "Logowanie zakończone: {outcome}. Zaloguj się ponownie, aby otrzymać nowy kod.", + "cliCredentialUnsupported": "Nie można uruchomić Muse Code: jego plik logowania {path} ma format systemu macOS, którego Muse Code nie może odczytać w tym systemie. Przenieś ten plik lub zmień jego nazwę, a potem zaloguj się ponownie.", "hostExited": "Muse Code nieoczekiwanie się zatrzymał", "hostStarting": "Uruchamianie Muse Code…", "hostRestartsOnSend": "Następna wiadomość uruchomi go ponownie i będzie kontynuować tę rozmowę.", diff --git a/l10n/ui.pt-br.json b/l10n/ui.pt-br.json index 732fc9ab..e1583053 100644 --- a/l10n/ui.pt-br.json +++ b/l10n/ui.pt-br.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Uma chave da Model API começa com LLM_ (chaves antigas têm o formato LLM||).", "signInWaiting": "Aguardando a conclusão da entrada no navegador…", "signInTimedOut": "A entrada não foi concluída a tempo. Tente novamente.", + "signInExpired": "O código expirou antes de ser aprovado. Entre novamente para receber um novo código.", + "signInDenied": "Você recusou a entrada no navegador.", + "signInSaveFailed": "O Muse Code entrou, mas não conseguiu salvar a credencial.", + "signInEnded": "A entrada terminou: {outcome}. Entre novamente para receber um novo código.", + "cliCredentialUnsupported": "O Muse Code não consegue iniciar: o arquivo de entrada {path} está no formato do macOS, que o Muse Code não consegue ler neste sistema. Mova ou renomeie esse arquivo e entre novamente.", "hostExited": "O Muse Code parou inesperadamente", "hostStarting": "Iniciando o Muse Code…", "hostRestartsOnSend": "A próxima mensagem o reinicia e continua esta conversa.", diff --git a/l10n/ui.ru.json b/l10n/ui.ru.json index 95da3493..ba78dee0 100644 --- a/l10n/ui.ru.json +++ b/l10n/ui.ru.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Ключ Model API начинается с LLM_ (старые ключи имеют вид LLM||).", "signInWaiting": "Ожидание завершения входа в браузере…", "signInTimedOut": "Вход не был выполнен вовремя. Повторите попытку.", + "signInExpired": "Срок действия кода истёк до подтверждения. Войдите снова, чтобы получить новый код.", + "signInDenied": "Вы отклонили вход в браузере.", + "signInSaveFailed": "Muse Code выполнил вход, но не смог сохранить учётные данные.", + "signInEnded": "Вход завершён: {outcome}. Войдите снова, чтобы получить новый код.", + "cliCredentialUnsupported": "Muse Code не может запуститься: его файл входа {path} имеет формат macOS, который Muse Code не может прочитать в этой системе. Переместите или переименуйте этот файл, затем войдите снова.", "hostExited": "Muse Code неожиданно остановился", "hostStarting": "Запуск Muse Code…", "hostRestartsOnSend": "Следующее сообщение перезапустит его и продолжит эту беседу.", diff --git a/l10n/ui.tr.json b/l10n/ui.tr.json index cb628f09..6d216167 100644 --- a/l10n/ui.tr.json +++ b/l10n/ui.tr.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Model API anahtarı LLM_ ile başlar (eski anahtarlar LLM|| biçimindedir).", "signInWaiting": "Tarayıcıda oturum açmanın tamamlanması bekleniyor…", "signInTimedOut": "Oturum açma zamanında tamamlanmadı. Yeniden deneyin.", + "signInExpired": "Kodun süresi onaylanmadan önce doldu. Yeni bir kod almak için yeniden oturum açın.", + "signInDenied": "Oturum açmayı tarayıcıda reddettiniz.", + "signInSaveFailed": "Muse Code oturum açtı ancak kimlik bilgisini kaydedemedi.", + "signInEnded": "Oturum açma sona erdi: {outcome}. Yeni bir kod almak için yeniden oturum açın.", + "cliCredentialUnsupported": "Muse Code başlatılamıyor: oturum açma dosyası {path}, Muse Code’un bu sistemde okuyamadığı macOS biçiminde. Bu dosyayı taşıyın veya yeniden adlandırın, ardından yeniden oturum açın.", "hostExited": "Muse Code beklenmedik şekilde durdu", "hostStarting": "Muse Code başlatılıyor…", "hostRestartsOnSend": "Sonraki mesaj onu yeniden başlatır ve bu konuşmayı sürdürür.", diff --git a/l10n/ui.zh-cn.json b/l10n/ui.zh-cn.json index 00959506..e1eb49cd 100644 --- a/l10n/ui.zh-cn.json +++ b/l10n/ui.zh-cn.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Model API 密钥以 LLM_ 开头(旧密钥的格式类似 LLM||)。", "signInWaiting": "正在等待浏览器登录完成…", "signInTimedOut": "登录未在规定时间内完成。请重试。", + "signInExpired": "代码在获得批准前已过期。请重新登录以获取新代码。", + "signInDenied": "你已在浏览器中拒绝登录。", + "signInSaveFailed": "Muse Code 已登录,但无法保存凭据。", + "signInEnded": "登录已结束:{outcome}。请重新登录以获取新代码。", + "cliCredentialUnsupported": "Muse Code 无法启动:其登录文件 {path} 为 macOS 格式,此系统上的 Muse Code 无法读取。请移动或重命名该文件,然后重新登录。", "hostExited": "Muse Code 意外停止", "hostStarting": "正在启动 Muse Code…", "hostRestartsOnSend": "下一条消息会重启它并继续此对话。", diff --git a/l10n/ui.zh-tw.json b/l10n/ui.zh-tw.json index 84e1dadc..9e04c9a5 100644 --- a/l10n/ui.zh-tw.json +++ b/l10n/ui.zh-tw.json @@ -48,6 +48,11 @@ "apiKeyInvalid": "Model API 金鑰以 LLM_ 開頭(舊金鑰的格式類似 LLM||)。", "signInWaiting": "正在等候瀏覽器登入完成…", "signInTimedOut": "登入未在時限內完成。請再試一次。", + "signInExpired": "代碼在獲得核准前已過期。請重新登入以取得新代碼。", + "signInDenied": "您已在瀏覽器中拒絕登入。", + "signInSaveFailed": "Muse Code 已登入,但無法儲存認證資訊。", + "signInEnded": "登入已結束:{outcome}。請重新登入以取得新代碼。", + "cliCredentialUnsupported": "Muse Code 無法啟動:其登入檔案 {path} 為 macOS 格式,此系統上的 Muse Code 無法讀取。請移動或重新命名該檔案,然後重新登入。", "hostExited": "Muse Code 意外停止", "hostStarting": "正在啟動 Muse Code…", "hostRestartsOnSend": "下一則訊息會重新啟動它並繼續此對話。", diff --git a/package.nls.cs.json b/package.nls.cs.json index fed09db5..85378da4 100644 --- a/package.nls.cs.json +++ b/package.nls.cs.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Považovat tento pracovní prostor za důvěrný: modely přispěvatelské úrovně (jejichž provoz může Meta použít k trénování) jsou blokovány.", "config.allowDangerouslySkipPermissions.description": "Povolit nebezpečné přeskočení oprávnění: zobrazit režim Obejít oprávnění v nabídce Režimy. Muse pak upravuje soubory a spouští příkazy bez ptaní; používejte jen v sandboxu.", "config.museBinaryPath.description": "Absolutní cesta ke spustitelnému souboru Muse Code. Ponechte prázdné, aby se vyhledal v PATH nebo ve výchozím instalačním adresáři.", - "config.environmentVariables.description": "Proměnné prostředí nastavené pro proces Muse Code. Nevkládejte sem klíče API; použijte přihlášení, které je uloží do úložiště tajných kódů.", + "config.environmentVariables.description": "Proměnné prostředí nastavené pro proces Muse Code a pro terminály, ve kterých běží CLI Muse Code. Nevkládejte sem klíče API; použijte přihlášení, které je uloží do úložiště tajných kódů.", "config.environmentVariables.name.description": "Název proměnné.", "config.environmentVariables.value.description": "Hodnota proměnné.", "config.enableNewConversationShortcut.description": "Používat Ctrl+N (v macOS Cmd+N) k zahájení nové konverzace, když má fokus panel Muse Spark.", diff --git a/package.nls.de.json b/package.nls.de.json index 46a49966..425adaf0 100644 --- a/package.nls.de.json +++ b/package.nls.de.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Diesen Arbeitsbereich als vertraulich behandeln: Modelle der Contributor-Stufe (deren Datenverkehr Meta für Training verwenden darf) sind gesperrt.", "config.allowDangerouslySkipPermissions.description": "Gefährliches Überspringen von Berechtigungen zulassen: „Berechtigungen umgehen“ im Menü „Modi“ anzeigen. Muse bearbeitet dann Dateien und führt Befehle ohne Nachfrage aus; nur in einer Sandbox verwenden.", "config.museBinaryPath.description": "Absoluter Pfad zur ausführbaren Datei von Muse Code. Leer lassen, um sie über PATH oder im Standardinstallationsverzeichnis zu finden.", - "config.environmentVariables.description": "Umgebungsvariablen, die für den Muse Code-Prozess gesetzt werden. Tragen Sie hier keine API-Schlüssel ein; verwenden Sie den Anmeldevorgang, der sie im geheimen Speicher ablegt.", + "config.environmentVariables.description": "Umgebungsvariablen, die für den Muse Code-Prozess und die Terminals gesetzt werden, in denen die Muse Code-CLI läuft. Tragen Sie hier keine API-Schlüssel ein; verwenden Sie den Anmeldevorgang, der sie im geheimen Speicher ablegt.", "config.environmentVariables.name.description": "Variablenname.", "config.environmentVariables.value.description": "Variablenwert.", "config.enableNewConversationShortcut.description": "Strg+N (Cmd+N unter macOS) verwenden, um eine neue Unterhaltung zu starten, während ein Muse Spark-Panel den Fokus hat.", diff --git a/package.nls.es.json b/package.nls.es.json index 43274f6d..767df57a 100644 --- a/package.nls.es.json +++ b/package.nls.es.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Tratar esta área de trabajo como confidencial: se bloquean los modelos del nivel colaborador (cuyo tráfico Meta puede usar para entrenamiento).", "config.allowDangerouslySkipPermissions.description": "Permitir omitir permisos de forma peligrosa: mostrar Omitir permisos en el menú Modos. Muse edita entonces archivos y ejecuta comandos sin preguntar; úselo solo en un espacio aislado.", "config.museBinaryPath.description": "Ruta de acceso absoluta al ejecutable de Muse Code. Déjela vacía para buscarlo en PATH o en el directorio de instalación predeterminado.", - "config.environmentVariables.description": "Variables de entorno establecidas para el proceso de Muse Code. No ponga claves de API aquí; use el flujo de inicio de sesión, que las guarda en el almacenamiento de secretos.", + "config.environmentVariables.description": "Variables de entorno establecidas para el proceso de Muse Code y los terminales que ejecutan la CLI de Muse Code. No ponga claves de API aquí; use el flujo de inicio de sesión, que las guarda en el almacenamiento de secretos.", "config.environmentVariables.name.description": "Nombre de la variable.", "config.environmentVariables.value.description": "Valor de la variable.", "config.enableNewConversationShortcut.description": "Usar Ctrl+N (Cmd+N en macOS) para iniciar una nueva conversación mientras un panel de Muse Spark tiene el foco.", diff --git a/package.nls.fr.json b/package.nls.fr.json index 792c427c..0e58741c 100644 --- a/package.nls.fr.json +++ b/package.nls.fr.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Traiter cet espace de travail comme confidentiel : les modèles de niveau contributeur (dont Meta peut utiliser le trafic pour l’entraînement) sont bloqués.", "config.allowDangerouslySkipPermissions.description": "Autoriser le contournement dangereux des autorisations : afficher Contourner les autorisations dans le menu Modes. Muse modifie alors des fichiers et exécute des commandes sans demander ; à utiliser uniquement dans un bac à sable.", "config.museBinaryPath.description": "Chemin absolu de l’exécutable Muse Code. Laissez vide pour le rechercher dans le PATH ou dans le répertoire d’installation par défaut.", - "config.environmentVariables.description": "Variables d’environnement définies pour le processus Muse Code. N’y placez pas de clés API ; utilisez la procédure de connexion, qui les conserve dans le stockage des secrets.", + "config.environmentVariables.description": "Variables d’environnement définies pour le processus Muse Code et les terminaux qui exécutent la CLI Muse Code. N’y placez pas de clés API ; utilisez la procédure de connexion, qui les conserve dans le stockage des secrets.", "config.environmentVariables.name.description": "Nom de la variable.", "config.environmentVariables.value.description": "Valeur de la variable.", "config.enableNewConversationShortcut.description": "Utiliser Ctrl+N (Cmd+N sous macOS) pour démarrer une nouvelle conversation lorsqu’un panneau Muse Spark a le focus.", diff --git a/package.nls.hu.json b/package.nls.hu.json index 67493d0f..a6863c01 100644 --- a/package.nls.hu.json +++ b/package.nls.hu.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "A munkaterület bizalmasként kezelése: a contributor-szintű modellek (amelyek forgalmát a Meta tanításra használhatja) le vannak tiltva.", "config.allowDangerouslySkipPermissions.description": "Engedélyek veszélyes kihagyásának engedélyezése: az Engedélyek megkerülése mód megjelenik a Módok menüben. A Muse ekkor kérdezés nélkül szerkeszt fájlokat és futtat parancsokat; csak homokozóban használja.", "config.museBinaryPath.description": "A Muse Code végrehajtható fájljának abszolút elérési útja. Hagyja üresen, hogy a PATH-on vagy az alapértelmezett telepítési könyvtárban keresse meg.", - "config.environmentVariables.description": "A Muse Code folyamat számára beállított környezeti változók. Ne adjon meg itt API-kulcsokat; használja a bejelentkezési folyamatot, amely a titkos tárolóban tárolja őket.", + "config.environmentVariables.description": "A Muse Code folyamat és a Muse Code CLI-t futtató terminálok számára beállított környezeti változók. Ne adjon meg itt API-kulcsokat; használja a bejelentkezési folyamatot, amely a titkos tárolóban tárolja őket.", "config.environmentVariables.name.description": "Változó neve.", "config.environmentVariables.value.description": "Változó értéke.", "config.enableNewConversationShortcut.description": "A Ctrl+N (macOS-en Cmd+N) billentyűparancs új beszélgetést indít, amíg egy Muse Spark panel van fókuszban.", diff --git a/package.nls.it.json b/package.nls.it.json index aa4ac146..da333102 100644 --- a/package.nls.it.json +++ b/package.nls.it.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Tratta questa area di lavoro come riservata: i modelli di livello contributore (il cui traffico può essere usato da Meta per l’addestramento) sono bloccati.", "config.allowDangerouslySkipPermissions.description": "Consenti di ignorare pericolosamente le autorizzazioni: mostra Ignora autorizzazioni nel menu Modalità. Muse quindi modifica i file ed esegue comandi senza chiedere; da usare solo in una sandbox.", "config.museBinaryPath.description": "Percorso assoluto dell’eseguibile di Muse Code. Lascia vuoto per cercarlo nel PATH o nella directory di installazione predefinita.", - "config.environmentVariables.description": "Variabili di ambiente impostate per il processo di Muse Code. Non inserire qui chiavi API; usa la procedura di accesso, che le conserva nell’archivio dei segreti.", + "config.environmentVariables.description": "Variabili di ambiente impostate per il processo di Muse Code e per i terminali che eseguono la CLI di Muse Code. Non inserire qui chiavi API; usa la procedura di accesso, che le conserva nell’archivio dei segreti.", "config.environmentVariables.name.description": "Nome della variabile.", "config.environmentVariables.value.description": "Valore della variabile.", "config.enableNewConversationShortcut.description": "Usa CTRL+N (CMD+N in macOS) per avviare una nuova conversazione quando lo stato attivo è su un pannello di Muse Spark.", diff --git a/package.nls.ja.json b/package.nls.ja.json index 1d8054a4..f1a5a98c 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "このワークスペースを機密として扱います: コントリビューター ティアのモデル (Meta がそのトラフィックをトレーニングに使用する可能性があるモデル) はブロックされます。", "config.allowDangerouslySkipPermissions.description": "危険を承知で権限のスキップを許可します: モード メニューに権限バイパスを表示します。Muse は確認なしでファイルを編集し、コマンドを実行するようになります。サンドボックス内でのみ使用してください。", "config.museBinaryPath.description": "Muse Code 実行可能ファイルへの絶対パス。空のままにすると、PATH または既定のインストール ディレクトリから検出します。", - "config.environmentVariables.description": "Muse Code プロセスに設定する環境変数。API キーはここに入力しないでください。サインイン フローを使用すると、キーはシークレット ストレージに保存されます。", + "config.environmentVariables.description": "Muse Code プロセスと、Muse Code CLI を実行するターミナルに設定する環境変数。API キーはここに入力しないでください。サインイン フローを使用すると、キーはシークレット ストレージに保存されます。", "config.environmentVariables.name.description": "変数名。", "config.environmentVariables.value.description": "変数の値。", "config.enableNewConversationShortcut.description": "Muse Spark パネルにフォーカスがあるときに、Ctrl+N (macOS では Cmd+N) で新しい会話を開始します。", diff --git a/package.nls.json b/package.nls.json index bb08a091..5485932b 100644 --- a/package.nls.json +++ b/package.nls.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Treat this workspace as confidential: contributor-tier models (whose traffic Meta may use for training) are blocked.", "config.allowDangerouslySkipPermissions.description": "Allow dangerously skip permissions: list Bypass permissions in the Modes menu. Muse then edits files and runs commands without asking; use only in a sandbox.", "config.museBinaryPath.description": "Absolute path to the Muse Code executable. Leave empty to discover it on PATH or in the default install directory.", - "config.environmentVariables.description": "Environment variables set for the Muse Code process. Do not put API keys here; use the Sign in flow, which stores them in secret storage.", + "config.environmentVariables.description": "Environment variables set for the Muse Code process and the terminals that run the Muse Code CLI. Do not put API keys here; use the Sign in flow, which stores them in secret storage.", "config.environmentVariables.name.description": "Variable name.", "config.environmentVariables.value.description": "Variable value.", "config.enableNewConversationShortcut.description": "Use Ctrl+N (Cmd+N on macOS) to start a new conversation while a Muse Spark panel is focused.", diff --git a/package.nls.ko.json b/package.nls.ko.json index 633902a2..30b2ee7e 100644 --- a/package.nls.ko.json +++ b/package.nls.ko.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "이 작업 영역을 기밀로 처리합니다. 기여자 등급 모델(Meta가 트래픽을 학습에 사용할 수 있는 모델)이 차단됩니다.", "config.allowDangerouslySkipPermissions.description": "위험을 감수하고 권한 건너뛰기 허용: 모드 메뉴에 권한 우회를 표시합니다. 그러면 Muse가 묻지 않고 파일을 편집하고 명령을 실행합니다. 샌드박스에서만 사용하세요.", "config.museBinaryPath.description": "Muse Code 실행 파일의 절대 경로입니다. 비워 두면 PATH 또는 기본 설치 디렉터리에서 찾습니다.", - "config.environmentVariables.description": "Muse Code 프로세스에 설정되는 환경 변수입니다. 여기에 API 키를 넣지 마세요. 키를 비밀 저장소에 저장하는 로그인 흐름을 사용하세요.", + "config.environmentVariables.description": "Muse Code 프로세스와 Muse Code CLI를 실행하는 터미널에 설정되는 환경 변수입니다. 여기에 API 키를 넣지 마세요. 키를 비밀 저장소에 저장하는 로그인 흐름을 사용하세요.", "config.environmentVariables.name.description": "변수 이름입니다.", "config.environmentVariables.value.description": "변수 값입니다.", "config.enableNewConversationShortcut.description": "Muse Spark 패널에 포커스가 있을 때 Ctrl+N(macOS에서는 Cmd+N)을 사용하여 새 대화를 시작합니다.", diff --git a/package.nls.pl.json b/package.nls.pl.json index 24794847..7395b7bd 100644 --- a/package.nls.pl.json +++ b/package.nls.pl.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Traktuj ten obszar roboczy jako poufny: modele z poziomu Contributor (których ruch Meta może wykorzystywać do trenowania) są blokowane.", "config.allowDangerouslySkipPermissions.description": "Zezwalaj na niebezpieczne pomijanie uprawnień: pokazuj tryb „Pomijanie uprawnień” w menu Tryby. Muse edytuje wtedy pliki i uruchamia polecenia bez pytania; używaj tylko w piaskownicy.", "config.museBinaryPath.description": "Ścieżka bezwzględna do pliku wykonywalnego Muse Code. Pozostaw puste, aby wyszukać go w PATH lub w domyślnym katalogu instalacji.", - "config.environmentVariables.description": "Zmienne środowiskowe ustawiane dla procesu Muse Code. Nie umieszczaj tu kluczy API; użyj logowania, które przechowuje je w magazynie wpisów tajnych.", + "config.environmentVariables.description": "Zmienne środowiskowe ustawiane dla procesu Muse Code i terminali, w których działa CLI Muse Code. Nie umieszczaj tu kluczy API; użyj logowania, które przechowuje je w magazynie wpisów tajnych.", "config.environmentVariables.name.description": "Nazwa zmiennej.", "config.environmentVariables.value.description": "Wartość zmiennej.", "config.enableNewConversationShortcut.description": "Używaj Ctrl+N (Cmd+N w systemie macOS), aby rozpocząć nową rozmowę, gdy panel Muse Spark ma fokus.", diff --git a/package.nls.pt-br.json b/package.nls.pt-br.json index 0ba35fd7..86f9e200 100644 --- a/package.nls.pt-br.json +++ b/package.nls.pt-br.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Tratar este espaço de trabalho como confidencial: os modelos do nível colaborador (cujo tráfego a Meta pode usar para treinamento) são bloqueados.", "config.allowDangerouslySkipPermissions.description": "Permitir ignorar permissões perigosamente: listar Ignorar permissões no menu Modos. O Muse então edita arquivos e executa comandos sem perguntar; use apenas em uma área restrita.", "config.museBinaryPath.description": "Caminho absoluto para o executável do Muse Code. Deixe vazio para descobri-lo no PATH ou no diretório de instalação padrão.", - "config.environmentVariables.description": "Variáveis de ambiente definidas para o processo do Muse Code. Não coloque chaves de API aqui; use o fluxo de entrada, que as guarda no armazenamento secreto.", + "config.environmentVariables.description": "Variáveis de ambiente definidas para o processo do Muse Code e para os terminais que executam a CLI do Muse Code. Não coloque chaves de API aqui; use o fluxo de entrada, que as guarda no armazenamento secreto.", "config.environmentVariables.name.description": "Nome da variável.", "config.environmentVariables.value.description": "Valor da variável.", "config.enableNewConversationShortcut.description": "Usar Ctrl+N (Cmd+N no macOS) para iniciar uma nova conversa enquanto um painel do Muse Spark estiver em foco.", diff --git a/package.nls.ru.json b/package.nls.ru.json index a35a8a2f..8fdedf0b 100644 --- a/package.nls.ru.json +++ b/package.nls.ru.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Считать эту рабочую область конфиденциальной: модели уровня Contributor (трафик которых Meta может использовать для обучения) блокируются.", "config.allowDangerouslySkipPermissions.description": "Разрешить опасный пропуск разрешений: показывать «Обход разрешений» в меню «Режимы». Тогда Muse изменяет файлы и выполняет команды без запроса; используйте только в песочнице.", "config.museBinaryPath.description": "Абсолютный путь к исполняемому файлу Muse Code. Оставьте пустым, чтобы найти его в PATH или в каталоге установки по умолчанию.", - "config.environmentVariables.description": "Переменные среды, задаваемые для процесса Muse Code. Не указывайте здесь ключи API; используйте вход, который хранит их в хранилище секретов.", + "config.environmentVariables.description": "Переменные среды, задаваемые для процесса Muse Code и терминалов, в которых работает CLI Muse Code. Не указывайте здесь ключи API; используйте вход, который хранит их в хранилище секретов.", "config.environmentVariables.name.description": "Имя переменной.", "config.environmentVariables.value.description": "Значение переменной.", "config.enableNewConversationShortcut.description": "Использовать Ctrl+N (Cmd+N в macOS), чтобы начать новую беседу, когда панель Muse Spark в фокусе.", diff --git a/package.nls.tr.json b/package.nls.tr.json index 948665f6..47891445 100644 --- a/package.nls.tr.json +++ b/package.nls.tr.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "Bu çalışma alanını gizli olarak değerlendir: katkı katmanı modelleri (trafiğini Meta'nın eğitim için kullanabileceği modeller) engellenir.", "config.allowDangerouslySkipPermissions.description": "İzinleri tehlikeli şekilde atlamaya izin ver: Modlar menüsünde İzinleri atla modunu listele. Muse bu durumda sormadan dosya düzenler ve komut çalıştırır; yalnızca bir korumalı alanda kullanın.", "config.museBinaryPath.description": "Muse Code yürütülebilir dosyasının mutlak yolu. PATH üzerinde veya varsayılan yükleme dizininde bulunması için boş bırakın.", - "config.environmentVariables.description": "Muse Code işlemi için ayarlanan ortam değişkenleri. API anahtarlarını buraya koymayın; bunları gizli depolama alanında saklayan oturum açma akışını kullanın.", + "config.environmentVariables.description": "Muse Code işlemi ve Muse Code CLI’yi çalıştıran terminaller için ayarlanan ortam değişkenleri. API anahtarlarını buraya koymayın; bunları gizli depolama alanında saklayan oturum açma akışını kullanın.", "config.environmentVariables.name.description": "Değişken adı.", "config.environmentVariables.value.description": "Değişken değeri.", "config.enableNewConversationShortcut.description": "Bir Muse Spark paneli odaktayken yeni konuşma başlatmak için Ctrl+N (macOS'ta Cmd+N) kullan.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index 98efa3e2..6911374a 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "将此工作区视为机密:阻止贡献者级模型(Meta 可能会将其流量用于训练)。", "config.allowDangerouslySkipPermissions.description": "允许危险地跳过权限:在“模式”菜单中列出“绕过权限”。这样 Muse 会直接编辑文件和运行命令而不询问;仅在沙盒中使用。", "config.museBinaryPath.description": "Muse Code 可执行文件的绝对路径。留空则在 PATH 或默认安装目录中查找。", - "config.environmentVariables.description": "为 Muse Code 进程设置的环境变量。请勿在此处放置 API 密钥;请使用登录流程,它会将密钥存储在机密存储中。", + "config.environmentVariables.description": "为 Muse Code 进程以及运行 Muse Code CLI 的终端设置的环境变量。请勿在此处放置 API 密钥;请使用登录流程,它会将密钥存储在机密存储中。", "config.environmentVariables.name.description": "变量名称。", "config.environmentVariables.value.description": "变量值。", "config.enableNewConversationShortcut.description": "在 Muse Spark 面板获得焦点时,使用 Ctrl+N(macOS 上为 Cmd+N)开始新对话。", diff --git a/package.nls.zh-tw.json b/package.nls.zh-tw.json index 53edbd28..fcded0dc 100644 --- a/package.nls.zh-tw.json +++ b/package.nls.zh-tw.json @@ -64,7 +64,7 @@ "config.confidentialWorkspace.description": "將此工作區視為機密:封鎖貢獻者層級模型(Meta 可能會將其流量用於訓練)。", "config.allowDangerouslySkipPermissions.description": "允許危險地略過權限:在「模式」功能表中列出「略過權限」。這樣 Muse 就會直接編輯檔案及執行命令,不會詢問;僅限在沙箱中使用。", "config.museBinaryPath.description": "Muse Code 可執行檔的絕對路徑。保留空白即可在 PATH 或預設安裝目錄中尋找。", - "config.environmentVariables.description": "為 Muse Code 處理程序設定的環境變數。請勿將 API 金鑰放在這裡;請使用登入流程,它會將金鑰儲存在秘密儲存空間中。", + "config.environmentVariables.description": "為 Muse Code 處理程序以及執行 Muse Code CLI 的終端機設定的環境變數。請勿將 API 金鑰放在這裡;請使用登入流程,它會將金鑰儲存在秘密儲存空間中。", "config.environmentVariables.name.description": "變數名稱。", "config.environmentVariables.value.description": "變數值。", "config.enableNewConversationShortcut.description": "在 Muse Spark 面板有焦點時,使用 Ctrl+N(macOS 上為 Cmd+N)開始新對話。", diff --git a/src/core/backendSelection.ts b/src/core/backendSelection.ts index bdc1c70d..729e6add 100644 --- a/src/core/backendSelection.ts +++ b/src/core/backendSelection.ts @@ -37,6 +37,31 @@ const BOTH: readonly SignInMethod[] = ['browser', 'apiKey'] const BROWSER_ONLY: readonly SignInMethod[] = ['browser'] const KEY_ONLY: readonly SignInMethod[] = ['apiKey'] +/** + * Whether the choice needs the CLI's own sign-in. With the backend forced to + * the Model API it does not, so nothing asks the CLI (no probe, no + * `account/read`): a CLI that is slow to answer about an ambiguous file must + * not hold up a user whose key is stored (Codex on 328efb52). + */ +export function isCliSignInConsulted(setting: BackendMode): boolean { + return setting !== 'modelApi' +} + +/** The choice, asking for the CLI's sign-in only when the setting needs it. */ +export async function readBackendChoice(asked: { + readonly setting: BackendMode + readonly hasCli: boolean + readonly hasCliSession: () => Promise + readonly hasStoredKey: () => Promise +}): Promise { + return selectBackend({ + setting: asked.setting, + hasCli: asked.hasCli, + hasCliSession: isCliSignInConsulted(asked.setting) && (await asked.hasCliSession()), + hasStoredKey: await asked.hasStoredKey(), + }) +} + export function selectBackend(facts: BackendFacts): BackendChoice { switch (facts.setting) { case 'museCode': { diff --git a/src/core/backends/musecode/MuseCodeHost.ts b/src/core/backends/musecode/MuseCodeHost.ts index 3eff7663..27a11687 100644 --- a/src/core/backends/musecode/MuseCodeHost.ts +++ b/src/core/backends/musecode/MuseCodeHost.ts @@ -78,6 +78,7 @@ import { UNKNOWN_METHOD, type WireNotification, } from './mapNotification' +import { failureForLog } from './logText' import { PromptLedger } from './promptLedger' import { historyOutcome, @@ -378,7 +379,7 @@ async function sendCommand( const ceiling = Math.min(MSP_RETRY_MAX_DELAY_MS, MSP_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1)) const delayMs = Math.floor(Math.random() * (ceiling + 1)) log.warn( - `${method} refused (${error instanceof Error ? error.message : String(error)}); attempt ${String(attempt + 1)} in ${String(delayMs)} ms`, + `${method} refused (${failureForLog(error)}); attempt ${String(attempt + 1)} in ${String(delayMs)} ms`, ) await pause(delayMs) } @@ -796,7 +797,7 @@ export class MuseSession implements AgentSession { // connection is still open, even when the turn that started it has ended. void this.stopAllTasks().catch((error: unknown) => { this.log.warn( - `task/stopAll before releasing session ${this.sessionId} failed: ${error instanceof Error ? error.message : String(error)}`, + `task/stopAll before releasing session ${this.sessionId} failed: ${failureForLog(error)}`, ) }) this.finishDispose() @@ -1095,7 +1096,9 @@ export class MuseCodeHost implements AgentHost { this.deliver({ method: 'userInput/requested', params }) } } catch (error: unknown) { - this.log.warn(`approval/listPending after resuming ${sessionId} failed: ${String(error)}`) + this.log.warn( + `approval/listPending after resuming ${sessionId} failed: ${failureForLog(error)}`, + ) } } diff --git a/src/core/backends/musecode/credentialFile.ts b/src/core/backends/musecode/credentialFile.ts new file mode 100644 index 00000000..5d1cb615 --- /dev/null +++ b/src/core/backends/musecode/credentialFile.ts @@ -0,0 +1,146 @@ +// What the Muse Code CLI's credential file (`auth.json`) says about its +// sign-in, from the file's structure alone (PLAN.md D26, 2026-09-27). The +// schema keeps four facts: the schema version, which providers are named, +// the `storage` lane of each, and whether the Muse provider carries one of +// the credential keys it was captured writing (`api_key`, `access_token`). +// A key's value is replaced by `true` in the parse, and every other field is +// dropped, so no token reaches anything; nothing from the file is stored, +// logged or passed on. +// +// Shapes seen on Muse Code 1.3.0 and 1.4.0-R4302.1 (isolated homes): +// - `{"schema_version": 1, "providers": {}}`: what `muse logout` and MSP +// `account/logout` leave behind (they rewrite the file, never delete it). +// Signed out on every OS. +// - Version 1 with `providers.meta` holding its credential, no `storage`: +// `muse auth set` writes `api_key` alone, a browser sign-in `access_token`, +// the key and the account's name (Windows and Linux). Signed in there. On +// macOS nobody captured whether 1.4.0 reads or migrates such a file, so the +// CLI is asked. +// - Only `providers.meta` speaks for the sign-in: 1.4.0-R4302.1's bundled +// Slack connector reads its own `providers.slack_connector`, so a file +// naming other providers alone is left to the CLI, as is a `meta` entry in +// any other shape (another `storage`, or no captured credential key). +// - Version 2 with `providers.meta.storage: "keychain"`: macOS keeps the +// token in the login Keychain and the file is a pointer. On Windows and +// Linux `muse serve` exits 3 at startup with any version-2 file, the empty +// one included ("unsupported auth schema version 2"), and with a version-1 +// `meta` whose storage is the Keychain ("keychain item for meta is +// unreadable"); with META_API_KEY set it starts with each of the three. +// An empty version-2 file on macOS was not captured: the CLI is asked. + +import * as z from 'zod/mini' +import { + MACOS_KEYCHAIN_ITEM_NOT_FOUND_EXIT, + MUSE_CREDENTIAL_INLINE_SCHEMA, + MUSE_CREDENTIAL_KEYCHAIN_STORAGE, + MUSE_CREDENTIAL_POINTER_SCHEMA, + MUSE_CREDENTIAL_PROVIDER, +} from '../../../shared/constants' + +/** + * - `empty`: a version-1 file naming no provider; the file a sign-out leaves. + * - `inline`: holds the credential itself, in a captured shape (off macOS). + * - `keychain`: points to the macOS Keychain (on macOS). + * - `unsupportedHere`: a macOS file on Windows or Linux (version 2, or the + * Keychain lane), where `muse serve` cannot start with it. + * - `unrecognized`: anything the structure cannot settle here; only the CLI + * can say. + */ +export type CredentialFileVerdict = + 'empty' | 'inline' | 'keychain' | 'unsupportedHere' | 'unrecognized' + +/** + * The CLI's own sign-in as the extension sees it: `unknown` when only the + * CLI could say and it has not (the estimate counts it as signed in, and a + * turn's `authRequired` corrects it); `unsupportedHere` when the file stops + * `muse serve` from starting. + */ +export type CliSignIn = 'signedIn' | 'signedOut' | 'unknown' | 'unsupportedHere' + +/** Whether the macOS Keychain holds the CLI's item, by attribute lookup only. */ +export type KeychainItemPresence = 'present' | 'absent' | 'unknown' + +// A credential key's presence, never its value: the parse replaces it with `true`. +const present = z.optional( + z.pipe( + z.unknown(), + z.transform((): true => true), + ), +) + +// Unknown keys are dropped by the parse: a provider keeps its storage lane +// and whether it carries a captured credential key, never a key or a token. +const credentialFileSchema = z.object({ + schema_version: z.number(), + providers: z.record( + z.string(), + z.object({ storage: z.optional(z.string()), api_key: present, access_token: present }), + ), +}) + +type ProviderEntry = z.infer['providers'][string] + +/** + * The shapes captured holding the sign-in in the file (the review of PR #49): + * no `storage` lane, and `api_key` (`muse auth set`) or `access_token` (a + * browser sign-in). Any other `meta` entry is the CLI's to place. + */ +function isCapturedInlineEntry(entry: ProviderEntry): boolean { + return entry.storage === undefined && (entry.api_key === true || entry.access_token === true) +} + +export function credentialFileVerdict( + text: string, + platform: NodeJS.Platform, +): CredentialFileVerdict { + let raw: unknown + try { + raw = JSON.parse(text) + } catch { + return 'unrecognized' + } + const parsed = credentialFileSchema.safeParse(raw) + if (!parsed.success) { + return 'unrecognized' + } + const version = parsed.data.schema_version + if (version !== MUSE_CREDENTIAL_INLINE_SCHEMA && version !== MUSE_CREDENTIAL_POINTER_SCHEMA) { + return 'unrecognized' + } + const isMacOs = platform === 'darwin' + // Off macOS the version alone stops `muse serve`, whatever the file holds + // (captured on Windows and Linux for a pointer and for an empty file; the + // review of PR #49). + if (version === MUSE_CREDENTIAL_POINTER_SCHEMA && !isMacOs) { + return 'unsupportedHere' + } + const providers = parsed.data.providers + const muse = Object.hasOwn(providers, MUSE_CREDENTIAL_PROVIDER) + ? providers[MUSE_CREDENTIAL_PROVIDER] + : undefined + if (muse === undefined) { + // A version-1 file naming no provider is what a sign-out leaves, on every + // OS. Another provider alone (a connector's token), or an empty + // version-2 file on macOS (never captured), is the CLI's to say. + const isSignOutShell = + version === MUSE_CREDENTIAL_INLINE_SCHEMA && Object.keys(providers).length === 0 + return isSignOutShell ? 'empty' : 'unrecognized' + } + if ( + version === MUSE_CREDENTIAL_POINTER_SCHEMA || + muse.storage === MUSE_CREDENTIAL_KEYCHAIN_STORAGE + ) { + return isMacOs ? 'keychain' : 'unsupportedHere' + } + // Whether macOS 1.4.0 reads or migrates a version-1 file holding the + // credential was never captured: there the CLI says (the review of PR #49). + return !isMacOs && isCapturedInlineEntry(muse) ? 'inline' : 'unrecognized' +} + +/** `security find-generic-password` without `-g`/`-w`: 0 found, 44 not found. */ +export function keychainItemPresence(exitCode: number): KeychainItemPresence { + if (exitCode === 0) { + return 'present' + } + return exitCode === MACOS_KEYCHAIN_ITEM_NOT_FOUND_EXIT ? 'absent' : 'unknown' +} diff --git a/src/core/backends/musecode/launch.ts b/src/core/backends/musecode/launch.ts index 7367d358..2808ae3e 100644 --- a/src/core/backends/musecode/launch.ts +++ b/src/core/backends/musecode/launch.ts @@ -305,6 +305,24 @@ export function buildChildEnvironment(input: ChildEnvironmentInput): NodeJS.Proc return env } +/** + * `museSpark.environmentVariables` for a terminal that runs the CLI (`muse + * logout`, `muse mcp login`, Open in Terminal). VS Code adds them to the + * terminal's own environment, so the CLI there reads the same config home + * as `muse serve` (the review of PR #49). On Windows one spelling per name, + * the last one given winning, as for `muse serve`. + */ +export function terminalEnvironment( + variables: readonly EnvironmentVariable[], + platform: NodeJS.Platform, +): Record { + const env: Record = {} + for (const variable of variables) { + setEnvironmentVariable(env, platform, variable.name, variable.value) + } + return env +} + /** * Keeps loopback off the proxy (M56, PLAN.md D43). Muse Code sends every * HTTP request through the proxy its environment names, including its diff --git a/src/core/backends/musecode/logText.ts b/src/core/backends/musecode/logText.ts new file mode 100644 index 00000000..30b9eb14 --- /dev/null +++ b/src/core/backends/musecode/logText.ts @@ -0,0 +1,64 @@ +// What the log says about text the Muse Code CLI wrote (the review of PR +// #49): its MSP error messages and its stderr are free text that can name a +// path under the user's profile or an account, and the log channel's +// redactor catches only key-shaped strings. So an MSP failure is named by its +// kind and code, and stderr by the lines 1.4.0-R4302.1 was captured writing +// (docs/certification/sign-in-detection.md), in fixed words; anything else +// by its length alone. + +import { MspError } from '@muse-code/sdk' +import { wireWordForLog } from '../../logging' +import { DeadlineError } from '../../timeouts' + +/** A failure as the log names it: never the CLI's message. */ +export function failureForLog(error: unknown): string { + if (error instanceof MspError) { + return `${wireWordForLog(error.kind)} (MSP error ${String(error.code)})` + } + // The extension's own words, naming the method that went unanswered. + if (error instanceof DeadlineError) { + return error.message + } + return error instanceof Error ? error.name : 'an unknown failure' +} + +// The stderr lines captured from `muse serve` 1.4.0-R4302.1 (Windows and +// Linux, 2026-09-27; scratchpad/m140cred), each named in fixed words. +const CAPTURED_STDERR: readonly { + readonly pattern: RegExp + readonly logged: (match: RegExpExecArray) => string +}[] = [ + { + pattern: /unsupported auth schema version (\d{1,9})/u, + logged: (match) => + `unsupported auth schema version ${match[1] ?? '?'} (the credential file's path is not logged)`, + }, + { + pattern: /keychain item for meta is unreadable/u, + logged: () => 'the Keychain item for meta is unreadable', + }, + { + pattern: /startup model-catalog fetch failed/u, + logged: () => 'the startup model-catalog fetch failed', + }, +] + +/** One line the CLI wrote to stderr, as the log names it. */ +function stderrLineForLog(line: string): string { + for (const captured of CAPTURED_STDERR) { + const match = captured.pattern.exec(line) + if (match !== null) { + return captured.logged(match) + } + } + return `a line of ${String(line.length)} characters (not logged: it may name a path or an account)` +} + +/** What the CLI wrote to stderr, as the log names it: each line in fixed words. */ +export function stderrForLog(chunk: string): string { + return chunk + .split(/\r?\n/u) + .filter((line) => line.trim() !== '') + .map((line) => stderrLineForLog(line)) + .join('; ') +} diff --git a/src/core/logging.ts b/src/core/logging.ts index a8d21a06..50ca4dc1 100644 --- a/src/core/logging.ts +++ b/src/core/logging.ts @@ -10,6 +10,17 @@ export function clipForLog(text: string, maxChars: number = CLI_STDERR_LOG_MAX_C : text } +// A word from the wire (an MSP state, error kind or outcome) in the shape of +// one: a letter, then up to 63 letters, digits, `_` or `-`. Free text of any +// other shape (a message, a path, an e-mail address) is never logged, since +// the redactor catches only key-shaped strings (the review of PR #49). +const WIRE_WORD = /^[A-Za-z][\w-]{0,63}$/u + +/** A protocol word as the log names it; any other text becomes a fixed phrase. */ +export function wireWordForLog(value: string): string { + return WIRE_WORD.test(value) ? value : 'an unrecognized value' +} + export interface CoreLogger { /** The finest detail (M39): shown when the Muse Spark channel's level is Trace. */ trace(message: string): void diff --git a/src/core/support/report.ts b/src/core/support/report.ts index 34e55a8a..d73cdd37 100644 --- a/src/core/support/report.ts +++ b/src/core/support/report.ts @@ -1,13 +1,23 @@ // The "Muse Spark: Diagnostics" report (PLAN.md D14): the facts a bug // report needs, as text for the log channel. Pure: the host gathers the -// facts. Nothing secret is ever in it: credentials appear as booleans, +// facts. Nothing secret is ever in it: credentials appear as booleans or, +// for the CLI's credential file, as one word for its structure (D26), // environment variables as a count, the home directory as `~` (PLAN.md // D24: the report is meant to be pasted into a public issue), and the // logger redacts on top. The network posture (M56, PLAN.md D43) is stated // the same way: whether a proxy is set, never its address, which can hold // a password; and `muse config status` contributes only known-safe fields. -import { MUSE_CONFIG_STATUS_MAX_CHARS, PRODUCT_NAME } from '../../shared/constants' +import { + MUSE_CONFIG_STATUS_MAX_CHARS, + MUSE_KEYCHAIN_SERVICE, + PRODUCT_NAME, +} from '../../shared/constants' +import type { + CliSignIn, + CredentialFileVerdict, + KeychainItemPresence, +} from '../backends/musecode/credentialFile' /** * The network the extension's own requests and Muse Code's run under (M56). @@ -58,7 +68,17 @@ export interface SupportFacts { readonly cli: | { readonly ok: true; readonly installDir: string; readonly version: string | undefined } | { readonly ok: false; readonly reason: string } - readonly hasCliCredentialFile: boolean + /** + * What the CLI's credential file's structure says, never a value from it: + * `absent`, `empty` (no sign-in), `inline` (holds one), `keychain` (a macOS + * pointer), `unsupportedHere` (a macOS file where `muse serve` cannot + * start with it) or `unrecognized`. + */ + readonly cliCredentialFile: CredentialFileVerdict | 'absent' + /** The CLI's sign-in as the gate counts it (`unknown` counts as signed in). */ + readonly cliSignIn: CliSignIn + /** macOS only: whether the login Keychain holds the CLI's item, by attribute lookup. */ + readonly keychainItem: KeychainItemPresence | undefined /** Muse Code's `run.subagent_delegation_mode` as read from its settings file (M14). */ readonly delegationMode: string /** Muse Code's `run.workflow_trigger_mode`, read the same way (M47). */ @@ -168,7 +188,10 @@ export function renderSupportReport(facts: SupportFacts): string { `muse binary path configured: ${yesNo(facts.isBinaryPathConfigured)}; environment variables: ${String(facts.environmentVariableCount)}`, `muse cli: ${cli}`, `muse subagent delegation: ${facts.delegationMode}; workflow trigger mode: ${facts.workflowTriggerMode}`, - `cli credential file: ${yesNo(facts.hasCliCredentialFile)}; stored model api key: ${yesNo(facts.hasStoredApiKey)}; META_API_KEY in environment: ${yesNo(facts.hasEnvironmentApiKey)}`, + `cli credential file: ${facts.cliCredentialFile}; cli sign-in: ${facts.cliSignIn}; stored model api key: ${yesNo(facts.hasStoredApiKey)}; META_API_KEY in environment: ${yesNo(facts.hasEnvironmentApiKey)}`, + ...(facts.keychainItem === undefined + ? [] + : [`macOS Keychain item ${MUSE_KEYCHAIN_SERVICE}: ${facts.keychainItem}`]), `voice dictation: ${dictation}`, ...networkLines(facts.network), ...managedConfigurationLines(facts.managedConfiguration), diff --git a/src/core/timeouts.ts b/src/core/timeouts.ts index e50dff7f..b40d71cd 100644 --- a/src/core/timeouts.ts +++ b/src/core/timeouts.ts @@ -1,5 +1,6 @@ // A deadline for a promise that has none of its own (PLAN.md D25): the MSP -// handshake and commands, which the SDK waits on for ever. +// handshake and commands, which the SDK waits on for ever. And an end to the +// wait when the caller stops caring (Cancel, sign-out, the window closing). export class DeadlineError extends Error { public constructor(message: string) { @@ -30,3 +31,34 @@ export async function withDeadline( clearTimeout(timer) } } + +/** Does nothing: the abort listener until it is made, and a late failure nobody waits for. */ +const IGNORE = (): void => undefined + +/** + * `work`'s value, or undefined as soon as `signal` aborts. `work` runs on + * (a probe's answer is still cached); its failure after the abort is + * handled by the race. + */ +export async function unlessAborted( + work: Promise, + signal: AbortSignal, +): Promise { + if (signal.aborted) { + // Nobody waits for it now: a late failure is nobody's to report. + void work.catch(IGNORE) + return undefined + } + let onAbort = IGNORE + const aborted = new Promise((resolve) => { + onAbort = () => { + resolve(undefined) + } + signal.addEventListener('abort', onAbort, { once: true }) + }) + try { + return await Promise.race([work, aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} diff --git a/src/extension.ts b/src/extension.ts index 5db37423..b0260bfc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,9 +8,9 @@ import path from 'node:path' import * as vscode from 'vscode' import * as z from 'zod/mini' import type { AgentHost, BackendKind } from './core/agent/agentBackend' -import { environmentValue } from './core/backends/musecode/launch' +import { environmentValue, terminalEnvironment } from './core/backends/musecode/launch' import { confineWorkspacePath } from './core/workspacePath' -import { selectBackend } from './core/backendSelection' +import { readBackendChoice } from './core/backendSelection' import { personalSkillsRoot } from './core/context/skills' import { memoryDataRoot } from './core/memory/memoryLocation' import { MemoryStore } from './core/memory/memoryStore' @@ -28,8 +28,11 @@ import { resolveAgainstRoot, rootRelativePath, } from './core/workspaceRoot' +import { keychainItemPresence } from './core/backends/musecode/credentialFile' +import { AccountHosts, connectAccountSession } from './host/auth/accountHost' import { AuthService } from './host/auth/authService' -import { connectDeviceSession, runDeviceSignIn } from './host/auth/deviceSignIn' +import { CliAccount, isCliSignedIn } from './host/auth/cliAccount' +import { runDeviceSignIn } from './host/auth/deviceSignIn' import { CredentialStore, isValidModelApiKey } from './host/auth/credentialStore' import { ModelApiBackendManager } from './host/backend/modelApiBackendManager' import { createFileScheduleStore } from './host/backend/fileScheduleStore' @@ -124,6 +127,9 @@ import { PERSONAL_SKILLS_GLOB, PROJECT_SKILLS_GLOB, GLOBAL_STATE_KEYS, + MACOS_KEYCHAIN_LOOKUP_ARGS, + MACOS_KEYCHAIN_LOOKUP_TIMEOUT_MS, + MACOS_SECURITY_TOOL, MENTION_INDEX_LIMIT, MENTION_INDEX_TTL_MS, MUSE_CONFIG_STATUS_ARGS, @@ -275,12 +281,13 @@ function modifiedAt(fsPath: string): number | undefined { * shell is pinned so the call syntax is known (PLAN.md D25): Windows * PowerShell on Windows (the CLI's own shim shell), `/bin/sh` elsewhere (a * default shell of pwsh or nushell would not run `"path" login` as a - * command). + * command). `env` is added to the terminal's own environment. */ function runInTerminal( cliPath: string, args: readonly string[], options: TerminalLaunchOptions, + env: Record, ): void { const isWindows = process.platform === 'win32' const systemRoot = process.env['SystemRoot'] @@ -291,6 +298,7 @@ function runInTerminal( name: options.name, ...(options.cwd !== undefined && { cwd: options.cwd }), ...(shellPath !== undefined && { shellPath }), + env, }) terminal.show(true) // The path and each argument single-quoted for the pinned shell (M31): @@ -596,7 +604,23 @@ export async function activate(context: vscode.ExtensionContext): Promise ) await Promise.all([backend.dispose(), modelApi.dispose()]) } - lifecycle.shutdown = () => restartBackend('the window is closing', true) + /** + * The CLI in a terminal (`muse logout`, `muse mcp login`, Open in + * Terminal), with `museSpark.environmentVariables` as `muse serve` gets + * them, so it reads the same config home (the review of PR #49). + */ + const runCliInTerminal = ( + cliPath: string, + args: readonly string[], + options: TerminalLaunchOptions, + ): void => { + runInTerminal( + cliPath, + args, + options, + terminalEnvironment(currentSettings().environmentVariables, process.platform), + ) + } // Skills, imports and export (M30): the CLI by absolute path, in the // environment `muse serve` gets, from the workspace root. const cliFeatures = createCliFeatures({ @@ -618,7 +642,7 @@ export async function activate(context: vscode.ExtensionContext): Promise if (!resolution.ok) { return false } - runInTerminal(resolution.launch.cliPath, args, { name: terminalName, cwd: workspaceRoot }) + runCliInTerminal(resolution.launch.cliPath, args, { name: terminalName, cwd: workspaceRoot }) return true }, museSettingsPath: () => museSettingsPath(museConfig()), @@ -653,6 +677,21 @@ export async function activate(context: vscode.ExtensionContext): Promise }, log, }) + // Muse Code's account methods run on a short-lived host of their own (M55, + // D26): the device sign-in, `account/read` and `account/logout`. + const connectAccountHost = (signal: AbortSignal) => + connectAccountSession(backend, version, log, workspaceRoot, signal) + // The short-lived account/read and account/logout hosts end with the window. + const accountHosts = new AccountHosts(connectAccountHost, log) + const cliAccount = new CliAccount({ + platform: process.platform, + credentialFilePath: () => backend.credentialFilePath(), + probe: () => accountHosts.probe(), + log, + }) + /** The CLI's own credential without a question to the CLI on macOS: META_API_KEY or its sign-in. */ + const hasCliSession = async () => + backend.hasEnvironmentKey() || isCliSignedIn(await cliAccount.signIn(false)) const auth = new AuthService({ backend: { resolveCli: () => { @@ -666,12 +705,20 @@ export async function activate(context: vscode.ExtensionContext): Promise reason: `${resolution.reason} ${fill(UI_TEXT.cliSearched, { paths: resolution.searched.join(', ') })}`, } }, - credentialFileExists: () => backend.credentialFileExists(), + cliSignIn: (isUserAction) => cliAccount.signIn(isUserAction), + abandonCliProbe: () => { + cliAccount.abandonProbe() + }, + forgetCliAnswers: () => { + cliAccount.forgetAnswers() + }, + credentialFilePath: () => backend.credentialFilePath(), credentialFileModifiedAt: () => modifiedAt(backend.credentialFilePath()), hasEnvironmentKey: () => backend.hasEnvironmentKey(), getBackendMode: () => currentSettings().backend, restartBackend: (isConversationEnding) => restartBackend('authentication changed', isConversationEnding), + logOutCli: async () => (await accountHosts.logOut()) === 'confirmed', }, credentials, logoutHold: { @@ -679,7 +726,7 @@ export async function activate(context: vscode.ExtensionContext): Promise set: (isHeld) => context.globalState.update(GLOBAL_STATE_KEYS.cliLogoutHold, isHeld), }, runInTerminal: (cliPath, args) => { - runInTerminal(cliPath, args, { name: UI_TEXT.museLoginTerminalName, cwd: undefined }) + runCliInTerminal(cliPath, args, { name: UI_TEXT.museLoginTerminalName, cwd: undefined }) }, installCommand: process.platform === 'win32' ? MUSE_INSTALL_COMMANDS.win32 : MUSE_INSTALL_COMMANDS.posix, @@ -700,13 +747,13 @@ export async function activate(context: vscode.ExtensionContext): Promise }, runDeviceSignIn: (signal, onCode) => runDeviceSignIn({ - connect: (connectSignal) => - connectDeviceSession(backend, version, log, workspaceRoot, connectSignal), + connect: connectAccountHost, credentialFileModifiedAt: () => modifiedAt(backend.credentialFilePath()), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now: Date.now, signal, onCode, + log, }), promptForApiKey, broadcast: (message) => { @@ -734,6 +781,14 @@ export async function activate(context: vscode.ExtensionContext): Promise now: () => Date.now(), log, }) + // The window closing ends the account hosts, then the browser sign-in + // (awaited, so its host is closed too), then the backends (the review of + // PR #49). + lifecycle.shutdown = async () => { + accountHosts.close() + await auth.stopSignIn() + await restartBackend('the window is closing', true) + } const editorContext = new EditorContextTracker({ broadcast: (summary) => { @@ -1031,12 +1086,13 @@ export async function activate(context: vscode.ExtensionContext): Promise const ensureSelectedHost = async () => { const host = await chooseAuthorizedHost( () => auth.backend, + // Forced Model API asks the CLI nothing (Codex on 328efb52). async () => - selectBackend({ + await readBackendChoice({ setting: currentSettings().backend, hasCli: backend.resolveLaunch().ok, - hasCliSession: backend.credentialFileExists() || backend.hasEnvironmentKey(), - hasStoredKey: (await credentials.getApiKey()) !== undefined, + hasCliSession, + hasStoredKey: async () => (await credentials.getApiKey()) !== undefined, }), async (kind): Promise => kind === 'modelApi' ? await modelApi.ensureHost() : await backend.ensureHost(), @@ -1308,9 +1364,9 @@ export async function activate(context: vscode.ExtensionContext): Promise // The usage modal's Account section and insights (M14). accountFacts: async (kind) => { const resolution = backend.resolveLaunch() - const hasCliSession = backend.credentialFileExists() || backend.hasEnvironmentKey() + const isCliSession = await hasCliSession() const hasKey = (await credentials.getApiKey()) !== undefined - const signInMethod = signInMethodFor(kind, hasCliSession, hasKey) + const signInMethod = signInMethodFor(kind, isCliSession, hasKey) const cliVersion = resolution.ok ? backend.installedVersion(resolution.launch.installDir) : undefined @@ -1570,7 +1626,7 @@ export async function activate(context: vscode.ExtensionContext): Promise registerLoggedCommand(log, COMMAND_IDS.openInTerminal, () => { openMuseTerminal({ resolveCli, - runInTerminal, + runInTerminal: runCliInTerminal, workspaceRoot, showWarning: (message) => { void vscode.window.showWarningMessage(message) @@ -1639,6 +1695,15 @@ export async function activate(context: vscode.ExtensionContext): Promise ) : undefined, ) + // Where a macOS sign-in's token lives, looked up by attribute only (no + // `-g`/`-w`: no secret, no prompt); never the sign-in signal (D26). + const keychainLookup = + process.platform === 'darwin' + ? await runProcess( + { command: MACOS_SECURITY_TOOL, args: MACOS_KEYCHAIN_LOOKUP_ARGS }, + MACOS_KEYCHAIN_LOOKUP_TIMEOUT_MS, + ) + : undefined log.info( renderSupportReport({ extensionVersion: version, @@ -1663,7 +1728,12 @@ export async function activate(context: vscode.ExtensionContext): Promise version: backend.installedVersion(resolution.launch.installDir), } : { ok: false, reason: resolution.reason }, - hasCliCredentialFile: backend.credentialFileExists(), + cliCredentialFile: backend.credentialFileVerdict(), + cliSignIn: await cliAccount.signIn(true), + keychainItem: + keychainLookup === undefined + ? undefined + : keychainItemPresence(keychainLookup.exitCode), delegationMode: delegationMode(), workflowTriggerMode: workflowTriggerMode(), hasStoredApiKey: (await credentials.getApiKey()) !== undefined, diff --git a/src/host/auth/accountHost.ts b/src/host/auth/accountHost.ts new file mode 100644 index 00000000..b774a86f --- /dev/null +++ b/src/host/auth/accountHost.ts @@ -0,0 +1,261 @@ +// Muse Code's experimental account methods on a short-lived `muse serve` +// that owns no conversation (M55; PLAN.md D26, 2026-09-27): the device +// sign-in, `account/read` (which credential the CLI would use) and +// `account/logout`. The chat host is not started with `experimentalApi`, so +// these always get a process of their own, closed when they finish. The +// extension never sends its Model API key to it, and never keeps, logs or +// shows the account's `label` (an e-mail address) or `avatarUrl`. + +import { spawnMspConnection, type Connection } from '@muse-code/sdk' +import * as z from 'zod/mini' +import { wireWordForLog } from '../../core/logging' +import { unlessAborted, withDeadline } from '../../core/timeouts' +import { + MSP_CLIENT_NAME, + MSP_HANDSHAKE_TIMEOUT_MS, + MUSE_ACCOUNT_LOGOUT, + MUSE_ACCOUNT_READ, + MUSE_ACCOUNT_STATES, +} from '../../shared/constants' +import type { MuseCodeBackendManager } from '../backend/museCodeBackendManager' +import type { Logger } from '../logger' + +// `AccountState` as captured (1.3.0 and 1.4.0): `label` and `avatarUrl` are +// not in the schema, so parsing drops them. +const accountStateSchema = z.object({ + state: z.string(), + credentialRequired: z.boolean(), +}) + +export type AccountState = z.infer + +export interface AccountSession { + /** `closed` settles when the host's output ends: it exited or died. */ + readonly connection: Pick + readonly close: () => Promise +} + +/** Starts one short-lived host; `signal` cancels the start. */ +export type ConnectAccountHost = (signal: AbortSignal) => Promise + +const CANCELLED_CONNECT = Symbol('cancelled account host connection') + +/** The error's name only: a CLI message can carry a path or an account. */ +function errorName(error: unknown): string { + return error instanceof Error ? error.name : 'unknown error' +} + +/** Whether a stored credential (a sign-in or a key the CLI saved) is the one in use. */ +export function isStoredSignIn(account: AccountState): boolean { + return ( + account.state === MUSE_ACCOUNT_STATES.accountLogin || + account.state === MUSE_ACCOUNT_STATES.apiKey + ) +} + +/** + * The answer every captured `account/read` after a sign-out gave + * (docs/certification/sign-in-detection.md). Nothing else counts as signed + * out: `envKey` masks the stored lane, and other answers were never + * captured (AGENTS.md rule 13, the review of PR #49). + */ +export function isCapturedSignedOut(account: AccountState): boolean { + return account.state === MUSE_ACCOUNT_STATES.loggedOut && account.credentialRequired +} + +type AccountConnection = AccountSession['connection'] + +/** An account method's `AccountState` answer; undefined when it failed or came in another shape. */ +async function requestAccount( + connection: AccountConnection, + method: string, +): Promise { + try { + const result = accountStateSchema.safeParse( + await withDeadline( + connection.request(method, {}), + MSP_HANDSHAKE_TIMEOUT_MS, + `Muse Code did not answer ${method} in time`, + ), + ) + return result.success ? result.data : undefined + } catch { + return undefined + } +} + +/** `account/read` on an open host; undefined when the host cannot say. */ +export async function readAccountState( + connection: AccountConnection, +): Promise { + return await requestAccount(connection, MUSE_ACCOUNT_READ) +} + +/** + * `use` on one short-lived host, closed afterwards; `fallback` when it + * cannot start, or once `ended` aborts (the window closing), which closes + * the host at once instead of waiting for its answer. + */ +async function onAccountHost( + connect: ConnectAccountHost, + log: Logger, + ended: AbortSignal, + fallback: T, + use: (connection: AccountConnection) => Promise, +): Promise { + let session: AccountSession + try { + session = await connect(ended) + } catch (error: unknown) { + log.warn(`The Muse Code account host could not start: ${errorName(error)}`) + return fallback + } + try { + return (await unlessAborted(use(session.connection), ended)) ?? fallback + } finally { + try { + await session.close() + } catch (error: unknown) { + log.warn(`The Muse Code account host did not close cleanly: ${errorName(error)}`) + } + } +} + +/** One short-lived host asked `account/read`; undefined when it could not start or say. */ +export async function probeAccount( + connect: ConnectAccountHost, + log: Logger, + ended: AbortSignal, +): Promise { + return await onAccountHost(connect, log, ended, undefined, readAccountState) +} + +/** + * MSP `account/logout` on a short-lived host, confirmed by `account/read`: + * `confirmed` only on the captured signed-out answer; `unconfirmed` when the + * host could not start or refused, or answered anything else: a stored + * credential, `envKey` (`META_API_KEY`, which no logout can unset, masks + * the stored lane), or a state never captured. + */ +export async function logOutAccount( + connect: ConnectAccountHost, + log: Logger, + ended: AbortSignal, +): Promise<'confirmed' | 'unconfirmed'> { + return await onAccountHost<'confirmed' | 'unconfirmed'>( + connect, + log, + ended, + 'unconfirmed', + async (connection) => { + const answer = await requestAccount(connection, MUSE_ACCOUNT_LOGOUT) + const after = answer === undefined ? undefined : await readAccountState(connection) + // The state word only, and only in the shape of one: its vocabulary + // is open (the review of PR #49). + if (after === undefined || !isCapturedSignedOut(after)) { + log.warn( + after?.state === MUSE_ACCOUNT_STATES.envKey + ? 'Muse Code runs on META_API_KEY, which hides whether account/logout cleared the stored sign-in' + : `Muse Code did not confirm account/logout (${after === undefined ? 'no answer' : wireWordForLog(after.state)})`, + ) + return 'unconfirmed' + } + log.info(`Muse Code signed out through account/logout (now ${after.state})`) + return 'confirmed' + }, + ) +} + +/** + * The window's short-lived `account/read` and `account/logout` hosts, which + * end together when it closes (the review of PR #49): a probe still waiting, + * or one Cancel left behind, is closed rather than left running, and none + * starts afterwards. + */ +export class AccountHosts { + private readonly windowClosing = new AbortController() + + public constructor( + private readonly connect: ConnectAccountHost, + private readonly log: Logger, + ) {} + + public async probe(): Promise { + return await probeAccount(this.connect, this.log, this.windowClosing.signal) + } + + public async logOut(): Promise<'confirmed' | 'unconfirmed'> { + return await logOutAccount(this.connect, this.log, this.windowClosing.signal) + } + + public close(): void { + this.windowClosing.abort() + } +} + +/** Start a dedicated experimental MSP process. Keep it separate from chat. */ +export async function connectAccountSession( + backend: MuseCodeBackendManager, + extensionVersion: string, + log: Logger, + workspaceRoot: string | undefined, + signal: AbortSignal, +): Promise { + const isAborted = () => signal.aborted + if (isAborted()) { + throw new Error('The Muse Code account host was cancelled') + } + backend.invalidateLaunch() + const resolution = backend.resolveLaunch() + if (!resolution.ok) { + throw new Error(resolution.reason) + } + let isStderrReported = false + const handshake = spawnMspConnection({ + command: resolution.launch.command, + args: resolution.launch.args, + ...(workspaceRoot !== undefined && { cwd: workspaceRoot }), + env: backend.childEnvironment(), + onStderr: () => { + if (isStderrReported) { + return + } + + log.warn('The Muse Code account host wrote to stderr') + isStderrReported = true + }, + }) + const cancelledConnect = Promise.withResolvers() + const onAbort = () => { + cancelledConnect.resolve(CANCELLED_CONNECT) + } + signal.addEventListener('abort', onAbort, { once: true }) + if (isAborted()) { + onAbort() + } + try { + const spawned = await Promise.race([ + withDeadline( + handshake.initialize({ + clientInfo: { name: MSP_CLIENT_NAME, version: extensionVersion }, + capabilities: { experimentalApi: true, userInputDialogs: false }, + }), + MSP_HANDSHAKE_TIMEOUT_MS, + 'The Muse Code account host did not start in time', + ), + cancelledConnect.promise, + ]) + if (spawned === CANCELLED_CONNECT || isAborted()) { + throw new Error('The Muse Code account host was cancelled') + } + if (!spawned.initializeResult.experimentalApi) { + throw new Error('This Muse Code version does not offer its account methods') + } + return { connection: spawned.connection, close: () => spawned.close() } + } catch (error: unknown) { + await handshake.close() + throw error + } finally { + signal.removeEventListener('abort', onAbort) + } +} diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index f913a6a7..8a3f083b 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -2,12 +2,17 @@ // M7). The backend selection decides which credential counts: the Muse // Code CLI's own sign-in (subscription) for the CLI backend, the pasted // Model API key for the Model API backend; they are never mixed. `status` -// is the presence-based estimate; a backend's own `authRequired` turn error -// overrides it through `markAuthRequired`. All VS Code interactions -// (terminals, input boxes) are injected so the flow is unit-tested end to end. +// is the estimate from the credential facts (the CLI's from its credential +// file's structure, confirmed by the CLI when that is ambiguous, PLAN.md +// D26); a backend's own `authRequired` turn error overrides it through +// `markAuthRequired`. All VS Code interactions (terminals, input boxes) are +// injected so the flow is unit-tested end to end. -import { selectBackend } from '../../core/backendSelection' +import { isCliSignInConsulted, selectBackend } from '../../core/backendSelection' import type { BackendKind } from '../../core/agent/agentBackend' +import type { CliSignIn } from '../../core/backends/musecode/credentialFile' +import { wireWordForLog } from '../../core/logging' +import { unlessAborted } from '../../core/timeouts' import { type BackendMode, MUSE_INSTALL_POLL_INTERVAL_MS, @@ -15,9 +20,11 @@ import { MUSE_LOGOUT_ARGS, UI_TEXT, } from '../../shared/constants' +import { fill } from '../../shared/l10n/text' import type { AuthStatus, HostToWebviewMessage, SignInMethod } from '../../shared/protocol' import type { Logger } from '../logger' -import type { DeviceSignInOutcome } from './deviceSignIn' +import { isCliSignedIn } from './cliAccount' +import type { CapturedSignInEnding, DeviceSignInEnded, DeviceSignInOutcome } from './deviceSignIn' import type { CredentialStore } from './credentialStore' export interface AuthBackendFacts { @@ -25,8 +32,25 @@ export interface AuthBackendFacts { readonly resolveCli: () => | { readonly ok: true; readonly cliPath: string } | { readonly ok: false; readonly reason: string } - readonly credentialFileExists: () => boolean - /** The credential file's modification time (epoch ms); undefined when absent. */ + /** + * The CLI's own sign-in: its credential file's structure, and the CLI's + * `account/read` when that cannot say. With `isUserAction` macOS may ask + * too (its host reads the Keychain); otherwise it does not. + */ + readonly cliSignIn: (isUserAction: boolean) => Promise + /** + * Leaves an unanswered probe behind (Cancel): the next question asks + * afresh, and a remembered answer stands. + */ + readonly abandonCliProbe: () => void + /** + * Drops everything the CLI said, the remembered answer too (a sign-out, a + * new sign-in, Check again): the next question asks afresh. + */ + readonly forgetCliAnswers: () => void + /** Where the CLI keeps its sign-in, named when the file stops it starting. */ + readonly credentialFilePath: () => string + /** The credential file's modification time; undefined when there is none. */ readonly credentialFileModifiedAt: () => number | undefined readonly hasEnvironmentKey: () => boolean /** `museSpark.backend`. */ @@ -36,6 +60,14 @@ export interface AuthBackendFacts { * with `isConversationEnding` (sign-out) the conversations are not resumed. */ readonly restartBackend: (isConversationEnding: boolean) => Promise + /** + * The CLI's own sign-out, MSP `account/logout` on a short-lived host: true + * only when `account/read` then gives the captured signed-out answer. A + * host that could not start or refused, `envKey` (META_API_KEY hides the + * stored lane), a stored credential or a state never captured is false. + * Never rejects. + */ + readonly logOutCli: () => Promise } export interface AuthServiceDeps { @@ -89,21 +121,71 @@ export type AuthPort = Pick< | 'cancelSignIn' | 'signOut' | 'refresh' + | 'checkAgain' | 'markAuthRequired' | 'markBackendError' > /** * The sign-in story for the log (M39): the state, the backend it chose and, - * for an error, why. Written when the state or the backend changes. + * for an error, why (`loggedDetail`, which may stand in for a detail that + * names a path). Written when the state or the backend changes. */ -function signInLine(snapshot: AuthSnapshot): string { +function signInLine(snapshot: AuthSnapshot, loggedDetail: string | undefined): string { const backend = snapshot.backend === undefined ? '' : ` on the ${snapshot.backend} backend` - const why = - snapshot.status === 'error' && snapshot.detail !== undefined ? `: ${snapshot.detail}` : '' + const why = loggedDetail !== undefined && snapshot.status === 'error' ? `: ${loggedDetail}` : '' return `Sign-in state: ${snapshot.status}${backend}${why}` } +// What the log says in place of the unsupported-file message, which names +// the credential file's full path under the user's profile (the review of +// PR #49); the panel still shows the path. +const UNSUPPORTED_FILE_LOGGED = + 'the Muse Code credential file is in a format Muse Code cannot start with on this system' + +/** The browser sign-in's pre-check found a file the sign-in host could not start with. */ +const UNSUPPORTED_FILE = Symbol('unsupported credential file') + +/** + * Why a device sign-in the host ended did not sign in (read when shown, D33): + * each captured ending in its own words, any other as Muse Code named it + * (AGENTS.md rule 13). + */ +function endedSignInText( + ending: Exclude | DeviceSignInEnded, +): string { + switch (ending) { + case 'expired': { + return UI_TEXT.signInExpired + } + case 'denied': { + return UI_TEXT.signInDenied + } + case 'failed': { + return UI_TEXT.signInSaveFailed + } + default: { + return fill(UI_TEXT.signInEnded, { outcome: ending.endedAs }) + } + } +} + +/** One browser sign-in, as its steps see it. */ +interface CliSignInFlow { + /** What the panel showed when the flow began. */ + readonly initial: AuthSnapshot + readonly epoch: number + readonly wasLogoutHeld: boolean + /** Aborted by Cancel and by a sign-out. */ + readonly abort: AbortController + /** Aborted when a sign-out starts; Cancel leaves it. */ + readonly signOut: AbortSignal + /** The credential file's modification time when the flow began. */ + readonly fileBefore: number | undefined + /** The device code was shown: the browser may have approved it. */ + isCodeShown: boolean +} + export class AuthService { private snapshot: AuthSnapshot = { status: 'checking', detail: undefined } /** The browser sign-in in flight: a second click joins it (PLAN.md D25). */ @@ -112,15 +194,44 @@ export class AuthService { /** Every panel joins one sign-out; the hold cannot be released by an earlier caller. */ private signOutPromise: Promise | undefined private deviceAbort: AbortController | undefined - /** A sign-out invalidates key prompts already open in any surface. */ + /** + * A sign-out invalidates key prompts already open in any surface, and + * refreshes begun before it ended (it moves on as it starts and ends). + */ private signOutEpoch = 0 + /** Aborted as a sign-out starts: a finishing sign-in stops waiting on the CLI. */ + private signOutStarts = new AbortController() /** Invalidates selectors across a same-kind sign-out/sign-in or key replacement. */ private admissionGenerationValue = 0 /** Sign-out waits for accepted key writes and backend restarts before ending sessions. */ private readonly keyActivations = new Set>() + /** Every Check again pressed while one runs joins it: one question to the CLI. */ + private checkingAgain: Promise | undefined private isLogoutHeld: boolean private isSigningOut = false private isLogoutPersistenceFailed = false + /** The window is closing: no browser sign-in starts any more. */ + private isStopped = false + /** + * Every publication's place in time (the review of PR #49): a refresh + * takes a ticket as it starts and publishes only if nothing that started + * or was published after it has published first. + */ + private tickets = 0 + private publishedTicket = 0 + /** The browser sign-in's code is on screen: a refresh leaves the panel to that flow. */ + private isDeviceFlowWaiting = false + /** + * The backend conversations were last admitted on: the backend of the + * last signed-in state published, until a restart ends its conversations. + * The panel may show another state meanwhile (a sign-in's code) while a + * conversation still runs there. + */ + private liveBackend: BackendKind | undefined + /** Moves each time `liveBackend` is recorded: a restart clears only what it ended. */ + private liveVersion = 0 + /** Counts logout-hold writes: only the latest one's outcome is kept. */ + private holdWrites = 0 public constructor(private readonly deps: AuthServiceDeps) { this.isLogoutHeld = deps.logoutHold.get() @@ -128,12 +239,21 @@ export class AuthService { private async setLogoutHold(isHeld: boolean): Promise { this.isLogoutHeld = isHeld + // Only the latest write says whether the hold is saved: an older one + // that settles late speaks for a value that no longer holds (the review + // of PR #49). + this.holdWrites += 1 + const write = this.holdWrites try { await this.deps.logoutHold.set(isHeld) - this.isLogoutPersistenceFailed = false + if (write === this.holdWrites) { + this.isLogoutPersistenceFailed = false + } return true } catch { - this.isLogoutPersistenceFailed = true + if (write === this.holdWrites) { + this.isLogoutPersistenceFailed = true + } this.deps.log.warn('Muse Code sign-out state could not be saved') return false } @@ -143,9 +263,34 @@ export class AuthService { return this.isLogoutPersistenceFailed ? UI_TEXT.signOutHoldFailed : UI_TEXT.signOutPending } + /** + * Whether a sign-out runs now: read afresh after an await, which the + * compiler's narrowing of `isSigningOut` does not see. + */ + private isSignOutRunning(): boolean { + return this.isSigningOut + } + + /** + * Stops the running hosts; with `isConversationEnding` their conversations + * end too, so none runs on any backend any more. Rejects as the restart + * does. + */ + private async restartHosts(isConversationEnding: boolean): Promise { + // Cleared after the restart, so a failed one leaves the record of the + // conversations still running; and only if no signed-in state was + // published meanwhile, whose conversations did not end (the review of + // PR #49). + const live = this.liveVersion + await this.deps.backend.restartBackend(isConversationEnding) + if (isConversationEnding && live === this.liveVersion) { + this.liveBackend = undefined + } + } + private async stopBackendForSignOut(): Promise { try { - await this.deps.backend.restartBackend(true) + await this.restartHosts(true) return true } catch { this.deps.log.warn('Muse Code backend could not stop during sign-out') @@ -153,26 +298,147 @@ export class AuthService { } } - private set(snapshot: AuthSnapshot): AuthSnapshot { + /** Whether publishing `next` moves signed-in conversations to another backend. */ + private isBackendSwitch(next: AuthSnapshot): boolean { + return ( + next.status === 'signedIn' && + this.liveBackend !== undefined && + next.backend !== this.liveBackend + ) + } + + /** + * The one way a state is published that may sign in on another backend + * than conversations run on (the review of PR #49): new hosts are gated + * and that backend's conversations end first, as a device sign-in's + * success does, then `next` is published if it still may be (`isCurrent`, + * and no newer publication meanwhile). + */ + private async publishSelection( + next: AuthSnapshot, + ticket: number = this.nextTicket(), + isCurrent: () => boolean = () => true, + ): Promise { + if (!this.isBackendSwitch(next)) { + return this.set(next, ticket) + } + this.deps.log.info( + `Conversations move from the ${String(this.liveBackend)} backend to the ${String(next.backend)} backend: ending them first`, + ) + this.admissionGenerationValue += 1 + this.set( + { + ...this.snapshot, + status: 'checking', + detail: undefined, + verificationUrl: undefined, + userCode: undefined, + }, + ticket, + ) + try { + await this.restartHosts(true) + } catch { + this.deps.log.warn('The running backend could not stop before switching backends') + // The failure is published under the same guards as the success: a + // newer state, or a sign-out, published meanwhile stands (Codex on + // 86d63652). + return ticket < this.publishedTicket || !isCurrent() + ? this.snapshot + : this.set( + { + ...this.snapshot, + status: 'error', + detail: UI_TEXT.signOutStopFailed, + installState: + this.snapshot.installState === 'running' ? 'failed' : this.snapshot.installState, + }, + ticket, + ) + } + return ticket < this.publishedTicket || !isCurrent() ? this.snapshot : this.set(next, ticket) + } + + private nextTicket(): number { + this.tickets += 1 + return this.tickets + } + + /** + * A refresh's answer, unless something newer was published while it was + * asked, or a browser sign-in's code is on screen: its facts may be older + * than what the panel shows (the review of PR #49). + */ + private async publishRefresh( + ticket: number, + epoch: number, + snapshot: AuthSnapshot, + ): Promise { + return ticket < this.publishedTicket || this.isDeviceFlowWaiting + ? this.snapshot + : await this.publishSelection(snapshot, ticket, () => epoch === this.signOutEpoch) + } + + private set(snapshot: AuthSnapshot, ticket = this.nextTicket()): AuthSnapshot { const previous = this.snapshot this.snapshot = snapshot + this.publishedTicket = ticket + if (snapshot.status === 'signedIn') { + this.liveBackend = snapshot.backend + this.liveVersion += 1 + } if (previous.status !== snapshot.status || previous.backend !== snapshot.backend) { - this.deps.log.info(signInLine(snapshot)) + const isUnsupportedFile = + snapshot.detail !== undefined && snapshot.detail === this.unsupportedFileText() + this.deps.log.info( + signInLine(snapshot, isUnsupportedFile ? UNSUPPORTED_FILE_LOGGED : snapshot.detail), + ) } this.deps.broadcast(this.toMessage()) return this.snapshot } - /** The backend selection from the current facts. */ - private async choose() { + /** + * The CLI's own credential as the gate counts it: `META_API_KEY` in its + * environment, or its sign-in, including one only the CLI could confirm. + * With the key set no file is looked at: `muse serve` then starts even + * with a version-2 file it refuses otherwise, and uses the key (captured + * 2026-09-27, docs/certification/sign-in-detection.md). + */ + private async cliCredential( + isUserAction: boolean, + ): Promise<{ readonly hasCliSession: boolean; readonly isUnsupportedFile: boolean }> { + if (this.deps.backend.hasEnvironmentKey()) { + return { hasCliSession: true, isUnsupportedFile: false } + } + const signIn = await this.deps.backend.cliSignIn(isUserAction) + return { + hasCliSession: isCliSignedIn(signIn), + isUnsupportedFile: signIn === 'unsupportedHere', + } + } + + private async hasCliCredential(isUserAction: boolean): Promise { + const { hasCliSession } = await this.cliCredential(isUserAction) + return hasCliSession + } + + /** + * The backend selection from the current facts. With the backend forced + * to the Model API the CLI is not asked at all (Codex on 328efb52). + */ + private async choose(isUserAction: boolean) { const cli = this.deps.backend.resolveCli() - const hasCliSession = - this.deps.backend.credentialFileExists() || this.deps.backend.hasEnvironmentKey() + const setting = this.deps.backend.getBackendMode() + const { hasCliSession, isUnsupportedFile } = isCliSignInConsulted(setting) + ? await this.cliCredential(isUserAction) + : { hasCliSession: false, isUnsupportedFile: false } return { cli, hasCliSession, + isUnsupportedFile, choice: selectBackend({ - setting: this.deps.backend.getBackendMode(), + setting, hasCli: cli.ok, hasCliSession, hasStoredKey: (await this.deps.credentials.getApiKey()) !== undefined, @@ -180,11 +446,23 @@ export class AuthService { } } + /** A macOS credential file on Windows or Linux: `muse serve` exits at startup. */ + private unsupportedFileText(): string { + return fill(UI_TEXT.cliCredentialUnsupported, { + path: this.deps.backend.credentialFilePath(), + }) + } + /** One device-code sign-in process, however often the button is pressed (D25). */ private async joinDeviceSignIn(): Promise { if (this.deviceSignIn !== undefined) { return await this.deviceSignIn } + // A click whose pre-flight questions outlived the window starts no host + // after the backends stopped (the review of PR #49). + if (this.isStopped) { + return this.snapshot + } this.deviceSignIn = this.signInWithCli() try { return await this.deviceSignIn @@ -193,136 +471,240 @@ export class AuthService { } } + /** + * The flow's pre-check and the device runner, while the flow owns the + * panel: a refresh meanwhile does not publish over its code (the review of + * PR #49). + */ + private async awaitDeviceRunner( + flow: CliSignInFlow, + ): Promise { + const { abort } = flow + this.isDeviceFlowWaiting = true + try { + // The sign-in host could not start with that file either. A probe the + // CLI never answers must not hold Cancel or sign-out (the review of + // PR #49): the look ends with the flow's own signal. + const credential = await unlessAborted(this.cliCredential(false), abort.signal) + if (credential?.isUnsupportedFile === true) { + return UNSUPPORTED_FILE + } + // A cancel or sign-out during that look ends the flow before it starts. + return abort.signal.aborted + ? 'cancelled' + : await this.deps.runDeviceSignIn(abort.signal, (url, code) => { + if (abort.signal.aborted) { + return + } + flow.isCodeShown = true + this.set({ ...this.snapshot, verificationUrl: url, userCode: code }) + }) + } finally { + this.isDeviceFlowWaiting = false + } + } + private async signInWithCli(): Promise { - const initial = this.snapshot - const epoch = this.signOutEpoch - const wasLogoutHeld = this.isLogoutHeld - const beforeCredential = wasLogoutHeld - ? this.deps.backend.credentialFileModifiedAt() - : undefined + const flow: CliSignInFlow = { + initial: this.snapshot, + epoch: this.signOutEpoch, + wasLogoutHeld: this.isLogoutHeld, + abort: new AbortController(), + signOut: this.signOutStarts.signal, + fileBefore: this.deps.backend.credentialFileModifiedAt(), + isCodeShown: false, + } + const { abort } = flow const cli = this.deps.backend.resolveCli() if (!cli.ok) { - return await this.finishFailedCliSignIn(initial, 'noCli', cli.reason, 'warning') + return await this.finishFailedCliSignIn(flow, 'noCli', cli.reason, 'warning') } - const abort = new AbortController() + // Cancel and sign-out reach this flow from here on, before any await. this.deviceAbort = abort this.set({ ...this.snapshot, status: 'signingIn', detail: UI_TEXT.signInWaiting }) try { - const outcome = await this.deps.runDeviceSignIn(abort.signal, (url, code) => { - if (abort.signal.aborted) { - return - } - this.set({ ...this.snapshot, verificationUrl: url, userCode: code }) - }) - if (outcome === 'cancelled') { + const outcome = await this.awaitDeviceRunner(flow) + if (outcome === UNSUPPORTED_FILE) { return await this.finishFailedCliSignIn( - initial, - 'signedOut', - UI_TEXT.signInCancelled, - 'info', + flow, + 'error', + this.unsupportedFileText(), + 'warning', ) } + if (outcome === 'cancelled') { + return await this.finishCancelledCliSignIn(flow) + } if (outcome === 'timedOut') { return await this.finishFailedCliSignIn( - initial, + flow, 'signedOut', UI_TEXT.signInTimedOut, 'warning', ) } + if (outcome !== 'signedIn') { + return await this.finishFailedCliSignIn( + flow, + 'signedOut', + endedSignInText(outcome), + 'warning', + ) + } if (this.isSigningOut) { return this.snapshot } - if (wasLogoutHeld) { - const afterCredential = this.deps.backend.credentialFileModifiedAt() - if ( - beforeCredential === undefined || - afterCredential === undefined || - afterCredential === beforeCredential || - this.deps.backend.hasEnvironmentKey() || - (await this.deps.credentials.getApiKey()) !== undefined - ) { - return await this.refresh() - } - } - this.admissionGenerationValue += 1 - await this.deps.backend.restartBackend(initial.status === 'signedIn') - if (wasLogoutHeld) { - if (this.signOutEpoch !== epoch || this.deps.backend.hasEnvironmentKey()) { - return await this.refresh() - } - const isHoldSaved = await this.setLogoutHold(false) - if (!isHoldSaved) { - return this.set({ ...this.snapshot, status: 'error', detail: UI_TEXT.signOutHoldFailed }) - } - } - return await this.refresh() + // What the CLI said before this sign-in no longer holds, even where + // the file did not change (a Keychain sign-in; the review of PR #49). + this.deps.backend.forgetCliAnswers() + return await this.confirmCliSignIn(flow) } catch (error: unknown) { this.deps.log.warn( `In-panel sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`, ) - return await this.finishFailedCliSignIn(initial, 'error', UI_TEXT.signInFailed, 'warning') + return await this.finishFailedCliSignIn(flow, 'error', UI_TEXT.signInFailed, 'warning') } finally { this.deviceAbort = undefined } } + /** + * The device runner saw the sign-in land. Every question to the CLI here + * yields to the flow's signal, so a sign-out (or Cancel) never waits on + * one (the review of PR #49). + */ + private async confirmCliSignIn(flow: CliSignInFlow): Promise { + const { abort } = flow + // After a sign-out, only the CLI's own confirmation of the new sign-in + // (the device runner's, then the file or `account/read` here) lifts the + // hold, and never while a key would bill instead. + if (flow.wasLogoutHeld) { + const hasBillingKey = + this.deps.backend.hasEnvironmentKey() || + (await this.deps.credentials.getApiKey()) !== undefined + const confirmed = hasBillingKey + ? undefined + : await unlessAborted(this.deps.backend.cliSignIn(true), abort.signal) + if (abort.signal.aborted) { + return await this.finishCancelledCliSignIn(flow) + } + if (confirmed !== 'signedIn') { + return await this.refreshUnlessCancelled(flow) + } + } + this.admissionGenerationValue += 1 + // A restart that fails after a newer state was published (a refresh + // meanwhile) leaves that state standing, rather than the flow's failure + // (Codex on 86d63652). + const published = this.publishedTicket + try { + await this.restartHosts(flow.initial.status === 'signedIn') + } catch (error: unknown) { + if (this.publishedTicket !== published) { + this.deps.log.warn('The backend did not restart after sign-in; a newer state stands') + return this.snapshot + } + throw error + } + if (abort.signal.aborted) { + return await this.finishCancelledCliSignIn(flow) + } + if (flow.wasLogoutHeld) { + if (this.signOutEpoch !== flow.epoch || this.deps.backend.hasEnvironmentKey()) { + return await this.refreshUnlessCancelled(flow) + } + const isHoldSaved = await this.setLogoutHold(false) + if (!isHoldSaved) { + return this.set({ ...this.snapshot, status: 'error', detail: UI_TEXT.signOutHoldFailed }) + } + } + return await this.refreshUnlessCancelled(flow) + } + + /** A user-action refresh that ends with the flow's signal. */ + private async refreshUnlessCancelled(flow: CliSignInFlow): Promise { + const refreshed = await unlessAborted(this.refresh(true), flow.abort.signal) + return refreshed ?? (await this.finishCancelledCliSignIn(flow)) + } + + /** + * Cancel decides the flow, but not against what the CLI saved (the review + * of PR #49). When the credential file changed since the flow began (the + * browser approved as Cancel was pressed), the CLI's sign-in is read + * afresh, a Cancel being a click that may ask it. When the code was shown + * and the file did not change, the CLI is asked afresh too: on macOS an + * approval may land in the Keychain alone, the pointer file as it was + * (Codex on 55b9e24c). + */ + private async finishCancelledCliSignIn(flow: CliSignInFlow): Promise { + if (this.isSigningOut) { + return this.snapshot + } + const isFileChanged = this.deps.backend.credentialFileModifiedAt() !== flow.fileBefore + if (isFileChanged) { + return (await unlessAborted(this.refresh(true), flow.signOut)) ?? this.snapshot + } + if (flow.isCodeShown) { + this.deps.backend.forgetCliAnswers() + } + return await this.finishFailedCliSignIn( + flow, + 'signedOut', + UI_TEXT.signInCancelled, + 'info', + flow.isCodeShown, + ) + } + + /** + * A flow that did not sign in: its state is published at once, then, with + * the hold on, a Model API session, or `isAskingAfresh` (a Cancel after + * the code was shown), a refresh reads the CLI's sign-in and publishes + * what it finds through `publishSelection`'s guards, with the ending as a + * notice. + */ private async finishFailedCliSignIn( - initial: AuthSnapshot, + flow: CliSignInFlow, status: 'noCli' | 'signedOut' | 'error', detail: string, noticeLevel: 'info' | 'warning', + isAskingAfresh = false, ): Promise { - if (this.isLogoutHeld) { - const refreshed = await this.refresh() - this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) - return refreshed - } - if (initial.status === 'signedIn' && initial.backend === 'modelApi') { - const refreshed = await this.refresh() - this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) - return refreshed + // A sign-out that cancelled this sign-in publishes its own state. + if (this.isSigningOut) { + return this.snapshot } - return this.set({ + const { initial } = flow + // The code leaves the panel at once, not after the refresh below, which + // may wait on the CLI (the review of PR #49). + const ended = this.set({ ...this.snapshot, status, detail, verificationUrl: undefined, userCode: undefined, }) - } - - private async refreshAfterCliDiscovery(epoch: number): Promise { - const selected = await this.selectedSnapshot() - if (epoch !== this.signOutEpoch) { - return this.snapshot - } if ( - this.snapshot.status === 'signedIn' && - this.snapshot.backend === 'modelApi' && - selected.status === 'signedIn' && - selected.backend === 'museCode' + isAskingAfresh || + this.isLogoutHeld || + (initial.status === 'signedIn' && initial.backend === 'modelApi') ) { - // Auto selection crossed backends. Gate new hosts and retire the Model - // API conversation before reporting a signed-in CLI host. - this.admissionGenerationValue += 1 - this.set({ ...this.snapshot, status: 'checking', detail: undefined }) - try { - await this.deps.backend.restartBackend(true) - } catch { - this.deps.log.warn('The Model API host could not stop after Muse Code installation') - return this.set({ - ...this.snapshot, - status: 'error', - detail: UI_TEXT.signOutStopFailed, - installState: 'failed', - }) - } - if (epoch !== this.signOutEpoch) { + // A sign-out that starts meanwhile publishes its own state, and does + // not wait on the question this refresh may ask (the review of PR #49). + const refreshed = await unlessAborted(this.refresh(true), flow.signOut) + if (refreshed === undefined) { return this.snapshot } + this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) + return refreshed } - return await this.refresh() + return ended + } + + private async refreshAfterCliDiscovery(epoch: number): Promise { + // Auto selection may cross backends: the refresh publishes through + // `publishSelection`, which retires the Model API conversation first. + return epoch === this.signOutEpoch ? await this.refresh(true) : this.snapshot } private async installWithCli(): Promise { @@ -370,8 +752,11 @@ export class AuthService { } private async installFailed(detail: string, epoch: number): Promise { - const selected = await this.selectedSnapshot() - if (epoch !== this.signOutEpoch || this.isSigningOut) { + // The install's error path publishes what it asks here only if nothing + // newer was published while it asked (Codex on 86d63652). + const ticket = this.nextTicket() + const selected = await this.selectedSnapshot(true) + if (epoch !== this.signOutEpoch || this.isSigningOut || ticket < this.publishedTicket) { return this.snapshot } if (this.isLogoutHeld) { @@ -383,10 +768,11 @@ export class AuthService { installState: 'failed', }) } + // Either may sign in on another backend than conversations run on. if (selected.hasCli === true) { - return this.set(selected) + return await this.publishSelection(selected) } - const failed = this.set({ + const failed = await this.publishSelection({ ...selected, detail, installState: 'failed', @@ -398,8 +784,8 @@ export class AuthService { } /** Derive from current CLI, setting and SecretStorage facts. */ - private async selectedSnapshot(): Promise { - const { cli, hasCliSession, choice } = await this.choose() + private async selectedSnapshot(isUserAction: boolean): Promise { + const { cli, hasCliSession, isUnsupportedFile, choice } = await this.choose(isUserAction) if (choice.kind === undefined) { return { status: 'noCli', @@ -410,9 +796,12 @@ export class AuthService { hasCliSession, } } + // Muse Code would be used but cannot start with its credential file: + // said by name, not left to a host that exits at every message. + const isBlocked = choice.kind === 'museCode' && cli.ok && isUnsupportedFile return { - status: choice.status, - detail: undefined, + status: isBlocked ? 'error' : choice.status, + detail: isBlocked ? this.unsupportedFileText() : undefined, backend: choice.kind, methods: choice.methods, hasCli: cli.ok, @@ -433,7 +822,7 @@ export class AuthService { // Stop old turns while they still read the old key. A tool round must not // resume after SecretStorage begins returning the replacement key. if (isReplacingActiveAccount) { - await this.deps.backend.restartBackend(true) + await this.restartHosts(true) } if (epoch !== this.signOutEpoch) { return this.snapshot @@ -446,16 +835,55 @@ export class AuthService { !isReplacingActiveAccount && (this.snapshot.status !== 'signedIn' || this.snapshot.backend !== 'museCode') ) { - await this.deps.backend.restartBackend(false) + await this.restartHosts(false) + } + const selected = await this.selectedSnapshot(true) + // A key can move conversations off Muse Code (a CLI no longer signed in). + return epoch === this.signOutEpoch + ? await this.publishSelection(selected, undefined, () => epoch === this.signOutEpoch) + : this.snapshot + } + + /** + * The CLI's own sign-out: `account/logout` on a short-lived host, else + * `muse logout` in a terminal (confirmed later, by the file or the CLI). + * Returns false when that terminal could not open. + */ + private async logOutCli(cliPath: string): Promise { + const isConfirmed = await this.deps.backend.logOutCli() + // What the CLI said before the logout no longer holds, even when the + // file did not change (the review of PR #49). + this.deps.backend.forgetCliAnswers() + if (isConfirmed) { + return true + } + // `account/logout` may have worked while META_API_KEY hid it from + // `account/read` (`envKey`): the sign-in itself, read afresh, says + // whether a terminal is still needed (the review of PR #49). + if ((await this.deps.backend.cliSignIn(true)) === 'signedOut') { + this.deps.log.info( + 'Muse Code did not confirm account/logout, but its sign-in now reads signed out; no terminal needed', + ) + return true + } + this.deps.log.warn( + 'Muse Code still reads signed in after account/logout; running muse logout in a terminal', + ) + try { + this.deps.runInTerminal(cliPath, MUSE_LOGOUT_ARGS) + return true + } catch { + this.deps.log.warn('Muse Code logout terminal could not open') + return false } - const selected = await this.selectedSnapshot() - return epoch === this.signOutEpoch ? this.set(selected) : this.snapshot } private async performSignOut(): Promise { this.signOutEpoch += 1 this.admissionGenerationValue += 1 this.isSigningOut = true + this.signOutStarts.abort() + this.signOutStarts = new AbortController() this.set({ ...this.snapshot, status: 'error', @@ -464,6 +892,10 @@ export class AuthService { userCode: undefined, }) this.cancelSignIn() + // Cancel keeps a remembered answer; a sign-out must not decide on it: a + // Keychain sign-in made since leaves the file as it was, and a stale + // `signedOut` would skip `account/logout` (the review of PR #49). + this.deps.backend.forgetCliAnswers() const hasPendingSignIn = this.deviceSignIn !== undefined || this.keyActivations.size > 0 const stopping = this.stopBackendForSignOut() try { @@ -484,17 +916,13 @@ export class AuthService { this.deps.log.warn('Stored Model API key could not be cleared during sign-out') } const cli = this.deps.backend.resolveCli() - let isTerminalUnavailable = false - if (cli.ok && this.deps.backend.credentialFileExists()) { - try { - this.deps.runInTerminal(cli.cliPath, MUSE_LOGOUT_ARGS) - } catch { - isTerminalUnavailable = true - this.deps.log.warn('Muse Code logout terminal could not open') - } - } - const hasCliCredential = - this.deps.backend.credentialFileExists() || this.deps.backend.hasEnvironmentKey() + const isTerminalUnavailable = + cli.ok && + isCliSignedIn(await this.deps.backend.cliSignIn(true)) && + !(await this.logOutCli(cli.cliPath)) + // `muse logout` rewrites the file rather than deleting it: what is in + // it, or the CLI's own answer, says whether a sign-in remains. + const hasCliCredential = await this.hasCliCredential(true) const hasStoredKey = isKeyClearFailed || (await this.deps.credentials.getApiKey()) !== undefined const shouldKeepHold = hasCliCredential || hasStoredKey || !isHostStopped @@ -511,24 +939,70 @@ export class AuthService { } else if (shouldKeepHold) { detail = UI_TEXT.signOutPending } + // Every detail asks for a step and a Check again, which the panel + // offers on `error` only; `refresh` says `error` for the same state + // (the review of PR #49). return this.set({ ...this.snapshot, - status: - !isHostStopped || isKeyClearFailed || !isHoldSaved || !isReleaseSaved - ? 'error' - : 'signedOut', + status: detail === undefined ? 'signedOut' : 'error', detail, verificationUrl: undefined, userCode: undefined, installState: undefined, + hasCliSession: hasCliCredential, }) } finally { this.isSigningOut = false + // A refresh begun during the sign-out answers too late to publish (the + // review of PR #49). + this.signOutEpoch += 1 } } public cancelSignIn(): void { this.deviceAbort?.abort() + // Sign-out and the next sign-in ask afresh rather than wait on a probe + // the CLI left unanswered; an answer already given stands, so what + // Cancel shows needs no new question (the review of PR #49). + this.deps.backend.abandonCliProbe() + } + + /** + * The window is closing: the browser sign-in is cancelled and waited + * for, so its host is closed before the backends stop, and none starts + * afterwards, not even from a click still in its pre-flight questions (the + * review of PR #49). A flag rather than the window's signal joined to the + * flow's connect: a stopped click then never publishes `signingIn`, asks + * its pre-check or opens a key prompt, where a signal would reach it only + * at the connect. + */ + public async stopSignIn(): Promise { + this.isStopped = true + this.cancelSignIn() + const signingIn = this.deviceSignIn + if (signingIn !== undefined) { + await Promise.allSettled([signingIn]) + } + } + + /** + * Check again: the CLI is asked afresh, even about a sign-in it confirmed + * before (a Keychain sign-in or sign-out leaves the file as it was; the + * review of PR #49). Presses while one runs join it, so a second press does + * not forget the probe the first started and start another host. + */ + public async checkAgain(): Promise { + if (this.checkingAgain !== undefined) { + return await this.checkingAgain + } + this.deps.backend.forgetCliAnswers() + const checking = this.refresh(true) + this.checkingAgain = checking + try { + return await checking + } finally { + this.checkingAgain = undefined + } } /** One visible installer terminal and one location watch per window. */ @@ -586,57 +1060,96 @@ export class AuthService { } } - /** Re-derive the status from the CLI, credential and setting facts, then broadcast. */ - public async refresh(): Promise { + /** + * Re-derive the status from the CLI, credential and setting facts, then + * broadcast. `isUserAction` (Check again, a sign-in or sign-out) lets + * macOS ask the CLI about a Keychain sign-in; opening a panel does not. + */ + public async refresh(isUserAction = false): Promise { const epoch = this.signOutEpoch - const selected = await this.selectedSnapshot() - if (this.isSigningOut || this.signOutEpoch !== epoch) { - return this.set({ ...selected, status: 'error', detail: this.logoutDetail() }) + // Publications are ordered by when their questions began: a slower, + // older refresh never overwrites a newer state (the review of PR #49). + const ticket = this.nextTicket() + const selected = await this.selectedSnapshot(isUserAction) + // A refresh begun before a sign-out (its probe may answer long after) + // never overwrites what the sign-out, or a later sign-in, published (the + // review of PR #49). + if (this.signOutEpoch !== epoch) { + return this.snapshot + } + if (this.isSigningOut) { + return await this.publishRefresh(ticket, epoch, { + ...selected, + status: 'error', + detail: this.logoutDetail(), + }) } if (!this.isLogoutHeld) { - return this.set(selected) + return await this.publishRefresh(ticket, epoch, selected) } - const hasCliCredential = - this.deps.backend.credentialFileExists() || this.deps.backend.hasEnvironmentKey() + const hasCliCredential = await this.hasCliCredential(isUserAction) const hasStoredKey = (await this.deps.credentials.getApiKey()) !== undefined if (this.signOutEpoch !== epoch) { - return this.set({ ...selected, status: 'error', detail: this.logoutDetail() }) + return this.snapshot } if (hasCliCredential || hasStoredKey) { - return this.set({ ...selected, status: 'error', detail: this.logoutDetail() }) + return await this.publishRefresh(ticket, epoch, { + ...selected, + status: 'error', + detail: this.logoutDetail(), + }) } const isHoldSaved = await this.setLogoutHold(false) if (!isHoldSaved) { - return this.set({ ...selected, status: 'error', detail: UI_TEXT.signOutHoldFailed }) + return await this.publishRefresh(ticket, epoch, { + ...selected, + status: 'error', + detail: UI_TEXT.signOutHoldFailed, + }) } - const current = await this.selectedSnapshot() + const current = await this.selectedSnapshot(isUserAction) const hasCurrentCredential = - this.deps.backend.credentialFileExists() || - this.deps.backend.hasEnvironmentKey() || + (await this.hasCliCredential(isUserAction)) || (await this.deps.credentials.getApiKey()) !== undefined - if (hasCurrentCredential || this.signOutEpoch !== epoch || current.status === 'signedIn') { + if (this.signOutEpoch !== epoch) { + // A sign-out raced this release: its state is its own, and so is the + // hold. One still running holds it again; one that has ended decided + // it, and a late answer must not put back a hold it released (the + // review of PR #49). + if (this.isSignOutRunning()) { + await this.setLogoutHold(true) + } + return this.snapshot + } + if (hasCurrentCredential || current.status === 'signedIn') { await this.setLogoutHold(true) - return this.set({ ...current, status: 'error', detail: this.logoutDetail() }) + return await this.publishRefresh(ticket, epoch, { + ...current, + status: 'error', + detail: this.logoutDetail(), + }) } - return this.set(current) + return await this.publishRefresh(ticket, epoch, current) } public async signIn(method: SignInMethod): Promise { - if (this.isSigningOut) { + if (this.isSigningOut || this.isStopped) { return this.snapshot } const epoch = this.signOutEpoch if (this.isLogoutHeld) { - const canRecoverCliFile = + // A sign-in the CLI still holds after sign-out can be replaced only by + // an explicit new browser approval, with no key that would bill instead. + const canRecoverCliSignIn = method === 'browser' && !this.deps.backend.hasEnvironmentKey() && - this.deps.backend.credentialFileExists() && + isCliSignedIn(await this.deps.backend.cliSignIn(true)) && (await this.deps.credentials.getApiKey()) === undefined if (epoch !== this.signOutEpoch) { return this.snapshot } - if (!canRecoverCliFile) { - const refreshed = await this.refresh() + if (!canRecoverCliSignIn) { + const refreshed = await this.refresh(true) if (epoch !== this.signOutEpoch) { return this.snapshot } @@ -680,7 +1193,9 @@ export class AuthService { /** The backend answered a turn with `authRequired`: the estimate was wrong. */ public markAuthRequired(reason: string): AuthSnapshot { - this.deps.log.warn(`The backend reported authRequired: ${reason}`) + // The reason is the backend's own text: the panel shows it, and the log + // names it only in the shape of a protocol word (the review of PR #49). + this.deps.log.warn(`The backend reported authRequired: ${wireWordForLog(reason)}`) return this.set({ ...this.snapshot, status: 'signedOut', detail: reason }) } diff --git a/src/host/auth/authState.ts b/src/host/auth/authState.ts deleted file mode 100644 index f5eb4483..00000000 --- a/src/host/auth/authState.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Pure decision: is there any credential the Muse Code CLI could use? -// -// The CLI's precedence is META_API_KEY → stored API key → browser session -// (dev.meta.ai/docs/muse-code/auth). The extension mirrors it: a key in secret -// storage (injected as META_API_KEY), a key already in the environment, or the -// CLI's own credential file. This is a *presence* check only; the authoritative -// answer is the host's `authRequired` turn error, which the controller maps -// back to `signedOut`. - -export type AuthStatus = 'signedIn' | 'signedOut' - -export interface AuthEvidence { - readonly hasStoredKey: boolean - readonly hasEnvironmentKey: boolean - readonly credentialFileExists: boolean -} - -export function deriveAuthStatus(evidence: AuthEvidence): AuthStatus { - return evidence.hasStoredKey || evidence.hasEnvironmentKey || evidence.credentialFileExists - ? 'signedIn' - : 'signedOut' -} diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts new file mode 100644 index 00000000..182d8dee --- /dev/null +++ b/src/host/auth/cliAccount.ts @@ -0,0 +1,251 @@ +// The Muse Code CLI's own sign-in, as the extension tells it (PLAN.md D26, +// 2026-09-27). `muse logout` rewrites the credential file instead of +// deleting it, so the file being there says nothing on its own: +// - No file: signed out on every OS, and no process is started. +// - A file is read for its structure only (credentialFile.ts); a sign-out's +// empty file is signed out, and off macOS a file holding the credential in +// a captured shape is signed in. +// - A macOS Keychain pointer, any other file on macOS, and a file the +// structure cannot place are asked of the CLI itself (`account/read` on a +// short-lived host), and that answer is kept until the file's size or +// modification time changes. +// - On macOS the question waits for a user action (a click in the panel), so +// a Keychain prompt never appears just because VS Code opened. + +import { readFileSync, statSync } from 'node:fs' +import { + type CliSignIn, + type CredentialFileVerdict, + credentialFileVerdict, +} from '../../core/backends/musecode/credentialFile' +import { wireWordForLog } from '../../core/logging' +import { + MUSE_CREDENTIAL_FILE_MAX_BYTES, + MUSE_CREDENTIAL_READ_ATTEMPTS, + MUSE_USER_ACTION_ANSWER_REUSE_MS, +} from '../../shared/constants' +import type { Logger } from '../logger' +import { type AccountState, isCapturedSignedOut, isStoredSignIn } from './accountHost' + +export interface CredentialFileReading { + /** `:`: a write changes it. */ + readonly signature: string + readonly verdict: CredentialFileVerdict +} + +// A path that is not there, as opposed to one that is there and unreadable. +const MISSING_CODES: ReadonlySet = new Set(['ENOENT', 'ENOTDIR']) +const UNREADABLE_SIGNATURE = 'unreadable' + +function errorCode(error: unknown): string { + return error instanceof Error && 'code' in error ? String(error.code) : '' +} + +/** The credential file's structure, or undefined when there is no file. Never a value from it. */ +export function readCredentialFile( + filePath: string, + platform: NodeJS.Platform, +): CredentialFileReading | undefined { + let size: number + let modifiedAt: number + try { + const stats = statSync(filePath) + if (!stats.isFile()) { + return { signature: UNREADABLE_SIGNATURE, verdict: 'unrecognized' } + } + size = stats.size + modifiedAt = stats.mtimeMs + } catch (error: unknown) { + return MISSING_CODES.has(errorCode(error)) + ? undefined + : { signature: UNREADABLE_SIGNATURE, verdict: 'unrecognized' } + } + const signature = `${String(size)}:${String(modifiedAt)}` + if (size > MUSE_CREDENTIAL_FILE_MAX_BYTES) { + return { signature, verdict: 'unrecognized' } + } + try { + return { signature, verdict: credentialFileVerdict(readFileSync(filePath, 'utf8'), platform) } + } catch { + return { signature, verdict: 'unrecognized' } + } +} + +/** What `account/read` says about the stored sign-in; undefined when it said nothing. */ +export function cliSignInFromAccount(account: AccountState | undefined): CliSignIn { + if (account === undefined) { + return 'unknown' + } + if (isStoredSignIn(account)) { + return 'signedIn' + } + // Only `credentialRequired: true` was captured (every `account/read` in + // docs/certification/sign-in-detection.md): another value, `envKey` masking + // the stored lane, or a future state is not guessed at (AGENTS.md rule 13, + // the review of PR #49). + return isCapturedSignedOut(account) ? 'signedOut' : 'unknown' +} + +/** What the structure settles alone; a Keychain pointer on macOS and anything unrecognized go to the CLI. */ +const FILE_SIGN_IN: Partial> = { + empty: 'signedOut', + inline: 'signedIn', + unsupportedHere: 'unsupportedHere', +} + +/** Signed in, or possibly: the estimate the gate and the backend choice use. */ +export function isCliSignedIn(signIn: CliSignIn): boolean { + return signIn === 'signedIn' || signIn === 'unknown' +} + +export interface CliAccountDeps { + readonly platform: NodeJS.Platform + readonly credentialFilePath: () => string + /** `account/read` on a short-lived host; undefined when it could not say. */ + readonly probe: () => Promise + readonly log: Logger + /** The clock an answer's age is read on; `Date.now` unless a test gives one. */ + readonly now?: () => number +} + +/** One question to the CLI, which Cancel, a sign-out or Check again may leave behind. */ +interface Probe { + readonly key: string + readonly signIn: Promise + isAbandoned: boolean +} + +// What an abandoned probe gives its callers instead of its answer: they look +// again, so none of them publishes an answer older than what was asked since +// (the review of PR #49). +const OBSOLETE = Symbol('the answer of an abandoned probe') + +export class CliAccount { + private answered: + { readonly key: string; readonly signIn: CliSignIn; readonly at: number } | undefined + private asking: Probe | undefined + + public constructor(private readonly deps: CliAccountDeps) {} + + private now(): number { + return (this.deps.now ?? Date.now)() + } + + /** + * Whether a remembered answer stands for this question. A passive look + * takes it. A user action asks again after "could not say", and on macOS + * after any answer older than the click: a sign-in or sign-out made + * elsewhere may change only the Keychain, the file as it was (Codex on + * 2a324d48). + */ + private mayReuse(answered: NonNullable, isUserAction: boolean): boolean { + return ( + !isUserAction || + (answered.signIn !== 'unknown' && + (this.deps.platform !== 'darwin' || + this.now() - answered.at < MUSE_USER_ACTION_ANSWER_REUSE_MS)) + ) + } + + private async confirm(key: string, isUserAction: boolean): Promise { + const answered = this.answered + if (answered?.key === key && this.mayReuse(answered, isUserAction)) { + return answered.signIn + } + if (!isUserAction && this.deps.platform === 'darwin') { + return 'unknown' + } + if (this.asking?.key === key) { + const joined = this.asking + const signIn = await joined.signIn + return joined.isAbandoned ? OBSOLETE : signIn + } + // A probe about an older version of the file is left behind: its late + // answer must not replace this one's, remembered meanwhile, and its + // callers look again anyway (the review of PR #49). + if (this.asking !== undefined) { + this.asking.isAbandoned = true + } + const asking: Probe = { key, signIn: this.ask(), isAbandoned: false } + this.asking = asking + try { + const signIn = await asking.signIn + // An abandoned probe's late answer is neither remembered nor given. + if (asking.isAbandoned) { + return OBSOLETE + } + this.answered = { key, signIn, at: this.now() } + return signIn + } finally { + if (this.asking === asking) { + this.asking = undefined + } + } + } + + private async ask(): Promise { + const account = await this.deps.probe() + const signIn = cliSignInFromAccount(account) + // The state word only, and only in the shape of one: the account's label + // is an e-mail address, and the state vocabulary is open. + const said = account === undefined ? 'no answer' : wireWordForLog(account.state) + this.deps.log.info(`Muse Code sign-in confirmed by account/read: ${said} (${signIn})`) + return signIn + } + + /** What the file settles alone, or the key the CLI's answer about it is kept under. */ + private look(): { readonly settled: CliSignIn | undefined; readonly key: string } { + const filePath = this.deps.credentialFilePath() + const reading = readCredentialFile(filePath, this.deps.platform) + return reading === undefined + ? { settled: 'signedOut', key: filePath } + : { settled: FILE_SIGN_IN[reading.verdict], key: `${filePath}\n${reading.signature}` } + } + + /** + * Leaves an unanswered probe behind (Cancel; the review of PR #49): the + * next question asks afresh instead of joining it, and its late answer is + * neither remembered nor given to the callers that waited on it, who look + * again. A remembered answer stands, so what Cancel shows next needs no + * new question. + */ + public abandonProbe(): void { + if (this.asking !== undefined) { + this.asking.isAbandoned = true + } + this.asking = undefined + } + + /** + * Forgets everything the CLI said (after a sign-out, a new sign-in or + * Check again; the review of PR #49): the unanswered probe too, and the + * remembered answer, so the next question asks afresh. A change that + * leaves the file as it was (a Keychain sign-in or sign-out) would + * otherwise keep reading as before. + */ + public forgetAnswers(): void { + this.abandonProbe() + this.answered = undefined + } + + /** + * The CLI's sign-in. `isUserAction` lets macOS ask the CLI (the host may + * read the Keychain); elsewhere an ambiguous file is asked about at once. + */ + public async signIn(isUserAction: boolean): Promise { + for (let attempt = 0; attempt < MUSE_CREDENTIAL_READ_ATTEMPTS; attempt += 1) { + const look = this.look() + if (look.settled !== undefined) { + return look.settled + } + const signIn = await this.confirm(look.key, isUserAction) + // An answer about a file that has since been rewritten is not an + // answer about this one, and an abandoned probe's is older than what + // was asked since (the review of PR #49): look again. + if (signIn !== OBSOLETE && this.look().key === look.key) { + return signIn + } + } + return 'unknown' + } +} diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index c15aa649..7c474390 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -1,48 +1,140 @@ // Muse Code's captured experimental account/device-code flow (M55). This // process owns no chat session and is always closed after success, cancel, // timeout or error. The extension never sends its Model API key to this host. +// +// A sign-in has succeeded (PLAN.md D26, 2026-09-27) when polling +// `account/read` on the same host sees the account sign in, or when the +// credential file is written and `account/read` does not contradict it; a +// CLI that cannot answer `account/read` falls back to the file alone. +// +// `account/loginCompleted` ends the flow on any outcome but `granted`. Every +// outcome was captured live (docs/certification/sign-in-detection.md, "Live +// capture"): `cancelled`, `expired`, `denied` and `failed` each have a +// meaning of their own, and any other word is shown as the CLI named it +// (AGENTS.md rule 13). `granted` came 205 ms after the file was written and +// `account/read` said `accountLogin`, so those usually decide first. It is +// remembered for when they cannot: `granted` with `account/read` saying +// `accountLogin` is the sign-in, with no first `account/read` to compare, +// or when the account was already `accountLogin` (a same-account re-sign-in +// on macOS may change only the Keychain). `granted` alone never signs in. `account/changed` is not relied on: it fired neither +// for a change made outside the host nor for an expired, denied or failed +// code. +// +// Cancel, the host's ending and the host's exit are noticed at once, even +// while an `account/read` is unanswered, and the `account/loginCancel` sent +// on the way out is bounded: the host is closed either way, which ends its +// flow. A host that exits after the credential file changed has signed in, +// unless an answered `account/read` said signed out about that same write: +// that evidence stands for the exit and for a question left unanswered. -import { spawnMspConnection, type Connection } from '@muse-code/sdk' import * as z from 'zod/mini' +import { wireWordForLog } from '../../core/logging' import { withDeadline } from '../../core/timeouts' import { CREDENTIAL_POLL_INTERVAL_MS, CREDENTIAL_POLL_TIMEOUT_MS, - MSP_CLIENT_NAME, MSP_HANDSHAKE_TIMEOUT_MS, MUSE_ACCOUNT_DEVICE_CODE_TYPE, MUSE_ACCOUNT_LOGIN_CANCEL, MUSE_ACCOUNT_LOGIN_COMPLETED, MUSE_ACCOUNT_LOGIN_START, + MUSE_ACCOUNT_STATES, MUSE_DEVICE_SIGN_IN_URL_ORIGIN, + MUSE_LOGIN_CANCEL_TIMEOUT_MS, + MUSE_LOGIN_OUTCOME_SHOWN_MAX_CHARS, + MUSE_LOGIN_OUTCOMES, } from '../../shared/constants' -import type { MuseCodeBackendManager } from '../backend/museCodeBackendManager' import type { Logger } from '../logger' +import { type AccountSession, type AccountState, readAccountState } from './accountHost' const loginStartSchema = z.object({ verificationUrl: z.url(), userCode: z.string().check(z.minLength(1)), }) -const loginCompletedSchema = z.object({ outcome: z.string() }) +// As captured: `expired` and `denied` carry a message ("login failed: the +// request expired" / "… was denied"), `failed` one that names the credential +// file's path, `cancelled` and `granted` none. The message is free text the +// CLI chose, so neither the log nor the panel shows it: the log names each +// captured ending in fixed words (`loggedEnding`). +const loginCompletedSchema = z.object({ + outcome: z.string().check(z.minLength(1)), +}) const loginCancelSchema = z.object({ cancelled: z.boolean() }) -export interface DeviceSession { - readonly connection: Pick - readonly close: () => Promise -} - export interface DeviceSignInDeps { - readonly connect: (signal: AbortSignal) => Promise + readonly connect: (signal: AbortSignal) => Promise readonly credentialFileModifiedAt: () => number | undefined readonly sleep: (ms: number) => Promise readonly now: () => number readonly signal: AbortSignal readonly onCode: (url: string, code: string) => void + readonly log: Logger +} + +/** An ending Muse Code named that no capture covers: shown as it came (AGENTS.md rule 13). */ +export interface DeviceSignInEnded { + readonly endedAs: string +} +/** The endings captured live, each with a meaning of its own. */ +export type CapturedSignInEnding = 'cancelled' | 'expired' | 'denied' | 'failed' +/** How the host ended a flow that did not sign in. */ +type HostEnding = CapturedSignInEnding | DeviceSignInEnded +export type DeviceSignInOutcome = 'signedIn' | 'timedOut' | HostEnding + +const STOPPED = Symbol('device sign-in stopped') + +/** + * A host that exits mid-flow fails the sign-in at once, instead of being + * polled until the backstop (the review of PR #49). + */ +function hostGone(): Error { + return new Error('The Muse Code sign-in host exited during sign-in') +} + +// The endings captured live: `cancelled` (1.3.0, M55; 1.4.0-R4302.1), +// `expired` (600 s after `account/loginStart`), `denied` (Deny clicked) and +// `failed` (approved, but the file could not be written), all 1.4.0-R4302.1. +const CAPTURED_ENDINGS: ReadonlyMap = new Map([ + [MUSE_LOGIN_OUTCOMES.cancelled, 'cancelled'], + [MUSE_LOGIN_OUTCOMES.expired, 'expired'], + [MUSE_LOGIN_OUTCOMES.denied, 'denied'], + [MUSE_LOGIN_OUTCOMES.failed, 'failed'], +]) + +// How the log names each captured ending: fixed words, never the CLI's +// message (the review of PR #49). `clipForLog` only shortens, and the +// redactor catches keys, not a path or an e-mail address a message may hold. +const LOGGED_ENDINGS: ReadonlyMap = new Map([ + [MUSE_LOGIN_OUTCOMES.granted, 'granted'], + [MUSE_LOGIN_OUTCOMES.cancelled, 'cancelled'], + [MUSE_LOGIN_OUTCOMES.expired, 'expired: the code expired before it was approved'], + [MUSE_LOGIN_OUTCOMES.denied, 'denied: the sign-in was denied in the browser'], + [MUSE_LOGIN_OUTCOMES.failed, 'failed: saving the credential failed'], +]) + +/** How the log names an ending: fixed words, or an uncovered word in the shape of one. */ +function loggedEnding(outcome: string): string { + return ( + LOGGED_ENDINGS.get(outcome) ?? + `${wireWordForLog(outcome)} (an ending no capture covers; its message is not logged)` + ) } -export type DeviceSignInOutcome = 'signedIn' | 'cancelled' | 'timedOut' -const CANCELLED_START = Symbol('cancelled device sign-in start') -const CANCELLED_CONNECT = Symbol('cancelled device sign-in connection') +/** How `outcome` ends the flow; undefined for `granted`, which `account/read` bears out. */ +function endingOf(outcome: string): HostEnding | undefined { + if (outcome === MUSE_LOGIN_OUTCOMES.granted) { + return undefined + } + const captured = CAPTURED_ENDINGS.get(outcome) + if (captured !== undefined) { + return captured + } + const shown = + outcome.length > MUSE_LOGIN_OUTCOME_SHOWN_MAX_CHARS + ? `${outcome.slice(0, MUSE_LOGIN_OUTCOME_SHOWN_MAX_CHARS)}…` + : outcome + return { endedAs: shown } +} /** The returned URL is data from a CLI process: do not open arbitrary origins. */ export function parseDeviceCode(raw: unknown): { readonly url: string; readonly code: string } { @@ -54,13 +146,52 @@ export function parseDeviceCode(raw: unknown): { readonly url: string; readonly return { url: url.href, code: parsed.userCode } } +/** The signals that a new sign-in landed, as one poll sees them. */ +interface SignInSignals { + /** Undefined when the host did not answer the first question. */ + readonly initial: AccountState | undefined + /** Undefined when the host did not answer, or the poll was cut short. */ + readonly current: AccountState | undefined + readonly isFileWritten: boolean + /** The host sent `account/loginCompleted {outcome: "granted"}`. */ + readonly isGranted: boolean +} + +function isSignedIn(signals: SignInSignals): boolean { + const { initial, current, isFileWritten, isGranted } = signals + if (current === undefined) { + return isFileWritten + } + // A file written by a sign-out. Signed out is signed out, whatever the + // uncaptured `credentialRequired: false` might mean (the review of PR #49). + if (current.state === MUSE_ACCOUNT_STATES.loggedOut) { + return false + } + // `envKey` or a stored key may mask the new login, so a new file counts + // while the account is anything but signed out. + if (isFileWritten) { + return true + } + const isAccountLogin = current.state === MUSE_ACCOUNT_STATES.accountLogin + // The host's own `granted`, borne out by `account/read` saying + // `accountLogin` (the captured success), is a sign-in whatever came + // before. That matters where no change of state or file can show it: with + // no first answer, and for a same-account re-sign-in on macOS that + // replaces only the Keychain item while the logout hold keeps the old + // sign-in (Codex on 328efb52). Otherwise a sign-in shows as a change from + // an account read before the flow. `granted` alone never counts. + const hasChangedToLogin = + initial !== undefined && initial.state !== MUSE_ACCOUNT_STATES.accountLogin + return isAccountLogin && (isGranted || hasChangedToLogin) +} + export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { const isAborted = () => deps.signal.aborted if (isAborted()) { return 'cancelled' } const before = deps.credentialFileModifiedAt() - let session: DeviceSession + let session: AccountSession try { session = await deps.connect(deps.signal) } catch (error: unknown) { @@ -69,146 +200,154 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise() + const stop = () => { + stopped.resolve(STOPPED) + } + let hostEnding: HostEnding | undefined + let isEnded = false + let isGranted = false + let isHostGone = false + // The file's modification time when an answered `account/read` said + // signed out: that write (another Muse process's sign-out) is no sign-in, + // whatever follows it, a host exit or a question left unanswered (the + // review of PR #49). + let refutedWrite: number | undefined + /** A write since the flow began that no answer contradicted. */ + const isFreshWrite = (modified: number | undefined) => + modified !== undefined && modified !== before && modified !== refutedWrite + const isFileWritten = () => isFreshWrite(deps.credentialFileModifiedAt()) try { session.connection.onNotification((notification) => { - if (notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED) { + if (isEnded || notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED) { return } const result = loginCompletedSchema.safeParse(notification.params) - if (result.success && result.data.outcome === 'cancelled') { - state.isCancelledByHost = true + if (!result.success) { + return + } + // The first ending counts; the host runs one flow. + isEnded = true + isGranted = result.data.outcome === MUSE_LOGIN_OUTCOMES.granted + hostEnding = endingOf(result.data.outcome) + deps.log.info(`Muse Code sign-in ended: ${loggedEnding(result.data.outcome)}`) + if (hostEnding !== undefined) { + stop() } }) + void session.connection.closed.then(() => { + isHostGone = true + stop() + }) + deps.signal.addEventListener('abort', stop, { once: true }) if (isAborted()) { return 'cancelled' } - const cancelledStart = Promise.withResolvers() - const onAbort = () => { - cancelledStart.resolve(CANCELLED_START) + /** `work`, or STOPPED as soon as Cancel, the host's ending or its exit arrives. */ + const untilStopped = (work: Promise) => Promise.race([work, stopped.promise]) + /** Why a wait before the polling was cut short; no loginCancel is owed. */ + const stoppedEarly = (): HostEnding => { + if (hostEnding !== undefined) { + return hostEnding + } + if (isHostGone && !isAborted()) { + throw hostGone() + } + return 'cancelled' } - deps.signal.addEventListener('abort', onAbort, { once: true }) - let start: unknown - try { - start = await Promise.race([ - withDeadline( - session.connection.request(MUSE_ACCOUNT_LOGIN_START, { - type: MUSE_ACCOUNT_DEVICE_CODE_TYPE, - }), - MSP_HANDSHAKE_TIMEOUT_MS, - 'Muse Code did not start sign-in in time', - ), - cancelledStart.promise, - ]) - } finally { - deps.signal.removeEventListener('abort', onAbort) + // The account before the flow, so a sign-in shows as a change. + const initial = await untilStopped(readAccountState(session.connection)) + if (initial === STOPPED) { + return stoppedEarly() } - if (start === CANCELLED_START) { - return 'cancelled' + const start = await untilStopped( + withDeadline( + session.connection.request(MUSE_ACCOUNT_LOGIN_START, { + type: MUSE_ACCOUNT_DEVICE_CODE_TYPE, + }), + MSP_HANDSHAKE_TIMEOUT_MS, + 'Muse Code did not start sign-in in time', + ), + ) + if (start === STOPPED) { + return stoppedEarly() } const { url, code } = parseDeviceCode(start) deps.onCode(url, code) const deadline = deps.now() + CREDENTIAL_POLL_TIMEOUT_MS + // Captured: the ending arrives before this answer, in milliseconds. A + // host that does not answer is closed all the same. const cancelLogin = async () => { - loginCancelSchema.parse( - await withDeadline( - session.connection.request(MUSE_ACCOUNT_LOGIN_CANCEL, {}), - MSP_HANDSHAKE_TIMEOUT_MS, - 'Muse Code did not cancel sign-in in time', - ), - ) + try { + loginCancelSchema.parse( + await withDeadline( + session.connection.request(MUSE_ACCOUNT_LOGIN_CANCEL, {}), + MUSE_LOGIN_CANCEL_TIMEOUT_MS, + 'Muse Code did not cancel sign-in in time', + ), + ) + } catch { + deps.log.warn('Muse Code did not confirm the sign-in cancel; closing its host') + } } - while (deps.now() < deadline) { - const current = deps.credentialFileModifiedAt() - if (current !== undefined && current !== before) { - return 'signedIn' + const hasLanded = async () => { + // Read before the question, so an answer refutes only a write it saw. + const modified = deps.credentialFileModifiedAt() + const current = await untilStopped(readAccountState(session.connection)) + // A stop (Cancel, an ending) decides the flow: a file change alone + // must not turn it into a sign-in (the review of PR #49). + if (current === STOPPED) { + return false } - if (state.isCancelledByHost) { - return 'cancelled' + if (current?.state === MUSE_ACCOUNT_STATES.loggedOut) { + refutedWrite = modified + } + return isSignedIn({ initial, current, isFileWritten: isFreshWrite(modified), isGranted }) + } + /** How a flow that has not landed ends now; undefined while it goes on. */ + const endedAs = async (): Promise => { + if (hostEnding !== undefined) { + return hostEnding } if (isAborted()) { await cancelLogin() return 'cancelled' } - await deps.sleep(CREDENTIAL_POLL_INTERVAL_MS) + if (isHostGone) { + // An exit is no decision: a host that wrote the credential file + // before it went has signed in, unless an answer already said that + // write left it signed out (the review of PR #49). + if (isFileWritten()) { + deps.log.warn('The Muse Code sign-in host exited after writing the credential file') + return 'signedIn' + } + throw hostGone() + } + return undefined } - const current = deps.credentialFileModifiedAt() - if (current !== undefined && current !== before) { + while (deps.now() < deadline) { + if (await hasLanded()) { + return 'signedIn' + } + const ending = await endedAs() + if (ending !== undefined) { + return ending + } + await untilStopped(deps.sleep(CREDENTIAL_POLL_INTERVAL_MS)) + } + if (await hasLanded()) { return 'signedIn' } - if (state.isCancelledByHost) { - return 'cancelled' + const ending = await endedAs() + if (ending !== undefined) { + return ending } await cancelLogin() return isAborted() ? 'cancelled' : 'timedOut' } finally { + deps.signal.removeEventListener('abort', stop) await session.close() } } - -/** Start a dedicated experimental MSP process. Keep it separate from chat. */ -export async function connectDeviceSession( - backend: MuseCodeBackendManager, - extensionVersion: string, - log: Logger, - workspaceRoot: string | undefined, - signal: AbortSignal, -): Promise { - const isAborted = () => signal.aborted - if (isAborted()) { - throw new Error('Muse Code sign-in was cancelled') - } - backend.invalidateLaunch() - const resolution = backend.resolveLaunch() - if (!resolution.ok) { - throw new Error(resolution.reason) - } - let isStderrReported = false - const handshake = spawnMspConnection({ - command: resolution.launch.command, - args: resolution.launch.args, - ...(workspaceRoot !== undefined && { cwd: workspaceRoot }), - env: backend.childEnvironment(), - onStderr: () => { - if (isStderrReported) { - return - } - - log.warn('Muse Code sign-in host wrote to stderr') - isStderrReported = true - }, - }) - const cancelledConnect = Promise.withResolvers() - const onAbort = () => { - cancelledConnect.resolve(CANCELLED_CONNECT) - } - signal.addEventListener('abort', onAbort, { once: true }) - if (isAborted()) { - onAbort() - } - try { - const spawned = await Promise.race([ - withDeadline( - handshake.initialize({ - clientInfo: { name: MSP_CLIENT_NAME, version: extensionVersion }, - capabilities: { experimentalApi: true, userInputDialogs: false }, - }), - MSP_HANDSHAKE_TIMEOUT_MS, - 'Muse Code did not start sign-in host in time', - ), - cancelledConnect.promise, - ]) - if (spawned === CANCELLED_CONNECT || isAborted()) { - throw new Error('Muse Code sign-in was cancelled') - } - if (!spawned.initializeResult.experimentalApi) { - throw new Error('This Muse Code version does not offer in-panel sign-in') - } - return { connection: spawned.connection, close: () => spawned.close() } - } catch (error: unknown) { - await handshake.close() - throw error - } finally { - signal.removeEventListener('abort', onAbort) - } -} diff --git a/src/host/backend/museCodeBackendManager.ts b/src/host/backend/museCodeBackendManager.ts index 26373a13..36bbd69e 100644 --- a/src/host/backend/museCodeBackendManager.ts +++ b/src/host/backend/museCodeBackendManager.ts @@ -15,6 +15,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import path from 'node:path' import { type FingerprintWarning, spawnMspConnection } from '@muse-code/sdk' +import type { CredentialFileVerdict } from '../../core/backends/musecode/credentialFile' import { MuseCodeHost, type MspHost } from '../../core/backends/musecode/MuseCodeHost' import { buildChildEnvironment, @@ -31,6 +32,7 @@ import { serveArguments, type ShellSandboxPosture, } from '../../core/backends/musecode/sandbox' +import { failureForLog, stderrForLog } from '../../core/backends/musecode/logText' import { clipForLog } from '../../core/logging' import { withDeadline } from '../../core/timeouts' import { @@ -49,6 +51,7 @@ import { type SandboxNetworkMode, type ShellSandboxMode, } from '../../shared/constants' +import { readCredentialFile } from '../auth/cliAccount' import type { Logger } from '../logger' /** VS Code's proxy settings (`http.proxy`, `http.noProxy`), handed to the CLI when its environment has none. */ @@ -188,7 +191,7 @@ export class MuseCodeBackendManager { // The CLI's own credential pays (its login or its own key); the key the // panel stores is for the Model API backend and is never passed here. this.deps.log.info( - `muse serve credentials: the CLI's own (credential file ${this.credentialFileExists() ? 'present' : 'absent'}, META_API_KEY in the environment ${this.hasEnvironmentKey() ? 'present' : 'absent'}); the extension's stored key is not passed`, + `muse serve credentials: the CLI's own (credential file ${this.credentialFileVerdict()}, META_API_KEY in the environment ${this.hasEnvironmentKey() ? 'present' : 'absent'}); the extension's stored key is not passed`, ) this.deps.log.info(`Spawning ${launch.command} ${launch.args.join(' ')}`) // Spawn to handshake, for the log (M39). @@ -199,8 +202,9 @@ export class MuseCodeBackendManager { ...(this.deps.workspaceRoot !== undefined && { cwd: this.deps.workspaceRoot }), env, onStderr: (chunk) => { - // A chatty or looping CLI must not flood the log (PLAN.md D24). - this.deps.log.warn(`muse serve stderr: ${clipForLog(chunk.trimEnd())}`) + // A chatty or looping CLI must not flood the log (PLAN.md D24), and + // its free text is named in fixed words (the review of PR #49). + this.deps.log.warn(`muse serve stderr: ${clipForLog(stderrForLog(chunk))}`) }, }) const timeoutMs = this.deps.handshakeTimeoutMs ?? MSP_HANDSHAKE_TIMEOUT_MS @@ -225,7 +229,7 @@ export class MuseCodeBackendManager { try { await handshake.close() } catch (closeError: unknown) { - this.deps.log.warn(`Closing the unstarted muse serve failed: ${String(closeError)}`) + this.deps.log.warn(`Closing the unstarted muse serve failed: ${failureForLog(closeError)}`) } throw error } @@ -380,8 +384,9 @@ export class MuseCodeBackendManager { }) } - public credentialFileExists(): boolean { - return existsSync(this.credentialFilePath()) + /** What the credential file's structure says, never a value in it (D26): the log and Diagnostics. */ + public credentialFileVerdict(): CredentialFileVerdict | 'absent' { + return readCredentialFile(this.credentialFilePath(), process.platform)?.verdict ?? 'absent' } /** A META_API_KEY in the CLI's environment (the user's own, or one the settings add). */ @@ -415,7 +420,7 @@ export class MuseCodeBackendManager { const host = await pending await host.close() } catch (error: unknown) { - this.deps.log.warn(`Ignoring error while closing muse serve: ${String(error)}`) + this.deps.log.warn(`Ignoring error while closing muse serve: ${failureForLog(error)}`) } } } diff --git a/src/host/commands/skillsCommands.ts b/src/host/commands/skillsCommands.ts index bdab50b5..cba8dbb8 100644 --- a/src/host/commands/skillsCommands.ts +++ b/src/host/commands/skillsCommands.ts @@ -18,6 +18,7 @@ import { skillImportArgs, skillsListArgs, } from '../../core/backends/musecode/skillsCli' +import { stderrForLog } from '../../core/backends/musecode/logText' import { clipForLog } from '../../core/logging' import { type SkillImportSource, UI_TEXT } from '../../shared/constants' import type { PluralForms } from '../../shared/l10n/forms' @@ -99,7 +100,9 @@ async function runForOutput( } const result = await running if (result.exitCode !== 0) { - deps.log.warn(`muse ${args.join(' ')} failed: ${clipForLog(result.stderr.trim())}`) + deps.log.warn( + `muse ${args.join(' ')} failed with exit code ${String(result.exitCode)}: ${clipForLog(stderrForLog(result.stderr))}`, + ) deps.showError(`${failure}: ${failureOf(result)}`) return undefined } diff --git a/src/host/conversation/conversationController.ts b/src/host/conversation/conversationController.ts index 708d634f..1de7c060 100644 --- a/src/host/conversation/conversationController.ts +++ b/src/host/conversation/conversationController.ts @@ -3728,7 +3728,9 @@ export class ConversationController { } case 'retryBackend': { this.dropSession() - await this.deps.auth.refresh() + // Check again is a click: the CLI is asked afresh, and macOS may ask + // it about a Keychain sign-in. + await this.deps.auth.checkAgain() break } case 'openExternal': { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 054b4085..6016b5f4 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1390,6 +1390,79 @@ export const MUSE_ACCOUNT_LOGIN_START = 'account/loginStart' export const MUSE_ACCOUNT_LOGIN_CANCEL = 'account/loginCancel' export const MUSE_ACCOUNT_LOGIN_COMPLETED = 'account/loginCompleted' export const MUSE_ACCOUNT_DEVICE_CODE_TYPE = 'deviceCode' +// The CLI's own answer about its sign-in, and its own sign-out (experimental +// MSP; captured on 1.3.0 and 1.4.0-R4302.1 in isolated homes, 2026-09-27). +export const MUSE_ACCOUNT_READ = 'account/read' +export const MUSE_ACCOUNT_LOGOUT = 'account/logout' +// `AccountStateKind`: which credential lane wins (`envKey` beats `apiKey` +// beats `accountLogin`), or none. The schema calls the vocabulary open. +export const MUSE_ACCOUNT_STATES = { + loggedOut: 'loggedOut', + envKey: 'envKey', + apiKey: 'apiKey', + accountLogin: 'accountLogin', +} as const +// `AccountLoginOutcome` (`account/loginCompleted`): how a device sign-in +// ended, every word captured live (1.4.0-R4302.1, 2026-09-27; `cancelled` +// also on 1.3.0). `granted` came after the file was written and +// `account/read` already said `accountLogin`, so those decide and it is the +// one word that does not end the flow. The schema calls the vocabulary open, +// so any other word ends the flow as the CLI named it (AGENTS.md rule 13). +export const MUSE_LOGIN_OUTCOMES = { + granted: 'granted', + expired: 'expired', + cancelled: 'cancelled', + denied: 'denied', + failed: 'failed', +} as const +// An outcome word the panel shows as it came is cut at this length. +export const MUSE_LOGIN_OUTCOME_SHOWN_MAX_CHARS = 40 +// How long `account/loginCancel` may take before the sign-in host is closed +// anyway (captured: answered within 4 ms, after the `cancelled` ending). +export const MUSE_LOGIN_CANCEL_TIMEOUT_MS = 2000 +// The CLI's credential file (`auth.json`), read for its structure only: its +// schema version, whether it names the Muse provider, that provider's +// `storage` lane, and whether it carries a captured credential key. Version +// 1 holds the credential itself on Windows and Linux (captured); on macOS no +// version-1 file holding one was captured (with TBH_CREDENTIAL_BACKEND=file +// the Mac wrote no file, and read a pointer as signed out). Version 2 is +// macOS's token-free pointer, which `muse serve` on Windows and Linux +// refuses whatever it holds, unless META_API_KEY is set. +export const MUSE_CREDENTIAL_INLINE_SCHEMA = 1 +export const MUSE_CREDENTIAL_POINTER_SCHEMA = 2 +export const MUSE_CREDENTIAL_KEYCHAIN_STORAGE = 'keychain' +// The provider the CLI keeps its own sign-in under. Other entries share the +// file (1.4.0-R4302.1's bundled Slack connector reads +// `providers.slack_connector`), so only this one speaks for the sign-in. +export const MUSE_CREDENTIAL_PROVIDER = 'meta' +/** A larger file is not read (the CLI's own is under 1 KiB). */ +export const MUSE_CREDENTIAL_FILE_MAX_BYTES = 64 * 1024 +// Looks at the credential file when it changes while the CLI answers about it, +// or when the probe it waited on was abandoned: a file rewritten again and +// again, or probes abandoned again and again, end as `unknown` (the review of +// PR #49). +export const MUSE_CREDENTIAL_READ_ATTEMPTS = 3 +// On macOS a sign-in or sign-out made elsewhere may change only the +// Keychain, the file as it was, so a user action asks the CLI afresh. An +// answer this young is from the same click (a sign-out asks up to three +// times, a refresh with the hold on four) and is reused, so one click is +// one question and at most one Keychain prompt (Codex on 2a324d48). +export const MUSE_USER_ACTION_ANSWER_REUSE_MS = 5000 +// The macOS login Keychain item the CLI keeps a sign-in in. Diagnostics looks +// it up by attribute only (no `-g`/`-w`, so no secret and no prompt): exit 0 +// found, 44 not found. +export const MACOS_SECURITY_TOOL = '/usr/bin/security' +export const MUSE_KEYCHAIN_SERVICE = 'ai.meta.dev.credentials' +export const MUSE_KEYCHAIN_ACCOUNT = 'meta' +export const MACOS_KEYCHAIN_LOOKUP_ARGS = [ + 'find-generic-password', + '-s', + MUSE_KEYCHAIN_SERVICE, + '-a', + MUSE_KEYCHAIN_ACCOUNT, +] as const +export const MACOS_KEYCHAIN_ITEM_NOT_FOUND_EXIT = 44 +export const MACOS_KEYCHAIN_LOOKUP_TIMEOUT_MS = 10 * 1000 export const MUSE_DOCS_URL = 'https://dev.meta.ai/products/muse-code/' // Muse Code's own page on MCP servers and hooks (M31). export const MUSE_EXTENDING_DOCS_URL = 'https://dev.meta.ai/docs/muse-code/extending' @@ -1520,10 +1593,14 @@ export const EXPORT_FILE_EXTENSIONS: Readonly> = { } // An unnamed conversation's export takes its title from the first prompt, cut here. export const EXPORT_TITLE_MAX_CHARS = 60 -// How long the browser sign-in may take before the extension stops watching -// for the credential file, and how often it looks. +// How often the browser sign-in asks the sign-in host (`account/read`) and +// looks at the credential file, and how long it may take before the +// extension stops waiting. Muse Code ends the flow itself when the code +// expires (captured on 1.4.0-R4302.1: `expired` 600 s after +// `account/loginStart`), so the extension's own limit is only a backstop set +// past that: a shorter one cancelled codes that were still live. export const CREDENTIAL_POLL_INTERVAL_MS = 2000 -export const CREDENTIAL_POLL_TIMEOUT_MS = 5 * 60 * 1000 +export const CREDENTIAL_POLL_TIMEOUT_MS = 11 * 60 * 1000 // Model API key shapes. Meta's current keys are `LLM_` and at least 16 // letters, digits, `_` or `-` (a key issued 2026-09-27 had 44 after the // prefix, no `|`); older keys were `LLM||`. The log diff --git a/src/shared/l10n/en.ts b/src/shared/l10n/en.ts index f00a4461..968fa1a0 100644 --- a/src/shared/l10n/en.ts +++ b/src/shared/l10n/en.ts @@ -78,6 +78,17 @@ export const EN = { 'A Model API key starts with LLM_ (older keys look like LLM||).', signInWaiting: 'Waiting for the browser sign-in to finish…', signInTimedOut: 'The sign-in did not complete in time. Try again.', + // How Muse Code ended a browser sign-in (`account/loginCompleted`, D26): + // `expired`, `denied` and `failed` as captured live; any other ending as + // Muse Code named it. + signInExpired: 'The code expired before it was approved. Sign in again to get a new code.', + signInDenied: 'You denied the sign-in in the browser.', + signInSaveFailed: 'Muse Code signed in but could not save the credential.', + signInEnded: 'Sign-in ended: {outcome}. Sign in again to get a new code.', + // A macOS credential file on Windows or Linux stops `muse serve` (D26): + // version 2, empty or a Keychain pointer, or the Keychain lane. + cliCredentialUnsupported: + 'Muse Code cannot start: its sign-in file {path} is in the macOS format, which Muse Code cannot read on this system. Move or rename that file, then sign in again.', hostExited: 'Muse Code stopped unexpectedly', hostStarting: 'Starting Muse Code…', // PLAN.md D25: restarts, crashes and closed sessions continue the conversation. diff --git a/test/e2e/cliAccount.e2e.test.ts b/test/e2e/cliAccount.e2e.test.ts new file mode 100644 index 00000000..6560a7ec --- /dev/null +++ b/test/e2e/cliAccount.e2e.test.ts @@ -0,0 +1,287 @@ +// The CLI's sign-in against a real child process (PLAN.md D26): the real +// backend manager spawns the fake CLI (fake-muse/serve.mjs) as a short-lived +// experimental host, asks it `account/read` about a credential file whose +// structure cannot say, and signs out through `account/logout`, which leaves +// the file behind, emptied, as Muse Code does. The device sign-in runs +// against the same fake replaying the frames captured live on 1.4.0-R4302.1. +// No token is in any file. + +import { mkdtempSync, readFileSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { setTimeout } from 'node:timers' +import { EXPECTED_SCHEMA_FINGERPRINT } from '@muse-code/sdk' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import { connectAccountSession, logOutAccount, probeAccount } from '../../src/host/auth/accountHost' +import { CliAccount, readCredentialFile } from '../../src/host/auth/cliAccount' +import { runDeviceSignIn } from '../../src/host/auth/deviceSignIn' +import { MuseCodeBackendManager } from '../../src/host/backend/museCodeBackendManager' +import { CAPTURES_FOLDER } from '../unit/helpers/accountLoginCapture' +import { + AUTH_SET_FILE, + capturedInlineVerdict, + DEVICE_LOGIN_FILE, + LOGOUT_SHELL, + SHIPPED_PLATFORMS, + SLACK_CONNECTOR_ONLY, +} from '../unit/helpers/credentialShapes' +import { FakeLogOutputChannel } from '../unit/helpers/fakes' +import { + fakeCredentialFile, + installFakeCredential, + installFakeMuse, + removeTestFolders, + writeFakeCredential, +} from './fakeMuse' + +const TEST_TIMEOUT_MS = 30_000 +// When the fake CLI sends its captured ending after loginStart. +const ENDING_AFTER_MS = 300 +// "At once": well under the 30 s an unanswered account/read would take. +const PROMPT_MS = 5000 +// Synthetic: a `meta` entry in a `storage` lane no build was seen writing, +// beside a captured credential key. The structure cannot place it; the fake +// CLI, which starts with any version-1 file, answers from its key. +const UNPLACEABLE = JSON.stringify({ + schema_version: 1, + providers: { meta: { storage: 'elsewhere', access_token: '' } }, +}) +// Synthetic: a schema no build has written. Muse Code exits 3 at startup with +// a schema it does not know, as captured for version 2 off macOS. +const FUTURE_SCHEMA = '{"schema_version": 9, "providers": {"meta": {}}}' +// A signal nothing aborts: the window stays open. +const OPEN = new AbortController().signal + +const fake = installFakeMuse() +const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'fake-muse-ws-')) +const configHome = installFakeCredential(UNPLACEABLE) +const managers: MuseCodeBackendManager[] = [] + +function setup(fakeEnvironment: readonly { name: string; value: string }[] = []) { + const log = new FakeLogOutputChannel() + const backend = new MuseCodeBackendManager({ + log, + extensionVersion: '0.0.0-e2e', + getConfiguredBinaryPath: () => fake.binaryPath, + // The config home reaches both the CLI and the extension's own look at + // the file through the documented setting, as a user's would. + getEnvironmentVariables: () => [ + { name: 'MUSE_FAKE_NODE', value: process.execPath }, + { name: 'MUSE_FAKE_FINGERPRINT', value: EXPECTED_SCHEMA_FINGERPRINT }, + { name: 'XDG_CONFIG_HOME', value: configHome }, + ...fakeEnvironment, + ], + workspaceRoot, + getShellSandbox: () => 'off', + getSandboxNetwork: () => 'default', + userProfileDir: undefined, + isWorkspaceTrusted: () => true, + getProxySettings: () => ({ proxy: '', noProxy: [] }), + }) + managers.push(backend) + const connect = (signal: AbortSignal) => + connectAccountSession(backend, '0.0.0-e2e', log, workspaceRoot, signal) + const probe = vi.fn(() => probeAccount(connect, log, OPEN)) + const account = new CliAccount({ + platform: process.platform, + credentialFilePath: () => backend.credentialFilePath(), + probe, + log, + }) + return { backend, log, connect, probe, account } +} + +function everythingLogged(log: FakeLogOutputChannel): string { + return [...log.info.mock.calls, ...log.warn.mock.calls, ...log.error.mock.calls].flat().join('\n') +} + +afterEach(async () => { + await Promise.all(managers.splice(0).map((created) => created.dispose())) +}) + +afterAll(() => { + removeTestFolders([fake.installDir, workspaceRoot, configHome]) +}) + +describe('The CLI’s sign-in against a real child process', { timeout: TEST_TIMEOUT_MS }, () => { + it('asks the CLI once about a file it cannot place, then signs out through account/logout', async () => { + writeFakeCredential(configHome, UNPLACEABLE) + const t = setup() + expect(t.backend.credentialFilePath()).toBe(fakeCredentialFile(configHome)) + await expect(t.account.signIn(true)).resolves.toBe('signedIn') + await expect(t.account.signIn(false)).resolves.toBe('signedIn') + expect(t.probe).toHaveBeenCalledOnce() + + await expect(logOutAccount(t.connect, t.log, OPEN)).resolves.toBe('confirmed') + // The file stays, emptied, as `muse logout` leaves it; its structure alone + // now says signed out, and no host is started to say so. + expect(readFileSync(fakeCredentialFile(configHome), 'utf8')).toBe(LOGOUT_SHELL) + expect(readCredentialFile(t.backend.credentialFilePath(), process.platform)?.verdict).toBe( + 'empty', + ) + await expect(t.account.signIn(true)).resolves.toBe('signedOut') + expect(t.probe).toHaveBeenCalledOnce() + expect(everythingLogged(t.log)).not.toContain('person@example.com') + }) + + // Off macOS the captured shape settles it; on macOS, where no such file was + // captured, the CLI is asked (the review of PR #49). + it('reads a stored sign-in from the file alone off macOS', async () => { + writeFakeCredential(configHome, DEVICE_LOGIN_FILE) + const t = setup() + await expect(t.account.signIn(true)).resolves.toBe('signedIn') + expect(t.probe).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0) + }) + + // The host exits 3 before `initialize`, as Muse Code does with a schema it + // cannot read: the CLI could not say (the review of PR #49). + it('answers unknown when the host exits at startup', async () => { + writeFakeCredential(configHome, FUTURE_SCHEMA) + const t = setup() + await expect(t.account.signIn(true)).resolves.toBe('unknown') + expect(t.probe).toHaveBeenCalledOnce() + expect(t.log.warn).toHaveBeenCalledWith( + expect.stringMatching(/^The Muse Code account host could not start: /), + ) + }) + + // What the fake answers is what the captures answered for each shape. + it.each([ + ['the muse auth set file', AUTH_SET_FILE, 'apiKey'], + ['the browser sign-in file', DEVICE_LOGIN_FILE, 'accountLogin'], + ['the file a sign-out leaves', LOGOUT_SHELL, 'loggedOut'], + ])('answers account/read about %s as captured', async (_name, contents, state) => { + writeFakeCredential(configHome, contents) + const t = setup() + await expect(probeAccount(t.connect, t.log, OPEN)).resolves.toEqual({ + state, + credentialRequired: true, + }) + }) + + // Only `meta` speaks for the sign-in (the review of PR #49). + it('asks the CLI about a file naming another provider alone', async () => { + writeFakeCredential(configHome, SLACK_CONNECTOR_ONLY) + const t = setup() + // A user action, so the CLI is asked on every OS (macOS asks only then). + await expect(t.account.signIn(true)).resolves.toBe('signedOut') + expect(t.probe).toHaveBeenCalledOnce() + }) +}) + +/** A device sign-in from a signed-out home; `fakeEnvironment` scripts the fake CLI. */ +function signIn(fakeEnvironment: readonly { name: string; value: string }[], signal: AbortSignal) { + writeFakeCredential(configHome, LOGOUT_SHELL) + const t = setup([{ name: 'MUSE_FAKE_CAPTURES', value: CAPTURES_FOLDER }, ...fakeEnvironment]) + // When the code was shown: "at once" is timed from here, not from the + // spawn, which a slow machine may take seconds over (the review of PR #49). + const shown = { at: NaN } + const onCode = vi.fn(() => { + shown.at = Date.now() + }) + const outcome = runDeviceSignIn({ + connect: (flowSignal) => + connectAccountSession(t.backend, '0.0.0-e2e', t.log, workspaceRoot, flowSignal), + credentialFileModifiedAt: () => statSync(t.backend.credentialFilePath()).mtimeMs, + sleep: (ms) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }), + now: Date.now, + signal, + onCode, + log: t.log, + }) + return { ...t, onCode, outcome, shown } +} + +/** The fake sends the named capture's ending, and what came with it, after loginStart. */ +function endingAfterStart(name: string): { name: string; value: string }[] { + return [ + { name: 'MUSE_FAKE_LOGIN_ENDING', value: name }, + { name: 'MUSE_FAKE_LOGIN_ENDING_MS', value: String(ENDING_AFTER_MS) }, + ] +} + +// The device sign-in against the fake CLI replaying the frames captured on +// 1.4.0-R4302.1 (test/fixtures/msp; PR #49 P1 and P2). +describe('The device sign-in against a real child process', { timeout: TEST_TIMEOUT_MS }, () => { + it('ends on the captured expired ending, shown the code as captured', async () => { + const t = signIn(endingAfterStart('expired'), OPEN) + await expect(t.outcome).resolves.toBe('expired') + expect(t.onCode).toHaveBeenCalledWith( + 'https://auth.meta.com/oauth/device/?code=AAAA-AAAA', + 'AAAA-AAAA', + ) + expect(t.log.info).toHaveBeenCalledWith( + 'Muse Code sign-in ended: expired: the code expired before it was approved', + ) + }) + + it('ends at once on the host’s ending while account/read goes unanswered', async () => { + const t = signIn( + [ + { name: 'MUSE_FAKE_ACCOUNT_READ', value: 'silentAfterStart' }, + ...endingAfterStart('expired'), + ], + OPEN, + ) + await expect(t.outcome).resolves.toBe('expired') + expect(Date.now() - t.shown.at).toBeLessThan(PROMPT_MS) + }) + + // The captured success: the file written, `account/changed`, then + // `granted` (the review of PR #49). + it('signs in on the captured granted sequence', async () => { + const t = signIn(endingAfterStart('granted'), OPEN) + await expect(t.outcome).resolves.toBe('signedIn') + // The file the sign-in left, read as each OS reads it: held in it off + // macOS, the CLI's to say on macOS, where no such file was captured. + // Every runner checks every OS (the review of PR #49). + for (const platform of SHIPPED_PLATFORMS) { + expect(readCredentialFile(t.backend.credentialFilePath(), platform)?.verdict).toBe( + capturedInlineVerdict(platform), + ) + } + expect(t.log.info).toHaveBeenCalledWith('Muse Code sign-in ended: granted') + expect(everythingLogged(t.log)).not.toContain('person@example.com') + }) + + // The log keeps fixed words; `failed`'s captured message names the + // credential file's path. + it.each([ + ['denied', 'the sign-in was denied in the browser'], + ['failed', 'saving the credential failed'], + ])('ends on the captured %s, and logs no path', async (outcome, logged) => { + const t = signIn(endingAfterStart(outcome), OPEN) + await expect(t.outcome).resolves.toBe(outcome) + expect(t.log.info).toHaveBeenCalledWith(`Muse Code sign-in ended: ${outcome}: ${logged}`) + expect(everythingLogged(t.log)).not.toContain('') + }) + + // A host that dies mid-flow fails the sign-in at once (the review of PR #49). + it('fails at once when the host exits during the flow', async () => { + const t = signIn([{ name: 'MUSE_FAKE_LOGIN_EXIT_MS', value: String(ENDING_AFTER_MS) }], OPEN) + await expect(t.outcome).rejects.toThrow('exited') + expect(Date.now() - t.shown.at).toBeLessThan(PROMPT_MS) + }) + + it('cancels at once while account/read goes unanswered, and the CLI ends its flow', async () => { + const abort = new AbortController() + const t = signIn([{ name: 'MUSE_FAKE_ACCOUNT_READ', value: 'silentAfterStart' }], abort.signal) + await vi.waitFor( + () => { + expect(t.onCode).toHaveBeenCalledOnce() + }, + { timeout: TEST_TIMEOUT_MS }, + ) + const cancelled = Date.now() + abort.abort() + await expect(t.outcome).resolves.toBe('cancelled') + expect(Date.now() - cancelled).toBeLessThan(PROMPT_MS) + // The captured `cancelled` ending the fake sends before its answer. + await vi.waitFor(() => { + expect(t.log.info).toHaveBeenCalledWith('Muse Code sign-in ended: cancelled') + }) + }) +}) diff --git a/test/e2e/fake-muse/serve.mjs b/test/e2e/fake-muse/serve.mjs index 71ec81b2..a1b1475a 100644 --- a/test/e2e/fake-muse/serve.mjs +++ b/test/e2e/fake-muse/serve.mjs @@ -24,13 +24,81 @@ // the handshake, the spawn-failure drill) or =silent (read the handshake and // never answer it, the wedged-CLI drill of PLAN.md D25). Node built-ins // only: the file is copied beside the executable the resolver spawns. +// +// The credential file under XDG_CONFIG_HOME (never the developer's own) is +// checked at startup as 1.4.0-R4302.1 was captured checking it on Windows +// and Linux (docs/certification/sign-in-detection.md): a schema version +// other than 1 (1 or 2 on macOS) exits 3 before `initialize` with "unsupported +// auth schema version …", and off macOS a version-1 `meta` whose storage is +// the Keychain exits 3 with "keychain item for meta is unreadable". The +// real CLI starts with each of these while META_API_KEY is set; the fake +// does not model that key. +// +// Account methods (PLAN.md D26, shapes captured on 1.3.0 and 1.4.0, +// 2026-09-27), for a client that asked for `experimentalApi` only: +// `account/read` answers from that file as captured: `accountLogin` for a +// `meta` holding `access_token` (a browser sign-in), `apiKey` for one holding +// `api_key` alone (`muse auth set`), otherwise signed out; `account/logout` +// rewrites the file as the empty one the CLI leaves. +// +// The device sign-in replays the frames captured live on 1.4.0-R4302.1 +// (test/fixtures/msp/account-login-*.json, 2026-09-27), read from the folder +// MUSE_FAKE_CAPTURES names. `account/loginStart` answers as captured; with +// MUSE_FAKE_LOGIN_ENDING= that capture's notifications up to its +// ending follow after MUSE_FAKE_LOGIN_ENDING_MS, in the captured order (for +// `granted`: the file written as the browser sign-in left it, then +// `account/changed`, the ending, and `account/changed` again). +// `account/loginCancel` sends the captured `cancelled` ending, then answers +// `{cancelled: true}`, in the captured order; with no flow pending it +// answers as captured after an ending. MUSE_FAKE_ACCOUNT_READ=silentAfterStart +// leaves every `account/read` after `account/loginStart` unanswered (a +// wedged CLI); MUSE_FAKE_LOGIN_EXIT_MS exits that long after +// `account/loginStart` (a host that dies mid-flow). -import { argv, env, exit, stderr, stdin, stdout } from 'node:process' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { argv, env, exit, platform, stderr, stdin, stdout } from 'node:process' import { createInterface } from 'node:readline' -import { setImmediate } from 'node:timers' +import { clearTimeout, setImmediate, setTimeout } from 'node:timers' const METHOD_NOT_FOUND = -32_601 const COMMAND_REJECTED = -32_000 +// What `muse logout` and `account/logout` leave behind (44 bytes). +const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' +// The account's display label: an e-mail address the extension never logs. +const ACCOUNT_LABEL = 'person@example.com' +// The provider the CLI keeps its own sign-in under; others share the file. +const MUSE_PROVIDER = 'meta' +// The credential file's versions: 1 holds the credential, 2 is macOS's pointer. +const INLINE_SCHEMA = 1 +const POINTER_SCHEMA = 2 +const KEYCHAIN_STORAGE = 'keychain' +// What 1.4.0-R4302.1 wrote to stderr for a version-1 Keychain lane off macOS. +const KEYCHAIN_UNREADABLE = 'keychain item for meta is unreadable (internal error -2147483648)' +// The lane `account/read` named for the Muse entry's key, as captured: a +// browser sign-in (`access_token`, with `api_key` beside it) is +// `accountLogin`; `muse auth set` (`api_key` alone) is `apiKey`. +const CAPTURED_LANES = [ + ['access_token', 'accountLogin'], + ['api_key', 'apiKey'], +] +// A browser sign-in as the granted capture left the file (schema 1, `meta` +// with these keys; placeholders for every secret or personal value). +const DEVICE_LOGIN_FILE = JSON.stringify({ + schema_version: 1, + providers: { + meta: { + access_token: '', + obtained_via: 'device_code', + mechanism: 'oauth', + api_key: '', + api_base_url: '', + user_full_name: '', + user_email: '', + user_avatar_url: '', + }, + }, +}) const CRASH_EXIT_CODE = 3 const DIE_EXIT_CODE = 1 const ECHO_PREFIX = 'echo: ' @@ -55,12 +123,16 @@ if (env['MUSE_FAKE_START'] === 'crash') { exit(CRASH_EXIT_CODE) } +refuseUnsupportedCredentialFile() + const serveArgs = argv.slice(2).join(' ') const fingerprint = env['MUSE_FAKE_FINGERPRINT'] ?? 'sha256:fake' /** sessionId → { record, items, approvalMode } */ const sessions = new Map() const state = { clientName: 'unknown', + /** The client asked for the experimental methods (account/*). */ + isExperimental: false, usage: undefined, nextId: 0, /** The turn in flight, if any: { sessionId, turnId, onCancel } */ @@ -69,6 +141,10 @@ const state = { pendingApproval: undefined, /** Long tool calls by item (M46): { sessionId, call, isBackground, onBackground } */ tasks: new Map(), + /** The device sign-in in flight, if any: { timer } */ + login: undefined, + /** `account/loginStart` was asked at least once. */ + isLoginStarted: false, } function id(prefix) { @@ -347,6 +423,152 @@ function rejected(reason) { return Object.assign(new Error(`rejected: ${reason}`), { reason }) } +/** An account method without `experimentalApi`, as 1.4.0 refuses it. */ +function requireExperimental(method) { + if (!state.isExperimental) { + throw Object.assign(new Error(`${method} requires experimentalApi capability`), { + code: METHOD_NOT_FOUND, + kind: 'experimentalRequired', + }) + } +} + +function credentialFile() { + const configHome = env['XDG_CONFIG_HOME'] + return configHome === undefined ? undefined : path.join(configHome, 'muse', 'auth.json') +} + +/** The credential file's JSON; an empty object when there is none or it is not JSON. */ +function credentialJson() { + const file = credentialFile() + if (file === undefined || !existsSync(file)) { + return {} + } + try { + return JSON.parse(readFileSync(file, 'utf8')) ?? {} + } catch { + return {} + } +} + +/** The Muse provider's entry (other providers, a connector's, share the file); `{}` when absent. */ +function museEntry() { + const providers = credentialJson().providers ?? {} + const entry = Object.hasOwn(providers, MUSE_PROVIDER) ? providers[MUSE_PROVIDER] : {} + return typeof entry === 'object' && entry !== null ? entry : {} +} + +/** Exits 3 before `initialize` on a file 1.4.0-R4302.1 was captured refusing. */ +function refuseUnsupportedCredentialFile() { + const version = credentialJson().schema_version + if (version === undefined) { + return + } + const isMacOs = platform === 'darwin' + const supported = isMacOs ? [INLINE_SCHEMA, POINTER_SCHEMA] : [INLINE_SCHEMA] + if (!supported.includes(version)) { + stderr.write( + `compose serve model client: unsupported auth schema version ${String(version)} at ${credentialFile()}\n`, + ) + exit(CRASH_EXIT_CODE) + } + if (isMacOs || museEntry().storage !== KEYCHAIN_STORAGE) { + return + } + stderr.write(`compose serve model client: ${KEYCHAIN_UNREADABLE}\n`) + exit(CRASH_EXIT_CODE) +} + +function accountState() { + const entry = museEntry() + const lane = CAPTURED_LANES.find(([key]) => Object.hasOwn(entry, key))?.[1] + return lane === undefined + ? { state: 'loggedOut', credentialRequired: true } + : { state: lane, label: ACCOUNT_LABEL, credentialRequired: true } +} + +/** A live capture's frames, in the order they crossed the wire. */ +function captureFrames(name) { + const folder = env['MUSE_FAKE_CAPTURES'] + if (folder === undefined) { + throw new Error('MUSE_FAKE_CAPTURES is not set') + } + return JSON.parse(readFileSync(path.join(folder, `account-login-${name}.json`), 'utf8')).frames +} + +/** What the captured host answered the first time it was asked `method`. */ +function capturedAnswer(name, method) { + const frames = captureFrames(name) + const asked = frames.find((entry) => entry.dir === 'out' && entry.frame.method === method) + return frames.find((entry) => entry.dir === 'in' && entry.frame.id === asked?.frame.id)?.frame + .result +} + +/** The captured `account/loginCompleted` frame, as it arrived. */ +function capturedEnding(name) { + return captureFrames(name).find( + (entry) => entry.dir === 'in' && entry.frame.method === 'account/loginCompleted', + )?.frame +} + +/** The notifications a capture received before it sent `account/loginCancel`. */ +function capturedFlowNotifications(name) { + const frames = captureFrames(name) + const cancelAt = frames.findIndex( + (entry) => entry.dir === 'out' && entry.frame.method === 'account/loginCancel', + ) + return frames + .slice(0, cancelAt === -1 ? frames.length : cancelAt) + .filter((entry) => entry.dir === 'in' && entry.frame.method !== undefined) + .map((entry) => entry.frame) +} + +/** A capture's flow as it ended: the file a sign-in left, then its notifications. */ +function endLogin(name) { + state.login = undefined + const file = credentialFile() + if (name === 'granted' && file !== undefined) { + writeFileSync(file, DEVICE_LOGIN_FILE) + } + for (const frame of capturedFlowNotifications(name)) { + send(frame) + } +} + +function startLogin() { + state.isLoginStarted = true + const ending = env['MUSE_FAKE_LOGIN_ENDING'] + const exitAfter = env['MUSE_FAKE_LOGIN_EXIT_MS'] + if (exitAfter !== undefined) { + setTimeout(() => { + exit(DIE_EXIT_CODE) + }, Number(exitAfter)) + } + const timer = + ending === undefined + ? undefined + : setTimeout( + () => { + endLogin(ending) + }, + Number(env['MUSE_FAKE_LOGIN_ENDING_MS'] ?? '0'), + ) + state.login = { timer } + return capturedAnswer('cancelled', 'account/loginStart') +} + +function cancelLogin() { + if (state.login === undefined) { + // As captured after the code expired: nothing left to cancel. + return capturedAnswer('expired', 'account/loginCancel') + } + clearTimeout(state.login.timer) + state.login = undefined + // As captured: the ending, then the answer. + send(capturedEnding('cancelled')) + return capturedAnswer('cancelled', 'account/loginCancel') +} + /** A background task stopped: cancelled, as the capture of 2026-09-25 shows. */ function stopTask(taskId) { const task = state.tasks.get(taskId) @@ -447,10 +669,11 @@ function envelope(session) { const handlers = { initialize: (params) => { state.clientName = String(params.clientInfo?.name ?? 'unknown') + state.isExperimental = params.capabilities?.experimentalApi === true return { serverInfo: { name: 'muse', version: `0.0.0-fake ${serveArgs}` }, museHome: `/fake/home/${state.clientName}`, - experimentalApi: false, + experimentalApi: state.isExperimental, grantedCapabilities: [...(params.capabilities?.requestedCapabilities ?? [])], platformFamily: 'fake', platformOs: 'fake', @@ -636,6 +859,31 @@ const handlers = { }), 'skill/list': () => ({ skills: [] }), 'usage/read': () => (state.usage === undefined ? {} : { usage: state.usage }), + 'account/read': () => { + requireExperimental('account/read') + return accountState() + }, + 'account/logout': () => { + requireExperimental('account/logout') + const file = credentialFile() + if (file !== undefined && existsSync(file)) { + writeFileSync(file, LOGOUT_SHELL) + } + const after = accountState() + notify('account/changed', after) + return after + }, + 'account/loginStart': (params) => { + requireExperimental('account/loginStart') + if (params.type !== 'deviceCode') { + throw new Error('this fake runs the device-code flow only') + } + return startLogin() + }, + 'account/loginCancel': () => { + requireExperimental('account/loginCancel') + return cancelLogin() + }, 'item/readOutput': (params) => { const content = `output of ${String(params.itemId)}` return { @@ -650,6 +898,7 @@ const handlers = { } const isSilent = env['MUSE_FAKE_START'] === 'silent' +const isAccountReadSilentAfterStart = env['MUSE_FAKE_ACCOUNT_READ'] === 'silentAfterStart' function handle(frame) { if (isSilent || frame.id === undefined) { @@ -657,6 +906,9 @@ function handle(frame) { // silent host answers nothing at all. return } + if (isAccountReadSilentAfterStart && state.isLoginStarted && frame.method === 'account/read') { + return + } const handler = handlers[frame.method] if (handler === undefined) { send({ @@ -674,12 +926,16 @@ function handle(frame) { } catch (error) { const message = error instanceof Error ? error.message : String(error) const reason = error instanceof Error && 'reason' in error ? error.reason : undefined + // Only a refusal built here names its own JSON-RPC code and kind. + const isOwn = error instanceof Error && 'kind' in error && typeof error.code === 'number' + const code = isOwn ? error.code : COMMAND_REJECTED + const kind = isOwn ? error.kind : 'commandRejected' send({ id: frame.id, error: { - code: COMMAND_REJECTED, + code, message, - data: { kind: 'commandRejected', ...(reason !== undefined && { reason }) }, + data: { kind, ...(reason !== undefined && { reason }) }, }, }) } diff --git a/test/e2e/fakeMuse.ts b/test/e2e/fakeMuse.ts index c3dcca2c..f20c6a2e 100644 --- a/test/e2e/fakeMuse.ts +++ b/test/e2e/fakeMuse.ts @@ -6,11 +6,20 @@ // which the extension honours on every platform (launch.ts). import { execFileSync } from 'node:child_process' -import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { MUSE_CREDENTIAL_FILE_SEGMENTS } from '../../src/shared/constants' +import { DEVICE_LOGIN_FILE } from '../unit/helpers/credentialShapes' const here = path.dirname(fileURLToPath(import.meta.url)) const SERVE_SOURCE = path.join(here, 'fake-muse', 'serve.mjs') @@ -18,6 +27,29 @@ const STUB_SOURCE = path.join(here, 'fake-muse', 'stub.cs') const STUB_EXE = 'muse-bin-0.0.0-fake.exe' const CSC_RELATIVE_PATH = String.raw`Microsoft.NET\Framework64\v4.0.30319\csc.exe` const EXECUTABLE_MODE = 0o755 +// On Windows the fake CLI's executable can still be held for a moment after +// its process has exited (seen in CI and under a full local run, every test +// green), and removing its folder then fails with EPERM. Node retries the +// removal; if the folder still cannot go, the suite says so and leaves it +// to the OS temp cleanup rather than fail on housekeeping. +const RM_RETRIES = 5 +const RM_RETRY_DELAY_MS = 200 + +/** A suite's teardown: its temporary folders, removed or named. */ +export function removeTestFolders(dirs: readonly string[]): void { + for (const dir of dirs) { + try { + rmSync(dir, { + recursive: true, + force: true, + maxRetries: RM_RETRIES, + retryDelay: RM_RETRY_DELAY_MS, + }) + } catch (error: unknown) { + process.stderr.write(`e2e teardown left ${dir} behind: ${String(error)}\n`) + } + } +} export interface FakeMuseInstall { /** What `museSpark.museBinaryPath` should point at. */ @@ -65,11 +97,24 @@ export function installFakeMuse(): FakeMuseInstall { return { binaryPath: script, installDir } } -/** A config home holding the CLI's credential file (metadata only, no secret). */ -export function installFakeCredential(): string { +/** + * A config home holding the CLI's credential file: a browser sign-in's + * structure as the granted capture left it, placeholders for every secret + * or personal value; or `contents` as given. + */ +export function installFakeCredential(contents = DEVICE_LOGIN_FILE): string { const configHome = mkdtempSync(path.join(tmpdir(), 'fake-muse-config-')) - const file = path.join(configHome, ...MUSE_CREDENTIAL_FILE_SEGMENTS) - mkdirSync(path.dirname(file), { recursive: true }) - writeFileSync(file, JSON.stringify({ fake: true })) + writeFakeCredential(configHome, contents) return configHome } + +/** Where the extension and the fake CLI look for it under `configHome`. */ +export function fakeCredentialFile(configHome: string): string { + return path.join(configHome, ...MUSE_CREDENTIAL_FILE_SEGMENTS) +} + +export function writeFakeCredential(configHome: string, contents: string): void { + const file = fakeCredentialFile(configHome) + mkdirSync(path.dirname(file), { recursive: true }) + writeFileSync(file, contents) +} diff --git a/test/e2e/museCode.e2e.test.ts b/test/e2e/museCode.e2e.test.ts index 5044ca8c..7e2c15f8 100644 --- a/test/e2e/museCode.e2e.test.ts +++ b/test/e2e/museCode.e2e.test.ts @@ -7,7 +7,7 @@ // history and usage, plus the drills: a host that dies mid-turn, a // malformed frame, a binary that will not start, and no binary at all. -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { EXPECTED_SCHEMA_FINGERPRINT } from '@muse-code/sdk' @@ -23,7 +23,8 @@ import { UI_TEXT, } from '../../src/shared/constants' import { FakeLogOutputChannel } from '../unit/helpers/fakes' -import { installFakeCredential, installFakeMuse } from './fakeMuse' +import { capturedInlineVerdict } from '../unit/helpers/credentialShapes' +import { installFakeCredential, installFakeMuse, removeTestFolders } from './fakeMuse' const TURN_TIMEOUT_MS = 10_000 const TEST_TIMEOUT_MS = 30_000 @@ -41,6 +42,14 @@ const configHome = installFakeCredential() process.env['XDG_CONFIG_HOME'] = configHome const managers: MuseCodeBackendManager[] = [] +/** + * How the log names a stderr line no capture covers: by its length, never + * its text (the review of PR #49). + */ +function stderrLogged(line: string): string { + return `muse serve stderr: a line of ${String(line.length)} characters (not logged: it may name a path or an account)` +} + function sleep(ms: number): Promise { return new Promise((resolve) => { setTimeout(resolve, ms) @@ -151,36 +160,22 @@ afterEach(async () => { await Promise.all(managers.splice(0).map((created) => created.dispose())) }) -// On Windows the fake CLI's executable can still be held for a moment after -// its process has exited (seen in CI and under a full local run, every test -// green), and removing its folder then fails with EPERM. Node retries the -// removal; if the folder still cannot go, the suite says so and leaves it -// to the OS temp cleanup rather than fail on housekeeping. -const RM_RETRIES = 5 -const RM_RETRY_DELAY_MS = 200 - afterAll(() => { delete process.env['XDG_CONFIG_HOME'] - for (const dir of [fake.installDir, workspaceRoot, configHome]) { - try { - rmSync(dir, { - recursive: true, - force: true, - maxRetries: RM_RETRIES, - retryDelay: RM_RETRY_DELAY_MS, - }) - } catch (error: unknown) { - process.stderr.write(`e2e teardown left ${dir} behind: ${String(error)}\n`) - } - } + removeTestFolders([fake.installDir, workspaceRoot, configHome]) }) // Each case spawns a process; CI runners are slower than a workstation. describe('Muse Code backend against a real child process', { timeout: TEST_TIMEOUT_MS }, () => { it('spawns the configured binary with the serve flags, shakes hands as the extension, and sees the credential file', async () => { const { manager: backend, log } = manager() - expect(backend.credentialFileExists()).toBe(true) + // The browser sign-in file, as this OS reads it (on macOS the CLI's to + // say; the table is pinned for every OS in credentialFile.test.ts). + const verdict = capturedInlineVerdict(process.platform) + expect(backend.credentialFileVerdict()).toBe(verdict) const host = await backend.ensureHost() + // Described by its structure, never by a value in it (D26). + expect(log.info).toHaveBeenCalledWith(expect.stringContaining(`(credential file ${verdict},`)) expect(host.info.serverName).toBe('muse') expect(host.info.serverVersion).toBe('0.0.0-fake serve --disable-sandbox --trust-workspace') expect(host.info.museHome).toBe(`/fake/home/${MSP_CLIENT_NAME}`) @@ -442,7 +437,8 @@ describe('Muse Code backend against a real child process', { timeout: TEST_TIMEO isPersistent: false, }) expect(backend.isRunning).toBe(false) - expect(log.warn).toHaveBeenCalledWith('muse serve stderr: fake muse: dying on purpose') + expect(log.warn).toHaveBeenCalledWith(stderrLogged('fake muse: dying on purpose')) + expect(log.warn).not.toHaveBeenCalledWith(expect.stringContaining('dying on purpose')) const next = await backend.ensureHost() expect(next).not.toBe(host) expect(next.info.serverName).toBe('muse') @@ -472,9 +468,7 @@ describe('Muse Code backend against a real child process', { timeout: TEST_TIMEO const crashing = manager({ start: 'crash' }) await expect(crashing.manager.ensureHost()).rejects.toThrow() expect(crashing.manager.isRunning).toBe(false) - expect(crashing.log.warn).toHaveBeenCalledWith( - 'muse serve stderr: fake muse: refusing to start', - ) + expect(crashing.log.warn).toHaveBeenCalledWith(stderrLogged('fake muse: refusing to start')) }) it('gives up on a host that never answers the handshake, and ends it (drill, D25)', async () => { diff --git a/test/fixtures/msp/account-login-cancelled.json b/test/fixtures/msp/account-login-cancelled.json new file mode 100644 index 00000000..d3469f50 --- /dev/null +++ b/test/fixtures/msp/account-login-cancelled.json @@ -0,0 +1,237 @@ +{ + "capture": { + "cli": "Muse Code 1.4.0-R4302.1 (serverInfo 1.4.0, build aebe0c188b3832bf0e2f68a30f797b42e7930072, windows-x86_64), muse-bin-1.4.0-R4302.1.exe serve", + "client": "raw MSP NDJSON over stdio; initialize with experimentalApi as the extension does", + "home": "a new mkdtemp folder under %TEMP% (HOME, USERPROFILE, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, APPDATA and LOCALAPPDATA inside it; META_API_KEY and TBH_CREDENTIAL_BACKEND removed), deleted afterwards", + "workspace": "an empty folder inside that home", + "modelAttempts": 0, + "ownerCredentialFile": "stat only (size and mtime), unchanged before and after", + "redaction": "the user code is replaced by its shape (AAAA-AAAA), in the result and in the URL; museHome is under ; nothing else needed it (no label, e-mail or token is in these frames)", + "name": "cancelled", + "date": "2026-09-27 18:14-18:22 PDT (2026-09-28T01:14Z-01:22Z)", + "browser": "nothing: the run was meant to approve the code, but the browser could not be driven, so loginCancel was sent at the 480 s deadline", + "polls": "account/read every second between loginStart and the cancel: 474 answers, all {state: loggedOut, credentialRequired: true}; the first two and the last are kept", + "finding": "the code was still live 482 s after loginStart; account/loginCompleted {outcome: cancelled} (no message) arrives before the loginCancel result {cancelled: true}" + }, + "frames": [ + { + "atMs": 15, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "muse_spark_code_capture", + "version": "0.0.0" + }, + "capabilities": { + "experimentalApi": true, + "userInputDialogs": false + } + } + } + }, + { + "atMs": 1064, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "serverInfo": { + "name": "muse", + "version": "1.4.0" + }, + "userAgent": "muse-build/1.4.0 (non-interactive; windows-x86_64; build aebe0c188b3832bf0e2f68a30f797b42e7930072)", + "museHome": "\\data\\muse", + "platformFamily": "windows", + "platformOs": "windows", + "schema": { + "version": 1, + "fingerprint": "sha256:99a7458c70a670dda3dda45512bdd1e270aba156f46a1324515de45dce95a658" + }, + "grantedCapabilities": [], + "experimentalApi": true, + "sessionDurability": "durable" + } + } + }, + { + "atMs": 1065, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "method": "initialized" + } + }, + { + "atMs": 1065, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 1066, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 1067, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "method": "account/loginStart", + "params": { + "type": "deviceCode" + } + } + }, + { + "atMs": 1529, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "verificationUrl": "https://auth.meta.com/oauth/device/?code=AAAA-AAAA", + "userCode": "AAAA-AAAA" + } + } + }, + { + "atMs": 2569, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 2570, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 3583, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 3585, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 482425, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 477, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 482427, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 477, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 482428, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 478, + "method": "account/loginCancel", + "params": {} + } + }, + { + "atMs": 482431, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/loginCompleted", + "params": { + "outcome": "cancelled" + }, + "emittedAtMs": 1790558520103 + } + }, + { + "atMs": 482432, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 478, + "result": { + "cancelled": true + } + } + }, + { + "atMs": 484440, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 479, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 484442, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 479, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + } + ] +} diff --git a/test/fixtures/msp/account-login-denied.json b/test/fixtures/msp/account-login-denied.json new file mode 100644 index 00000000..d5ec3233 --- /dev/null +++ b/test/fixtures/msp/account-login-denied.json @@ -0,0 +1,326 @@ +{ + "capture": { + "cli": "Muse Code 1.4.0-R4302.1 (serverInfo 1.4.0, build aebe0c188b3832bf0e2f68a30f797b42e7930072, windows-x86_64), muse-bin-1.4.0-R4302.1.exe serve", + "client": "raw MSP NDJSON over stdio; initialize with experimentalApi as the extension does", + "home": "a new mkdtemp folder under %TEMP% (HOME, USERPROFILE, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, APPDATA and LOCALAPPDATA inside it; META_API_KEY, TBH_CREDENTIAL_BACKEND and MUSE_AUTH_PATH removed), deleted afterwards; the trace confirmed kind=\"config_root\" source=\"xdg\" before loginStart", + "workspace": "an empty folder inside that home", + "modelAttempts": 0, + "ownerCredentialFile": "stat only (size and mtime): 640 bytes, mtime 2026-09-22T20:21:02.598Z, unchanged before and after", + "redaction": "the user code is replaced by its shape (AAAA-AAAA), in the result and in the URL; museHome and paths are under ; no label, e-mail or token is in these frames", + "name": "denied", + "date": "2026-09-27 21:29:03-21:29:28 PDT (2026-09-28T04:29:03Z-04:29:28Z)", + "browser": "the same \"Approve Muse Code?\" page; Deny was clicked about 20 s after loginStart and the page said \"Muse Code was denied\"", + "polls": "account/read every second between loginStart and the ending: 20 answers, all {\"state\":\"loggedOut\",\"credentialRequired\":true}; the first two and the last are kept", + "finding": "account/loginCompleted {outcome: \"denied\", message: \"login failed: the request was denied\"} came 20.4 s after loginStart's answer (within about 1 s of the click); no account/changed was sent; account/read stayed loggedOut and no credential file was written; a later loginCancel answers {cancelled: false}" + }, + "frames": [ + { + "atMs": 14, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "muse_spark_code_capture", + "version": "0.0.0" + }, + "capabilities": { + "experimentalApi": true, + "userInputDialogs": false + } + } + } + }, + { + "atMs": 1242, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "serverInfo": { + "name": "muse", + "version": "1.4.0" + }, + "userAgent": "muse-build/1.4.0 (non-interactive; windows-x86_64; build aebe0c188b3832bf0e2f68a30f797b42e7930072)", + "museHome": "\\data\\muse", + "platformFamily": "windows", + "platformOs": "windows", + "schema": { + "version": 1, + "fingerprint": "sha256:99a7458c70a670dda3dda45512bdd1e270aba156f46a1324515de45dce95a658" + }, + "grantedCapabilities": [], + "experimentalApi": true, + "sessionDurability": "durable" + } + } + }, + { + "atMs": 1243, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "method": "initialized" + } + }, + { + "atMs": 1243, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 1244, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 1275, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "method": "account/loginStart", + "params": { + "type": "deviceCode" + } + } + }, + { + "atMs": 1726, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "verificationUrl": "https://auth.meta.com/oauth/device/?code=AAAA-AAAA", + "userCode": "AAAA-AAAA" + } + } + }, + { + "atMs": 2756, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 2758, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 3770, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 3772, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 22017, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 23, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 22019, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 23, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 22134, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/loginCompleted", + "params": { + "outcome": "denied", + "message": "login failed: the request was denied" + }, + "emittedAtMs": 1790569765809 + } + }, + { + "atMs": 22135, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 24, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 22136, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 24, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 23142, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 25, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 23145, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 25, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 24156, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 26, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 24157, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 26, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 25172, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 27, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 25173, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 27, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 25174, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 28, + "method": "account/loginCancel", + "params": {} + } + }, + { + "atMs": 25174, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 28, + "result": { + "cancelled": false + } + } + }, + { + "atMs": 25175, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 29, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 25177, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 29, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + } + ] +} diff --git a/test/fixtures/msp/account-login-expired.json b/test/fixtures/msp/account-login-expired.json new file mode 100644 index 00000000..590cfe52 --- /dev/null +++ b/test/fixtures/msp/account-login-expired.json @@ -0,0 +1,304 @@ +{ + "capture": { + "cli": "Muse Code 1.4.0-R4302.1 (serverInfo 1.4.0, build aebe0c188b3832bf0e2f68a30f797b42e7930072, windows-x86_64), muse-bin-1.4.0-R4302.1.exe serve", + "client": "raw MSP NDJSON over stdio; initialize with experimentalApi as the extension does", + "home": "a new mkdtemp folder under %TEMP% (HOME, USERPROFILE, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, APPDATA and LOCALAPPDATA inside it; META_API_KEY and TBH_CREDENTIAL_BACKEND removed), deleted afterwards", + "workspace": "an empty folder inside that home", + "modelAttempts": 0, + "ownerCredentialFile": "stat only (size and mtime), unchanged before and after", + "redaction": "the user code is replaced by its shape (AAAA-AAAA), in the result and in the URL; museHome is under ; nothing else needed it (no label, e-mail or token is in these frames)", + "name": "expired", + "date": "2026-09-27 18:13-18:23 PDT (2026-09-28T01:13Z-01:23Z)", + "browser": "nothing: the code was never opened, and was left to expire", + "polls": "account/read every second between loginStart and the ending: 592 answers, all {state: loggedOut, credentialRequired: true}; the first two and the last are kept", + "finding": "the code expired 600.5 s after loginStart; no account/changed was sent; a later loginCancel answers {cancelled: false}" + }, + "frames": [ + { + "atMs": 2291, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "muse_spark_code_capture", + "version": "0.0.0" + }, + "capabilities": { + "experimentalApi": true, + "userInputDialogs": false + } + } + } + }, + { + "atMs": 3439, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "serverInfo": { + "name": "muse", + "version": "1.4.0" + }, + "userAgent": "muse-build/1.4.0 (non-interactive; windows-x86_64; build aebe0c188b3832bf0e2f68a30f797b42e7930072)", + "museHome": "\\data\\muse", + "platformFamily": "windows", + "platformOs": "windows", + "schema": { + "version": 1, + "fingerprint": "sha256:99a7458c70a670dda3dda45512bdd1e270aba156f46a1324515de45dce95a658" + }, + "grantedCapabilities": [], + "experimentalApi": true, + "sessionDurability": "durable" + } + } + }, + { + "atMs": 3439, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "method": "initialized" + } + }, + { + "atMs": 3440, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 3441, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 3441, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "method": "account/loginStart", + "params": { + "type": "deviceCode" + } + } + }, + { + "atMs": 3799, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "verificationUrl": "https://auth.meta.com/oauth/device/?code=AAAA-AAAA", + "userCode": "AAAA-AAAA" + } + } + }, + { + "atMs": 4830, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 4833, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 5842, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 5844, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 603947, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 595, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 603948, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 595, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 604271, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/loginCompleted", + "params": { + "outcome": "expired", + "message": "login failed: the request expired" + }, + "emittedAtMs": 1790558591314 + } + }, + { + "atMs": 605276, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 596, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 605278, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 596, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 606286, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 597, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 606287, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 597, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 607299, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 598, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 607300, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 598, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 607301, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 599, + "method": "account/loginCancel", + "params": {} + } + }, + { + "atMs": 607301, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 599, + "result": { + "cancelled": false + } + } + }, + { + "atMs": 607302, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 600, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 607302, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 600, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + } + ] +} diff --git a/test/fixtures/msp/account-login-failed.json b/test/fixtures/msp/account-login-failed.json new file mode 100644 index 00000000..90410c47 --- /dev/null +++ b/test/fixtures/msp/account-login-failed.json @@ -0,0 +1,327 @@ +{ + "capture": { + "cli": "Muse Code 1.4.0-R4302.1 (serverInfo 1.4.0, build aebe0c188b3832bf0e2f68a30f797b42e7930072, windows-x86_64), muse-bin-1.4.0-R4302.1.exe serve", + "client": "raw MSP NDJSON over stdio; initialize with experimentalApi as the extension does", + "home": "a new mkdtemp folder under %TEMP% (HOME, USERPROFILE, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, APPDATA and LOCALAPPDATA inside it; META_API_KEY, TBH_CREDENTIAL_BACKEND and MUSE_AUTH_PATH removed), deleted afterwards; the trace confirmed kind=\"config_root\" source=\"xdg\" before loginStart", + "workspace": "an empty folder inside that home", + "modelAttempts": 0, + "ownerCredentialFile": "stat only (size and mtime): 640 bytes, mtime 2026-09-22T20:21:02.598Z, unchanged before and after", + "redaction": "the user code is replaced by its shape (AAAA-AAAA), in the result and in the URL; museHome and paths are under ; no label, e-mail or token is in these frames", + "name": "failed", + "date": "2026-09-27 21:29:35-21:30:00 PDT (2026-09-28T04:29:35Z-04:30:00Z)", + "browser": "the same \"Approve Muse Code?\" page; Approve was clicked about 20 s after loginStart and the page said \"Muse Code was approved\"", + "setup": "a regular 49-byte file at \\cfg\\muse, where the CLI keeps auth.json; no permission or ACL was changed", + "polls": "account/read every second between loginStart and the ending: 20 answers, all {\"state\":\"loggedOut\",\"credentialRequired\":true}; the first two and the last are kept", + "finding": "account/loginCompleted {outcome: \"failed\", message: \"login succeeded but saving failed: failed to write credential file at \\cfg\\muse: Cannot create a file when that file already exists. (os error 183)\"} came 20.5 s after loginStart's answer; the message names the path; no account/changed was sent; account/read stayed loggedOut; nothing was written anywhere in the throwaway home (no auth.json, no .auth.json.lock); the trace shows no credential.refresh, so no API key was minted; a later loginCancel answers {cancelled: false}" + }, + "frames": [ + { + "atMs": 18, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "muse_spark_code_capture", + "version": "0.0.0" + }, + "capabilities": { + "experimentalApi": true, + "userInputDialogs": false + } + } + } + }, + { + "atMs": 1259, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "serverInfo": { + "name": "muse", + "version": "1.4.0" + }, + "userAgent": "muse-build/1.4.0 (non-interactive; windows-x86_64; build aebe0c188b3832bf0e2f68a30f797b42e7930072)", + "museHome": "\\data\\muse", + "platformFamily": "windows", + "platformOs": "windows", + "schema": { + "version": 1, + "fingerprint": "sha256:99a7458c70a670dda3dda45512bdd1e270aba156f46a1324515de45dce95a658" + }, + "grantedCapabilities": [], + "experimentalApi": true, + "sessionDurability": "durable" + } + } + }, + { + "atMs": 1261, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "method": "initialized" + } + }, + { + "atMs": 1262, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 1263, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 1289, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "method": "account/loginStart", + "params": { + "type": "deviceCode" + } + } + }, + { + "atMs": 1710, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "verificationUrl": "https://auth.meta.com/oauth/device/?code=AAAA-AAAA", + "userCode": "AAAA-AAAA" + } + } + }, + { + "atMs": 2731, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 2732, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 3745, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 3747, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 21980, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 23, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 21983, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 23, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 22203, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/loginCompleted", + "params": { + "outcome": "failed", + "message": "login succeeded but saving failed: failed to write credential file at \\cfg\\muse: Cannot create a file when that file already exists. (os error 183)" + }, + "emittedAtMs": 1790569797687 + } + }, + { + "atMs": 22204, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 24, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 22204, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 24, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 23210, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 25, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 23216, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 25, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 24224, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 26, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 24226, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 26, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 25227, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 27, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 25229, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 27, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 25229, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 28, + "method": "account/loginCancel", + "params": {} + } + }, + { + "atMs": 25229, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 28, + "result": { + "cancelled": false + } + } + }, + { + "atMs": 25230, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 29, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 25231, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 29, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + } + ] +} diff --git a/test/fixtures/msp/account-login-granted.json b/test/fixtures/msp/account-login-granted.json new file mode 100644 index 00000000..1af0c8ea --- /dev/null +++ b/test/fixtures/msp/account-login-granted.json @@ -0,0 +1,442 @@ +{ + "capture": { + "cli": "Muse Code 1.4.0-R4302.1 (serverInfo 1.4.0, build aebe0c188b3832bf0e2f68a30f797b42e7930072, windows-x86_64), muse-bin-1.4.0-R4302.1.exe serve", + "client": "raw MSP NDJSON over stdio; initialize with experimentalApi as the extension does", + "home": "a new mkdtemp folder under %TEMP% (HOME, USERPROFILE, XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME, APPDATA and LOCALAPPDATA inside it; META_API_KEY, TBH_CREDENTIAL_BACKEND and MUSE_AUTH_PATH removed), deleted afterwards; the trace confirmed kind=\"config_root\" source=\"xdg\" before loginStart", + "workspace": "an empty folder inside that home", + "modelAttempts": 0, + "ownerCredentialFile": "stat only (size and mtime): 640 bytes, mtime 2026-09-22T20:21:02.598Z, unchanged before and after", + "redaction": "the user code is replaced by its shape (AAAA-AAAA), in the result and in the URL; museHome is under ; every label (the account's e-mail address) is the stand-in \"someone@example.com\" and every avatarUrl the stand-in \"https://example.com/avatar.png\" (both were redacted by key before the frame was written, so the avatarUrl's own shape was not recorded); no token is in any frame", + "name": "granted", + "date": "2026-09-27 21:27:45-21:28:15 PDT (2026-09-28T04:27:45Z-04:28:15Z)", + "browser": "the device page (auth.meta.com/oauth/device/?code=…) in the owner's signed-in Chrome asked \"Approve Muse Code?\" with the code and two buttons, Approve and Deny; Approve was clicked about 20 s after loginStart and the page said \"Muse Code was approved\"", + "polls": "account/read every second between loginStart and the ending: 20 answers, all {\"state\":\"loggedOut\",\"credentialRequired\":true}; the first two and the last are kept, and the request in flight when the first notification arrived", + "finding": "the credential file first appeared (437 B) 20.4 s after loginStart's answer; the next poll saw it: account/changed {state: accountLogin, credentialRequired: true} with no label arrived 3 ms after that account/read was sent and 1 ms before its answer (also accountLogin, no label), about 820 ms after the file appeared; after a trace credential.refresh (outcome success, 1007 ms) the file was rewritten 21.44 s after loginStart's answer (1062 B, schema_version 1, providers.meta holding access_token, api_key, api_base_url, obtained_via device_code, mechanism oauth, and the user's name, e-mail and avatar URL); account/loginCompleted {outcome: \"granted\"} with no message came 14 ms later (21.46 s), 205 ms after the first account/changed; a second account/changed, now with label and avatarUrl, came 1 ms after it (emittedAtMs 2 ms later), and every account/read from then on carried label and avatarUrl; avatarUrl is not in the MSP AccountState schema; a later loginCancel answers {cancelled: false}; account/logout answered {state: loggedOut, credentialRequired: true} in 27 ms, preceded by 1 ms by account/changed {state: loggedOut, credentialRequired: true}; the file became the 44-byte {\"schema_version\":1,\"providers\":{}}; the trace logged credential.revoke outcome \"revoked\" (1305 ms); account/read 3 s later said loggedOut" + }, + "frames": [ + { + "atMs": 17, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "clientInfo": { + "name": "muse_spark_code_capture", + "version": "0.0.0" + }, + "capabilities": { + "experimentalApi": true, + "userInputDialogs": false + } + } + } + }, + { + "atMs": 1146, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "serverInfo": { + "name": "muse", + "version": "1.4.0" + }, + "userAgent": "muse-build/1.4.0 (non-interactive; windows-x86_64; build aebe0c188b3832bf0e2f68a30f797b42e7930072)", + "museHome": "\\data\\muse", + "platformFamily": "windows", + "platformOs": "windows", + "schema": { + "version": 1, + "fingerprint": "sha256:99a7458c70a670dda3dda45512bdd1e270aba156f46a1324515de45dce95a658" + }, + "grantedCapabilities": [], + "experimentalApi": true, + "sessionDurability": "durable" + } + } + }, + { + "atMs": 1147, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "method": "initialized" + } + }, + { + "atMs": 1147, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 1148, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 2, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 1170, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "method": "account/loginStart", + "params": { + "type": "deviceCode" + } + } + }, + { + "atMs": 1617, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 3, + "result": { + "verificationUrl": "https://auth.meta.com/oauth/device/?code=AAAA-AAAA", + "userCode": "AAAA-AAAA" + } + } + }, + { + "atMs": 2634, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 2636, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 4, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 3636, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 3637, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 5, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 21855, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 23, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 21856, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 23, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 22868, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 24, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 22870, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/changed", + "params": { + "state": "accountLogin", + "credentialRequired": true + }, + "emittedAtMs": 1790569688753 + } + }, + { + "atMs": 22871, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 24, + "result": { + "state": "accountLogin", + "credentialRequired": true + } + } + }, + { + "atMs": 23075, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/loginCompleted", + "params": { + "outcome": "granted" + }, + "emittedAtMs": 1790569688957 + } + }, + { + "atMs": 23076, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 25, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 23076, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/changed", + "params": { + "state": "accountLogin", + "label": "someone@example.com", + "avatarUrl": "https://example.com/avatar.png", + "credentialRequired": true + }, + "emittedAtMs": 1790569688959 + } + }, + { + "atMs": 23077, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 25, + "result": { + "state": "accountLogin", + "label": "someone@example.com", + "avatarUrl": "https://example.com/avatar.png", + "credentialRequired": true + } + } + }, + { + "atMs": 24090, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 26, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 24092, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 26, + "result": { + "state": "accountLogin", + "label": "someone@example.com", + "avatarUrl": "https://example.com/avatar.png", + "credentialRequired": true + } + } + }, + { + "atMs": 25102, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 27, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 25106, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 27, + "result": { + "state": "accountLogin", + "label": "someone@example.com", + "avatarUrl": "https://example.com/avatar.png", + "credentialRequired": true + } + } + }, + { + "atMs": 26123, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 28, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 26127, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 28, + "result": { + "state": "accountLogin", + "label": "someone@example.com", + "avatarUrl": "https://example.com/avatar.png", + "credentialRequired": true + } + } + }, + { + "atMs": 26128, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 29, + "method": "account/loginCancel", + "params": {} + } + }, + { + "atMs": 26129, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 29, + "result": { + "cancelled": false + } + } + }, + { + "atMs": 26130, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 30, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 26132, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 30, + "result": { + "state": "accountLogin", + "label": "someone@example.com", + "avatarUrl": "https://example.com/avatar.png", + "credentialRequired": true + } + } + }, + { + "atMs": 26134, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 31, + "method": "account/logout", + "params": {} + } + }, + { + "atMs": 26160, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "method": "account/changed", + "params": { + "state": "loggedOut", + "credentialRequired": true + }, + "emittedAtMs": 1790569692042 + } + }, + { + "atMs": 26161, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 31, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + }, + { + "atMs": 29164, + "dir": "out", + "frame": { + "jsonrpc": "2.0", + "id": 32, + "method": "account/read", + "params": {} + } + }, + { + "atMs": 29166, + "dir": "in", + "frame": { + "jsonrpc": "2.0", + "id": 32, + "result": { + "state": "loggedOut", + "credentialRequired": true + } + } + } + ] +} diff --git a/test/unit/MuseCodeHost.test.ts b/test/unit/MuseCodeHost.test.ts index 8c7ef8ba..84bfcb99 100644 --- a/test/unit/MuseCodeHost.test.ts +++ b/test/unit/MuseCodeHost.test.ts @@ -249,9 +249,10 @@ describe('MuseCodeHost', () => { }) await session.setModel('muse-spark-1.2') expect(calls).toBe(2) + // Named by its kind and code, not the CLI's message (the review of PR #49). expect(log.warn).toHaveBeenCalledWith( expect.stringMatching( - /^session\/setModel refused \(refused: overloaded\); attempt 2 in \d+ ms$/, + /^session\/setModel refused \(overloaded \(MSP error -32001\)\); attempt 2 in \d+ ms$/, ), ) expect(log.trace).toHaveBeenCalledWith( diff --git a/test/unit/accountHost.test.ts b/test/unit/accountHost.test.ts new file mode 100644 index 00000000..4379364b --- /dev/null +++ b/test/unit/accountHost.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest' +import { + AccountHosts, + type AccountSession, + isStoredSignIn, + logOutAccount, + probeAccount, + readAccountState, +} from '../../src/host/auth/accountHost' +import { CAPTURED_SIGNED_IN } from './helpers/accountLoginCapture' +import { FakeLogOutputChannel } from './helpers/fakes' + +// `account/read` and `account/logout` as captured (1.3.0 and 1.4.0-R4302.1, +// isolated homes, 2026-09-27); the label was redacted there and is an +// e-mail address in real life. +const LOGGED_OUT = { state: 'loggedOut', credentialRequired: true } +const STORED_KEY = { state: 'apiKey', label: 'person@example.com', credentialRequired: true } + +type Answer = Record | Error + +function host(answers: Record Answer)>) { + const request = vi.fn((method: string) => { + const entry = answers[method] + const answer = typeof entry === 'function' ? entry() : entry + if (answer === undefined) { + return Promise.reject(new Error(`no handler for ${method}`)) + } + return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer) + }) + const close = vi.fn(() => Promise.resolve()) + const session: AccountSession = { + connection: { request, onNotification: vi.fn(), closed: new Promise(() => undefined) }, + close, + } + return { request, close, session, connect: () => Promise.resolve(session) } +} + +/** A signal nothing aborts: the window stays open. */ +const OPEN = new AbortController().signal + +function allLogged(log: FakeLogOutputChannel): string { + return [...log.info.mock.calls, ...log.warn.mock.calls].flat().join('\n') +} + +describe('readAccountState', () => { + it('keeps the state and drops the label', async () => { + const t = host({ 'account/read': STORED_KEY }) + await expect(readAccountState(t.session.connection)).resolves.toEqual({ + state: 'apiKey', + credentialRequired: true, + }) + }) + + // As captured once the browser approved: `avatarUrl` is not in the schema. + it('drops the captured label and avatarUrl of a browser sign-in', async () => { + const t = host({ 'account/read': CAPTURED_SIGNED_IN }) + await expect(readAccountState(t.session.connection)).resolves.toEqual({ + state: 'accountLogin', + credentialRequired: true, + }) + }) + + it('says nothing for an unknown method or an unexpected shape', async () => { + await expect(readAccountState(host({}).session.connection)).resolves.toBeUndefined() + await expect( + readAccountState(host({ 'account/read': { state: 'loggedOut' } }).session.connection), + ).resolves.toBeUndefined() + }) +}) + +describe('isStoredSignIn', () => { + it('is a login or a stored key, not an environment key or none', () => { + expect(isStoredSignIn({ state: 'accountLogin', credentialRequired: true })).toBe(true) + expect(isStoredSignIn({ state: 'apiKey', credentialRequired: true })).toBe(true) + expect(isStoredSignIn({ state: 'envKey', credentialRequired: true })).toBe(false) + expect(isStoredSignIn(LOGGED_OUT)).toBe(false) + }) +}) + +describe('probeAccount', () => { + it('asks one host and closes it', async () => { + const t = host({ 'account/read': LOGGED_OUT }) + const log = new FakeLogOutputChannel() + await expect(probeAccount(t.connect, log, OPEN)).resolves.toEqual(LOGGED_OUT) + expect(t.close).toHaveBeenCalledOnce() + }) + + it('says nothing when the host cannot start, naming only the error', async () => { + const log = new FakeLogOutputChannel() + const failure = new Error(String.raw`failed at C:\Users\someone\.config\muse\auth.json`) + await expect(probeAccount(() => Promise.reject(failure), log, OPEN)).resolves.toBeUndefined() + expect(log.warn).toHaveBeenCalledWith('The Muse Code account host could not start: Error') + expect(allLogged(log)).not.toContain('auth.json') + }) + + it('reports a host that does not close cleanly and keeps the answer', async () => { + const t = host({ 'account/read': LOGGED_OUT }) + t.close.mockRejectedValue(new Error('still draining')) + const log = new FakeLogOutputChannel() + await expect(probeAccount(t.connect, log, OPEN)).resolves.toEqual(LOGGED_OUT) + expect(log.warn).toHaveBeenCalledWith('The Muse Code account host did not close cleanly: Error') + }) +}) + +describe('logOutAccount', () => { + it('signs out and confirms with account/read', async () => { + let state: Answer = STORED_KEY + const t = host({ + 'account/logout': () => { + state = LOGGED_OUT + return LOGGED_OUT + }, + 'account/read': () => state, + }) + const log = new FakeLogOutputChannel() + await expect(logOutAccount(t.connect, log, OPEN)).resolves.toBe('confirmed') + expect(t.request.mock.calls.map(([method]) => method)).toEqual([ + 'account/logout', + 'account/read', + ]) + expect(t.close).toHaveBeenCalledOnce() + expect(allLogged(log)).not.toContain('person@example.com') + }) + + it.each([ + ['the host cannot start', undefined], + ['account/logout is refused', { 'account/logout': new Error('experimentalRequired') }], + ['account/logout answers another shape', { 'account/logout': { ok: true } }], + ['a stored sign-in remains', { 'account/logout': LOGGED_OUT, 'account/read': STORED_KEY }], + ['account/read cannot confirm', { 'account/logout': LOGGED_OUT }], + // Only the captured signed-out answer confirms (the review of PR #49). + [ + 'META_API_KEY hides the stored lane', + { + 'account/logout': { state: 'envKey', credentialRequired: true }, + 'account/read': { state: 'envKey', credentialRequired: true }, + }, + ], + [ + 'account/read answers the uncaptured credentialRequired false', + { + 'account/logout': LOGGED_OUT, + 'account/read': { state: 'loggedOut', credentialRequired: false }, + }, + ], + ])('is false when %s', async (_name, answers) => { + const log = new FakeLogOutputChannel() + if (answers === undefined) { + await expect( + logOutAccount(() => Promise.reject(new Error('no CLI')), log, OPEN), + ).resolves.toBe('unconfirmed') + return + } + const t = host(answers) + await expect(logOutAccount(t.connect, log, OPEN)).resolves.toBe('unconfirmed') + expect(t.close).toHaveBeenCalledOnce() + expect(log.warn).toHaveBeenCalled() + }) + + // The state vocabulary is open: a state not shaped like a protocol word + // is not logged (the review of PR #49). + it('logs an unconfirming state only in the shape of a protocol word', async () => { + const log = new FakeLogOutputChannel() + const t = host({ + 'account/logout': LOGGED_OUT, + 'account/read': { state: String.raw`at C:\Users\someone`, credentialRequired: true }, + }) + await expect(logOutAccount(t.connect, log, OPEN)).resolves.toBe('unconfirmed') + expect(log.warn).toHaveBeenCalledWith( + 'Muse Code did not confirm account/logout (an unrecognized value)', + ) + expect(allLogged(log)).not.toContain('someone') + }) +}) + +// The window closing ends every account host still open (the review of PR #49). +describe('AccountHosts', () => { + it('closes a probe still waiting when the window closes, and starts none afterwards', async () => { + const t = host({}) + t.request.mockImplementation(() => new Promise(() => undefined)) + const connect = vi.fn((signal: AbortSignal) => + signal.aborted ? Promise.reject(new Error('cancelled')) : t.connect(), + ) + const hosts = new AccountHosts(connect, new FakeLogOutputChannel()) + const probing = hosts.probe() + const loggingOut = hosts.logOut() + await vi.waitFor(() => { + expect(t.request).toHaveBeenCalledTimes(2) + }) + hosts.close() + await expect(probing).resolves.toBeUndefined() + await expect(loggingOut).resolves.toBe('unconfirmed') + expect(t.close).toHaveBeenCalledTimes(2) + await expect(hosts.probe()).resolves.toBeUndefined() + expect(t.request).toHaveBeenCalledTimes(2) + }) +}) diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 49f39420..aca548d0 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -1,11 +1,25 @@ -import { describe, expect, it, vi } from 'vitest' -import { AuthService, type AuthServiceDeps } from '../../src/host/auth/authService' +import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CliSignIn } from '../../src/core/backends/musecode/credentialFile' +import type { AccountState } from '../../src/host/auth/accountHost' +import { + AuthService, + type AuthServiceDeps, + type AuthSnapshot, +} from '../../src/host/auth/authService' +import { CliAccount } from '../../src/host/auth/cliAccount' import { CredentialStore } from '../../src/host/auth/credentialStore' +import type { DeviceSignInOutcome } from '../../src/host/auth/deviceSignIn' import { MUSE_INSTALL_TIMEOUT_MS, type BackendMode } from '../../src/shared/constants' +import { EN } from '../../src/shared/l10n/en' +import { fill } from '../../src/shared/l10n/text' import type { HostToWebviewMessage } from '../../src/shared/protocol' +import { DEVICE_LOGIN_FILE, LOGOUT_SHELL } from './helpers/credentialShapes' import { FakeLogOutputChannel, memorySecrets, unexpectedWarning } from './helpers/fakes' -const CREDENTIAL_MTIME = 5000 +const CREDENTIAL_PATH = '/home/u/.config/muse/auth.json' interface Harness { readonly service: AuthService @@ -13,11 +27,18 @@ interface Harness { readonly broadcasts: HostToWebviewMessage[] readonly facts: { cliPresent: boolean - credentialFile: boolean - credentialMtime: number + /** What the CLI's credential file (or `account/read`) says. */ + cli: CliSignIn envKey: boolean backendMode: BackendMode + /** The credential file's modification time. */ + fileModifiedAt: number | undefined } + readonly cliSignIn: ReturnType Promise>> + readonly abandonCliProbe: ReturnType void>> + readonly forgetCliAnswers: ReturnType void>> + /** `account/logout`: by default it works and leaves the CLI signed out. */ + readonly logOutCli: ReturnType Promise>> readonly restartBackend: ReturnType< typeof vi.fn<(isConversationEnding: boolean) => Promise> > @@ -29,7 +50,7 @@ interface Harness { ( signal: AbortSignal, onCode: (url: string, code: string) => void, - ) => Promise<'signedIn' | 'cancelled' | 'timedOut'> + ) => Promise > > readonly runInstallerInTerminal: ReturnType void>> @@ -40,11 +61,20 @@ function harness(overrides: Partial = {}): Harness { const broadcasts: HostToWebviewMessage[] = [] const facts = { cliPresent: true, - credentialFile: false, - credentialMtime: CREDENTIAL_MTIME, + cli: 'signedOut' as CliSignIn, envKey: false, backendMode: 'auto' as BackendMode, + fileModifiedAt: 1 as number | undefined, } + const cliSignIn = vi.fn<(isUserAction: boolean) => Promise>(() => + Promise.resolve(facts.cli), + ) + const abandonCliProbe = vi.fn<() => void>() + const forgetCliAnswers = vi.fn<() => void>() + const logOutCli = vi.fn<() => Promise>(() => { + facts.cli = 'signedOut' + return Promise.resolve(true) + }) const restartBackend = vi.fn<(isConversationEnding: boolean) => Promise>(() => Promise.resolve(), ) @@ -53,7 +83,7 @@ function harness(overrides: Partial = {}): Harness { ( signal: AbortSignal, onCode: (url: string, code: string) => void, - ) => Promise<'signedIn' | 'cancelled' | 'timedOut'> + ) => Promise >(() => Promise.resolve('timedOut')) const runInstallerInTerminal = vi.fn<() => void>() const logoutHoldState = { isHeld: false } @@ -62,12 +92,15 @@ function harness(overrides: Partial = {}): Harness { backend: { resolveCli: () => facts.cliPresent ? { ok: true, cliPath: '/bin/muse' } : { ok: false, reason: 'missing' }, - credentialFileExists: () => facts.credentialFile, - // A written file has a new stamp; the harness writes it once. - credentialFileModifiedAt: () => (facts.credentialFile ? facts.credentialMtime : undefined), + cliSignIn, + abandonCliProbe, + forgetCliAnswers, + credentialFilePath: () => CREDENTIAL_PATH, + credentialFileModifiedAt: () => facts.fileModifiedAt, hasEnvironmentKey: () => facts.envKey, getBackendMode: () => facts.backendMode, restartBackend, + logOutCli, }, credentials: new CredentialStore(memorySecrets(), unexpectedWarning), runInTerminal, @@ -98,6 +131,10 @@ function harness(overrides: Partial = {}): Harness { deps, broadcasts, facts, + cliSignIn, + abandonCliProbe, + forgetCliAnswers, + logOutCli, restartBackend, runInTerminal, runDeviceSignIn, @@ -106,6 +143,22 @@ function harness(overrides: Partial = {}): Harness { } } +/** A CLI sign-in the account host could not end: `muse logout` runs in a terminal instead. */ +function withLogoutFallback(h: Harness): Harness { + h.logOutCli.mockResolvedValue(false) + return h +} + +/** A CLI sign-in a sign-out could not end: the logout hold is on. */ +async function heldCliSignIn(): Promise { + const h = withLogoutFallback(harness()) + h.facts.cli = 'signedIn' + await h.service.refresh() + await h.service.signOut() + expect(h.logoutHoldState.isHeld).toBe(true) + return h +} + async function signedInModelApi(overrides: Partial = {}): Promise { const h = harness(overrides) await h.deps.credentials.setApiKey('LLM|1|secret') @@ -152,7 +205,7 @@ describe('AuthService.refresh', () => { backend: 'museCode', methods: ['browser', 'apiKey'], }) - h.facts.credentialFile = true + h.facts.cli = 'signedIn' await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedIn' }) expect(h.broadcasts.at(-1)).toEqual({ type: 'authState', @@ -176,7 +229,7 @@ describe('AuthService.refresh', () => { }) // A CLI session takes precedence in auto: the subscription pays for CLI work. h.facts.cliPresent = true - h.facts.credentialFile = true + h.facts.cli = 'signedIn' await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedIn', backend: 'museCode', @@ -189,7 +242,7 @@ describe('AuthService.refresh', () => { methods: ['apiKey'], }) h.facts.backendMode = 'museCode' - h.facts.credentialFile = false + h.facts.cli = 'signedOut' await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedOut', backend: 'museCode', @@ -200,6 +253,23 @@ describe('AuthService.refresh', () => { }) }) +// A CLI slow to answer about an ambiguous file must not hold up a stored +// key when the backend is forced to the Model API (Codex on 328efb52). +describe('AuthService with the backend forced to the Model API', () => { + it('selects from the stored key without asking the CLI', async () => { + const h = harness() + h.facts.backendMode = 'modelApi' + h.cliSignIn.mockImplementation(unanswered) + await h.deps.credentials.setApiKey('LLM|1|secret') + await expect(h.service.refresh(true)).resolves.toMatchObject({ + status: 'signedIn', + backend: 'modelApi', + hasCliSession: false, + }) + expect(h.cliSignIn).not.toHaveBeenCalled() + }) +}) + describe('AuthService.signIn', () => { it.each(['cancelled', 'timedOut'] as const)( 'keeps a valid Model API session after CLI device sign-in is %s', @@ -260,11 +330,77 @@ describe('AuthService.signIn', () => { ).toBe(false) }) + it('cancels at once while the pre-flight account probe goes unanswered (the review of PR #49)', async () => { + const h = harness() + h.cliSignIn.mockImplementation(() => new Promise(() => undefined)) + const pending = h.service.signIn('browser') + await vi.waitFor(() => { + expect(h.cliSignIn).toHaveBeenCalled() + }) + h.service.cancelSignIn() + await expect(pending).resolves.toMatchObject({ status: 'signedOut' }) + expect(h.runDeviceSignIn).not.toHaveBeenCalled() + }) + + it('signs out without waiting on a pre-flight probe the CLI never answered (the review of PR #49)', async () => { + const h = harness() + // Like CliAccount: a question joins the unanswered probe until it is abandoned. + let isAbandoned = false + h.cliSignIn.mockImplementation(() => + isAbandoned ? Promise.resolve(h.facts.cli) : new Promise(() => undefined), + ) + h.abandonCliProbe.mockImplementation(() => { + isAbandoned = true + }) + const pending = h.service.signIn('browser') + await vi.waitFor(() => { + expect(h.cliSignIn).toHaveBeenCalled() + }) + await expect(h.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + // The interrupted sign-in settles too, instead of waiting out the probe. + await expect(pending).resolves.toBeDefined() + expect(h.abandonCliProbe).toHaveBeenCalled() + // Its cancellation never showed over the sign-out's own state or as a notice. + expect( + h.broadcasts.some( + (message) => + (message.type === 'authState' && message.detail === EN.signInCancelled) || + (message.type === 'notice' && message.text === EN.signInCancelled), + ), + ).toBe(false) + }) + + // A refresh begun before a sign-out, or during it, whose probe answers + // after the sign-out ended (the review of PR #49). + it.each([ + ['before it', false], + ['during it', true], + ])( + 'keeps the state a sign-out published when a refresh begun %s answers late', + async (_name, isDuring) => { + const h = harness() + const stopping = Promise.withResolvers() + h.restartBackend.mockImplementation(() => stopping.promise) + const late = Promise.withResolvers() + const signingOut = isDuring ? h.service.signOut() : undefined + h.cliSignIn.mockImplementationOnce(() => late.promise) + const stale = h.service.refresh() + const signedOut = signingOut ?? h.service.signOut() + stopping.resolve(undefined) + await expect(signedOut).resolves.toMatchObject({ status: 'signedOut' }) + const published = h.broadcasts.length + late.resolve('signedIn') + await expect(stale).resolves.toMatchObject({ status: 'signedOut' }) + expect(h.service.current.status).toBe('signedOut') + expect(h.broadcasts).toHaveLength(published) + }, + ) + it('shows the device code, waits for the credential, and restarts the backend', async () => { const h = harness() h.runDeviceSignIn.mockImplementation((_signal, onCode) => { onCode('https://auth.meta.com/oauth/device/', 'ABCD-EFGH') - h.facts.credentialFile = true + h.facts.cli = 'signedIn' return Promise.resolve('signedIn') }) await expect(h.service.signIn('browser')).resolves.toMatchObject({ status: 'signedIn' }) @@ -419,7 +555,7 @@ describe('AuthService.signIn', () => { it('starts one device flow however often the button is pressed (D25)', async () => { const h = harness() h.runDeviceSignIn.mockImplementation(() => { - h.facts.credentialFile = true + h.facts.cli = 'signedIn' return Promise.resolve('signedIn') }) const first = h.service.signIn('browser') @@ -462,6 +598,456 @@ describe('AuthService.signIn', () => { }) }) +/** A question to the CLI that is never answered. */ +function unanswered(): Promise { + return new Promise(() => undefined) +} + +/** Resolves as soon as `signal` aborts. */ +function aborted(signal: AbortSignal): Promise { + return new Promise((resolve) => { + signal.addEventListener( + 'abort', + () => { + resolve() + }, + { once: true }, + ) + }) +} + +/** + * A browser sign-in whose Cancel is pressed just after the browser + * approved: the credential file changed and the CLI reads signed in. + */ +async function cancelAfterApproval(h: Harness): Promise { + h.runDeviceSignIn.mockImplementation(async (signal) => { + h.facts.fileModifiedAt = 2 + h.facts.cli = 'signedIn' + await aborted(signal) + return 'cancelled' + }) + const pending = h.service.signIn('browser') + await vi.waitFor(() => { + expect(h.runDeviceSignIn).toHaveBeenCalled() + }) + h.service.cancelSignIn() + return await pending +} + +/** + * A Model API session switching to a CLI now signed in (Check again), held + * in the restart that ends the Model API conversation until the test + * settles `stopping`. + */ +async function switchHeldInRestart() { + const h = await signedInModelApi() + const stopping = Promise.withResolvers() + h.restartBackend.mockImplementationOnce(() => stopping.promise) + h.facts.cli = 'signedIn' + const switching = h.service.checkAgain() + await vi.waitFor(() => { + expect(h.restartBackend).toHaveBeenCalledOnce() + }) + return { h, stopping, switching } +} + +// The review of PR #49: what a sign-out, Cancel or the window closing must +// not wait on, and what they must not overwrite. +describe('AuthService: sign-in, sign-out and Cancel racing', () => { + // The device runner saw the sign-in; the CLI then never answers the + // confirming question, before the hold is lifted or in the last refresh. + it.each([ + ['confirming the sign-in after a sign-out', true], + ['refreshing after the sign-in', false], + ])('signs out without waiting on a finished sign-in %s', async (_name, isHeld) => { + const h = withLogoutFallback(harness()) + if (isHeld) { + h.facts.cli = 'signedIn' + await h.service.refresh() + await h.service.signOut() + expect(h.logoutHoldState.isHeld).toBe(true) + } + let isConfirming = false + h.runDeviceSignIn.mockImplementation(() => { + h.cliSignIn.mockImplementationOnce(() => { + isConfirming = true + return unanswered() + }) + return Promise.resolve('signedIn') + }) + const signingIn = h.service.signIn('browser') + await vi.waitFor(() => { + expect(isConfirming).toBe(true) + }) + h.facts.cli = 'signedOut' + await expect(h.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + await expect(signingIn).resolves.toBeDefined() + }) + + it('signs out without waiting on the refresh a failed sign-in began', async () => { + const h = await signedInModelApi() + let isRefreshing = false + h.runDeviceSignIn.mockImplementation(() => { + h.cliSignIn.mockImplementationOnce(() => { + isRefreshing = true + return unanswered() + }) + return Promise.resolve('timedOut') + }) + const signingIn = h.service.signIn('browser') + await vi.waitFor(() => { + expect(isRefreshing).toBe(true) + }) + await expect(h.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + await expect(signingIn).resolves.toBeDefined() + expect(await h.deps.credentials.getApiKey()).toBeUndefined() + }) + + // The browser approved as Cancel was pressed: the file changed since the + // flow began, so its structure decides, not the click. + it('lets the credential file decide a Cancel pressed just after the browser approved', async () => { + const h = harness() + await expect(cancelAfterApproval(h)).resolves.toMatchObject({ + status: 'signedIn', + backend: 'museCode', + }) + }) + + // `account/logout` cleared the file, but META_API_KEY made `account/read` + // answer `envKey`, which confirms nothing. + it('opens no muse logout terminal once the file shows no sign-in after an unconfirmed logout', async () => { + const h = harness() + h.facts.cli = 'signedIn' + h.facts.envKey = true + await h.service.refresh() + h.logOutCli.mockImplementation(() => { + h.facts.cli = 'signedOut' + return Promise.resolve(false) + }) + await h.service.signOut() + expect(h.logOutCli).toHaveBeenCalledOnce() + expect(h.forgetCliAnswers).toHaveBeenCalled() + expect(h.runInTerminal).not.toHaveBeenCalled() + }) + + // The window closed while a click was still asking the CLI whether the + // held sign-in can be replaced: no host starts afterwards, and no later + // click starts one or opens a key prompt (the review of PR #49). + it('starts no sign-in once the window is closing, not even from a click in its pre-flight', async () => { + const h = await heldCliSignIn() + const preflight = Promise.withResolvers() + let isAsking = false + h.cliSignIn.mockImplementationOnce(() => { + isAsking = true + return preflight.promise + }) + const clicked = h.service.signIn('browser') + await vi.waitFor(() => { + expect(isAsking).toBe(true) + }) + await h.service.stopSignIn() + preflight.resolve('signedIn') + await clicked + expect(h.runDeviceSignIn).not.toHaveBeenCalled() + }) + + it('opens no key prompt and starts no device flow once the window is closing', async () => { + const h = harness() + await h.service.refresh() + await h.service.stopSignIn() + await expect(h.service.signIn('apiKey')).resolves.toMatchObject({ status: 'signedOut' }) + await h.service.signIn('browser') + expect(h.deps.promptForApiKey).not.toHaveBeenCalled() + expect(h.runDeviceSignIn).not.toHaveBeenCalled() + }) + + // A refresh that released the hold, then answered after a whole sign-out + // ran and released it too, must not put the hold back (the review of PR + // #49). + it('leaves the hold as a finished sign-out left it when a refresh answers late', async () => { + const h = harness() + h.facts.envKey = true + await h.service.refresh() + await h.service.signOut() + expect(h.logoutHoldState.isHeld).toBe(true) + h.facts.envKey = false + const late = Promise.withResolvers() + let asked = 0 + // The third question is the refresh's look after it released the hold. + h.cliSignIn.mockImplementation(() => + ++asked === 3 ? late.promise : Promise.resolve(h.facts.cli), + ) + const stale = h.service.refresh() + await vi.waitFor(() => { + expect(asked).toBe(3) + }) + await expect(h.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + expect(h.logoutHoldState.isHeld).toBe(false) + late.resolve('signedOut') + await stale + expect(h.logoutHoldState.isHeld).toBe(false) + expect(h.service.current.status).toBe('signedOut') + }) + + // Cancel with the hold on, or with a Model API session: the code leaves + // the panel before the refresh that follows, which may wait on the CLI + // (the review of PR #49). + it.each([ + ['with the logout hold on', true], + ['with a Model API session', false], + ])('clears the code at once after Cancel %s', async (_name, isHeld) => { + const h = isHeld ? await heldCliSignIn() : await signedInModelApi() + h.runDeviceSignIn.mockImplementation(async (signal, onCode) => { + onCode('https://auth.meta.com/oauth/device/', 'ABCD-EFGH') + await aborted(signal) + return 'cancelled' + }) + const signingIn = h.service.signIn('browser') + await vi.waitFor(() => { + expect(h.service.current.userCode).toBe('ABCD-EFGH') + }) + const refreshing = Promise.withResolvers() + let isRefreshAsking = false + h.cliSignIn.mockImplementation(() => { + isRefreshAsking = true + return refreshing.promise + }) + h.service.cancelSignIn() + await vi.waitFor(() => { + expect(isRefreshAsking).toBe(true) + }) + expect(h.service.current).toMatchObject({ userCode: undefined, verificationUrl: undefined }) + expect(h.broadcasts.findLast((message) => message.type === 'authState')).not.toHaveProperty( + 'userCode', + ) + refreshing.resolve(h.facts.cli) + await signingIn + }) + + // Two refreshes answer out of order: the older one's facts never replace + // what the newer one published (the review of PR #49). + it('never publishes an older refresh over a newer one', async () => { + const h = harness() + const older = Promise.withResolvers() + h.cliSignIn.mockImplementationOnce(() => older.promise) + const stale = h.service.refresh() + h.facts.cli = 'signedIn' + await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedIn' }) + const published = h.broadcasts.length + older.resolve('signedOut') + await expect(stale).resolves.toMatchObject({ status: 'signedIn' }) + expect(h.service.current.status).toBe('signedIn') + expect(h.broadcasts).toHaveLength(published) + }) + + // A refresh while the browser sign-in waits (a setting changed) leaves the + // code on screen; the flow publishes its own ending (the review of PR #49). + it('keeps the device code on screen through a refresh while the flow waits', async () => { + const h = harness() + const runner = Promise.withResolvers() + h.runDeviceSignIn.mockImplementation((_signal, onCode) => { + onCode('https://auth.meta.com/oauth/device/', 'ABCD-EFGH') + return runner.promise + }) + const signingIn = h.service.signIn('browser') + await vi.waitFor(() => { + expect(h.service.current.userCode).toBe('ABCD-EFGH') + }) + await h.service.refresh() + expect(h.service.current).toMatchObject({ status: 'signingIn', userCode: 'ABCD-EFGH' }) + runner.resolve('timedOut') + await expect(signingIn).resolves.toMatchObject({ status: 'signedOut', userCode: undefined }) + }) + + // The window closing: its host must be closed before the backends stop. + it('cancels the browser sign-in and waits for it to end', async () => { + const h = harness() + h.runDeviceSignIn.mockImplementation(async (signal) => { + await aborted(signal) + await new Promise((resolve) => setTimeout(resolve, 10)) + return 'cancelled' + }) + const pending = h.service.signIn('browser') + await vi.waitFor(() => { + expect(h.runDeviceSignIn).toHaveBeenCalled() + }) + await h.service.stopSignIn() + expect(h.service.current.status).toBe('signedOut') + await pending + }) +}) + +// Every path that can publish a sign-in on another backend than +// conversations run on ends those conversations first, through one helper +// (Codex on 1ae3604f). +describe('AuthService: a switch of backends ends the running one’s conversations first', () => { + it('ends the Model API conversation when a Cancel still lands a CLI sign-in', async () => { + const h = await signedInModelApi() + const generation = h.service.admissionGeneration + await expect(cancelAfterApproval(h)).resolves.toMatchObject({ + status: 'signedIn', + backend: 'museCode', + }) + expect(h.restartBackend.mock.calls).toEqual([[true]]) + expect(h.service.admissionGeneration).toBeGreaterThan(generation) + }) + + it('ends the Model API conversation when Check again finds the CLI signed in', async () => { + const h = await signedInModelApi() + h.facts.cli = 'signedIn' + await expect(h.service.checkAgain()).resolves.toMatchObject({ + status: 'signedIn', + backend: 'museCode', + }) + expect(h.restartBackend.mock.calls).toEqual([[true]]) + }) + + it('ends the Model API conversation when a failed sign-in’s refresh finds the CLI signed in', async () => { + const h = await signedInModelApi() + h.runDeviceSignIn.mockImplementation(() => { + h.facts.cli = 'signedIn' + return Promise.resolve('timedOut') + }) + await expect(h.service.signIn('browser')).resolves.toMatchObject({ backend: 'museCode' }) + expect(h.restartBackend.mock.calls).toEqual([[true]]) + }) + + it('ends the Muse Code conversation when a pasted key moves conversations to the Model API', async () => { + const h = harness() + h.facts.cli = 'signedIn' + await h.service.refresh() + // The CLI turned out signed out mid-conversation. + h.service.markAuthRequired('authRequired') + h.facts.cli = 'signedOut' + await expect(h.service.signIn('apiKey')).resolves.toMatchObject({ + status: 'signedIn', + backend: 'modelApi', + }) + expect(h.restartBackend.mock.calls).toEqual([[false], [true]]) + }) + + it('ends the Model API conversation when a failed install finds the CLI signed in', async () => { + const h = await signedInModelApi() + h.facts.cliPresent = false + h.runInstallerInTerminal.mockImplementation(() => { + h.facts.cliPresent = true + h.facts.cli = 'signedIn' + throw new Error('terminal unavailable') + }) + await expect(h.service.installMuseCode()).resolves.toMatchObject({ + status: 'signedIn', + backend: 'museCode', + }) + expect(h.restartBackend.mock.calls).toEqual([[true]]) + }) + + // A newer refresh signs in while an older switch's restart is still under + // way: that restart's late end leaves the newer record, so the next switch + // still ends its conversations (Codex on 19e74b07). + it('keeps the backend a newer refresh signed in on when an older restart ends', async () => { + const { h, stopping, switching } = await switchHeldInRestart() + await expect(h.service.refresh()).resolves.toMatchObject({ backend: 'museCode' }) + stopping.resolve(undefined) + await switching + h.facts.cli = 'signedOut' + await expect(h.service.refresh()).resolves.toMatchObject({ backend: 'modelApi' }) + expect(h.restartBackend.mock.calls).toEqual([[true], [true], [true]]) + }) + + // An older switch's restart fails after a newer refresh, or a sign-out, + // published: the failure does not replace the newer state (Codex on + // 86d63652). + it.each([ + ['a newer refresh', 'signedIn'], + ['a sign-out', 'signedOut'], + ] as const)('lets %s stand when an older switch’s restart fails late', async (newer, status) => { + const { h, stopping, switching } = await switchHeldInRestart() + await (newer === 'a sign-out' ? h.service.signOut() : h.service.refresh()) + stopping.reject(new Error('the host would not stop')) + await switching + expect(h.service.current.status).toBe(status) + }) + + // A finished sign-in's restart fails after a newer refresh published: the + // flow's failure does not replace it (Codex on 86d63652). + it('lets a newer refresh stand when a finished sign-in’s restart fails late', async () => { + const h = harness() + const stopping = Promise.withResolvers() + h.restartBackend.mockImplementationOnce(() => stopping.promise) + h.runDeviceSignIn.mockImplementation(() => { + h.facts.cli = 'signedIn' + return Promise.resolve('signedIn') + }) + const signingIn = h.service.signIn('browser') + await vi.waitFor(() => { + expect(h.restartBackend).toHaveBeenCalledOnce() + }) + await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedIn' }) + stopping.reject(new Error('the host would not stop')) + await signingIn + expect(h.service.current).toMatchObject({ status: 'signedIn', backend: 'museCode' }) + }) + + // A failed install asks for the selection; a newer state published while + // it asked stands (Codex on 86d63652). + it('lets a newer state stand when a failed install’s question answers late', async () => { + const h = await signedInModelApi() + h.facts.cliPresent = false + const late = Promise.withResolvers() + let isAsking = false + h.runInstallerInTerminal.mockImplementation(() => { + h.cliSignIn.mockImplementationOnce(() => { + isAsking = true + return late.promise + }) + throw new Error('terminal unavailable') + }) + const installing = h.service.installMuseCode() + await vi.waitFor(() => { + expect(isAsking).toBe(true) + }) + h.service.markAuthRequired('authRequired') + late.resolve('signedOut') + await installing + expect(h.service.current).toMatchObject({ status: 'signedOut', detail: 'authRequired' }) + }) + + // An older logout-hold write fails after newer ones were saved: the hold + // is saved as it stands, so admission is not held shut (Codex on + // 19e74b07). + it('keeps the latest logout-hold write’s outcome when an older one fails late', async () => { + const older = Promise.withResolvers() + let writes = 0 + const h = harness({ + logoutHold: { + get: () => true, + set: () => (++writes === 1 ? older.promise : Promise.resolve()), + }, + }) + const releasing = h.service.refresh() + await vi.waitFor(() => { + expect(writes).toBe(1) + }) + await h.service.signOut() + older.reject(new Error('state storage unavailable')) + await releasing + await expect(h.service.signIn('apiKey')).resolves.toMatchObject({ backend: 'modelApi' }) + expect(h.service.backend).toBe('modelApi') + }) + + // A sign-out already ended every conversation: the next sign-in on the + // other backend has none to end. + it('ends conversations once: a sign-out leaves none for the next sign-in to end', async () => { + const h = harness() + h.facts.cli = 'signedIn' + await h.service.refresh() + await h.service.signOut() + await expect(h.service.signIn('apiKey')).resolves.toMatchObject({ backend: 'modelApi' }) + expect(h.restartBackend.mock.calls).toEqual([[true], [false]]) + }) +}) + describe('AuthService.installMuseCode', () => { it('reports terminal launch failure without signing out a Model API session', async () => { const h = await signedInModelApi() @@ -598,7 +1184,7 @@ describe('AuthService.installMuseCode', () => { it('stores a secondary key without restarting a signed-in Muse Code session', async () => { const h = harness() - h.facts.credentialFile = true + h.facts.cli = 'signedIn' await h.service.refresh() await h.service.signIn('apiKey') expect(h.service.current).toMatchObject({ status: 'signedIn', backend: 'museCode' }) @@ -611,7 +1197,7 @@ describe('AuthService.installMuseCode', () => { h.facts.cliPresent = false h.runInstallerInTerminal.mockImplementation(() => { h.facts.cliPresent = true - h.facts.credentialFile = true + h.facts.cli = 'signedIn' }) const selected = await h.service.installMuseCode() expect(selected).toMatchObject({ status: 'signedIn', backend: 'museCode' }) @@ -621,7 +1207,7 @@ describe('AuthService.installMuseCode', () => { it('retires the active Model API session when the CLI appeared before Install was pressed', async () => { const h = await signedInModelApi() h.facts.cliPresent = true - h.facts.credentialFile = true + h.facts.cli = 'signedIn' const selected = await h.service.installMuseCode() expect(selected).toMatchObject({ status: 'signedIn', backend: 'museCode' }) expect(h.restartBackend).toHaveBeenCalledWith(true) @@ -633,7 +1219,7 @@ describe('AuthService.installMuseCode', () => { h.facts.cliPresent = false h.runInstallerInTerminal.mockImplementation(() => { h.facts.cliPresent = true - h.facts.credentialFile = true + h.facts.cli = 'signedIn' }) h.restartBackend.mockRejectedValue(new Error('cannot stop')) const selected = await h.service.installMuseCode() @@ -643,7 +1229,7 @@ describe('AuthService.installMuseCode', () => { it('replaces the secondary key without ending the signed-in Muse Code session', async () => { const h = harness({ promptForApiKey: () => Promise.resolve('LLM|1|replacement') }) - h.facts.credentialFile = true + h.facts.cli = 'signedIn' await h.deps.credentials.setApiKey('LLM|1|secret') await h.service.refresh() await h.service.signIn('apiKey') @@ -720,8 +1306,8 @@ describe('AuthService.signOut and host reports', () => { }, unexpectedWarning, ) - const h = harness({ credentials }) - h.facts.credentialFile = true + const h = withLogoutFallback(harness({ credentials })) + h.facts.cli = 'signedIn' await h.service.refresh() await h.service.signOut() @@ -735,15 +1321,9 @@ describe('AuthService.signOut and host reports', () => { expect(h.logoutHoldState.isHeld).toBe(true) }) - it('recovers a held old CLI file through an explicit fresh device approval', async () => { - const h = harness() - h.facts.credentialFile = true - await h.service.refresh() - await h.service.signOut() - h.runDeviceSignIn.mockImplementation(() => { - h.facts.credentialMtime += 1 - return Promise.resolve('signedIn') - }) + it('recovers a held old CLI sign-in through an explicit fresh device approval', async () => { + const h = await heldCliSignIn() + h.runDeviceSignIn.mockResolvedValue('signedIn') await expect(h.service.signIn('browser')).resolves.toMatchObject({ status: 'signedIn', backend: 'museCode', @@ -752,12 +1332,16 @@ describe('AuthService.signOut and host reports', () => { expect(h.logoutHoldState.isHeld).toBe(false) }) - it('keeps the hold when a device runner reports success without a new credential write', async () => { - const h = harness() - h.facts.credentialFile = true + it('keeps the hold when the CLI does not confirm a device runner’s success', async () => { + const h = withLogoutFallback(harness()) + h.facts.cli = 'signedIn' await h.service.refresh() await h.service.signOut() - h.runDeviceSignIn.mockResolvedValue('signedIn') + h.runDeviceSignIn.mockImplementation(() => { + // The file changed, but `account/read` could not say whose sign-in it holds. + h.facts.cli = 'unknown' + return Promise.resolve('signedIn') + }) await expect(h.service.signIn('browser')).resolves.toMatchObject({ status: 'error' }) expect(h.runDeviceSignIn).toHaveBeenCalledOnce() expect(h.logoutHoldState.isHeld).toBe(true) @@ -772,7 +1356,7 @@ describe('AuthService.signOut and host reports', () => { set: (isHeld) => (isHeld ? saving.promise : Promise.resolve()), }, }) - h.facts.credentialFile = true + h.facts.cli = 'signedIn' await h.service.refresh() const ending = h.service.signOut() expect(h.service.current.status).toBe('error') @@ -793,7 +1377,7 @@ describe('AuthService.signOut and host reports', () => { unexpectedWarning, ) const h = harness({ credentials }) - h.facts.credentialFile = true + h.facts.cli = 'signedIn' await credentials.setApiKey('LLM|1|secret') await h.service.refresh() await expect(h.service.signOut()).resolves.toMatchObject({ @@ -805,15 +1389,17 @@ describe('AuthService.signOut and host reports', () => { }) it('ends the host and clears the key when the CLI logout terminal cannot open', async () => { - const h = harness() - h.facts.credentialFile = true + const h = withLogoutFallback(harness()) + h.facts.cli = 'signedIn' await h.deps.credentials.setApiKey('LLM|1|secret') await h.service.refresh() h.runInTerminal.mockImplementation(() => { throw new Error('terminal unavailable') }) + // `error`: the panel offers Check again, which the detail asks for (the + // review of PR #49). await expect(h.service.signOut()).resolves.toMatchObject({ - status: 'signedOut', + status: 'error', detail: expect.stringContaining('terminal'), }) expect(h.restartBackend).toHaveBeenCalledWith(true) @@ -826,10 +1412,11 @@ describe('AuthService.signOut and host reports', () => { h.facts.envKey = true await h.service.refresh() await expect(h.service.signOut()).resolves.toMatchObject({ - status: 'signedOut', + status: 'error', detail: expect.stringContaining('META_API_KEY'), }) expect(h.runInTerminal).not.toHaveBeenCalled() + expect(h.logOutCli).not.toHaveBeenCalled() await expect(h.service.refresh()).resolves.toMatchObject({ status: 'error', detail: expect.stringContaining('META_API_KEY'), @@ -842,14 +1429,26 @@ describe('AuthService.signOut and host reports', () => { }) }) - it('does not reassert CLI sign-in while terminal logout still has the credential file', async () => { - const h = harness() - h.facts.credentialFile = true + // `muse logout` rewrites auth.json as {"schema_version": 1, "providers": {}} + // and never deletes it (1.3.0 and 1.4.0, every OS): the hold ends when the + // CLI reports no sign-in, although the file is still there. + it('does not reassert CLI sign-in until terminal logout has emptied the credential file', async () => { + const h = withLogoutFallback(harness()) + h.facts.cli = 'signedIn' await h.service.refresh() await h.service.signOut() - await expect(h.service.refresh()).resolves.toMatchObject({ status: 'error' }) - h.facts.credentialFile = false - await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedOut' }) + expect(h.runInTerminal).toHaveBeenCalledWith('/bin/muse', ['logout']) + await expect(h.service.refresh()).resolves.toMatchObject({ + status: 'error', + detail: EN.signOutPending, + }) + h.facts.cli = 'signedOut' + await expect(h.service.refresh()).resolves.toMatchObject({ + status: 'signedOut', + detail: undefined, + }) + expect(h.logoutHoldState.isHeld).toBe(false) + expect(h.service.backend).toBeUndefined() }) it('does not publish a stale signed-in choice when the held CLI key disappears during refresh', async () => { @@ -912,7 +1511,7 @@ describe('AuthService.signOut and host reports', () => { await h.service.refresh() await h.service.signOut() h.runDeviceSignIn.mockImplementation(() => { - h.facts.credentialFile = true + h.facts.cli = 'signedIn' return Promise.resolve('signedIn') }) await expect(h.service.signIn('browser')).resolves.toMatchObject({ @@ -923,27 +1522,79 @@ describe('AuthService.signOut and host reports', () => { expect(h.logoutHoldState.isHeld).toBe(true) }) - it('clears the key, logs the CLI out when it holds a session, and restarts', async () => { + it('clears the key, signs the CLI out through account/logout, and restarts', async () => { const h = harness() await h.service.signIn('apiKey') - h.facts.credentialFile = true - await expect(h.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + h.facts.cli = 'signedIn' + await expect(h.service.signOut()).resolves.toMatchObject({ + status: 'signedOut', + detail: undefined, + hasCliSession: false, + }) await expect(h.deps.credentials.getApiKey()).resolves.toBeUndefined() - expect(h.runInTerminal).toHaveBeenLastCalledWith('/bin/muse', ['logout']) + expect(h.logOutCli).toHaveBeenCalledOnce() + expect(h.runInTerminal).not.toHaveBeenCalled() + // The CLI confirmed it: no hold is left to wait out. + expect(h.logoutHoldState.isHeld).toBe(false) expect(h.restartBackend).toHaveBeenCalledTimes(2) // A sign-in keeps the conversations; a sign-out ends them (D25). expect(h.restartBackend.mock.calls).toEqual([[false], [true]]) + // Sign-out is a click: macOS may ask the CLI about a Keychain sign-in. + expect(h.cliSignIn.mock.calls.at(-1)).toEqual([true]) }) - it('does not run muse logout when there is no CLI session to end', async () => { + // The hold is kept: `error`, so the panel offers the Check again its + // detail asks for, as `refresh` does for the same state (the review of PR + // #49). + it('falls back to muse logout in a terminal when account/logout does not confirm', async () => { + const h = withLogoutFallback(harness()) + h.facts.cli = 'signedIn' + await h.service.refresh() + await expect(h.service.signOut()).resolves.toMatchObject({ + status: 'error', + detail: EN.signOutPending, + }) + expect(h.logOutCli).toHaveBeenCalledOnce() + expect(h.runInTerminal).toHaveBeenLastCalledWith('/bin/muse', ['logout']) + expect(h.logoutHoldState.isHeld).toBe(true) + }) + + it('does not sign the CLI out when it holds no sign-in', async () => { const h = harness() await h.service.signOut() + expect(h.logOutCli).not.toHaveBeenCalled() expect(h.runInTerminal).not.toHaveBeenCalled() }) + it('signs out a sign-in only the CLI could confirm, and counts it until then', async () => { + const h = harness() + h.facts.cli = 'unknown' + await expect(h.service.refresh()).resolves.toMatchObject({ + status: 'signedIn', + backend: 'museCode', + }) + await expect(h.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + expect(h.logOutCli).toHaveBeenCalledOnce() + expect(h.logoutHoldState.isHeld).toBe(false) + }) + + // The reason is the backend's own text: the panel shows it, the log only + // in the shape of a protocol word (the review of PR #49). + it('logs an authRequired reason only in the shape of a protocol word', () => { + const log = new FakeLogOutputChannel() + const h = harness({ log }) + const reason = String.raw`not signed in; see C:\Users\someone\.config\muse` + expect(h.service.markAuthRequired(reason)).toMatchObject({ detail: reason }) + expect(log.warn).toHaveBeenCalledWith( + 'The backend reported authRequired: an unrecognized value', + ) + h.service.markAuthRequired('authRequired') + expect(log.warn).toHaveBeenLastCalledWith('The backend reported authRequired: authRequired') + }) + it('accepts the host verdict over its own estimate', async () => { const h = harness() - h.facts.credentialFile = true + h.facts.cli = 'signedIn' await h.service.refresh() expect(h.service.markAuthRequired('not logged in')).toMatchObject({ status: 'signedOut', @@ -966,3 +1617,372 @@ describe('AuthService.signOut and host reports', () => { }) }) }) + +// `expired`, `denied` and `failed` as captured live (2026-09-27); any other +// ending as Muse Code named it. +describe('AuthService: how Muse Code ends a browser sign-in (D26)', () => { + it.each([ + ['the captured expired', 'expired', EN.signInExpired], + ['the captured denied', 'denied', 'You denied the sign-in in the browser.'], + ['the captured failed', 'failed', 'Muse Code signed in but could not save the credential.'], + [ + 'an unknown', + { endedAs: 'somethingNew' }, + 'Sign-in ended: somethingNew. Sign in again to get a new code.', + ], + ] as const)( + 'ends %s sign-in at once, signed out with its reason', + async (_name, outcome, text) => { + const h = harness() + h.runDeviceSignIn.mockResolvedValue(outcome) + await expect(h.service.signIn('browser')).resolves.toMatchObject({ + status: 'signedOut', + detail: text, + }) + expect(h.restartBackend).not.toHaveBeenCalled() + }, + ) + + it('keeps a live Model API session after a CLI sign-in ends unsigned, with a notice', async () => { + const h = await signedInModelApi() + h.runDeviceSignIn.mockResolvedValue('denied') + await expect(h.service.signIn('browser')).resolves.toMatchObject({ + status: 'signedIn', + backend: 'modelApi', + }) + expect(h.broadcasts).toContainEqual({ + type: 'notice', + level: 'warning', + text: EN.signInDenied, + }) + }) +}) + +describe('AuthService: a credential file Muse Code cannot start with (D26)', () => { + it('names a macOS file on Windows or Linux instead of offering a dead sign-in', async () => { + const h = harness() + h.facts.cli = 'unsupportedHere' + const refreshed = await h.service.refresh() + expect(refreshed).toMatchObject({ status: 'error', backend: 'museCode', hasCliSession: false }) + expect(refreshed.detail).toBe(fill(EN.cliCredentialUnsupported, { path: CREDENTIAL_PATH })) + expect(h.service.backend).toBeUndefined() + await expect(h.service.signIn('browser')).resolves.toMatchObject({ status: 'error' }) + expect(h.runDeviceSignIn).not.toHaveBeenCalled() + }) + + it('does not block the Model API key a user already stored', async () => { + const h = await signedInModelApi() + h.facts.cli = 'unsupportedHere' + await expect(h.service.refresh()).resolves.toMatchObject({ + status: 'signedIn', + backend: 'modelApi', + detail: undefined, + }) + }) + + // The panel shows the path; the log names the state in fixed words, since + // the path is under the user's profile (the review of PR #49). + it('logs no credential path for that state', async () => { + const log = new FakeLogOutputChannel() + const h = harness({ log }) + h.facts.cli = 'unsupportedHere' + await h.service.refresh() + expect(log.info).toHaveBeenCalledWith( + 'Sign-in state: error on the museCode backend: the Muse Code credential file is in a format Muse Code cannot start with on this system', + ) + expect(log.info.mock.calls.flat().join('\n')).not.toContain(CREDENTIAL_PATH) + expect(h.service.current.detail).toContain(CREDENTIAL_PATH) + }) + + // Captured on Windows and Linux: with META_API_KEY set `muse serve` starts + // with an empty version-2 file, a version-2 pointer and a version-1 + // Keychain lane, and answers `envKey` (probe-v2-serve-win.json, + // probe-v2-serve-linux.json). + it('leaves the file to the CLI while META_API_KEY signs it in', async () => { + const h = harness() + h.facts.envKey = true + h.facts.cli = 'unsupportedHere' + await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedIn' }) + expect(h.cliSignIn).not.toHaveBeenCalled() + }) +}) + +describe('AuthService: when the CLI may be asked (macOS Keychain prompts follow a click)', () => { + it('does not let a passive refresh ask, and lets Check again and sign-in ask', async () => { + const h = harness() + await h.service.refresh() + expect(h.cliSignIn.mock.calls.every(([isUserAction]) => !isUserAction)).toBe(true) + h.cliSignIn.mockClear() + await h.service.refresh(true) + expect(h.cliSignIn.mock.calls.length).toBeGreaterThan(0) + expect(h.cliSignIn.mock.calls.every(([isUserAction]) => isUserAction)).toBe(true) + }) +}) + +// The same service over the CLI's real credential file in a temporary +// config home: the file shapes Muse Code 1.3.0 and 1.4.0 were captured +// writing (helpers/credentialShapes.ts), no token in any of them. +// Synthetic: a file cut short, which only the CLI can place. +const MALFORMED = '{"schema_version": 1, "providers": ' +const SIGNED_IN_ANSWER: AccountState = { state: 'accountLogin', credentialRequired: true } +const LOGGED_OUT_ANSWER: AccountState = { state: 'loggedOut', credentialRequired: true } +// Not captured here: macOS's pointer as a third party observed it (aonia +// §2.3); on macOS it is asked of the CLI, on a user action only. +const MAC_POINTER = + '{"schema_version":2,"providers":{"meta":{"mechanism":"oauth","storage":"keychain"}}}' + +describe('AuthService over the CLI’s real credential file', () => { + const homes: string[] = [] + afterEach(() => { + for (const home of homes.splice(0)) { + rmSync(home, { recursive: true, force: true }) + } + }) + + function withFile(contents: string | undefined, platform: NodeJS.Platform = 'linux') { + const home = mkdtempSync(path.join(tmpdir(), 'muse-auth-')) + homes.push(home) + const file = path.join(home, 'muse', 'auth.json') + mkdirSync(path.dirname(file), { recursive: true }) + if (contents !== undefined) { + writeFileSync(file, contents) + } + const probe = vi.fn<() => Promise>(() => Promise.resolve(undefined)) + const account = new CliAccount({ + platform, + credentialFilePath: () => file, + probe, + log: new FakeLogOutputChannel(), + }) + const h = harness() + const facts = h.deps.backend + const service = new AuthService({ + ...h.deps, + backend: { + ...facts, + cliSignIn: (isUserAction) => account.signIn(isUserAction), + abandonCliProbe: () => { + account.abandonProbe() + }, + forgetCliAnswers: () => { + account.forgetAnswers() + }, + credentialFileModifiedAt: () => statSync(file, { throwIfNoEntry: false })?.mtimeMs, + }, + }) + return { h, file, service, probe } + } + + /** + * A browser sign-in on macOS whose code is shown, then Cancel; `onShown` + * runs as the code shows (the browser approving, a file rewritten). + */ + async function cancelAfterCodeOnMacOs( + t: ReturnType, + onShown: () => void = () => undefined, + ): Promise { + t.h.runDeviceSignIn.mockImplementation(async (signal, onCode) => { + onCode('https://auth.meta.com/oauth/device/', 'ABCD-EFGH') + onShown() + await aborted(signal) + return 'cancelled' + }) + const pending = t.service.signIn('browser') + await vi.waitFor(() => { + expect(t.service.current.userCode).toBe('ABCD-EFGH') + }) + t.service.cancelSignIn() + return await pending + } + + // On macOS an approval may land in the Keychain alone, the pointer file + // as it was: a Cancel after the code was shown asks the CLI afresh, past + // the answer it gave before the flow (Codex on 55b9e24c). + it('asks the CLI afresh on a Cancel after the code, when the Keychain alone changed', async () => { + const t = withFile(MAC_POINTER, 'darwin') + t.probe.mockResolvedValueOnce(LOGGED_OUT_ANSWER).mockResolvedValue(SIGNED_IN_ANSWER) + await expect(t.service.refresh(true)).resolves.toMatchObject({ status: 'signedOut' }) + await expect(cancelAfterCodeOnMacOs(t)).resolves.toMatchObject({ + status: 'signedIn', + backend: 'museCode', + }) + expect(t.probe).toHaveBeenCalledTimes(2) + }) + + it('reports the Cancel when the CLI, asked afresh, is still signed out', async () => { + const t = withFile(MAC_POINTER, 'darwin') + t.probe.mockResolvedValue(LOGGED_OUT_ANSWER) + await t.service.refresh(true) + await expect(cancelAfterCodeOnMacOs(t)).resolves.toMatchObject({ status: 'signedOut' }) + expect(t.probe).toHaveBeenCalledTimes(2) + expect(t.h.broadcasts).toContainEqual({ + type: 'notice', + level: 'info', + text: EN.signInCancelled, + }) + }) + + // The file changed as Cancel was pressed: Cancel is a click, so on macOS + // the CLI is asked about the new file rather than estimated (Codex on + // 55b9e24c). + it('asks the CLI about a file rewritten as Cancel was pressed, on macOS too', async () => { + const t = withFile(MAC_POINTER, 'darwin') + t.probe.mockResolvedValue(SIGNED_IN_ANSWER) + const rewrite = () => { + writeFileSync(t.file, `${MAC_POINTER} `) + } + await expect(cancelAfterCodeOnMacOs(t, rewrite)).resolves.toMatchObject({ + status: 'signedIn', + }) + expect(t.probe).toHaveBeenCalledOnce() + }) + + it('reads the empty file a sign-out leaves as signed out, without starting the CLI', async () => { + const t = withFile(LOGOUT_SHELL) + await expect(t.service.refresh()).resolves.toMatchObject({ + status: 'signedOut', + hasCliSession: false, + }) + expect(t.probe).not.toHaveBeenCalled() + }) + + // A Keychain logout removes the vault item and leaves the pointer file as it was. + it('forgets the CLI answer once account/logout confirms, the file unchanged (the review of PR #49)', async () => { + const t = withFile(MALFORMED) + t.probe.mockResolvedValueOnce(SIGNED_IN_ANSWER).mockResolvedValue(LOGGED_OUT_ANSWER) + await expect(t.service.refresh(true)).resolves.toMatchObject({ status: 'signedIn' }) + await expect(t.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + expect(t.h.logoutHoldState.isHeld).toBe(false) + }) + + // A Keychain sign-in or sign-out made elsewhere leaves the file as it was: + // Check again asks the CLI afresh (the review of PR #49). + it('asks the CLI afresh on Check again, even about a sign-in it confirmed', async () => { + const t = withFile(MALFORMED) + t.probe.mockResolvedValueOnce(SIGNED_IN_ANSWER).mockResolvedValue(LOGGED_OUT_ANSWER) + await expect(t.service.refresh(true)).resolves.toMatchObject({ status: 'signedIn' }) + await expect(t.service.refresh(true)).resolves.toMatchObject({ status: 'signedIn' }) + await expect(t.service.checkAgain()).resolves.toMatchObject({ status: 'signedOut' }) + expect(t.probe).toHaveBeenCalledTimes(2) + }) + + // Two presses of Check again while the CLI answers: the second joins the + // first, rather than forgetting its probe and starting another host (the + // review of PR #49). + it('asks the CLI once however often Check again is pressed while it answers', async () => { + const t = withFile(MALFORMED) + const answer = Promise.withResolvers() + t.probe.mockImplementation(() => answer.promise) + const first = t.service.checkAgain() + const second = t.service.checkAgain() + answer.resolve(LOGGED_OUT_ANSWER) + await expect(Promise.all([first, second])).resolves.toMatchObject([ + { status: 'signedOut' }, + { status: 'signedOut' }, + ]) + expect(t.probe).toHaveBeenCalledOnce() + // Once it has answered, the next press asks afresh. + await t.service.checkAgain() + expect(t.probe).toHaveBeenCalledTimes(2) + }) + + // A passive refresh's probe answers after Check again forgot it and got a + // newer answer: the old answer is never published (the review of PR #49). + it('publishes no answer from a probe Check again left behind', async () => { + const t = withFile(MALFORMED) + const stale = Promise.withResolvers() + t.probe.mockImplementationOnce(() => stale.promise).mockResolvedValue(LOGGED_OUT_ANSWER) + const passive = t.service.refresh() + await vi.waitFor(() => { + expect(t.probe).toHaveBeenCalledOnce() + }) + await expect(t.service.checkAgain()).resolves.toMatchObject({ status: 'signedOut' }) + const published = t.h.broadcasts.length + stale.resolve(SIGNED_IN_ANSWER) + await passive + expect(t.service.current.status).toBe('signedOut') + expect(t.h.broadcasts.slice(published)).not.toContainEqual( + expect.objectContaining({ type: 'authState', status: 'signedIn' }), + ) + }) + + // A Keychain sign-in made elsewhere leaves the file as it was: the sign-out + // must not decide on the `signedOut` the CLI said before it (the review of + // PR #49). + it('asks the CLI afresh at sign-out, so a sign-in made since is signed out', async () => { + const t = withFile(MALFORMED) + t.probe + .mockResolvedValueOnce(LOGGED_OUT_ANSWER) + .mockResolvedValueOnce(SIGNED_IN_ANSWER) + .mockResolvedValue(LOGGED_OUT_ANSWER) + await expect(t.service.refresh(true)).resolves.toMatchObject({ status: 'signedOut' }) + await expect(t.service.signOut()).resolves.toMatchObject({ status: 'signedOut' }) + expect(t.h.logOutCli).toHaveBeenCalledOnce() + expect(t.h.logoutHoldState.isHeld).toBe(false) + }) + + // A sign-in that leaves the file as it was (a Keychain sign-in) is not + // hidden by what the CLI said before it (the review of PR #49). + it('asks the CLI afresh after a browser sign-in, the file unchanged', async () => { + const t = withFile(MALFORMED) + t.probe.mockResolvedValueOnce(LOGGED_OUT_ANSWER).mockResolvedValue(SIGNED_IN_ANSWER) + await expect(t.service.refresh(true)).resolves.toMatchObject({ status: 'signedOut' }) + t.h.runDeviceSignIn.mockResolvedValue('signedIn') + await expect(t.service.signIn('browser')).resolves.toMatchObject({ status: 'signedIn' }) + expect(t.probe).toHaveBeenCalledTimes(2) + }) + + // Cancel leaves an unanswered probe behind but keeps what the CLI already + // said, so what it shows next asks nothing new (the review of PR #49). + it('shows what follows Cancel from the answer the CLI already gave', async () => { + const t = withFile(MALFORMED) + t.probe.mockResolvedValue(LOGGED_OUT_ANSWER) + await t.h.deps.credentials.setApiKey('LLM|1|secret') + await expect(t.service.refresh(true)).resolves.toMatchObject({ backend: 'modelApi' }) + t.h.runDeviceSignIn.mockImplementation(async (signal) => { + await new Promise((resolve) => { + signal.addEventListener('abort', resolve, { once: true }) + }) + return 'cancelled' + }) + const pending = t.service.signIn('browser') + await vi.waitFor(() => { + expect(t.h.runDeviceSignIn).toHaveBeenCalled() + }) + t.service.cancelSignIn() + await expect(pending).resolves.toMatchObject({ status: 'signedIn', backend: 'modelApi' }) + expect(t.probe).toHaveBeenCalledOnce() + }) + + it('signs out through account/logout: the file stays, empty, and the hold is released', async () => { + const t = withFile(DEVICE_LOGIN_FILE) + t.h.logOutCli.mockImplementation(() => { + writeFileSync(t.file, LOGOUT_SHELL) + return Promise.resolve(true) + }) + await expect(t.service.refresh()).resolves.toMatchObject({ status: 'signedIn' }) + await expect(t.service.signOut()).resolves.toMatchObject({ + status: 'signedOut', + detail: undefined, + }) + expect(statSync(t.file).isFile()).toBe(true) + expect(t.h.logoutHoldState.isHeld).toBe(false) + await expect(t.service.refresh()).resolves.toMatchObject({ status: 'signedOut' }) + expect(t.probe).not.toHaveBeenCalled() + }) + + it('finishes a terminal sign-out once muse logout has rewritten the file', async () => { + const t = withFile(DEVICE_LOGIN_FILE) + t.h.logOutCli.mockResolvedValue(false) + await t.service.refresh() + await expect(t.service.signOut()).resolves.toMatchObject({ detail: EN.signOutPending }) + await expect(t.service.refresh(true)).resolves.toMatchObject({ status: 'error' }) + // What `muse logout` does in the terminal: the same file, emptied. + writeFileSync(t.file, LOGOUT_SHELL) + await expect(t.service.refresh(true)).resolves.toMatchObject({ + status: 'signedOut', + detail: undefined, + }) + expect(t.h.logoutHoldState.isHeld).toBe(false) + }) +}) diff --git a/test/unit/authState.test.ts b/test/unit/authState.test.ts deleted file mode 100644 index e4c9822b..00000000 --- a/test/unit/authState.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { deriveAuthStatus } from '../../src/host/auth/authState' - -describe('deriveAuthStatus', () => { - it('is signed out when no credential source exists', () => { - expect( - deriveAuthStatus({ - hasStoredKey: false, - hasEnvironmentKey: false, - credentialFileExists: false, - }), - ).toBe('signedOut') - }) - - it.each([ - ['a stored key', { hasStoredKey: true, hasEnvironmentKey: false, credentialFileExists: false }], - [ - 'an environment key', - { hasStoredKey: false, hasEnvironmentKey: true, credentialFileExists: false }, - ], - [ - 'the CLI credential file', - { hasStoredKey: false, hasEnvironmentKey: false, credentialFileExists: true }, - ], - ])('is signed in with %s', (_label, evidence) => { - expect(deriveAuthStatus(evidence)).toBe('signedIn') - }) -}) diff --git a/test/unit/backendSelection.test.ts b/test/unit/backendSelection.test.ts index 4b069462..55e78646 100644 --- a/test/unit/backendSelection.test.ts +++ b/test/unit/backendSelection.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest' -import { type BackendFacts, selectBackend } from '../../src/core/backendSelection' +import { describe, expect, it, vi } from 'vitest' +import { + type BackendFacts, + readBackendChoice, + selectBackend, +} from '../../src/core/backendSelection' function facts(overrides: Partial): BackendFacts { return { setting: 'auto', hasCli: true, hasCliSession: false, hasStoredKey: false, ...overrides } @@ -60,3 +64,33 @@ describe('selectBackend', () => { ).toMatchObject({ kind: 'modelApi', status: 'signedIn' }) }) }) + +// Forced Model API asks the CLI nothing: a slow answer about an ambiguous +// credential file must not hold up a stored key (Codex on 328efb52). +describe('readBackendChoice', () => { + it('never asks for the CLI’s sign-in when the backend is forced to the Model API', async () => { + const askCliSession = vi.fn(() => new Promise(() => undefined)) + await expect( + readBackendChoice({ + setting: 'modelApi', + hasCli: true, + hasCliSession: askCliSession, + hasStoredKey: () => Promise.resolve(true), + }), + ).resolves.toEqual({ kind: 'modelApi', status: 'signedIn', methods: ['apiKey'] }) + expect(askCliSession).not.toHaveBeenCalled() + }) + + it.each(['auto', 'museCode'] as const)('asks for it when the setting is %s', async (setting) => { + const askCliSession = vi.fn(() => Promise.resolve(true)) + await expect( + readBackendChoice({ + setting, + hasCli: true, + hasCliSession: askCliSession, + hasStoredKey: () => Promise.resolve(false), + }), + ).resolves.toMatchObject({ kind: 'museCode', status: 'signedIn' }) + expect(askCliSession).toHaveBeenCalledOnce() + }) +}) diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts new file mode 100644 index 00000000..66fb609d --- /dev/null +++ b/test/unit/cliAccount.test.ts @@ -0,0 +1,395 @@ +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AccountState } from '../../src/host/auth/accountHost' +import { + CliAccount, + cliSignInFromAccount, + isCliSignedIn, + readCredentialFile, +} from '../../src/host/auth/cliAccount' +import { + MUSE_CREDENTIAL_FILE_MAX_BYTES, + MUSE_USER_ACTION_ANSWER_REUSE_MS, +} from '../../src/shared/constants' +import { + capturedInlineVerdict, + DEVICE_LOGIN_FILE, + LOGOUT_SHELL, + SHIPPED_PLATFORMS, + SLACK_CONNECTOR_ONLY, +} from './helpers/credentialShapes' +import { FakeLogOutputChannel } from './helpers/fakes' + +// Not captured here: macOS's pointer as a third party observed it (aonia +// §2.3). Synthetic: a file cut short, which only the CLI can place. +const MAC_POINTER = + '{"schema_version":2,"providers":{"meta":{"mechanism":"oauth","storage":"keychain"}}}' +const MALFORMED = '{"schema_version": 1, "providers": ' + +const SIGNED_IN: AccountState = { state: 'accountLogin', credentialRequired: true } +const LOGGED_OUT: AccountState = { state: 'loggedOut', credentialRequired: true } + +const homes: string[] = [] + +afterEach(() => { + for (const home of homes.splice(0)) { + rmSync(home, { recursive: true, force: true }) + } +}) + +function configHome(): { readonly file: string; readonly write: (text: string) => void } { + const home = mkdtempSync(path.join(tmpdir(), 'muse-cred-')) + homes.push(home) + const file = path.join(home, 'muse', 'auth.json') + mkdirSync(path.dirname(file), { recursive: true }) + return { + file, + write: (text) => { + writeFileSync(file, text) + }, + } +} + +/** A later modification time, as a new write would leave. */ +function touchLater(file: string, seconds: number): void { + const at = new Date(Date.now() + seconds * 1000) + utimesSync(file, at, at) +} + +function account(platform: NodeJS.Platform, file: string, answers: (AccountState | undefined)[]) { + const probe = vi.fn(() => Promise.resolve(answers.shift())) + const log = new FakeLogOutputChannel() + const checker = new CliAccount({ platform, credentialFilePath: () => file, probe, log }) + return { checker, probe, log } +} + +/** A Linux checker over the file, asking the given probe. */ +function linuxChecker(file: string, probe: () => Promise) { + return new CliAccount({ + platform: 'linux', + credentialFilePath: () => file, + probe, + log: new FakeLogOutputChannel(), + }) +} + +/** + * An ambiguous file on Linux whose first CLI question waits until the test + * answers it (`stale`), and whose next is answered signed out. + */ +function staleThenLoggedOut() { + const home = configHome() + home.write(MALFORMED) + const stale = Promise.withResolvers() + const answers = [stale.promise, Promise.resolve(LOGGED_OUT)] + const probe = vi.fn(() => answers.shift() ?? Promise.resolve(undefined)) + return { home, stale, probe, checker: linuxChecker(home.file, probe) } +} + +/** An ambiguous file on Linux whose CLI question waits until the test answers it. */ +function heldQuestion() { + const home = configHome() + home.write(MALFORMED) + const answer = Promise.withResolvers() + const probe = vi.fn(() => answer.promise) + return { home, answer, probe, checker: linuxChecker(home.file, probe) } +} + +describe('readCredentialFile', () => { + it('is nothing when there is no file', () => { + const home = configHome() + expect(readCredentialFile(home.file, 'win32')).toBeUndefined() + expect(readCredentialFile(path.join(home.file, 'deeper'), 'win32')).toBeUndefined() + }) + + it('reads the structure and signs it by size and modification time', () => { + const home = configHome() + home.write(LOGOUT_SHELL) + const reading = readCredentialFile(home.file, 'linux') + expect(reading?.verdict).toBe('empty') + expect(reading?.signature).toMatch(/^44:\d+(\.\d+)?$/) + }) + + // The real file read as each OS reads it, macOS's branch included, on any + // runner (the review of PR #49: an e2e expectation failed in macOS CI only). + it.each(SHIPPED_PLATFORMS)('reads a browser sign-in file as %s does', (platform) => { + const home = configHome() + home.write(DEVICE_LOGIN_FILE) + expect(readCredentialFile(home.file, platform)?.verdict).toBe(capturedInlineVerdict(platform)) + }) + + it('does not read a folder or an oversized file', () => { + const home = configHome() + mkdirSync(home.file) + expect(readCredentialFile(home.file, 'linux')).toEqual({ + signature: 'unreadable', + verdict: 'unrecognized', + }) + const other = configHome() + other.write(' '.repeat(MUSE_CREDENTIAL_FILE_MAX_BYTES + 1)) + expect(readCredentialFile(other.file, 'linux')?.verdict).toBe('unrecognized') + }) +}) + +describe('cliSignInFromAccount', () => { + it('maps the CLI’s answer, never guessing past it', () => { + expect(cliSignInFromAccount(undefined)).toBe('unknown') + expect(cliSignInFromAccount(SIGNED_IN)).toBe('signedIn') + expect(cliSignInFromAccount({ state: 'apiKey', credentialRequired: true })).toBe('signedIn') + expect(cliSignInFromAccount(LOGGED_OUT)).toBe('signedOut') + // Never captured: its meaning is not guessed at (the review of PR #49). + expect(cliSignInFromAccount({ state: 'loggedOut', credentialRequired: false })).toBe('unknown') + // META_API_KEY hides the stored lane; a future state is not guessed at. + expect(cliSignInFromAccount({ state: 'envKey', credentialRequired: true })).toBe('unknown') + expect(cliSignInFromAccount({ state: 'somethingNew', credentialRequired: true })).toBe( + 'unknown', + ) + }) + + it('counts unknown as signed in for the estimate', () => { + expect(isCliSignedIn('signedIn')).toBe(true) + expect(isCliSignedIn('unknown')).toBe(true) + expect(isCliSignedIn('signedOut')).toBe(false) + expect(isCliSignedIn('unsupportedHere')).toBe(false) + }) +}) + +describe('CliAccount', () => { + it('reads no file and the file a sign-out leaves as signed out, starting no process', async () => { + const home = configHome() + const t = account('win32', home.file, []) + await expect(t.checker.signIn(true)).resolves.toBe('signedOut') + expect(readCredentialFile(home.file, 'win32')).toBeUndefined() + home.write(LOGOUT_SHELL) + await expect(t.checker.signIn(true)).resolves.toBe('signedOut') + expect(readCredentialFile(home.file, 'win32')?.verdict).toBe('empty') + expect(t.probe).not.toHaveBeenCalled() + }) + + it('reads a stored sign-in as signed in without asking', async () => { + const home = configHome() + home.write(DEVICE_LOGIN_FILE) + const t = account('linux', home.file, []) + await expect(t.checker.signIn(false)).resolves.toBe('signedIn') + expect(t.probe).not.toHaveBeenCalled() + }) + + // Uncaptured on macOS: a version-1 file holding the credential, and an + // empty version-2 file. Asked like a pointer, on a user action only; the + // passive estimate is unknown (the review of PR #49). + it.each([ + ['a browser sign-in file', DEVICE_LOGIN_FILE], + ['an empty version-2 file', '{"schema_version":2,"providers":{}}'], + ])('asks the CLI about %s on macOS, only on a user action', async (_name, contents) => { + const home = configHome() + home.write(contents) + const t = account('darwin', home.file, [LOGGED_OUT]) + await expect(t.checker.signIn(false)).resolves.toBe('unknown') + expect(t.probe).not.toHaveBeenCalled() + await expect(t.checker.signIn(true)).resolves.toBe('signedOut') + expect(t.probe).toHaveBeenCalledOnce() + }) + + it('still reads the file a sign-out leaves as signed out on macOS, starting no process', async () => { + const home = configHome() + home.write(LOGOUT_SHELL) + const t = account('darwin', home.file, []) + await expect(t.checker.signIn(true)).resolves.toBe('signedOut') + expect(t.probe).not.toHaveBeenCalled() + }) + + it('names a macOS pointer on Windows without starting a host that would exit', async () => { + const home = configHome() + home.write(MAC_POINTER) + const t = account('win32', home.file, []) + await expect(t.checker.signIn(true)).resolves.toBe('unsupportedHere') + expect(t.probe).not.toHaveBeenCalled() + }) + + it('asks about a macOS Keychain pointer only on a user action', async () => { + const home = configHome() + home.write(MAC_POINTER) + const t = account('darwin', home.file, [SIGNED_IN]) + await expect(t.checker.signIn(false)).resolves.toBe('unknown') + expect(t.probe).not.toHaveBeenCalled() + await expect(t.checker.signIn(true)).resolves.toBe('signedIn') + expect(t.probe).toHaveBeenCalledOnce() + // The answer stands, passive or not, until the file changes. + await expect(t.checker.signIn(false)).resolves.toBe('signedIn') + await expect(t.checker.signIn(true)).resolves.toBe('signedIn') + expect(t.probe).toHaveBeenCalledOnce() + expect(t.log.info).toHaveBeenCalledWith( + 'Muse Code sign-in confirmed by account/read: accountLogin (signedIn)', + ) + }) + + // A Keychain-only sign-in or sign-out elsewhere leaves the file as it was: + // on macOS a later user action (Diagnostics, Check again, Cancel, a + // sign-in or sign-out) asks afresh; the same click reuses its answer + // (Codex on 2a324d48). Off macOS the file speaks for the sign-in. + it.each([ + ['darwin', MAC_POINTER, 2, 'signedOut'], + ['linux', MALFORMED, 1, 'signedIn'], + ] as const)( + 'on %s, asks a later user action afresh only on macOS, the file unchanged', + async (platform, contents, asks, later) => { + const home = configHome() + home.write(contents) + let clock = 0 + const answers: AccountState[] = [SIGNED_IN, LOGGED_OUT] + const probe = vi.fn(() => Promise.resolve(answers.shift())) + const checker = new CliAccount({ + platform, + credentialFilePath: () => home.file, + probe, + log: new FakeLogOutputChannel(), + now: () => clock, + }) + await expect(checker.signIn(true)).resolves.toBe('signedIn') + clock += MUSE_USER_ACTION_ANSWER_REUSE_MS - 1 + await expect(checker.signIn(true)).resolves.toBe('signedIn') + expect(probe).toHaveBeenCalledOnce() + clock += 2 + await expect(checker.signIn(true)).resolves.toBe(later) + expect(probe).toHaveBeenCalledTimes(asks) + }, + ) + + it('asks about a malformed file off macOS at once, and keeps the answer until it changes', async () => { + const home = configHome() + home.write(MALFORMED) + const t = account('win32', home.file, [LOGGED_OUT, SIGNED_IN]) + await expect(t.checker.signIn(false)).resolves.toBe('signedOut') + await expect(t.checker.signIn(false)).resolves.toBe('signedOut') + expect(t.probe).toHaveBeenCalledOnce() + // Same size, new modification time: asked again. + touchLater(home.file, 60) + await expect(t.checker.signIn(false)).resolves.toBe('signedIn') + expect(t.probe).toHaveBeenCalledTimes(2) + }) + + it('asks again when the size changes', async () => { + const home = configHome() + home.write(MALFORMED) + const t = account('linux', home.file, [LOGGED_OUT, SIGNED_IN]) + await t.checker.signIn(false) + home.write(`${MALFORMED} `) + await expect(t.checker.signIn(false)).resolves.toBe('signedIn') + expect(t.probe).toHaveBeenCalledTimes(2) + }) + + it('keeps a failed answer for passive looks, and asks again on a user action', async () => { + const home = configHome() + home.write(MALFORMED) + const t = account('linux', home.file, [undefined, LOGGED_OUT]) + await expect(t.checker.signIn(false)).resolves.toBe('unknown') + await expect(t.checker.signIn(false)).resolves.toBe('unknown') + expect(t.probe).toHaveBeenCalledOnce() + await expect(t.checker.signIn(true)).resolves.toBe('signedOut') + expect(t.probe).toHaveBeenCalledTimes(2) + expect(t.log.info).toHaveBeenCalledWith( + 'Muse Code sign-in confirmed by account/read: no answer (unknown)', + ) + }) + + it('shares one question among callers asking at once', async () => { + const { answer, probe, checker } = heldQuestion() + const first = checker.signIn(false) + const second = checker.signIn(true) + answer.resolve(SIGNED_IN) + await expect(Promise.all([first, second])).resolves.toEqual(['signedIn', 'signedIn']) + expect(probe).toHaveBeenCalledOnce() + }) + + it('looks again when the file is rewritten while the CLI answers (the review of PR #49)', async () => { + const { home, answer, checker } = heldQuestion() + const pending = checker.signIn(true) + // A sign-out rewrites the file while the old question is out. + home.write(LOGOUT_SHELL) + answer.resolve(SIGNED_IN) + await expect(pending).resolves.toBe('signedOut') + }) + + // The late answer reaches no caller: the one that waited on it looks again + // and gets the newer answer, so it cannot publish an older state over a + // newer one (the review of PR #49). + it('asks afresh after an unanswered probe is abandoned, and gives its late answer to no one (the review of PR #49)', async () => { + const { stale, probe, checker } = staleThenLoggedOut() + const abandoned = checker.signIn(true) + checker.abandonProbe() + await expect(checker.signIn(true)).resolves.toBe('signedOut') + expect(probe).toHaveBeenCalledTimes(2) + stale.resolve(SIGNED_IN) + await expect(abandoned).resolves.toBe('signedOut') + await expect(checker.signIn(false)).resolves.toBe('signedOut') + expect(probe).toHaveBeenCalledTimes(2) + }) + + // Check again forgets a probe others are waiting on: every one of them + // gets the answer Check again got (the review of PR #49). + it('gives the callers of a forgotten probe the newer answer, the one that joined it too', async () => { + const { stale, probe, checker } = staleThenLoggedOut() + const started = checker.signIn(true) + const joined = checker.signIn(false) + checker.forgetAnswers() + await expect(checker.signIn(true)).resolves.toBe('signedOut') + stale.resolve(SIGNED_IN) + await expect(Promise.all([started, joined])).resolves.toEqual(['signedOut', 'signedOut']) + expect(probe).toHaveBeenCalledTimes(2) + }) + + // A probe about an older version of the file answers after one about the + // newer version was remembered: the older answer replaces nothing, and its + // caller gets the newer one (the review of PR #49). + it('keeps the newer file version’s answer when an older probe answers late', async () => { + const { home, stale, probe, checker } = staleThenLoggedOut() + const first = checker.signIn(true) + home.write(`${MALFORMED} `) + await expect(checker.signIn(true)).resolves.toBe('signedOut') + stale.resolve(SIGNED_IN) + await expect(first).resolves.toBe('signedOut') + await expect(checker.signIn(false)).resolves.toBe('signedOut') + expect(probe).toHaveBeenCalledTimes(2) + }) + + // The state vocabulary is open: a state not shaped like a protocol word is + // not logged (the review of PR #49). + it('logs the CLI’s state only in the shape of a protocol word', async () => { + const home = configHome() + home.write(MALFORMED) + const t = account('linux', home.file, [ + { state: 'someone@example.com', credentialRequired: true }, + ]) + await expect(t.checker.signIn(true)).resolves.toBe('unknown') + expect(t.log.info).toHaveBeenCalledWith( + 'Muse Code sign-in confirmed by account/read: an unrecognized value (unknown)', + ) + }) + + // Cancel abandons a probe but keeps what the CLI already said; a sign-out, + // a new sign-in or Check again forgets it (the review of PR #49). + it('keeps a remembered answer when a probe is abandoned, and asks afresh once forgotten', async () => { + const home = configHome() + home.write(MALFORMED) + const t = account('linux', home.file, [SIGNED_IN, LOGGED_OUT]) + await expect(t.checker.signIn(true)).resolves.toBe('signedIn') + t.checker.abandonProbe() + await expect(t.checker.signIn(true)).resolves.toBe('signedIn') + expect(t.probe).toHaveBeenCalledOnce() + t.checker.forgetAnswers() + await expect(t.checker.signIn(true)).resolves.toBe('signedOut') + expect(t.probe).toHaveBeenCalledTimes(2) + }) + + // The bundled Slack connector's own entry says nothing about the Muse + // sign-in (the review of PR #49): the CLI is asked. + it('asks the CLI about a file naming another provider alone', async () => { + const home = configHome() + home.write(SLACK_CONNECTOR_ONLY) + const t = account('win32', home.file, [LOGGED_OUT]) + await expect(t.checker.signIn(false)).resolves.toBe('signedOut') + expect(t.probe).toHaveBeenCalledOnce() + }) +}) diff --git a/test/unit/conversationController.test.ts b/test/unit/conversationController.test.ts index ff446dc4..8d4793fb 100644 --- a/test/unit/conversationController.test.ts +++ b/test/unit/conversationController.test.ts @@ -99,8 +99,12 @@ function fakeAuth(status: AuthSnapshot['status'] = 'signedIn'): FakeAuth { calls.push('signOut') return Promise.resolve(state.snapshot) }, - refresh: () => { - calls.push('refresh') + refresh: (isUserAction?: boolean) => { + calls.push(isUserAction === true ? 'refresh:userAction' : 'refresh') + return Promise.resolve(state.snapshot) + }, + checkAgain: () => { + calls.push('checkAgain') return Promise.resolve(state.snapshot) }, markAuthRequired: (reason: string) => { @@ -2313,7 +2317,8 @@ describe('ConversationController: other messages', () => { 'installMuseCode', 'cancelSignIn', 'signOut', - 'refresh', + // Check again is a click: the CLI is asked afresh (D26). + 'checkAgain', ]) expect(t.openExternal).toHaveBeenCalledWith('https://example.invalid/') }) diff --git a/test/unit/credentialFile.test.ts b/test/unit/credentialFile.test.ts new file mode 100644 index 00000000..86158add --- /dev/null +++ b/test/unit/credentialFile.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest' +import { + credentialFileVerdict, + keychainItemPresence, +} from '../../src/core/backends/musecode/credentialFile' +import { + AUTH_SET_FILE, + capturedInlineVerdict, + DEVICE_LOGIN_FILE, + LOGOUT_SHELL, + SHIPPED_PLATFORMS, + SLACK_CONNECTOR_ONLY, +} from './helpers/credentialShapes' + +// Not captured here: the macOS pointer a third party observed on 1.3.0 +// (aonia §2.3); the probes of 2026-09-27 wrote the same shape to see what +// `muse serve` does with it. +const MAC_POINTER = JSON.stringify({ + schema_version: 2, + providers: { + meta: { + mechanism: 'oauth', + storage: 'keychain', + obtained_via: 'device_code', + api_base_url: 'https://api.meta.ai/v1', + }, + }, +}) +// Synthetic, as the probes of 2026-09-27 wrote it: a version-1 `meta` whose +// storage is the Keychain (`muse serve` exits 3 with it on Windows and Linux). +const SCHEMA_1_POINTER = JSON.stringify({ + schema_version: 1, + providers: { meta: { storage: 'keychain' } }, +}) +// Synthetic: the empty version-2 file the probes of 2026-09-27 gave `muse +// serve`, which exits 3 with it on Windows and Linux. +const EMPTY_V2 = '{"schema_version":2,"providers":{}}' + +/** Synthetic: a version-1 file whose `meta` entry is `meta`, as given. */ +function withMeta(meta: Record): string { + return JSON.stringify({ schema_version: 1, providers: { meta } }) +} + +/** Synthetic: `meta` beside a connector whose own entry uses the Keychain. */ +function besideConnector(meta: Record): string { + return JSON.stringify({ + schema_version: 1, + providers: { slack_connector: { storage: 'keychain' }, meta }, + }) +} + +describe('credentialFileVerdict', () => { + it.each(['win32', 'linux', 'darwin'] as const)( + 'reads the file a sign-out leaves as empty on %s', + (platform) => { + expect(credentialFileVerdict(LOGOUT_SHELL, platform)).toBe('empty') + }, + ) + + it.each(['win32', 'linux'] as const)( + 'reads a stored key or login as held in the file on %s', + (platform) => { + expect(credentialFileVerdict(AUTH_SET_FILE, platform)).toBe('inline') + expect(credentialFileVerdict(DEVICE_LOGIN_FILE, platform)).toBe('inline') + }, + ) + + // Whether macOS 1.4.0 reads or migrates a version-1 file holding the + // credential was never captured: the CLI says (the review of PR #49). + it('leaves a file holding the credential to the CLI on macOS', () => { + expect(credentialFileVerdict(AUTH_SET_FILE, 'darwin')).toBe('unrecognized') + expect(credentialFileVerdict(DEVICE_LOGIN_FILE, 'darwin')).toBe('unrecognized') + }) + + // The table the e2e tests read a real file against on the host OS, pinned + // here for every OS: a macOS expectation fails on a Windows or Linux + // runner too, not only in macOS CI (the review of PR #49). + it('keeps the per-OS table the e2e tests expect in step with the read', () => { + expect(SHIPPED_PLATFORMS.map((platform) => capturedInlineVerdict(platform))).toEqual([ + 'inline', + 'inline', + 'unrecognized', + ]) + for (const platform of SHIPPED_PLATFORMS) { + expect(credentialFileVerdict(AUTH_SET_FILE, platform)).toBe(capturedInlineVerdict(platform)) + expect(credentialFileVerdict(DEVICE_LOGIN_FILE, platform)).toBe( + capturedInlineVerdict(platform), + ) + } + }) + + // An empty version-2 file on macOS was never captured either. + it('reads a macOS Keychain pointer as needing the CLI on macOS', () => { + expect(credentialFileVerdict(MAC_POINTER, 'darwin')).toBe('keychain') + expect(credentialFileVerdict(SCHEMA_1_POINTER, 'darwin')).toBe('keychain') + expect(credentialFileVerdict(EMPTY_V2, 'darwin')).toBe('unrecognized') + }) + + // Only the captured inline shapes are a sign-in: no `storage` lane, and + // `api_key` or `access_token` (the review of PR #49). + it.each(['win32', 'linux', 'darwin'] as const)( + 'leaves a meta entry in any other shape to the CLI on %s', + (platform) => { + expect(credentialFileVerdict(withMeta({}), platform)).toBe('unrecognized') + expect(credentialFileVerdict(withMeta({ storage: 'file' }), platform)).toBe('unrecognized') + expect( + credentialFileVerdict(withMeta({ storage: 'file', api_key: '' }), platform), + ).toBe('unrecognized') + expect(credentialFileVerdict(withMeta({ api_base_url: '' }), platform)).toBe( + 'unrecognized', + ) + }, + ) + + it('takes either captured credential key alone as the sign-in off macOS', () => { + expect(credentialFileVerdict(withMeta({ access_token: '' }), 'linux')).toBe( + 'inline', + ) + expect(credentialFileVerdict(withMeta({ api_key: '' }), 'linux')).toBe('inline') + }) + + // Each made `muse serve` exit 3 on 1.4.0-R4302.1, Windows and Linux + // (isolated homes), the empty version-2 file included: "unsupported auth + // schema version 2" (the review of PR #49; probe-v2-serve-win.json, + // probe-v2-serve-linux.json). + it.each(['win32', 'linux'] as const)( + 'names a macOS file Muse Code cannot start with on %s', + (platform) => { + expect(credentialFileVerdict(MAC_POINTER, platform)).toBe('unsupportedHere') + expect(credentialFileVerdict(SCHEMA_1_POINTER, platform)).toBe('unsupportedHere') + expect(credentialFileVerdict(EMPTY_V2, platform)).toBe('unsupportedHere') + }, + ) + + // Only `meta` speaks for the sign-in (the review of PR #49). + it.each(['win32', 'linux', 'darwin'] as const)( + 'leaves a file naming another provider alone to the CLI on %s', + (platform) => { + expect(credentialFileVerdict(SLACK_CONNECTOR_ONLY, platform)).toBe('unrecognized') + }, + ) + + it('decides on meta beside another provider, and on meta’s storage only, off macOS', () => { + expect(credentialFileVerdict(besideConnector({ api_key: '' }), 'win32')).toBe( + 'inline', + ) + expect(credentialFileVerdict(besideConnector({ storage: 'keychain' }), 'win32')).toBe( + 'unsupportedHere', + ) + }) + + it.each([ + ['not JSON', '{"schema_version": 1, "providers": '], + ['a future schema', '{"schema_version": 3, "providers": {"meta": {}}}'], + ['no providers', '{"schema_version": 1}'], + ['a provider that is not an object', '{"schema_version": 1, "providers": {"meta": "x"}}'], + ['a version that is not a number', '{"schema_version": "1", "providers": {}}'], + ['an array', '[]'], + ])('leaves %s to the CLI', (_name, text) => { + expect(credentialFileVerdict(text, 'linux')).toBe('unrecognized') + }) +}) + +describe('keychainItemPresence', () => { + it('reads the attribute lookup’s exit code', () => { + expect(keychainItemPresence(0)).toBe('present') + expect(keychainItemPresence(44)).toBe('absent') + expect(keychainItemPresence(-1)).toBe('unknown') + expect(keychainItemPresence(36)).toBe('unknown') + }) +}) diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index ff9eb57a..a695c236 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -1,33 +1,135 @@ import { describe, expect, it, vi } from 'vitest' -import { CREDENTIAL_POLL_TIMEOUT_MS } from '../../src/shared/constants' +import type { AccountSession } from '../../src/host/auth/accountHost' import { - parseDeviceCode, - runDeviceSignIn, - type DeviceSession, -} from '../../src/host/auth/deviceSignIn' + CREDENTIAL_POLL_TIMEOUT_MS, + MUSE_LOGIN_CANCEL_TIMEOUT_MS, +} from '../../src/shared/constants' +import { parseDeviceCode, runDeviceSignIn } from '../../src/host/auth/deviceSignIn' +import { + CAPTURED_CANCEL_AFTER_ENDING, + CAPTURED_CANCEL_ANSWER, + CAPTURED_CANCELLED_ENDING, + CAPTURED_CODE_LIFETIME_MS, + CAPTURED_DENIED_ENDING, + CAPTURED_EXPIRED_ENDING, + CAPTURED_FAILED_ENDING, + CAPTURED_GRANTED_ENDING, + CAPTURED_LOGGED_OUT, + CAPTURED_LOGIN_START, + CAPTURED_SIGNED_IN, + endingNamed, +} from './helpers/accountLoginCapture' +import { FakeLogOutputChannel } from './helpers/fakes' + +// As captured (1.4.0-R4302.1, 2026-09-27): the user code is its shape. +const DEVICE_URL = 'https://auth.meta.com/oauth/device/?code=AAAA-AAAA' +const DEVICE_CODE = 'AAAA-AAAA' +const log = new FakeLogOutputChannel() -const DEVICE_URL = 'https://auth.meta.com/oauth/device/?code=example' +// `account/read` once the browser approved, as captured (a stand-in label, +// an e-mail address in real life, which the parser drops). +const SIGNED_IN = CAPTURED_SIGNED_IN +const LABEL = String(CAPTURED_SIGNED_IN['label']) -function session() { - const request = vi.fn((method: string) => { - return method === 'account/loginStart' - ? Promise.resolve({ verificationUrl: DEVICE_URL, userCode: 'ABCD-EFGH' }) - : Promise.resolve({ cancelled: true }) +type AccountAnswer = Record | undefined +type Notify = Parameters[0] + +/** A promise that never settles: a CLI that stopped answering. */ +function never(): Promise { + return new Promise(() => undefined) +} + +/** + * A sign-in host replaying the captured frames. `account` answers + * `account/read` (undefined: a CLI that refuses it). `account/loginCancel` + * sends the captured `cancelled` ending before its answer, as captured, and + * answers `{cancelled: false}` once the flow has ended. + */ +function session(account: () => AccountAnswer = () => undefined) { + let notify: Notify | undefined + let hasEnded = false + const complete = (frame: Parameters[0]) => { + hasEnded = true + notify?.(frame) + } + const request = vi.fn((method: string): Promise> => { + if (method === 'account/loginStart') { + return Promise.resolve(CAPTURED_LOGIN_START) + } + if (method === 'account/read') { + const answer = account() + return answer === undefined + ? Promise.reject(new Error('no handler for account/read')) + : Promise.resolve(answer) + } + if (hasEnded) { + return Promise.resolve(CAPTURED_CANCEL_AFTER_ENDING) + } + complete(CAPTURED_CANCELLED_ENDING) + return Promise.resolve(CAPTURED_CANCEL_ANSWER) + }) + const onNotification = vi.fn((handler: Notify) => { + notify = handler }) - const onNotification = vi.fn() const close = vi.fn(() => Promise.resolve()) - const deviceSession: DeviceSession = { connection: { request, onNotification }, close } - return { request, onNotification, close, deviceSession } + // The host's output ends: it exited or died. + const hostExit = Promise.withResolvers() + const exit = () => { + hostExit.resolve(undefined) + } + const deviceSession: AccountSession = { + connection: { request, onNotification, closed: hostExit.promise }, + close, + } + return { request, onNotification, close, deviceSession, complete, exit } +} + +/** Leaves `method` unanswered on `t`'s host, as a CLI that stopped answering it. */ +function unanswered(t: ReturnType, method: string): void { + const answer = t.request.getMockImplementation() + t.request.mockImplementation((asked) => + asked === method ? never() : (answer?.(asked) ?? Promise.resolve({})), + ) +} + +/** A wait that lets queued timers run, as a real poll interval does. */ +function nextTurn(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, 0) + }) +} + +/** + * A host answering `account/read` with `before`, then the captured signed-in + * answer, and sending the captured `granted` just after the first signed-in + * answer: the captured order (undefined in `before`: a read refused). + */ +function capturedGrantedOrder(before: AccountAnswer[]) { + let reads = 0 + let isGrantedSent = false + const t = session(() => { + reads += 1 + return reads <= before.length ? before[reads - 1] : SIGNED_IN + }) + const answer = t.request.getMockImplementation() + t.request.mockImplementation((method) => { + const answered = answer?.(method) ?? Promise.resolve({}) + if (method === 'account/read' && reads === before.length + 1) { + setTimeout(() => { + isGrantedSent = true + t.complete(CAPTURED_GRANTED_ENDING) + }, 0) + } + return answered + }) + return { ...t, reads: () => reads, isGrantedSent: () => isGrantedSent } } describe('Muse Code device sign-in', () => { it('accepts the captured code shape only from Meta auth', () => { - expect(parseDeviceCode({ verificationUrl: DEVICE_URL, userCode: 'ABCD-EFGH' })).toEqual({ - url: DEVICE_URL, - code: 'ABCD-EFGH', - }) + expect(parseDeviceCode(CAPTURED_LOGIN_START)).toEqual({ url: DEVICE_URL, code: DEVICE_CODE }) expect(() => - parseDeviceCode({ verificationUrl: 'https://example.com/', userCode: 'ABCD-EFGH' }), + parseDeviceCode({ ...CAPTURED_LOGIN_START, verificationUrl: 'https://example.com/' }), ).toThrow() expect(() => parseDeviceCode({ verificationUrl: DEVICE_URL })).toThrow() }) @@ -51,54 +153,45 @@ describe('Muse Code device sign-in', () => { }, signal: new AbortController().signal, onCode, + log, }), ).resolves.toBe('signedIn') - expect(onCode).toHaveBeenCalledWith(DEVICE_URL, 'ABCD-EFGH') + expect(onCode).toHaveBeenCalledWith(DEVICE_URL, DEVICE_CODE) expect(t.request).toHaveBeenCalledWith('account/loginStart', { type: 'deviceCode' }) expect(t.close).toHaveBeenCalledOnce() }) - it('sends loginCancel after user cancellation and closes the host', async () => { - const t = session() - const abort = new AbortController() - await expect( - runDeviceSignIn({ - connect: () => Promise.resolve(t.deviceSession), - credentialFileModifiedAt: () => undefined, - sleep: () => { - abort.abort() - return Promise.resolve() - }, - now: () => 0, - signal: abort.signal, - onCode: vi.fn(), - }), - ).resolves.toBe('cancelled') - expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) - expect(t.close).toHaveBeenCalledOnce() - }) - - it('reports a completed credential write when cancellation races the same poll', async () => { - const t = session() - const abort = new AbortController() - let modified: number | undefined - await expect( - runDeviceSignIn({ - connect: () => Promise.resolve(t.deviceSession), - credentialFileModifiedAt: () => modified, - sleep: () => { - modified = 2 - abort.abort() - return Promise.resolve() - }, - now: () => 0, - signal: abort.signal, - onCode: vi.fn(), - }), - ).resolves.toBe('signedIn') - expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) - expect(t.close).toHaveBeenCalledOnce() - }) + // A stop decides the flow: a file written as Cancel lands is not a sign-in + // on its own (an unrelated sign-out rewrites the file too). A real sign-in + // is still seen afterwards from the file's structure (the review of PR #49). + it.each([ + ['with no credential write', false], + ['racing a credential write in the same poll', true], + ])( + 'sends loginCancel after user cancellation %s and closes the host', + async (_name, isWritten) => { + const t = session() + const abort = new AbortController() + let modified: number | undefined + await expect( + runDeviceSignIn({ + connect: () => Promise.resolve(t.deviceSession), + credentialFileModifiedAt: () => modified, + sleep: () => { + modified = isWritten ? 2 : undefined + abort.abort() + return Promise.resolve() + }, + now: () => 0, + signal: abort.signal, + onCode: vi.fn(), + log, + }), + ).resolves.toBe('cancelled') + expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + }, + ) it('reports a credential write at the timeout boundary', async () => { const t = session() @@ -116,30 +209,50 @@ describe('Muse Code device sign-in', () => { now: () => clock, signal: new AbortController().signal, onCode: vi.fn(), + log, }), ).resolves.toBe('signedIn') expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) }) - it('closes promptly when cancelled before loginStart answers', async () => { - const t = session() - t.request.mockImplementation( - () => new Promise<{ verificationUrl: string; userCode: string }>(() => undefined), - ) + // The account before the flow (D26), then the flow's start: neither holds + // up a cancel. + it.each([ + ['loginStart', 'account/loginStart', { type: 'deviceCode' }], + ['the first account/read', 'account/read', {}], + ])('closes promptly when cancelled before %s answers', async (_name, stuck, params) => { + const t = session(() => CAPTURED_LOGGED_OUT) + unanswered(t, stuck) const abort = new AbortController() + const pending = run(t, { signal: abort.signal }) + await vi.waitFor(() => { + expect(t.request).toHaveBeenCalledWith(stuck, params) + }) + abort.abort() + await expect(pending).resolves.toBe('cancelled') + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + }) + + it('ends on the host’s ending while loginStart is unanswered, with no code shown', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + unanswered(t, 'account/loginStart') + const onCode = vi.fn() const pending = runDeviceSignIn({ connect: () => Promise.resolve(t.deviceSession), credentialFileModifiedAt: () => undefined, sleep: () => Promise.resolve(), now: () => 0, - signal: abort.signal, - onCode: vi.fn(), + signal: new AbortController().signal, + onCode, + log, }) await vi.waitFor(() => { expect(t.request).toHaveBeenCalledWith('account/loginStart', { type: 'deviceCode' }) }) - abort.abort() - await expect(pending).resolves.toBe('cancelled') + t.complete(CAPTURED_EXPIRED_ENDING) + await expect(pending).resolves.toBe('expired') + expect(onCode).not.toHaveBeenCalled() expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) expect(t.close).toHaveBeenCalledOnce() }) @@ -149,7 +262,7 @@ describe('Muse Code device sign-in', () => { const close = vi.fn() const connect = vi.fn( () => - new Promise((_resolve, reject) => { + new Promise((_resolve, reject) => { abort.signal.addEventListener( 'abort', () => { @@ -167,6 +280,7 @@ describe('Muse Code device sign-in', () => { now: () => 0, signal: abort.signal, onCode: vi.fn(), + log, }) expect(connect).toHaveBeenCalledOnce() abort.abort() @@ -185,6 +299,7 @@ describe('Muse Code device sign-in', () => { now: () => 0, signal: abort.signal, onCode: vi.fn(), + log, } await expect(runDeviceSignIn(deps)).resolves.toBe('cancelled') expect(connect).not.toHaveBeenCalled() @@ -199,50 +314,564 @@ describe('Muse Code device sign-in', () => { ).rejects.toBe(failure) }) - it('treats the CLI cancellation notification as terminal', async () => { + it('treats the captured cancellation ending as terminal', async () => { const t = session() let isNotified = false - await expect( - runDeviceSignIn({ - connect: () => Promise.resolve(t.deviceSession), - credentialFileModifiedAt: () => undefined, - sleep: () => { - if (!isNotified) { - isNotified = true - t.onNotification.mock.calls[0]?.[0]({ - jsonrpc: '2.0', - method: 'account/loginCompleted', - params: { outcome: 'cancelled' }, - }) - } - return Promise.resolve() - }, - now: () => 0, - signal: new AbortController().signal, - onCode: vi.fn(), - }), - ).resolves.toBe('cancelled') + const sleep = () => { + if (!isNotified) { + isNotified = true + t.complete(CAPTURED_CANCELLED_ENDING) + } + return Promise.resolve() + } + await expect(run(t, { sleep, modified: () => undefined })).resolves.toBe('cancelled') expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) expect(t.close).toHaveBeenCalledOnce() }) it('cancels the CLI request and closes its host on timeout', async () => { const t = session() - let clock = 0 - await expect( - runDeviceSignIn({ - connect: () => Promise.resolve(t.deviceSession), - credentialFileModifiedAt: () => undefined, - sleep: () => Promise.resolve(), - now: () => { - clock += CREDENTIAL_POLL_TIMEOUT_MS / 2 - return clock - }, - signal: new AbortController().signal, - onCode: vi.fn(), - }), - ).resolves.toBe('timedOut') + await expect(run(t, { step: CREDENTIAL_POLL_TIMEOUT_MS / 2 })).resolves.toBe('timedOut') expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) expect(t.close).toHaveBeenCalledOnce() }) }) + +interface Flow { + /** Each poll's wait: where a test changes the host or the file. */ + readonly sleep?: () => Promise + readonly modified?: () => number | undefined + /** How far the clock moves at each look; the default never runs out. */ + readonly step?: number + readonly signal?: AbortSignal + readonly log?: FakeLogOutputChannel +} + +/** A flow on `t`'s host. */ +function run(t: ReturnType, flow: Flow = {}) { + let clock = 0 + return runDeviceSignIn({ + connect: () => Promise.resolve(t.deviceSession), + credentialFileModifiedAt: flow.modified ?? (() => 1), + sleep: flow.sleep ?? (() => Promise.resolve()), + now: () => { + clock += flow.step ?? 1 + return clock + }, + signal: flow.signal ?? new AbortController().signal, + onCode: vi.fn(), + log: flow.log ?? log, + }) +} + +/** A clock that runs out after one poll. */ +const ONE_POLL = CREDENTIAL_POLL_TIMEOUT_MS / 2 + +// PLAN.md D26 (2026-09-27): `account/read` on the open host and the +// credential file decide a sign-in; the host's captured endings end the flow. +describe('Muse Code device sign-in: how it ends', () => { + it('notices a sign-in by polling account/read', async () => { + let account: AccountAnswer = CAPTURED_LOGGED_OUT + const t = session(() => account) + const sleep = () => { + account = SIGNED_IN + return Promise.resolve() + } + await expect(run(t, { sleep })).resolves.toBe('signedIn') + expect(t.request).toHaveBeenCalledWith('account/read', {}) + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + }) + + // Not the captured order: here `granted` comes before `account/read` shows + // the sign-in. Its word neither ends the flow nor signs in on its own. + it('waits for account/read after a granted that comes before it', async () => { + const accounts: AccountAnswer[] = [ + CAPTURED_LOGGED_OUT, + CAPTURED_LOGGED_OUT, + CAPTURED_LOGGED_OUT, + ] + const t = session(() => accounts.shift() ?? SIGNED_IN) + let polls = 0 + const sleep = () => { + polls += 1 + t.complete(CAPTURED_GRANTED_ENDING) + return Promise.resolve() + } + await expect(run(t, { sleep })).resolves.toBe('signedIn') + expect(polls).toBe(2) + }) + + // The captured order (account-login-granted.json): `account/read` says + // `accountLogin`, and `granted` follows 205 ms later. With a first answer + // to compare, the poll that sees the sign-in ends the flow. + it('signs in on the poll that sees the account, before the captured granted follows', async () => { + const t = capturedGrantedOrder([CAPTURED_LOGGED_OUT, CAPTURED_LOGGED_OUT]) + await expect(run(t, { sleep: nextTurn })).resolves.toBe('signedIn') + expect(t.reads()).toBe(3) + expect(t.isGrantedSent()).toBe(false) + }) + + // With no first answer there is nothing to compare, and a Keychain sign-in + // may leave the file as it was: `granted`, borne out by `account/read` + // saying `accountLogin`, is the sign-in (the review of PR #49). + it('signs in on the captured granted and account/read when the first account/read went unanswered', async () => { + const t = capturedGrantedOrder([undefined]) + await expect(run(t, { sleep: nextTurn, modified: () => 1, step: ONE_POLL })).resolves.toBe( + 'signedIn', + ) + expect(t.isGrantedSent()).toBe(true) + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + }) + + // The logout hold kept the old sign-in, and a macOS re-sign-in to the same + // account replaced only the Keychain item: no change of state or file can + // show it, so the captured `granted`, borne out by `accountLogin`, does + // (Codex on 328efb52). + it('signs in on granted and accountLogin when the account was already signed in', async () => { + const t = session(() => SIGNED_IN) + const sleep = () => { + t.complete(CAPTURED_GRANTED_ENDING) + return Promise.resolve() + } + await expect(run(t, { sleep, modified: () => 1, step: ONE_POLL })).resolves.toBe('signedIn') + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + }) + + // `granted` alone never signs in, with no first answer and the file as it + // was: not when `account/read` cannot say, nor when it names another lane + // (META_API_KEY's `envKey`). + it.each([ + ['account/read cannot say', undefined], + ['account/read says envKey', { state: 'envKey', credentialRequired: true }], + ])('takes no sign-in from granted alone when %s', async (_name, later) => { + let reads = 0 + const t = session(() => (++reads === 1 ? undefined : later)) + const sleep = () => { + t.complete(CAPTURED_GRANTED_ENDING) + return Promise.resolve() + } + await expect(run(t, { sleep, modified: () => 1, step: ONE_POLL })).resolves.toBe('timedOut') + }) + + it('keeps waiting after a granted the account never shows', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const sleep = () => { + t.complete(CAPTURED_GRANTED_ENDING) + return Promise.resolve() + } + await expect(run(t, { sleep, step: ONE_POLL })).resolves.toBe('timedOut') + expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) + }) + + // With no first answer there is nothing to compare: an account signed in + // before the flow is no new sign-in, and only a new file counts (the + // review of PR #49). + it.each([ + ['takes no sign-in from before the flow for a new one', 1, 'timedOut'], + ['counts a file written since the flow began', 2, 'signedIn'], + ] as const)( + 'when the first account/read goes unanswered, %s', + async (_name, modifiedAfterStart, outcome) => { + let reads = 0 + const t = session(() => (++reads === 1 ? undefined : SIGNED_IN)) + let modified = 1 + const sleep = () => { + modified = modifiedAfterStart + return Promise.resolve() + } + await expect(run(t, { sleep, modified: () => modified, step: ONE_POLL })).resolves.toBe( + outcome, + ) + }, + ) + + // The second answer was never captured: signed out stays signed out (the review of PR #49). + it.each([ + ['the captured signed-out answer', CAPTURED_LOGGED_OUT], + [ + 'an uncaptured credentialRequired false', + { state: 'loggedOut', credentialRequired: false } as const, + ], + ])('does not take a file a sign-out rewrote for a sign-in under %s', async (_name, answer) => { + const t = session(() => answer) + let modified = 1 + const sleep = () => { + modified += 1 + return Promise.resolve() + } + await expect(run(t, { sleep, modified: () => modified, step: ONE_POLL })).resolves.toBe( + 'timedOut', + ) + }) + + // The same evidence stands when a later `account/read` cannot answer: the + // file alone then speaks only for a write no answer contradicted (the + // review of PR #49). + it('keeps the signed-out answer about a write when account/read then cannot say', async () => { + let modified = 1 + let reads = 0 + const t = session(() => { + reads += 1 + if (reads === 1) { + // A sign-out elsewhere rewrites the file as the flow starts. + modified = 2 + } + return reads <= 2 ? CAPTURED_LOGGED_OUT : undefined + }) + await expect(run(t, { modified: () => modified, step: ONE_POLL })).resolves.toBe('timedOut') + }) + + it('counts a new file while META_API_KEY masks the login', async () => { + const t = session(() => ({ state: 'envKey', credentialRequired: true })) + let modified = 1 + const sleep = () => { + modified = 2 + return Promise.resolve() + } + await expect(run(t, { sleep, modified: () => modified })).resolves.toBe('signedIn') + }) + + // A shorter limit cancelled codes the browser could still approve, and + // Muse Code's own `expired` never arrived. + it('waits past the captured code lifetime, so Muse Code ends an unapproved code itself', () => { + expect(CAPTURED_CODE_LIFETIME_MS).toBeGreaterThan(10 * 60 * 1000) + expect(CREDENTIAL_POLL_TIMEOUT_MS).toBeGreaterThan(CAPTURED_CODE_LIFETIME_MS) + }) + + it('ends at once on the captured expired ending, logs it in fixed words, and sends no loginCancel', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const logged = new FakeLogOutputChannel() + const sleep = () => { + t.complete(CAPTURED_EXPIRED_ENDING) + return Promise.resolve() + } + await expect(run(t, { sleep, log: logged })).resolves.toBe('expired') + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + expect(logged.info).toHaveBeenCalledWith( + 'Muse Code sign-in ended: expired: the code expired before it was approved', + ) + }) + + // The words are captured, the message is the CLI's free text: whatever it + // holds, the log keeps fixed words (the review of PR #49). + it.each(['expired', 'denied'])( + 'logs the captured %s in fixed words, never the message sent with it', + async (outcome) => { + const t = session(() => CAPTURED_LOGGED_OUT) + const log = new FakeLogOutputChannel() + const sleep = () => { + t.complete({ + ...endingNamed(outcome), + params: { + outcome, + message: String.raw`login failed for someone@example.com at C:\Users\someone\auth.json`, + }, + }) + return Promise.resolve() + } + await expect(run(t, { sleep, log })).resolves.toBe(outcome) + expect(allLogged(log)).not.toContain('someone') + }, + ) + + // Captured live: Deny clicked, and an approval whose file could not be + // written. Each has a meaning of its own (the review of PR #49). + it.each([ + ['denied', CAPTURED_DENIED_ENDING, 'the sign-in was denied in the browser'], + ['failed', CAPTURED_FAILED_ENDING, 'saving the credential failed'], + ] as const)( + 'ends at once on the captured %s, and logs no path', + async (outcome, frame, logged) => { + const t = session(() => CAPTURED_LOGGED_OUT) + const log = new FakeLogOutputChannel() + const sleep = () => { + t.complete(frame) + return Promise.resolve() + } + await expect(run(t, { sleep, log })).resolves.toBe(outcome) + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + expect(log.info).toHaveBeenCalledWith(`Muse Code sign-in ended: ${outcome}: ${logged}`) + // The captured `failed` message names the credential file's path. + expect(allLogged(log)).not.toContain('') + }, + ) + + // Rule 13: an ending no capture covers is shown as the CLI named it, and + // its message, whatever it holds, is not logged. + it('ends at once on a word no capture covers, as the CLI named it', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const log = new FakeLogOutputChannel() + const sleep = () => { + t.complete({ + ...endingNamed('somethingNew'), + params: { outcome: 'somethingNew', message: String.raw`at C:\Users\someone\x` }, + }) + return Promise.resolve() + } + await expect(run(t, { sleep, log })).resolves.toEqual({ endedAs: 'somethingNew' }) + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(log.info).toHaveBeenCalledWith( + 'Muse Code sign-in ended: somethingNew (an ending no capture covers; its message is not logged)', + ) + expect(allLogged(log)).not.toContain('someone') + }) + + // The word itself is the CLI's: one not shaped like a protocol word is + // shown in the panel, not logged (the review of PR #49). + it('logs an uncovered ending word only in the shape of one', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const log = new FakeLogOutputChannel() + const word = 'someone@example.com' + const sleep = () => { + t.complete(endingNamed(word)) + return Promise.resolve() + } + await expect(run(t, { sleep, log })).resolves.toEqual({ endedAs: word }) + expect(allLogged(log)).not.toContain('someone') + }) + + it('cuts a long outcome word before it is shown', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const sleep = () => { + t.complete(endingNamed('x'.repeat(100))) + return Promise.resolve() + } + await expect(run(t, { sleep })).resolves.toEqual({ endedAs: `${'x'.repeat(40)}…` }) + }) + + it('keeps the first ending', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const sleep = () => { + t.complete(CAPTURED_EXPIRED_ENDING) + t.complete(endingNamed('somethingNew')) + return Promise.resolve() + } + await expect(run(t, { sleep })).resolves.toBe('expired') + }) + + it.each([ + ['no outcome', { ...CAPTURED_EXPIRED_ENDING, params: { result: 'denied' } }], + ['an empty outcome', endingNamed('')], + ])('keeps waiting on an ending with %s', async (_name, frame) => { + const t = session(() => CAPTURED_LOGGED_OUT) + const sleep = () => { + t.complete(frame) + return Promise.resolve() + } + await expect(run(t, { sleep, step: ONE_POLL })).resolves.toBe('timedOut') + }) + + it('never logs the account’s label', async () => { + const logged = new FakeLogOutputChannel() + const accounts: AccountAnswer[] = [CAPTURED_LOGGED_OUT] + const t = session(() => accounts.shift() ?? SIGNED_IN) + await expect(run(t, { log: logged })).resolves.toBe('signedIn') + expect(allLogged(logged)).not.toContain(LABEL) + }) +}) + +function allLogged(log: FakeLogOutputChannel): string { + return [...log.info.mock.calls, ...log.warn.mock.calls].flat().join('\n') +} + +// A host that exits mid-flow fails the sign-in at once instead of being +// polled until the backstop (the review of PR #49). +describe('Muse Code device sign-in: a host that exits', () => { + it('fails at once when the host exits while the flow polls', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const sleep = vi.fn(() => never()) + const pending = run(t, { sleep }) + await vi.waitFor(() => { + expect(sleep).toHaveBeenCalled() + }) + t.exit() + await expect(pending).rejects.toThrow('exited') + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + }) + + // An exit is no decision: the host wrote the credential file, then went + // (the review of PR #49). + it('signs in when the host exits after the credential file changed', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const logged = new FakeLogOutputChannel() + let modified = 1 + const sleep = vi.fn(() => { + modified = 2 + unanswered(t, 'account/read') + t.exit() + return never() + }) + await expect(run(t, { sleep, modified: () => modified, log: logged })).resolves.toBe('signedIn') + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + expect(logged.warn).toHaveBeenCalledWith( + 'The Muse Code sign-in host exited after writing the credential file', + ) + }) + + // Another Muse process's sign-out rewrote the file, and `account/read` + // said signed out about that write; then the host exits. The signed-out + // answer stands (the review of PR #49). + it('keeps the signed-out answer about a write when the host then exits', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + let modified = 1 + let waits = 0 + const sleep = vi.fn(() => { + waits += 1 + if (waits === 1) { + modified = 2 + return Promise.resolve() + } + unanswered(t, 'account/read') + t.exit() + return never() + }) + await expect(run(t, { sleep, modified: () => modified })).rejects.toThrow('exited') + }) + + it('fails at once when the host exits before loginStart answers', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + unanswered(t, 'account/loginStart') + const pending = run(t) + await vi.waitFor(() => { + expect(t.request).toHaveBeenCalledWith('account/loginStart', { type: 'deviceCode' }) + }) + t.exit() + await expect(pending).rejects.toThrow('exited') + }) + + it('reports Cancel, not a failure, when the host exits after it', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + unanswered(t, 'account/loginStart') + const abort = new AbortController() + const pending = run(t, { signal: abort.signal }) + await vi.waitFor(() => { + expect(t.request).toHaveBeenCalledWith('account/loginStart', { type: 'deviceCode' }) + }) + abort.abort() + t.exit() + await expect(pending).resolves.toBe('cancelled') + }) +}) + +/** + * A host that answers the first `account/read` (the account before the + * flow) and then stops answering it, as a wedged CLI would (PR #49 P2). + */ +function wedgedSession() { + let reads = 0 + const t = session(() => CAPTURED_LOGGED_OUT) + const answer = t.request.getMockImplementation() + t.request.mockImplementation((method) => { + if (method === 'account/read') { + reads += 1 + if (reads > 1) { + return never() + } + } + return answer?.(method) ?? Promise.resolve({}) + }) + const polling = async () => { + await vi.waitFor(() => { + expect(reads).toBe(2) + }) + } + return { t, polling } +} + +// PR #49 P2: an unanswered `account/read` holds up neither Cancel nor the +// host's ending, and an unanswered `loginCancel` does not hold up the close. +describe('Muse Code device sign-in: a CLI that stops answering', () => { + it('ends on the host’s ending, not a sign-in, when the file changes while a poll is unanswered', async () => { + const { t, polling } = wedgedSession() + let modified = 1 + const pending = run(t, { modified: () => modified }) + await polling() + // An unrelated sign-out rewrites the file as the code expires. + modified = 2 + t.complete(CAPTURED_EXPIRED_ENDING) + await expect(pending).resolves.toBe('expired') + }) + + it('notices Cancel at once while a poll is unanswered', async () => { + const { t, polling } = wedgedSession() + const abort = new AbortController() + const pending = run(t, { signal: abort.signal }) + await polling() + abort.abort() + await expect(pending).resolves.toBe('cancelled') + expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + }) + + it.each([ + ['the captured expired ending', CAPTURED_EXPIRED_ENDING, 'expired'], + ['the captured denied ending', CAPTURED_DENIED_ENDING, 'denied'], + ['an ending no capture covers', endingNamed('somethingNew'), { endedAs: 'somethingNew' }], + ])('ends at once on %s while a poll is unanswered', async (_name, frame, outcome) => { + const { t, polling } = wedgedSession() + const pending = run(t) + await polling() + t.complete(frame) + await expect(pending).resolves.toEqual(outcome) + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + }) + + it('notices Cancel during the wait between polls', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) + const abort = new AbortController() + const sleep = vi.fn(() => never()) + const pending = run(t, { signal: abort.signal, sleep }) + await vi.waitFor(() => { + expect(sleep).toHaveBeenCalled() + }) + abort.abort() + await expect(pending).resolves.toBe('cancelled') + }) + + it.each([ + ['Cancel', 'cancelled'], + ['the timeout', 'timedOut'], + ] as const)( + 'closes the host after %s even when loginCancel is never answered', + async (name, outcome) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const isCancel = name === 'Cancel' + // Cancel while a poll is unanswered; or every poll answered and the + // clock run out. + const { t, polling } = isCancel + ? wedgedSession() + : { t: session(() => CAPTURED_LOGGED_OUT), polling: () => Promise.resolve() } + unanswered(t, 'account/loginCancel') + const logged = new FakeLogOutputChannel() + const abort = new AbortController() + const pending = run(t, { + signal: abort.signal, + log: logged, + ...(!isCancel && { step: ONE_POLL }), + }) + await polling() + if (isCancel) { + abort.abort() + } + await vi.waitFor(() => { + expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) + }) + await vi.advanceTimersByTimeAsync(MUSE_LOGIN_CANCEL_TIMEOUT_MS) + await expect(pending).resolves.toBe(outcome) + expect(t.close).toHaveBeenCalledOnce() + expect(logged.warn).toHaveBeenCalledWith( + 'Muse Code did not confirm the sign-in cancel; closing its host', + ) + } finally { + vi.useRealTimers() + } + }, + ) +}) diff --git a/test/unit/helpers/accountLoginCapture.ts b/test/unit/helpers/accountLoginCapture.ts new file mode 100644 index 00000000..356dd61d --- /dev/null +++ b/test/unit/helpers/accountLoginCapture.ts @@ -0,0 +1,146 @@ +// Muse Code 1.4.0-R4302.1's device sign-in as captured live on 2026-09-27 in +// throwaway homes (test/fixtures/msp/account-login-*.json; +// docs/certification/sign-in-detection.md, "Live capture"; 0 model +// attempts). The unit tests and the fake CLI (test/e2e/fake-muse/serve.mjs) +// replay these frames, not guesses (AGENTS.md rule 13). The user code is its +// shape, AAAA-AAAA; the account's label and avatar are stand-ins. + +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { NotificationHandler } from '@muse-code/sdk' +import * as z from 'zod/mini' + +/** The folder the fake CLI reads the captures from (MUSE_FAKE_CAPTURES). */ +export const CAPTURES_FOLDER = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'fixtures', + 'msp', +) + +const captureSchema = z.object({ + frames: z.array( + z.object({ + atMs: z.number(), + dir: z.enum(['in', 'out']), + frame: z.object({ + jsonrpc: z.literal('2.0'), + id: z.optional(z.number()), + method: z.optional(z.string()), + params: z.optional(z.record(z.string(), z.unknown())), + result: z.optional(z.record(z.string(), z.unknown())), + emittedAtMs: z.optional(z.number()), + }), + }), + ), +}) + +type CaptureName = 'expired' | 'cancelled' | 'granted' | 'denied' | 'failed' +type CapturedNotification = Parameters[0] + +function framesOf(name: CaptureName) { + const file = path.join(CAPTURES_FOLDER, `account-login-${name}.json`) + return captureSchema.parse(JSON.parse(readFileSync(file, 'utf8'))).frames +} + +/** What the captured host answered each time it was asked `method`, in order. */ +function answersOf(name: CaptureName, method: string): Record[] { + const frames = framesOf(name) + return frames + .filter((entry) => entry.dir === 'out' && entry.frame.method === method) + .flatMap((asked): Record[] => { + const answer = frames.find((entry) => entry.dir === 'in' && entry.frame.id === asked.frame.id) + ?.frame.result + return answer === undefined ? [] : [answer] + }) +} + +/** What the captured host answered the first time it was asked `method`. */ +function answerOf(name: CaptureName, method: string): Record { + const [first] = answersOf(name, method) + if (first === undefined) { + throw new Error(`the ${name} capture has no answer to ${method}`) + } + return first +} + +/** The captured `account/loginCompleted` frame, as it arrived. */ +function endingOf(name: CaptureName): CapturedNotification { + const frame = framesOf(name).find( + (entry) => entry.dir === 'in' && entry.frame.method === 'account/loginCompleted', + )?.frame + if (frame?.method === undefined) { + throw new Error(`the ${name} capture has no account/loginCompleted`) + } + return { + jsonrpc: frame.jsonrpc, + method: frame.method, + ...(frame.params !== undefined && { params: frame.params }), + ...(frame.emittedAtMs !== undefined && { emittedAtMs: frame.emittedAtMs }), + } +} + +/** How long a code lived: from loginStart's answer to the `expired` ending (600.5 s). */ +function codeLifetimeMs(): number { + const frames = framesOf('expired') + const started = frames.find((entry) => entry.frame.result?.['userCode'] !== undefined) + const ended = frames.find((entry) => entry.frame.method === 'account/loginCompleted') + if (started === undefined || ended === undefined) { + throw new Error('the expired capture has no loginStart answer or no ending') + } + return ended.atMs - started.atMs +} + +/** The first `account/read` answer in the granted capture that shows the sign-in. */ +function signedInAnswer(): Record { + const signedIn = answersOf('granted', 'account/read').find( + (answer) => answer['state'] === 'accountLogin' && answer['label'] !== undefined, + ) + if (signedIn === undefined) { + throw new Error('the granted capture has no signed-in account/read answer') + } + return signedIn +} + +export const CAPTURED_CODE_LIFETIME_MS = codeLifetimeMs() +/** `account/loginStart {type: "deviceCode"}`: `{verificationUrl, userCode}`. */ +export const CAPTURED_LOGIN_START = answerOf('cancelled', 'account/loginStart') +/** `account/read` with nothing stored: `{state: "loggedOut", credentialRequired: true}`. */ +export const CAPTURED_LOGGED_OUT = answerOf('expired', 'account/read') +/** + * `account/read` once the browser approved: `{state: "accountLogin", label, + * avatarUrl, credentialRequired: true}`; the label is an e-mail address in + * real life (a stand-in here), and `avatarUrl` is not in the MSP schema. + */ +export const CAPTURED_SIGNED_IN = signedInAnswer() +/** 600 s after loginStart: `{outcome: "expired", message: "login failed: the request expired"}`. */ +export const CAPTURED_EXPIRED_ENDING = endingOf('expired') +/** Sent before the loginCancel answer: `{outcome: "cancelled"}`, no message. */ +export const CAPTURED_CANCELLED_ENDING = endingOf('cancelled') +/** + * `{outcome: "granted"}`, no message: after the file was written and + * `account/read` already said `accountLogin` (205 ms after `account/changed`). + */ +export const CAPTURED_GRANTED_ENDING = endingOf('granted') +/** Deny clicked: `{outcome: "denied", message: "login failed: the request was denied"}`. */ +export const CAPTURED_DENIED_ENDING = endingOf('denied') +/** + * Approved, but the file could not be written: `{outcome: "failed", message: + * "login succeeded but saving failed: failed to write credential file at + * : …"}`; the message names a path under the user's profile. + */ +export const CAPTURED_FAILED_ENDING = endingOf('failed') +/** `account/loginCancel` with a flow pending: `{cancelled: true}`. */ +export const CAPTURED_CANCEL_ANSWER = answerOf('cancelled', 'account/loginCancel') +/** `account/loginCancel` after the flow ended: `{cancelled: false}`. */ +export const CAPTURED_CANCEL_AFTER_ENDING = answerOf('expired', 'account/loginCancel') + +/** + * The captured ending frame carrying a word no capture covers (a future + * one): as the `cancelled` capture, the outcome alone. + */ +export function endingNamed(outcome: string): CapturedNotification { + return { ...CAPTURED_CANCELLED_ENDING, params: { outcome } } +} diff --git a/test/unit/helpers/credentialShapes.ts b/test/unit/helpers/credentialShapes.ts new file mode 100644 index 00000000..1f864338 --- /dev/null +++ b/test/unit/helpers/credentialShapes.ts @@ -0,0 +1,63 @@ +// The Muse Code CLI's credential file (`auth.json`) in the shapes it was +// captured writing (AGENTS.md rule 13; docs/certification/sign-in-detection.md). +// Every value that was a secret or personal is a placeholder: the extension +// reads the structure only, so the tests need nothing more. + +/** What `muse logout` and `account/logout` leave (1.3.0, 1.4.0-R4302.1; 44 bytes). */ +export const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' + +/** + * A key saved with `muse auth set` (1.4.0-R4161.1 and R4302.1, Windows and + * Linux, `scratchpad/m140cred/probe-authset-*.json`): schema 1, `meta` + * holding `api_key` alone. + */ +export const AUTH_SET_FILE = JSON.stringify({ + schema_version: 1, + providers: { meta: { api_key: '' } }, +}) + +/** + * Synthetic: the bundled Slack connector's own entry (1.4.0-R4302.1's + * `slack_connector.py` reads `providers.slack_connector.bot_token`), with + * no Muse sign-in beside it. + */ +export const SLACK_CONNECTOR_ONLY = JSON.stringify({ + schema_version: 1, + providers: { slack_connector: { bot_token: '' } }, +}) + +/** + * What the structural read makes of the captured inline shapes + * (`AUTH_SET_FILE`, `DEVICE_LOGIN_FILE`) on `platform`: held in the file on + * Windows and Linux, where they were captured; on macOS, where no such file + * was captured, left to the CLI (the review of PR #49). Tests that read a + * real file on the host OS expect this, and `credentialFile.test.ts` pins it + * for every OS, so a macOS-only expectation is caught on any runner. + */ +export function capturedInlineVerdict(platform: NodeJS.Platform): 'inline' | 'unrecognized' { + return platform === 'darwin' ? 'unrecognized' : 'inline' +} + +/** The OSes the extension ships for, each with its own reading of the file. */ +export const SHIPPED_PLATFORMS = ['win32', 'linux', 'darwin'] as const + +/** + * A browser (device-code) sign-in as the granted capture left it (1.4.0-R4302.1, + * Windows, 2026-09-27; 1062 bytes): schema 1, `meta` with these keys in this + * order; `obtained_via` and `mechanism` are the captured values. + */ +export const DEVICE_LOGIN_FILE = JSON.stringify({ + schema_version: 1, + providers: { + meta: { + access_token: '', + obtained_via: 'device_code', + mechanism: 'oauth', + api_key: '', + api_base_url: '', + user_full_name: '', + user_email: '', + user_avatar_url: '', + }, + }, +}) diff --git a/test/unit/launch.test.ts b/test/unit/launch.test.ts index cc902596..05e99b69 100644 --- a/test/unit/launch.test.ts +++ b/test/unit/launch.test.ts @@ -5,6 +5,7 @@ import { credentialFilePath, type LaunchProbe, resolveMuseLaunch, + terminalEnvironment, withLoopbackBypass, } from '../../src/core/backends/musecode/launch' @@ -244,6 +245,29 @@ describe('buildChildEnvironment', () => { }) }) +// The CLI in a terminal (`muse logout`, `muse mcp login`) reads the config +// home `muse serve` does: with XDG_CONFIG_HOME moved, a logout there must +// not sign out the default one (the review of PR #49). +describe('terminalEnvironment', () => { + it('gives a CLI terminal the configured variables, one spelling each on Windows', () => { + const variables = [ + { name: 'xdg_config_home', value: 'C:/old' }, + { name: 'XDG_CONFIG_HOME', value: 'D:/muse-config' }, + { name: 'TBH_CREDENTIAL_BACKEND', value: 'file' }, + ] + expect(terminalEnvironment(variables, 'win32')).toEqual({ + XDG_CONFIG_HOME: 'D:/muse-config', + TBH_CREDENTIAL_BACKEND: 'file', + }) + expect(terminalEnvironment(variables, 'linux')).toEqual({ + xdg_config_home: 'C:/old', + XDG_CONFIG_HOME: 'D:/muse-config', + TBH_CREDENTIAL_BACKEND: 'file', + }) + expect(terminalEnvironment([], 'linux')).toEqual({}) + }) +}) + // M56 (PLAN.md D43): Muse Code sent the loopback `ide` server's requests to // the proxy until NO_PROXY listed 127.0.0.1 (captured 2026-09-25). describe('withLoopbackBypass', () => { diff --git a/test/unit/logText.test.ts b/test/unit/logText.test.ts new file mode 100644 index 00000000..f18353d5 --- /dev/null +++ b/test/unit/logText.test.ts @@ -0,0 +1,76 @@ +// What the log keeps of text the Muse Code CLI wrote (the review of PR #49): +// protocol words in their shape, MSP failures by kind and code, and stderr +// by the lines 1.4.0-R4302.1 was captured writing, in fixed words. + +import { MspError } from '@muse-code/sdk' +import { describe, expect, it } from 'vitest' +import { failureForLog, stderrForLog } from '../../src/core/backends/musecode/logText' +import { wireWordForLog } from '../../src/core/logging' +import { DeadlineError } from '../../src/core/timeouts' + +// Synthetic: free text naming a path under a profile and an e-mail address. +const PERSONAL = String.raw`failed for someone@example.com at C:\Users\someone\.config\muse\auth.json` + +describe('wireWordForLog', () => { + it('keeps a protocol word and replaces anything else', () => { + expect(wireWordForLog('accountLogin')).toBe('accountLogin') + expect(wireWordForLog('session_not-loaded2')).toBe('session_not-loaded2') + expect(wireWordForLog('someone@example.com')).toBe('an unrecognized value') + expect(wireWordForLog(String.raw`C:\Users\someone`)).toBe('an unrecognized value') + expect(wireWordForLog('/home/someone')).toBe('an unrecognized value') + expect(wireWordForLog('two words')).toBe('an unrecognized value') + expect(wireWordForLog('')).toBe('an unrecognized value') + expect(wireWordForLog('x'.repeat(65))).toBe('an unrecognized value') + }) +}) + +describe('failureForLog', () => { + it('names an MSP error by its kind and code, never its message', () => { + const refused = new MspError({ + code: -32_000, + message: PERSONAL, + data: { kind: 'commandRejected' }, + }) + expect(failureForLog(refused)).toBe('commandRejected (MSP error -32000)') + const odd = new MspError({ code: -32_000, message: PERSONAL, data: { kind: PERSONAL } }) + expect(failureForLog(odd)).toBe('an unrecognized value (MSP error -32000)') + }) + + it('keeps the extension’s own deadline words, and names anything else by its type', () => { + expect(failureForLog(new DeadlineError('Muse Code did not answer task/stopAll in time'))).toBe( + 'Muse Code did not answer task/stopAll in time', + ) + expect(failureForLog(new Error(PERSONAL))).toBe('Error') + expect(failureForLog(new TypeError(PERSONAL))).toBe('TypeError') + expect(failureForLog(PERSONAL)).toBe('an unknown failure') + }) +}) + +describe('stderrForLog', () => { + // As captured from `muse serve` 1.4.0-R4302.1 (probe-v2-serve-win.json, + // probe-v2-serve-linux.json, envkey-stderr.txt), a profile path put back. + it.each([ + [ + String.raw`compose serve model client: unsupported auth schema version 2 at C:\Users\someone\cfg\muse\auth.json`, + "unsupported auth schema version 2 (the credential file's path is not logged)", + ], + [ + 'compose serve model client: keychain item for meta is unreadable (internal error -2147483648)', + 'the Keychain item for meta is unreadable', + ], + [ + 'tbh serve: startup model-catalog fetch failed (failed to fetch model catalog: transport error: error sending request for url (https://api.meta.ai/muse-code/models)); no cached default — composing the logged-out fallback', + 'the startup model-catalog fetch failed', + ], + ])('names the captured line %s in fixed words', (line, logged) => { + expect(stderrForLog(`${line}\n`)).toBe(logged) + }) + + it('logs any other line by its length alone, one entry per line', () => { + const logged = stderrForLog(`${PERSONAL}\r\n\nsecond\n`) + expect(logged).toBe( + `a line of ${String(PERSONAL.length)} characters (not logged: it may name a path or an account); a line of 6 characters (not logged: it may name a path or an account)`, + ) + expect(logged).not.toContain('someone') + }) +}) diff --git a/test/unit/skillsCommands.test.ts b/test/unit/skillsCommands.test.ts index c92e4c00..89ab9664 100644 --- a/test/unit/skillsCommands.test.ts +++ b/test/unit/skillsCommands.test.ts @@ -184,6 +184,12 @@ describe('manageSkills', () => { }) await manageSkills(failing.manage) expect(failing.errors).toEqual(['Muse Code could not list its skills: malformed settings file']) + // The panel shows the CLI's first line; the log names its stderr by + // length only, since it is free text (the review of PR #49). + expect(failing.log.warn).toHaveBeenCalledWith( + expect.stringMatching(/ failed with exit code 2: a line of 23 characters \(not logged/u), + ) + expect(failing.log.warn).not.toHaveBeenCalledWith(expect.stringContaining('malformed')) expect(failing.shown).toEqual([]) const garbled = harness({ cli: () => ok('Skills: 26 loaded') }) await manageSkills(garbled.manage) diff --git a/test/unit/supportReport.test.ts b/test/unit/supportReport.test.ts index a9641b94..c4a2eaf0 100644 --- a/test/unit/supportReport.test.ts +++ b/test/unit/supportReport.test.ts @@ -38,7 +38,9 @@ const base: SupportFacts = { installDir: String.raw`C:\Users\r\AppData\Local\Programs\muse`, version: '1.3.0', }, - hasCliCredentialFile: true, + cliCredentialFile: 'inline', + cliSignIn: 'signedIn', + keychainItem: undefined, delegationMode: 'off', workflowTriggerMode: 'auto', hasStoredApiKey: false, @@ -65,7 +67,7 @@ describe('renderSupportReport', () => { // The home directory is `~` in a report meant for a public issue (D24). String.raw`muse cli: found in ~\AppData\Local\Programs\muse (version 1.3.0)`, 'muse subagent delegation: off; workflow trigger mode: auto', - 'cli credential file: yes; stored model api key: no; META_API_KEY in environment: no', + 'cli credential file: inline; cli sign-in: signedIn; stored model api key: no; META_API_KEY in environment: no', 'voice dictation: available', 'network: http.proxy set: no; proxySupport: override; proxyStrictSSL: yes; proxyAuthorization set: no; noProxy entries: 0; proxy in environment: no', 'certificates: system certificates: yes; NODE_EXTRA_CA_CERTS: no', @@ -106,6 +108,27 @@ describe('renderSupportReport', () => { ).toContain('(version unknown)') }) + // PLAN.md D26 (2026-09-27): the CLI's credential file by its structure, + // and on macOS whether the Keychain holds its item; never a value. + it('describes the CLI’s sign-in by the file’s structure and the Keychain item', () => { + expect( + renderSupportReport({ + ...base, + platform: 'darwin', + cliCredentialFile: 'keychain', + cliSignIn: 'unknown', + keychainItem: 'absent', + }), + ).toContain('cli credential file: keychain; cli sign-in: unknown;') + expect(renderSupportReport({ ...base, keychainItem: 'present' })).toContain( + 'macOS Keychain item ai.meta.dev.credentials: present', + ) + expect(renderSupportReport(base)).not.toContain('Keychain item') + expect( + renderSupportReport({ ...base, cliCredentialFile: 'empty', cliSignIn: 'signedOut' }), + ).toContain('cli credential file: empty; cli sign-in: signedOut;') + }) + // M56 (PLAN.md D43): the network posture, as yes/no and counts only. it('states a corporate network’s posture without its addresses', () => { const text = renderSupportReport({