From 7bb5cfa08c9e9795f8c711d24397c0d8fb1002a0 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 17:18:43 -0700 Subject: [PATCH 01/25] Read the Muse Code CLI's sign-in from its credential file, not its presence `muse logout` (and MSP `account/logout`) rewrite auth.json as {"schema_version": 1, "providers": {}} instead of deleting it, so the extension kept reporting the CLI signed in after a sign-out, and a panel sign-out stayed on "Sign-out is in progress or credentials remain". On macOS a sign-in lives in the login Keychain and auth.json is a pointer. - credentialFile.ts parses only the file's structure (schema version, whether a provider is named, its storage lane); tokens are dropped by the schema and nothing from the file is kept or logged. Empty providers are signed out; a Keychain pointer off macOS is a named error instead of a `muse serve` that exits 3. - A macOS pointer or an unrecognised file is confirmed with `account/read` on a short-lived host, cached by path, size and mtime; on macOS only on a user action, so a Keychain prompt follows one. - Sign-out uses MSP `account/logout`, confirmed by `account/read`, with the terminal `muse logout` as the fallback. - Device sign-in ends on `granted` (confirmed by `account/read`), on `account/read` while polling, or on a file change; `denied`, `expired` and `failed` end it at once with their own messages. - Diagnostics shows the file's verdict, the CLI's state and, on macOS, whether the Keychain item exists (attribute lookup only). - The unused `authState.ts`, which still read "file exists = signed in", is removed with its test. Drills A-Q in docs/certification/sign-in-detection.md; strings in all 14 tables; PRIVACY, SECURITY, README, AGENTS, PLAN and CHANGELOG updated. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 14 +- CHANGELOG.md | 34 +- PLAN.md | 68 +++- README.md | 57 ++- SECURITY.md | 14 +- docs/PRIVACY.md | 38 +- docs/certification/README.md | 1 + docs/certification/sign-in-detection.md | 134 +++++++ l10n/ui.cs.json | 4 + l10n/ui.de.json | 4 + l10n/ui.es.json | 4 + l10n/ui.fr.json | 4 + l10n/ui.hu.json | 4 + l10n/ui.it.json | 4 + l10n/ui.ja.json | 4 + l10n/ui.ko.json | 4 + l10n/ui.pl.json | 4 + l10n/ui.pt-br.json | 4 + l10n/ui.ru.json | 4 + l10n/ui.tr.json | 4 + l10n/ui.zh-cn.json | 4 + l10n/ui.zh-tw.json | 4 + src/core/backends/musecode/credentialFile.ts | 92 +++++ src/core/support/report.ts | 31 +- src/extension.ts | 54 ++- src/host/auth/accountHost.ts | 204 +++++++++++ src/host/auth/authService.ts | 229 ++++++++---- src/host/auth/authState.ts | 22 -- src/host/auth/cliAccount.ts | 153 ++++++++ src/host/auth/deviceSignIn.ts | 191 +++++----- src/host/backend/museCodeBackendManager.ts | 9 +- .../conversation/conversationController.ts | 3 +- src/shared/constants.ts | 51 ++- src/shared/l10n/en.ts | 7 + test/e2e/cliAccount.e2e.test.ts | 107 ++++++ test/e2e/fake-muse/serve.mjs | 72 +++- test/e2e/fakeMuse.ts | 61 +++- test/e2e/museCode.e2e.test.ts | 29 +- test/unit/accountHost.test.ts | 133 +++++++ test/unit/authService.test.ts | 329 +++++++++++++++--- test/unit/authState.test.ts | 28 -- test/unit/cliAccount.test.ts | 210 +++++++++++ test/unit/conversationController.test.ts | 7 +- test/unit/credentialFile.test.ts | 86 +++++ test/unit/deviceSignIn.test.ts | 223 ++++++++++-- test/unit/supportReport.test.ts | 27 +- 46 files changed, 2398 insertions(+), 376 deletions(-) create mode 100644 docs/certification/sign-in-detection.md create mode 100644 src/core/backends/musecode/credentialFile.ts create mode 100644 src/host/auth/accountHost.ts delete mode 100644 src/host/auth/authState.ts create mode 100644 src/host/auth/cliAccount.ts create mode 100644 test/e2e/cliAccount.e2e.test.ts create mode 100644 test/unit/accountHost.test.ts delete mode 100644 test/unit/authState.test.ts create mode 100644 test/unit/cliAccount.test.ts create mode 100644 test/unit/credentialFile.test.ts diff --git a/AGENTS.md b/AGENTS.md index 5814f3068..22b8fd541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,10 +52,16 @@ 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, + whether a provider is named, and a Keychain `storage` lane. 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) is never kept, logged + or shown. + - **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 0de0e9903..0f79b5cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,39 @@ happened, not what was planned; superseded entries are kept. ## [Unreleased] -Nothing yet. +### 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, whether it names a provider, and whether it points to + the macOS Keychain. Every other value, the token included, is dropped + as the file is parsed. + - **Asking Muse Code.** When the structure cannot say, the extension asks + Muse Code itself (`account/read` on a short-lived host). It keeps the + answer until the file changes. On macOS it asks only after a click in + the panel. + - **Signing out.** Sign-out uses Muse Code's own `account/logout` and + confirms it with `account/read`. `muse logout` in a terminal remains + the fallback. +- **Browser sign-in ends at once when it is declined, the code expires, or + Muse Code cannot save it.** The panel says which of the three happened. + Before, it waited out the full five minutes. Success is taken from Muse + Code's own `granted` outcome confirmed by `account/read`, from polling + `account/read`, or from a new credential file. +- **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. +- **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: + no secret and no prompt. ## [0.9.0] - 2026-09-27 diff --git a/PLAN.md b/PLAN.md index 97fba51f2..c2e7cf046 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. @@ -773,6 +774,60 @@ automatically" was M21's (D24) and the exit codes M22's (D25). | Stop and refusals (Model API) | Queued messages vanished on Stop; a refusal part rendered as an empty reply. | Each queued message ends with the reason above; a refusal's own words are the reply. | | A late acceptance makes a finished turn "running" | `send()` set the active turn from the ack, which can land after its own `turn/completed` (the D28 companion), and from a queued turn. | The controller remembers finished turns; neither a finished nor a queued turn becomes the running one, and a `turnCompleted` for another turn leaves the running one alone. | +**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 three facts are kept: the schema version, whether any provider is + named, and whether a provider's `storage` is the Keychain. The schema + parse drops every other field. It decides: + - no file → signed out, with no process; + - an empty file → signed out; + - a file holding the credential → signed in; + - a Keychain pointer off macOS → a named error (`muse serve` exits 3 + there with it). +- **Asking the CLI.** A Keychain pointer on macOS, 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. + - On macOS the CLI is asked only on a user action (Check again, sign-in, + sign-out, Diagnostics), 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`. The terminal `muse logout` is the + fallback, confirmed later by the file or the CLI. +- **M55's device flow ends on:** + - `account/loginCompleted` `granted`, confirmed by `account/read`; + - `account/read` turning `accountLogin` on the open host; + - or a new file that `account/read` does not contradict. + + `denied`, `expired` and `failed` end it at once with their own messages. + `account/changed` is not relied on: a terminal `muse logout` did not fire + it. This supersedes M55's "accept only after a new credential-file + modification". + +- **Where it is only as good as the schema.** `account/*` stays + experimental, and `granted`, `denied`, `expired` and `failed` are the + schema's words: only `cancelled` was captured. +- **Owner steps.** A real sign-in on each OS remains an owner step: + - the macOS pointer after a 1.4.0 login; + - the success sequence; + - logout of an OAuth slot. +- **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 @@ -5584,6 +5639,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 @@ -5773,7 +5831,9 @@ 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: +the schema's `granted` counts only when `account/read`, whose shapes were +captured on 2026-09-27, agrees; the file change stays as a third signal.) **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 617defe30..4925c607b 100644 --- a/README.md +++ b/README.md @@ -1201,7 +1201,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 | @@ -1212,7 +1212,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 | @@ -1447,18 +1447,47 @@ 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 that cannot + run does it open `muse logout` in a terminal. + - **Waiting for the terminal:** the panel stays gated 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 points + to the macOS Keychain** — the `auth.json` it names was written on a Mac, + where the token lives in the Keychain. Muse Code on Windows or Linux + exits at startup with that file. Move or rename the file, then sign in + again. +- **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 after you click something in the + panel, so a Keychain prompt never appears just because VS Code opened. + Choose **Check again** to have it asked now. **Muse Spark: Diagnostics** + says whether the Keychain item exists; it never reads the secret. +- **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 8798c1e84..726d6fe21 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,11 +30,15 @@ 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 + extension reads only the structure of the Muse Code CLI's own credential + file, never a token in it: the schema version, whether it names a + provider, and whether it points to the macOS Keychain. When that is not + enough, it asks the CLI (`account/read`) and discards the account label + 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 or error. 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. - **Workspace trust.** In VS Code's Restricted Mode the agent loads no diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index f8291b92c..1a575118d 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -193,15 +193,37 @@ 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, whether it names a + provider, and whether it points to the Keychain. + - Every other 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). It keeps the answer until the file changes. + - The CLI's answer carries your account's e-mail address as a label. The + extension discards it without logging or showing it. + - On macOS it asks only after you click something in the panel, since + the CLI may read the Keychain to answer. + - It also uses the file's size and modification time, to notice a new + sign-in. +- **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 reads no secret and shows no prompt. The report says whether + the item is there. - **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 that cannot run, it + runs `muse logout` in a terminal instead. + - 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 a7b38429b..c56a15d02 100644 --- a/docs/certification/README.md +++ b/docs/certification/README.md @@ -69,3 +69,4 @@ The PNGs beside the records are that day's harness renders. - [M55](m55.md): install and sign in to Muse Code from the panel; absorbs the M41 installer proposal (PLAN.md M55; merged as PR #43; ships in 0.9.0) - [M56](m56.md): enterprise network and posture: proxies and certificates, the sandbox network, `muse config status`, prompt caching (PLAN.md D43; merged as PR #44; ships in 0.9.0) - [0.9.0 release fixes](release-0.9.0.md): Model API keys in Meta's current format, hooks only in a trusted workspace, the search worker's parsed job (PLAN.md §10) +- [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 000000000..3709c1c78 --- /dev/null +++ b/docs/certification/sign-in-detection.md @@ -0,0 +1,134 @@ +# 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; +- `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. + +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, any provider named, a Keychain `storage` lane. The schema drops every other field. A file over 64 KiB, a folder or a stat failure is not read. | `src/core/backends/musecode/credentialFile.ts`, `readCredentialFile` in `src/host/auth/cliAccount.ts` | `credentialFile.test.ts` (the captured shapes on each OS, six malformed files); `cliAccount.test.ts` (`readCredentialFile`) | +| No file, or the empty file a sign-out leaves, is signed out without a process. A file holding the credential is signed in. | `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, 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`, `probeAccount` | `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, Diagnostics), never on activation or panel open. | `CliAccount.confirm`; `AuthService.refresh(isUserAction)`; the controller's `retryBackend` | `cliAccount.test.ts`; `authService.test.ts` "when the CLI may be asked"; `conversationController.test.ts` | +| A macOS pointer on Windows or Linux is a named error that gives the file's path; the browser sign-in is refused with the same text instead of starting a host that exits 3. | `AuthService.selectedSnapshot`, `signInWithCli`; `UI_TEXT.cliKeychainElsewhere` | `authService.test.ts` "a credential file Muse Code cannot start with" | +| Sign-out goes through MSP `account/logout`, confirmed by `account/read`. `muse logout` in a terminal is the fallback. The logout hold ends once the file or the CLI shows no sign-in, even though the file stays. | `logOutAccount`; `AuthService.performSignOut`, `refresh` | `accountHost.test.ts`; `authService.test.ts` (the rewritten former 845–853 case, the account/logout path, the fallback, the real-file cases); e2e | +| The device sign-in succeeds on any of three signals: `granted` confirmed by `account/read`; `account/read` turning `accountLogin` while polling; a new file that `account/read` does not contradict. A CLI without `account/read` falls back to the host's word or the file. | `runDeviceSignIn` | `deviceSignIn.test.ts` "how it ends" | +| `denied`, `expired` and `failed` end the flow at once. Each shows its own localized message, and the host's message goes to the log, clipped. An unknown or malformed ending keeps waiting. | `runDeviceSignIn`; `AuthService` (`signInDenied`, `signInExpired`, `signInNotSaved`) | `deviceSignIn.test.ts`; `authService.test.ts` "how Muse Code ends a browser sign-in" | +| A Cancel pressed while the pre-check reads the file is kept: the controller exists before that read, and an aborted flow never starts. | `AuthService.signInWithCli` | `authService.test.ts` "cancels the running device flow" (it caught the regression during this work) | +| The account's `label` (an e-mail address) is dropped by the `account/read` parse and never logged. | `accountStateSchema` | `accountHost.test.ts`; `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). | `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`. It serves `account/logout` by +rewriting that file as the empty one the CLI leaves. +`installFakeCredential` writes a stored login's structure (schema 1, one +provider, no secret) instead of `{"fake": true}`. + +## 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" | + +## 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. +- **The success sequence.** The order and timing of + `account/loginCompleted {outcome: "granted"}` against `account/read`. + `granted`, `denied`, `expired` and `failed` are the schema's + `AccountLoginOutcome` words; only `cancelled` was captured live (M55). +- **Logout of an OAuth login slot.** It should leave the same empty file, + and the server-side revoke is unverified. Stored keys were verified. +- **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). +- **Windows and Linux R4302.1.** Whether a device-code login persists to + the file. Only the stored-key writer was exercised. + +## 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. + +The full `npm run quality` run is reported with the pull request. diff --git a/l10n/ui.cs.json b/l10n/ui.cs.json index 5c0ebf161..c67ef386f 100644 --- a/l10n/ui.cs.json +++ b/l10n/ui.cs.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "Přihlášení bylo v prohlížeči odmítnuto. Přihlaste se znovu a získejte nový kód.", + "signInExpired": "Platnost kódu vypršela dříve, než byl schválen. Přihlaste se znovu a získejte nový kód.", + "signInNotSaved": "Muse Code nemohl přihlášení dokončit. Zkuste to znovu; důvod najdete v protokolu Muse Spark.", + "cliKeychainElsewhere": "Muse Code nelze spustit: jeho soubor přihlášení {path} odkazuje na Klíčenku systému macOS, kterou Muse Code v tomto systému nemůže použít. 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 4d073dfe5..7e5372008 100644 --- a/l10n/ui.de.json +++ b/l10n/ui.de.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "Die Anmeldung wurde im Browser abgelehnt. Melden Sie sich erneut an, um einen neuen Code zu erhalten.", + "signInExpired": "Der Code ist abgelaufen, bevor er bestätigt wurde. Melden Sie sich erneut an, um einen neuen Code zu erhalten.", + "signInNotSaved": "Muse Code konnte die Anmeldung nicht abschließen. Versuchen Sie es erneut; den Grund finden Sie im Muse Spark-Protokoll.", + "cliKeychainElsewhere": "Muse Code kann nicht starten: Seine Anmeldedatei {path} verweist auf den macOS-Schlüsselbund, den Muse Code auf diesem System nicht nutzen 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 04cd2d299..1ae99d6ea 100644 --- a/l10n/ui.es.json +++ b/l10n/ui.es.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "El inicio de sesión se rechazó en el navegador. Vuelva a iniciar sesión para obtener un código nuevo.", + "signInExpired": "El código caducó antes de aprobarse. Vuelva a iniciar sesión para obtener un código nuevo.", + "signInNotSaved": "Muse Code no pudo completar el inicio de sesión. Vuelva a intentarlo; el registro de Muse Spark indica el motivo.", + "cliKeychainElsewhere": "Muse Code no puede iniciarse: su archivo de inicio de sesión {path} apunta al llavero de macOS, que Muse Code no puede usar 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 7932c5b36..ffd6e3193 100644 --- a/l10n/ui.fr.json +++ b/l10n/ui.fr.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "La connexion a été refusée dans le navigateur. Reconnectez-vous pour obtenir un nouveau code.", + "signInExpired": "Le code a expiré avant d’être approuvé. Reconnectez-vous pour obtenir un nouveau code.", + "signInNotSaved": "Muse Code n’a pas pu terminer la connexion. Réessayez. Le journal de Muse Spark en indique la raison.", + "cliKeychainElsewhere": "Muse Code ne peut pas démarrer : son fichier de connexion {path} pointe vers le trousseau macOS, que Muse Code ne peut pas utiliser 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 3d1f27ecb..3041b1b2e 100644 --- a/l10n/ui.hu.json +++ b/l10n/ui.hu.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "A bejelentkezést elutasították a böngészőben. Jelentkezzen be újra, hogy új kódot kapjon.", + "signInExpired": "A kód lejárt, mielőtt jóváhagyták volna. Jelentkezzen be újra, hogy új kódot kapjon.", + "signInNotSaved": "A Muse Code nem tudta befejezni a bejelentkezést. Próbálja újra; az okot a Muse Spark naplója tartalmazza.", + "cliKeychainElsewhere": "A Muse Code nem tud elindulni: a bejelentkezési fájlja ({path}) a macOS kulcskarikájára mutat, amelyet a Muse Code ezen a rendszeren nem tud használni. 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 19f54b135..1e2e7f6be 100644 --- a/l10n/ui.it.json +++ b/l10n/ui.it.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "L’accesso è stato rifiutato nel browser. Accedi di nuovo per ottenere un nuovo codice.", + "signInExpired": "Il codice è scaduto prima di essere approvato. Accedi di nuovo per ottenere un nuovo codice.", + "signInNotSaved": "Muse Code non è riuscito a completare l’accesso. Riprova; il log di Muse Spark indica il motivo.", + "cliKeychainElsewhere": "Muse Code non può avviarsi: il suo file di accesso {path} rimanda al Portachiavi di macOS, che Muse Code non può usare 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 c3bab9d0f..dab56596e 100644 --- a/l10n/ui.ja.json +++ b/l10n/ui.ja.json @@ -48,6 +48,10 @@ "apiKeyInvalid": "Model API キーは LLM_ で始まります(古いキーの形式は LLM|| です)。", "signInWaiting": "ブラウザーでのサインインが完了するのを待っています…", "signInTimedOut": "サインインが時間内に完了しませんでした。もう一度お試しください。", + "signInDenied": "ブラウザーでサインインが拒否されました。新しいコードを取得するには、もう一度サインインしてください。", + "signInExpired": "承認される前にコードの有効期限が切れました。新しいコードを取得するには、もう一度サインインしてください。", + "signInNotSaved": "Muse Code はサインインを完了できませんでした。もう一度お試しください。理由は Muse Spark のログに記録されています。", + "cliKeychainElsewhere": "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 693f52638..993c5052a 100644 --- a/l10n/ui.ko.json +++ b/l10n/ui.ko.json @@ -48,6 +48,10 @@ "apiKeyInvalid": "Model API 키는 LLM_로 시작합니다(이전 키의 형식은 LLM||입니다).", "signInWaiting": "브라우저 로그인이 완료되기를 기다리는 중…", "signInTimedOut": "로그인이 제시간에 완료되지 않았습니다. 다시 시도하세요.", + "signInDenied": "브라우저에서 로그인이 거부되었습니다. 새 코드를 받으려면 다시 로그인하세요.", + "signInExpired": "승인되기 전에 코드가 만료되었습니다. 새 코드를 받으려면 다시 로그인하세요.", + "signInNotSaved": "Muse Code가 로그인을 완료하지 못했습니다. 다시 시도하세요. 이유는 Muse Spark 로그에서 확인할 수 있습니다.", + "cliKeychainElsewhere": "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 419e65d28..51c26ac17 100644 --- a/l10n/ui.pl.json +++ b/l10n/ui.pl.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "Logowanie zostało odrzucone w przeglądarce. Zaloguj się ponownie, aby otrzymać nowy kod.", + "signInExpired": "Kod wygasł, zanim został zatwierdzony. Zaloguj się ponownie, aby otrzymać nowy kod.", + "signInNotSaved": "Muse Code nie mógł dokończyć logowania. Spróbuj ponownie; przyczynę znajdziesz w dzienniku Muse Spark.", + "cliKeychainElsewhere": "Nie można uruchomić Muse Code: jego plik logowania {path} wskazuje na pęk kluczy macOS, z którego Muse Code nie może korzystać 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 800bc3d54..44fa8ee24 100644 --- a/l10n/ui.pt-br.json +++ b/l10n/ui.pt-br.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "A entrada foi recusada no navegador. Entre novamente para receber um novo código.", + "signInExpired": "O código expirou antes de ser aprovado. Entre novamente para receber um novo código.", + "signInNotSaved": "O Muse Code não conseguiu concluir a entrada. Tente novamente; o log do Muse Spark mostra o motivo.", + "cliKeychainElsewhere": "O Muse Code não consegue iniciar: o arquivo de entrada {path} aponta para as Chaves do macOS, que o Muse Code não pode usar 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 022eda048..2f6920da2 100644 --- a/l10n/ui.ru.json +++ b/l10n/ui.ru.json @@ -48,6 +48,10 @@ "apiKeyInvalid": "Ключ Model API начинается с LLM_ (старые ключи имеют вид LLM||).", "signInWaiting": "Ожидание завершения входа в браузере…", "signInTimedOut": "Вход не был выполнен вовремя. Повторите попытку.", + "signInDenied": "Вход отклонён в браузере. Войдите снова, чтобы получить новый код.", + "signInExpired": "Срок действия кода истёк до подтверждения. Войдите снова, чтобы получить новый код.", + "signInNotSaved": "Muse Code не удалось завершить вход. Повторите попытку; причина указана в журнале Muse Spark.", + "cliKeychainElsewhere": "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 34a8c2d5d..6cbc803b4 100644 --- a/l10n/ui.tr.json +++ b/l10n/ui.tr.json @@ -48,6 +48,10 @@ "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.", + "signInDenied": "Oturum açma isteği tarayıcıda reddedildi. Yeni bir kod almak için yeniden oturum açın.", + "signInExpired": "Kodun süresi onaylanmadan önce doldu. Yeni bir kod almak için yeniden oturum açın.", + "signInNotSaved": "Muse Code oturum açmayı tamamlayamadı. Yeniden deneyin; nedeni Muse Spark günlüğünde yazıyor.", + "cliKeychainElsewhere": "Muse Code başlatılamıyor: oturum açma dosyası {path}, Muse Code’un bu sistemde kullanamadığı macOS Anahtar Zinciri’ni gösteriyor. 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 c79b8e8ad..579ed3715 100644 --- a/l10n/ui.zh-cn.json +++ b/l10n/ui.zh-cn.json @@ -48,6 +48,10 @@ "apiKeyInvalid": "Model API 密钥以 LLM_ 开头(旧密钥的格式类似 LLM||)。", "signInWaiting": "正在等待浏览器登录完成…", "signInTimedOut": "登录未在规定时间内完成。请重试。", + "signInDenied": "已在浏览器中拒绝登录。请重新登录以获取新代码。", + "signInExpired": "代码在获得批准前已过期。请重新登录以获取新代码。", + "signInNotSaved": "Muse Code 无法完成登录。请重试;原因见 Muse Spark 日志。", + "cliKeychainElsewhere": "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 d0d827e09..847a444b3 100644 --- a/l10n/ui.zh-tw.json +++ b/l10n/ui.zh-tw.json @@ -48,6 +48,10 @@ "apiKeyInvalid": "Model API 金鑰以 LLM_ 開頭(舊金鑰的格式類似 LLM||)。", "signInWaiting": "正在等候瀏覽器登入完成…", "signInTimedOut": "登入未在時限內完成。請再試一次。", + "signInDenied": "已在瀏覽器中拒絕登入。請重新登入以取得新代碼。", + "signInExpired": "代碼在獲得核准前已過期。請重新登入以取得新代碼。", + "signInNotSaved": "Muse Code 無法完成登入。請再試一次;原因請見 Muse Spark 記錄。", + "cliKeychainElsewhere": "Muse Code 無法啟動:其登入檔案 {path} 指向 macOS 鑰匙圈,但此系統上的 Muse Code 無法使用鑰匙圈。請移動或重新命名該檔案,然後重新登入。", "hostExited": "Muse Code 意外停止", "hostStarting": "正在啟動 Muse Code…", "hostRestartsOnSend": "下一則訊息會重新啟動它並繼續此對話。", diff --git a/src/core/backends/musecode/credentialFile.ts b/src/core/backends/musecode/credentialFile.ts new file mode 100644 index 000000000..a978fcfb6 --- /dev/null +++ b/src/core/backends/musecode/credentialFile.ts @@ -0,0 +1,92 @@ +// 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 three facts: the schema version, whether any provider is +// named, and whether a provider's `storage` points to the macOS Keychain. +// Every other field, the token included, is dropped by the parse, and +// 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 a provider holding its credential: signed in. +// - 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 that file, and with a +// version-1 provider whose storage is the Keychain. + +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, +} from '../../../shared/constants' + +/** + * - `empty`: names no provider; the file a sign-out leaves. + * - `inline`: holds the credential itself. + * - `keychain`: points to the macOS Keychain (on macOS). + * - `keychainElsewhere`: a macOS pointer on Windows or Linux, where `muse + * serve` cannot start with it. + * - `unrecognized`: anything else; only the CLI can say. + */ +export type CredentialFileVerdict = + 'empty' | 'inline' | 'keychain' | 'keychainElsewhere' | '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); `keychainElsewhere` when the file + * stops `muse serve` from starting. + */ +export type CliSignIn = 'signedIn' | 'signedOut' | 'unknown' | 'keychainElsewhere' + +/** Whether the macOS Keychain holds the CLI's item, by attribute lookup only. */ +export type KeychainItemPresence = 'present' | 'absent' | 'unknown' + +// Unknown keys are dropped by the parse: a provider keeps its storage lane, +// never its key or tokens. +const credentialFileSchema = z.object({ + schema_version: z.number(), + providers: z.record(z.string(), z.object({ storage: z.optional(z.string()) })), +}) + +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 + const providers = Object.values(parsed.data.providers) + const isKeychainStyle = + version === MUSE_CREDENTIAL_POINTER_SCHEMA || + providers.some((provider) => provider.storage === MUSE_CREDENTIAL_KEYCHAIN_STORAGE) + if (isKeychainStyle && platform !== 'darwin') { + return 'keychainElsewhere' + } + if (isKeychainStyle) { + return providers.length === 0 ? 'empty' : 'keychain' + } + if (version !== MUSE_CREDENTIAL_INLINE_SCHEMA) { + return 'unrecognized' + } + return providers.length === 0 ? 'empty' : 'inline' +} + +/** `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/support/report.ts b/src/core/support/report.ts index 34e55a8a3..cb53f4ef1 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), `keychainElsewhere` (a macOS pointer 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/extension.ts b/src/extension.ts index 6a35ec153..028344c33 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -28,8 +28,11 @@ import { resolveAgainstRoot, rootRelativePath, } from './core/workspaceRoot' +import { keychainItemPresence } from './core/backends/musecode/credentialFile' +import { connectAccountSession, logOutAccount, probeAccount } 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' @@ -127,6 +130,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, @@ -651,6 +657,19 @@ 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) + const cliAccount = new CliAccount({ + platform: process.platform, + credentialFilePath: () => backend.credentialFilePath(), + probe: () => probeAccount(() => connectAccountHost(new AbortController().signal), log), + 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: () => { @@ -664,12 +683,15 @@ export async function activate(context: vscode.ExtensionContext): Promise reason: `${resolution.reason} ${fill(UI_TEXT.cliSearched, { paths: resolution.searched.join(', ') })}`, } }, - credentialFileExists: () => backend.credentialFileExists(), - credentialFileModifiedAt: () => modifiedAt(backend.credentialFilePath()), + cliSignIn: (isUserAction) => cliAccount.signIn(isUserAction), + credentialFilePath: () => backend.credentialFilePath(), hasEnvironmentKey: () => backend.hasEnvironmentKey(), getBackendMode: () => currentSettings().backend, restartBackend: (isConversationEnding) => restartBackend('authentication changed', isConversationEnding), + logOutCli: async () => + (await logOutAccount(() => connectAccountHost(new AbortController().signal), log)) === + 'confirmed', }, credentials, logoutHold: { @@ -698,13 +720,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) => { @@ -1027,7 +1049,7 @@ export async function activate(context: vscode.ExtensionContext): Promise selectBackend({ setting: currentSettings().backend, hasCli: backend.resolveLaunch().ok, - hasCliSession: backend.credentialFileExists() || backend.hasEnvironmentKey(), + hasCliSession: await hasCliSession(), hasStoredKey: (await credentials.getApiKey()) !== undefined, }), async (kind): Promise => @@ -1300,9 +1322,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 @@ -1623,6 +1645,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, @@ -1647,7 +1678,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 000000000..7bbe2091d --- /dev/null +++ b/src/host/auth/accountHost.ts @@ -0,0 +1,204 @@ +// 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 { 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 { + readonly connection: Pick + readonly close: () => 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 + ) +} + +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. */ +async function onAccountHost( + connect: () => Promise, + log: Logger, + fallback: T, + use: (connection: AccountConnection) => Promise, +): Promise { + let session: AccountSession + try { + session = await connect() + } catch (error: unknown) { + log.warn(`The Muse Code account host could not start: ${errorName(error)}`) + return fallback + } + try { + return await use(session.connection) + } 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: () => Promise, + log: Logger, +): Promise { + return await onAccountHost(connect, log, undefined, readAccountState) +} + +/** + * MSP `account/logout` on a short-lived host, confirmed by `account/read`: + * `confirmed` when no stored credential is in use afterwards (`META_API_KEY`, + * which no logout can unset, is the caller's to report); `unconfirmed` when + * the host could not start, refused, or still reports a stored credential. + */ +export async function logOutAccount( + connect: () => Promise, + log: Logger, +): Promise<'confirmed' | 'unconfirmed'> { + return await onAccountHost<'confirmed' | 'unconfirmed'>( + connect, + log, + 'unconfirmed', + async (connection) => { + const answer = await requestAccount(connection, MUSE_ACCOUNT_LOGOUT) + const after = answer === undefined ? undefined : await readAccountState(connection) + if (after === undefined || isStoredSignIn(after)) { + log.warn(`Muse Code did not confirm account/logout (${after?.state ?? 'no answer'})`) + return 'unconfirmed' + } + log.info(`Muse Code signed out through account/logout (now ${after.state})`) + return 'confirmed' + }, + ) +} + +/** 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 f913a6a7e..56d140bf7 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -2,12 +2,15 @@ // 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 type { BackendKind } from '../../core/agent/agentBackend' +import type { CliSignIn } from '../../core/backends/musecode/credentialFile' import { type BackendMode, MUSE_INSTALL_POLL_INTERVAL_MS, @@ -15,9 +18,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 { DeviceSignInOutcome, DeviceSignInRefusal } from './deviceSignIn' import type { CredentialStore } from './credentialStore' export interface AuthBackendFacts { @@ -25,9 +30,14 @@ 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. */ - readonly credentialFileModifiedAt: () => number | undefined + /** + * 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 + /** Where the CLI keeps its sign-in, named when the file stops it starting. */ + readonly credentialFilePath: () => string readonly hasEnvironmentKey: () => boolean /** `museSpark.backend`. */ readonly getBackendMode: () => BackendMode @@ -36,6 +46,11 @@ 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 + * once `account/read` shows no stored credential in use. Never rejects. + */ + readonly logOutCli: () => Promise } export interface AuthServiceDeps { @@ -104,6 +119,21 @@ function signInLine(snapshot: AuthSnapshot): string { return `Sign-in state: ${snapshot.status}${backend}${why}` } +/** Why a device sign-in the host ended did not sign in (read when shown, D33). */ +function refusedSignInText(refusal: DeviceSignInRefusal): string { + switch (refusal) { + case 'denied': { + return UI_TEXT.signInDenied + } + case 'expired': { + return UI_TEXT.signInExpired + } + case 'failed': { + return UI_TEXT.signInNotSaved + } + } +} + export class AuthService { private snapshot: AuthSnapshot = { status: 'checking', detail: undefined } /** The browser sign-in in flight: a second click joins it (PLAN.md D25). */ @@ -163,14 +193,37 @@ export class AuthService { return this.snapshot } + /** + * The CLI's own credential as the gate counts it: `META_API_KEY` in its + * environment (no file is looked at then), or its sign-in, including one + * only the CLI could confirm. + */ + private async cliCredential( + isUserAction: boolean, + ): Promise<{ readonly hasCliSession: boolean; readonly isKeychainElsewhere: boolean }> { + if (this.deps.backend.hasEnvironmentKey()) { + return { hasCliSession: true, isKeychainElsewhere: false } + } + const signIn = await this.deps.backend.cliSignIn(isUserAction) + return { + hasCliSession: isCliSignedIn(signIn), + isKeychainElsewhere: signIn === 'keychainElsewhere', + } + } + + private async hasCliCredential(isUserAction: boolean): Promise { + const { hasCliSession } = await this.cliCredential(isUserAction) + return hasCliSession + } + /** The backend selection from the current facts. */ - private async choose() { + private async choose(isUserAction: boolean) { const cli = this.deps.backend.resolveCli() - const hasCliSession = - this.deps.backend.credentialFileExists() || this.deps.backend.hasEnvironmentKey() + const { hasCliSession, isKeychainElsewhere } = await this.cliCredential(isUserAction) return { cli, hasCliSession, + isKeychainElsewhere, choice: selectBackend({ setting: this.deps.backend.getBackendMode(), hasCli: cli.ok, @@ -180,6 +233,11 @@ export class AuthService { } } + /** A macOS Keychain pointer on Windows or Linux: `muse serve` exits at startup. */ + private keychainElsewhereText(): string { + return fill(UI_TEXT.cliKeychainElsewhere, { 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) { @@ -197,23 +255,34 @@ export class AuthService { const initial = this.snapshot const epoch = this.signOutEpoch const wasLogoutHeld = this.isLogoutHeld - const beforeCredential = wasLogoutHeld - ? this.deps.backend.credentialFileModifiedAt() - : undefined const cli = this.deps.backend.resolveCli() if (!cli.ok) { return await this.finishFailedCliSignIn(initial, 'noCli', cli.reason, 'warning') } + // Cancel and sign-out reach this flow from here on, before any await. const abort = new AbortController() 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 }) - }) + // The sign-in host could not start with that file either. + const { isKeychainElsewhere } = await this.cliCredential(false) + if (isKeychainElsewhere) { + return await this.finishFailedCliSignIn( + initial, + 'error', + this.keychainElsewhereText(), + 'warning', + ) + } + // A cancel or sign-out during that look ends the flow before it starts. + const outcome: DeviceSignInOutcome = abort.signal.aborted + ? 'cancelled' + : await this.deps.runDeviceSignIn(abort.signal, (url, code) => { + if (abort.signal.aborted) { + return + } + this.set({ ...this.snapshot, verificationUrl: url, userCode: code }) + }) if (outcome === 'cancelled') { return await this.finishFailedCliSignIn( initial, @@ -230,33 +299,40 @@ export class AuthService { 'warning', ) } + if (outcome !== 'signedIn') { + return await this.finishFailedCliSignIn( + initial, + 'signedOut', + refusedSignInText(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() - } + // 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 ( + wasLogoutHeld && + (this.deps.backend.hasEnvironmentKey() || + (await this.deps.credentials.getApiKey()) !== undefined || + (await this.deps.backend.cliSignIn(true)) !== 'signedIn') + ) { + return await this.refresh(true) } 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() + return await this.refresh(true) } const isHoldSaved = await this.setLogoutHold(false) if (!isHoldSaved) { return this.set({ ...this.snapshot, status: 'error', detail: UI_TEXT.signOutHoldFailed }) } } - return await this.refresh() + return await this.refresh(true) } catch (error: unknown) { this.deps.log.warn( `In-panel sign-in failed: ${error instanceof Error ? error.name : 'unknown error'}`, @@ -274,12 +350,12 @@ export class AuthService { noticeLevel: 'info' | 'warning', ): Promise { if (this.isLogoutHeld) { - const refreshed = await this.refresh() + const refreshed = await this.refresh(true) this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) return refreshed } if (initial.status === 'signedIn' && initial.backend === 'modelApi') { - const refreshed = await this.refresh() + const refreshed = await this.refresh(true) this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) return refreshed } @@ -293,7 +369,7 @@ export class AuthService { } private async refreshAfterCliDiscovery(epoch: number): Promise { - const selected = await this.selectedSnapshot() + const selected = await this.selectedSnapshot(true) if (epoch !== this.signOutEpoch) { return this.snapshot } @@ -322,7 +398,7 @@ export class AuthService { return this.snapshot } } - return await this.refresh() + return await this.refresh(true) } private async installWithCli(): Promise { @@ -370,7 +446,7 @@ export class AuthService { } private async installFailed(detail: string, epoch: number): Promise { - const selected = await this.selectedSnapshot() + const selected = await this.selectedSnapshot(true) if (epoch !== this.signOutEpoch || this.isSigningOut) { return this.snapshot } @@ -398,8 +474,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, isKeychainElsewhere, choice } = await this.choose(isUserAction) if (choice.kind === undefined) { return { status: 'noCli', @@ -410,9 +486,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 && isKeychainElsewhere return { - status: choice.status, - detail: undefined, + status: isBlocked ? 'error' : choice.status, + detail: isBlocked ? this.keychainElsewhereText() : undefined, backend: choice.kind, methods: choice.methods, hasCli: cli.ok, @@ -448,10 +527,29 @@ export class AuthService { ) { await this.deps.backend.restartBackend(false) } - const selected = await this.selectedSnapshot() + const selected = await this.selectedSnapshot(true) return epoch === this.signOutEpoch ? this.set(selected) : 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 { + if (await this.deps.backend.logOutCli()) { + return true + } + this.deps.log.warn('Muse Code did not sign out through its account host; running muse logout') + try { + this.deps.runInTerminal(cliPath, MUSE_LOGOUT_ARGS) + return true + } catch { + this.deps.log.warn('Muse Code logout terminal could not open') + return false + } + } + private async performSignOut(): Promise { this.signOutEpoch += 1 this.admissionGenerationValue += 1 @@ -484,17 +582,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 @@ -521,6 +615,7 @@ export class AuthService { verificationUrl: undefined, userCode: undefined, installState: undefined, + hasCliSession: hasCliCredential, }) } finally { this.isSigningOut = false @@ -586,18 +681,21 @@ 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() + const selected = await this.selectedSnapshot(isUserAction) if (this.isSigningOut || this.signOutEpoch !== epoch) { return this.set({ ...selected, status: 'error', detail: this.logoutDetail() }) } if (!this.isLogoutHeld) { return this.set(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() }) @@ -609,10 +707,9 @@ export class AuthService { if (!isHoldSaved) { return this.set({ ...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') { await this.setLogoutHold(true) @@ -627,16 +724,18 @@ export class AuthService { } 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 } diff --git a/src/host/auth/authState.ts b/src/host/auth/authState.ts deleted file mode 100644 index f5eb44833..000000000 --- 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 000000000..aa00e98e6 --- /dev/null +++ b/src/host/auth/cliAccount.ts @@ -0,0 +1,153 @@ +// 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 a file holding the credential signed in. +// - A macOS Keychain pointer or a file the structure cannot place is 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 { MUSE_ACCOUNT_STATES, MUSE_CREDENTIAL_FILE_MAX_BYTES } from '../../shared/constants' +import type { Logger } from '../logger' +import { type AccountState, 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' + } + // A keyless gateway needs no credential (the schema's `credentialRequired`). + if (!account.credentialRequired || isStoredSignIn(account)) { + return 'signedIn' + } + // `envKey` masks the stored lane, and a future state is not guessed at. + return account.state === MUSE_ACCOUNT_STATES.loggedOut ? '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', + keychainElsewhere: 'keychainElsewhere', +} + +/** 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 +} + +export class CliAccount { + private answered: { readonly key: string; readonly signIn: CliSignIn } | undefined + private asking: { readonly key: string; readonly signIn: Promise } | undefined + + public constructor(private readonly deps: CliAccountDeps) {} + + private async confirm(key: string, isUserAction: boolean): Promise { + const answered = this.answered + // A remembered "could not say" is asked again when the user acts. + if (answered?.key === key && (!isUserAction || answered.signIn !== 'unknown')) { + return answered.signIn + } + if (!isUserAction && this.deps.platform === 'darwin') { + return 'unknown' + } + if (this.asking?.key === key) { + return await this.asking.signIn + } + const asking = { key, signIn: this.ask() } + this.asking = asking + try { + const signIn = await asking.signIn + this.answered = { key, signIn } + 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: the account's label is an e-mail address. + this.deps.log.info( + `Muse Code sign-in confirmed by account/read: ${account?.state ?? 'no answer'} (${signIn})`, + ) + return signIn + } + + /** + * 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 { + const filePath = this.deps.credentialFilePath() + const reading = readCredentialFile(filePath, this.deps.platform) + return reading === undefined + ? 'signedOut' + : (FILE_SIGN_IN[reading.verdict] ?? + (await this.confirm(`${filePath}\n${reading.signature}`, isUserAction))) + } +} diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index c15aa649d..a6e8c9557 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -1,48 +1,66 @@ // 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 the host says so and +// `account/read` agrees, or 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 without `account/read` falls +// back to the host's word or the file alone. `account/loginCompleted` ends a +// denied, expired or failed flow at once. `account/changed` is not relied on: +// it did not fire for a change made outside the host. -import { spawnMspConnection, type Connection } from '@muse-code/sdk' import * as z from 'zod/mini' +import { clipForLog } 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_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() }) +// `message` is display text for denied, expired and failed (the schema's +// `AccountLoginCompletedParams`); it goes to the log, clipped, never the panel. +const loginCompletedSchema = z.object({ + outcome: z.string(), + message: z.optional(z.string()), +}) 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 } -export type DeviceSignInOutcome = 'signedIn' | 'cancelled' | 'timedOut' +/** How the flow ended the host's way: refused in the browser, too late, or not saved. */ +export type DeviceSignInRefusal = 'denied' | 'expired' | 'failed' +export type DeviceSignInOutcome = 'signedIn' | 'cancelled' | 'timedOut' | DeviceSignInRefusal const CANCELLED_START = Symbol('cancelled device sign-in start') -const CANCELLED_CONNECT = Symbol('cancelled device sign-in connection') + +const ENDING_OUTCOMES: ReadonlyMap = new Map([ + [MUSE_LOGIN_OUTCOMES.cancelled, 'cancelled'], + [MUSE_LOGIN_OUTCOMES.denied, 'denied'], + [MUSE_LOGIN_OUTCOMES.expired, 'expired'], + [MUSE_LOGIN_OUTCOMES.failed, 'failed'], +]) /** 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 +72,44 @@ export function parseDeviceCode(raw: unknown): { readonly url: string; readonly return { url: url.href, code: parsed.userCode } } +/** What the host said when the flow ended; the first ending counts. */ +interface HostEnding { + outcome: string | undefined +} + +/** The signals that a new sign-in landed, as one poll sees them. */ +interface SignInSignals { + readonly initial: AccountState | undefined + readonly current: AccountState | undefined + readonly isGranted: boolean + readonly isFileWritten: boolean +} + +function isSignedIn(signals: SignInSignals): boolean { + const { initial, current, isGranted, isFileWritten } = signals + if (current === undefined) { + // A CLI without `account/read`: the host's word, or a new file. + return isGranted || isFileWritten + } + // A file written by a sign-out, or a "granted" the store does not show yet. + if (current.state === MUSE_ACCOUNT_STATES.loggedOut && current.credentialRequired) { + return false + } + // `envKey` or a stored key may mask the new login, so the host's word and + // a new file count while the account is anything but signed out. + const hasSignedIn = + current.state === MUSE_ACCOUNT_STATES.accountLogin && + initial?.state !== MUSE_ACCOUNT_STATES.accountLogin + return isGranted || isFileWritten || hasSignedIn +} + 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,16 +118,20 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { - if (notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED) { + if (notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED || ending.outcome !== undefined) { return } const result = loginCompletedSchema.safeParse(notification.params) - if (result.success && result.data.outcome === 'cancelled') { - state.isCancelledByHost = true + if (!result.success) { + return } + ending.outcome = result.data.outcome + const message = + result.data.message === undefined ? '' : `: ${clipForLog(result.data.message)}` + deps.log.info(`Muse Code sign-in ended: ${result.data.outcome}${message}`) }) if (isAborted()) { return 'cancelled' @@ -88,8 +141,18 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { + const modified = deps.credentialFileModifiedAt() + return isSignedIn({ + initial, + current: await readAccountState(session.connection), + isGranted: ending.outcome === MUSE_LOGIN_OUTCOMES.granted, + isFileWritten: modified !== undefined && modified !== before, + }) + } + const hostEnding = () => + ending.outcome === undefined ? undefined : ENDING_OUTCOMES.get(ending.outcome) while (deps.now() < deadline) { - const current = deps.credentialFileModifiedAt() - if (current !== undefined && current !== before) { + if (await hasLanded()) { return 'signedIn' } - if (state.isCancelledByHost) { - return 'cancelled' + const ended = hostEnding() + if (ended !== undefined) { + return ended } if (isAborted()) { await cancelLogin() @@ -132,12 +206,12 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): 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 6da8503e0..b0e26f03c 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 { spawnMspConnection } from '@muse-code/sdk' +import type { CredentialFileVerdict } from '../../core/backends/musecode/credentialFile' import { MuseCodeHost, type MspHost } from '../../core/backends/musecode/MuseCodeHost' import { buildChildEnvironment, @@ -48,6 +49,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. */ @@ -168,7 +170,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). @@ -364,8 +366,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). */ diff --git a/src/host/conversation/conversationController.ts b/src/host/conversation/conversationController.ts index 993e84ef2..d9121e353 100644 --- a/src/host/conversation/conversationController.ts +++ b/src/host/conversation/conversationController.ts @@ -3707,7 +3707,8 @@ export class ConversationController { } case 'retryBackend': { this.dropSession() - await this.deps.auth.refresh() + // Check again is a click: macOS may ask the CLI about a Keychain sign-in. + await this.deps.auth.refresh(true) break } case 'openExternal': { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f7c5a1f95..ac388a38a 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1377,6 +1377,52 @@ 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. Only `cancelled` was captured live; the rest are the schema's words, +// and the schema calls the vocabulary open. +export const MUSE_LOGIN_OUTCOMES = { + granted: 'granted', + denied: 'denied', + expired: 'expired', + failed: 'failed', + cancelled: 'cancelled', +} as const +// The CLI's credential file (`auth.json`), read for its structure only: its +// schema version and whether a provider's `storage` points to the macOS +// Keychain. Version 1 holds the credential itself (Windows, Linux, and macOS +// with TBH_CREDENTIAL_BACKEND=file); version 2 is macOS's token-free pointer. +export const MUSE_CREDENTIAL_INLINE_SCHEMA = 1 +export const MUSE_CREDENTIAL_POINTER_SCHEMA = 2 +export const MUSE_CREDENTIAL_KEYCHAIN_STORAGE = 'keychain' +/** A larger file is not read (the CLI's own is under 1 KiB). */ +export const MUSE_CREDENTIAL_FILE_MAX_BYTES = 64 * 1024 +// 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' @@ -1507,8 +1553,9 @@ 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 long the browser sign-in may take before the extension stops waiting, +// and how often it asks the sign-in host (`account/read`) and looks at the +// credential file. export const CREDENTIAL_POLL_INTERVAL_MS = 2000 export const CREDENTIAL_POLL_TIMEOUT_MS = 5 * 60 * 1000 // Model API key shapes. Meta's current keys are `LLM_` and at least 16 diff --git a/src/shared/l10n/en.ts b/src/shared/l10n/en.ts index 4319e6da6..e65fb46ac 100644 --- a/src/shared/l10n/en.ts +++ b/src/shared/l10n/en.ts @@ -78,6 +78,13 @@ 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). + signInDenied: 'The sign-in was declined in the browser. Sign in again to get a new code.', + signInExpired: 'The code expired before it was approved. Sign in again to get a new code.', + signInNotSaved: 'Muse Code could not finish the sign-in. Try again; the Muse Spark log says why.', + // A macOS Keychain pointer on Windows or Linux stops `muse serve` (D26). + cliKeychainElsewhere: + 'Muse Code cannot start: its sign-in file {path} points to the macOS Keychain, which Muse Code cannot use 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 000000000..72409586d --- /dev/null +++ b/test/e2e/cliAccount.e2e.test.ts @@ -0,0 +1,107 @@ +// 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. No token is in any file. + +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +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 { MuseCodeBackendManager } from '../../src/host/backend/museCodeBackendManager' +import { FakeLogOutputChannel } from '../unit/helpers/fakes' +import { + FAKE_STORED_SIGN_IN, + fakeCredentialFile, + installFakeCredential, + installFakeMuse, + removeTestFolders, + writeFakeCredential, +} from './fakeMuse' + +const TEST_TIMEOUT_MS = 30_000 +// A schema no build has written: only the CLI can say what it holds. +const UNPLACEABLE = '{"schema_version": 9, "providers": {"meta": {}}}' +const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' + +const fake = installFakeMuse() +const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'fake-muse-ws-')) +const configHome = installFakeCredential(UNPLACEABLE) +const managers: MuseCodeBackendManager[] = [] + +function setup() { + 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 }, + ], + workspaceRoot, + getShellSandbox: () => 'off', + getSandboxNetwork: () => 'default', + userProfileDir: undefined, + isWorkspaceTrusted: () => true, + getProxySettings: () => ({ proxy: '', noProxy: [] }), + }) + managers.push(backend) + const connect = () => + connectAccountSession(backend, '0.0.0-e2e', log, workspaceRoot, new AbortController().signal) + const probe = vi.fn(() => probeAccount(connect, log)) + 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)).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') + }) + + it('reads a stored sign-in from the file alone', async () => { + writeFakeCredential(configHome, FAKE_STORED_SIGN_IN) + const t = setup() + await expect(t.account.signIn(true)).resolves.toBe('signedIn') + expect(t.probe).not.toHaveBeenCalled() + }) +}) diff --git a/test/e2e/fake-muse/serve.mjs b/test/e2e/fake-muse/serve.mjs index 71ec81b27..13653b870 100644 --- a/test/e2e/fake-muse/serve.mjs +++ b/test/e2e/fake-muse/serve.mjs @@ -24,13 +24,25 @@ // 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. +// +// 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 the credential file under XDG_CONFIG_HOME +// (a named provider is a login, anything else signed out), and +// `account/logout` rewrites that file as the empty one the CLI leaves. +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' import { argv, env, exit, stderr, stdin, stdout } from 'node:process' import { createInterface } from 'node:readline' import { setImmediate } 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' const CRASH_EXIT_CODE = 3 const DIE_EXIT_CODE = 1 const ECHO_PREFIX = 'echo: ' @@ -61,6 +73,8 @@ const fingerprint = env['MUSE_FAKE_FINGERPRINT'] ?? 'sha256:fake' 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 } */ @@ -347,6 +361,39 @@ 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') +} + +function hasStoredSignIn() { + const file = credentialFile() + if (file === undefined || !existsSync(file)) { + return false + } + try { + return Object.keys(JSON.parse(readFileSync(file, 'utf8')).providers ?? {}).length > 0 + } catch { + return false + } +} + +function accountState() { + return hasStoredSignIn() + ? { state: 'accountLogin', label: ACCOUNT_LABEL, credentialRequired: true } + : { state: 'loggedOut', credentialRequired: true } +} + /** A background task stopped: cancelled, as the capture of 2026-09-25 shows. */ function stopTask(taskId) { const task = state.tasks.get(taskId) @@ -447,10 +494,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 +684,20 @@ 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 + }, 'item/readOutput': (params) => { const content = `output of ${String(params.itemId)}` return { @@ -674,12 +736,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 c3dcca2cc..fabd68a03 100644 --- a/test/e2e/fakeMuse.ts +++ b/test/e2e/fakeMuse.ts @@ -6,7 +6,15 @@ // 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' @@ -18,6 +26,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 +96,29 @@ 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 stored login's + * structure as Muse Code writes it (schema 1, one provider), metadata only, + * no secret; or `contents` as given. + */ +export function installFakeCredential(contents = FAKE_STORED_SIGN_IN): 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 } + +export const FAKE_STORED_SIGN_IN = JSON.stringify({ + schema_version: 1, + providers: { meta: { mechanism: 'oauth', obtained_via: 'device_code' } }, +}) + +/** 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 fb899797a..c46d79685 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' @@ -18,7 +18,7 @@ import type { MuseCodeHost } from '../../src/core/backends/musecode/MuseCodeHost import { MuseCodeBackendManager } from '../../src/host/backend/museCodeBackendManager' import { DEFAULT_MODEL_ID, MSP_CLIENT_NAME, UI_TEXT } from '../../src/shared/constants' import { FakeLogOutputChannel } from '../unit/helpers/fakes' -import { installFakeCredential, installFakeMuse } from './fakeMuse' +import { installFakeCredential, installFakeMuse, removeTestFolders } from './fakeMuse' const TURN_TIMEOUT_MS = 10_000 const TEST_TIMEOUT_MS = 30_000 @@ -141,36 +141,19 @@ 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) + expect(backend.credentialFileVerdict()).toBe('inline') const host = await backend.ensureHost() + // Described by its structure, never by a value in it (D26). + expect(log.info).toHaveBeenCalledWith(expect.stringContaining('(credential file inline,')) 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}`) diff --git a/test/unit/accountHost.test.ts b/test/unit/accountHost.test.ts new file mode 100644 index 000000000..ea6c1e14e --- /dev/null +++ b/test/unit/accountHost.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest' +import { + type AccountSession, + isStoredSignIn, + logOutAccount, + probeAccount, + readAccountState, +} from '../../src/host/auth/accountHost' +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() }, close } + return { request, close, session, connect: () => Promise.resolve(session) } +} + +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, + }) + }) + + 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)).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)).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)).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)).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('leaves META_API_KEY to the caller: an environment key afterwards still confirms', async () => { + const envKey = { state: 'envKey', credentialRequired: true } + const t = host({ 'account/logout': envKey, 'account/read': envKey }) + await expect(logOutAccount(t.connect, new FakeLogOutputChannel())).resolves.toBe('confirmed') + }) + + 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 }], + ])('is false when %s', async (_name, answers) => { + const log = new FakeLogOutputChannel() + if (answers === undefined) { + await expect(logOutAccount(() => Promise.reject(new Error('no CLI')), log)).resolves.toBe( + 'unconfirmed', + ) + return + } + const t = host(answers) + await expect(logOutAccount(t.connect, log)).resolves.toBe('unconfirmed') + expect(t.close).toHaveBeenCalledOnce() + expect(log.warn).toHaveBeenCalled() + }) +}) diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 49f394200..21a9b0aa9 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -1,11 +1,18 @@ -import { describe, expect, it, vi } from 'vitest' +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 { AuthService, type AuthServiceDeps } 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 type { HostToWebviewMessage } from '../../src/shared/protocol' 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 +20,14 @@ 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 } + readonly cliSignIn: ReturnType Promise>> + /** `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 +39,7 @@ interface Harness { ( signal: AbortSignal, onCode: (url: string, code: string) => void, - ) => Promise<'signedIn' | 'cancelled' | 'timedOut'> + ) => Promise > > readonly runInstallerInTerminal: ReturnType void>> @@ -40,11 +50,17 @@ 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, } + const cliSignIn = vi.fn<(isUserAction: boolean) => Promise>(() => + Promise.resolve(facts.cli), + ) + const logOutCli = vi.fn<() => Promise>(() => { + facts.cli = 'signedOut' + return Promise.resolve(true) + }) const restartBackend = vi.fn<(isConversationEnding: boolean) => Promise>(() => Promise.resolve(), ) @@ -53,7 +69,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 +78,12 @@ 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, + credentialFilePath: () => CREDENTIAL_PATH, hasEnvironmentKey: () => facts.envKey, getBackendMode: () => facts.backendMode, restartBackend, + logOutCli, }, credentials: new CredentialStore(memorySecrets(), unexpectedWarning), runInTerminal, @@ -98,6 +114,8 @@ function harness(overrides: Partial = {}): Harness { deps, broadcasts, facts, + cliSignIn, + logOutCli, restartBackend, runInTerminal, runDeviceSignIn, @@ -106,6 +124,12 @@ 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 +} + async function signedInModelApi(overrides: Partial = {}): Promise { const h = harness(overrides) await h.deps.credentials.setApiKey('LLM|1|secret') @@ -152,7 +176,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 +200,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 +213,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', @@ -264,7 +288,7 @@ describe('AuthService.signIn', () => { 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 +443,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') @@ -598,7 +622,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 +635,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 +645,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 +657,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 +667,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 +744,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 +759,13 @@ 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 + it('recovers a held old CLI sign-in through an explicit fresh device approval', async () => { + const h = withLogoutFallback(harness()) + h.facts.cli = 'signedIn' await h.service.refresh() await h.service.signOut() - h.runDeviceSignIn.mockImplementation(() => { - h.facts.credentialMtime += 1 - return Promise.resolve('signedIn') - }) + expect(h.logoutHoldState.isHeld).toBe(true) + h.runDeviceSignIn.mockResolvedValue('signedIn') await expect(h.service.signIn('browser')).resolves.toMatchObject({ status: 'signedIn', backend: 'museCode', @@ -752,12 +774,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 +798,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 +819,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,8 +831,8 @@ 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(() => { @@ -830,6 +856,7 @@ describe('AuthService.signOut and host reports', () => { 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 +869,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 +951,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 +962,62 @@ 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('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: 'signedOut', + detail: EN.signOutPending, + }) + expect(h.logOutCli).toHaveBeenCalledOnce() + expect(h.runInTerminal).toHaveBeenLastCalledWith('/bin/muse', ['logout']) + expect(h.logoutHoldState.isHeld).toBe(true) }) - it('does not run muse logout when there is no CLI session to end', async () => { + 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) + }) + 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 +1040,156 @@ describe('AuthService.signOut and host reports', () => { }) }) }) + +describe('AuthService: how Muse Code ends a browser sign-in (D26)', () => { + it.each([ + ['denied', EN.signInDenied], + ['expired', EN.signInExpired], + ['failed', EN.signInNotSaved], + ] as const)('ends a %s sign-in at once, signed out with its reason', async (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 denied CLI sign-in, 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 Keychain pointer on Windows or Linux instead of offering a dead sign-in', async () => { + const h = harness() + h.facts.cli = 'keychainElsewhere' + const refreshed = await h.service.refresh() + expect(refreshed).toMatchObject({ status: 'error', backend: 'museCode', hasCliSession: false }) + expect(refreshed.detail).toContain(CREDENTIAL_PATH) + expect(refreshed.detail).toContain('macOS Keychain') + 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 = 'keychainElsewhere' + await expect(h.service.refresh()).resolves.toMatchObject({ + status: 'signedIn', + backend: 'modelApi', + detail: undefined, + }) + }) + + 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 = 'keychainElsewhere' + 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 write (isolated +// homes, 2026-09-27), no token in any of them. +const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' +const STORED_SIGN_IN = JSON.stringify({ + schema_version: 1, + providers: { meta: { mechanism: 'oauth', obtained_via: 'device_code' } }, +}) + +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) { + 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.resolve(undefined)) + const account = new CliAccount({ + platform: 'linux', + 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) }, + }) + return { h, file, service, probe } + } + + 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() + }) + + it('signs out through account/logout: the file stays, empty, and the hold is released', async () => { + const t = withFile(STORED_SIGN_IN) + 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(STORED_SIGN_IN) + 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 e4c9822b6..000000000 --- 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/cliAccount.test.ts b/test/unit/cliAccount.test.ts new file mode 100644 index 000000000..981229624 --- /dev/null +++ b/test/unit/cliAccount.test.ts @@ -0,0 +1,210 @@ +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 } from '../../src/shared/constants' +import { FakeLogOutputChannel } from './helpers/fakes' + +// What Muse Code 1.3.0 and 1.4.0 write (isolated homes, 2026-09-27) and +// macOS's pointer (aonia §2.3); placeholders, never a token. +const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' +const STORED_SIGN_IN = '{"schema_version":1,"providers":{"meta":{"mechanism":"oauth"}}}' +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 } +} + +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+)?$/) + }) + + 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') + // A keyless gateway needs no sign-in at all. + expect(cliSignInFromAccount({ state: 'loggedOut', credentialRequired: false })).toBe('signedIn') + // 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('keychainElsewhere')).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(STORED_SIGN_IN) + const t = account('linux', home.file, []) + await expect(t.checker.signIn(false)).resolves.toBe('signedIn') + 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('keychainElsewhere') + 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)', + ) + }) + + 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 home = configHome() + home.write(MALFORMED) + const answer = Promise.withResolvers() + const probe = vi.fn(() => answer.promise) + const checker = new CliAccount({ + platform: 'linux', + credentialFilePath: () => home.file, + probe, + log: new FakeLogOutputChannel(), + }) + 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() + }) +}) diff --git a/test/unit/conversationController.test.ts b/test/unit/conversationController.test.ts index 05048f090..682a9f908 100644 --- a/test/unit/conversationController.test.ts +++ b/test/unit/conversationController.test.ts @@ -96,8 +96,8 @@ 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) }, markAuthRequired: (reason: string) => { @@ -2305,7 +2305,8 @@ describe('ConversationController: other messages', () => { 'installMuseCode', 'cancelSignIn', 'signOut', - 'refresh', + // Check again is a click: macOS may ask the CLI (D26). + 'refresh:userAction', ]) 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 000000000..7480790f3 --- /dev/null +++ b/test/unit/credentialFile.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import { + credentialFileVerdict, + keychainItemPresence, +} from '../../src/core/backends/musecode/credentialFile' + +// The shapes Muse Code 1.3.0 and 1.4.0-R4302.1 wrote in isolated homes +// (2026-09-27), and the macOS pointer a third party observed on 1.3.0 +// (aonia §2.3). No token in any of them: the values are placeholders. +const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' +const STORED_KEY = JSON.stringify({ + schema_version: 1, + providers: { meta: { mechanism: 'api_key', api_key: '' } }, +}) +const OAUTH_LOGIN = JSON.stringify({ + schema_version: 1, + providers: { + meta: { + mechanism: 'oauth', + obtained_via: 'device_code', + oauth: { access_token: '', refresh_token: '', expires_at: 1 }, + }, + }, +}) +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', + }, + }, +}) +const SCHEMA_1_POINTER = JSON.stringify({ + schema_version: 1, + providers: { meta: { storage: 'keychain' } }, +}) + +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('reads a stored key or login as held in the file', () => { + expect(credentialFileVerdict(STORED_KEY, 'win32')).toBe('inline') + expect(credentialFileVerdict(OAUTH_LOGIN, 'linux')).toBe('inline') + expect(credentialFileVerdict(OAUTH_LOGIN, 'darwin')).toBe('inline') + }) + + 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('{"schema_version":2,"providers":{}}', 'darwin')).toBe('empty') + }) + + // Both made `muse serve` exit 3 on Windows 1.4.0 (isolated homes). + it.each(['win32', 'linux'] as const)('names a Keychain pointer on %s', (platform) => { + expect(credentialFileVerdict(MAC_POINTER, platform)).toBe('keychainElsewhere') + expect(credentialFileVerdict(SCHEMA_1_POINTER, platform)).toBe('keychainElsewhere') + }) + + 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 ff9eb57aa..1fbee2477 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -1,23 +1,47 @@ import { describe, expect, it, vi } from 'vitest' +import type { AccountSession } from '../../src/host/auth/accountHost' import { CREDENTIAL_POLL_TIMEOUT_MS } from '../../src/shared/constants' -import { - parseDeviceCode, - runDeviceSignIn, - type DeviceSession, -} from '../../src/host/auth/deviceSignIn' +import { parseDeviceCode, runDeviceSignIn } from '../../src/host/auth/deviceSignIn' +import { FakeLogOutputChannel } from './helpers/fakes' const DEVICE_URL = 'https://auth.meta.com/oauth/device/?code=example' +const log = new FakeLogOutputChannel() -function session() { +// `account/read` as captured on 1.3.0 and 1.4.0 (the label redacted there, +// an e-mail address in real life, and dropped by the parser here). +const LOGGED_OUT = { state: 'loggedOut', credentialRequired: true } +const SIGNED_IN = { state: 'accountLogin', label: 'person@example.com', credentialRequired: true } + +type AccountAnswer = Record | undefined + +/** + * A sign-in host. `account` answers `account/read` (undefined: a CLI without + * it, which refuses the method as unknown). + */ +function session(account: () => AccountAnswer = () => undefined) { const request = vi.fn((method: string) => { - return method === 'account/loginStart' - ? Promise.resolve({ verificationUrl: DEVICE_URL, userCode: 'ABCD-EFGH' }) - : Promise.resolve({ cancelled: true }) + if (method === 'account/loginStart') { + return Promise.resolve({ verificationUrl: DEVICE_URL, userCode: 'ABCD-EFGH' }) + } + if (method === 'account/read') { + const answer = account() + return answer === undefined + ? Promise.reject(new Error('no handler for account/read')) + : Promise.resolve(answer) + } + return Promise.resolve({ cancelled: true }) }) - const onNotification = vi.fn() + const onNotification = vi.fn() const close = vi.fn(() => Promise.resolve()) - const deviceSession: DeviceSession = { connection: { request, onNotification }, close } - return { request, onNotification, close, deviceSession } + const deviceSession: AccountSession = { connection: { request, onNotification }, close } + const complete = (params: Record) => { + onNotification.mock.calls[0]?.[0]({ + jsonrpc: '2.0', + method: 'account/loginCompleted', + params, + }) + } + return { request, onNotification, close, deviceSession, complete } } describe('Muse Code device sign-in', () => { @@ -51,6 +75,7 @@ describe('Muse Code device sign-in', () => { }, signal: new AbortController().signal, onCode, + log, }), ).resolves.toBe('signedIn') expect(onCode).toHaveBeenCalledWith(DEVICE_URL, 'ABCD-EFGH') @@ -72,6 +97,7 @@ describe('Muse Code device sign-in', () => { now: () => 0, signal: abort.signal, onCode: vi.fn(), + log, }), ).resolves.toBe('cancelled') expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) @@ -94,6 +120,7 @@ describe('Muse Code device sign-in', () => { now: () => 0, signal: abort.signal, onCode: vi.fn(), + log, }), ).resolves.toBe('signedIn') expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) @@ -116,27 +143,29 @@ 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(() => LOGGED_OUT) + const answer = t.request.getMockImplementation() + t.request.mockImplementation((method) => + method === stuck + ? new Promise>(() => undefined) + : (answer?.(method) ?? Promise.resolve({})), ) const abort = new AbortController() - const pending = runDeviceSignIn({ - connect: () => Promise.resolve(t.deviceSession), - credentialFileModifiedAt: () => undefined, - sleep: () => Promise.resolve(), - now: () => 0, - signal: abort.signal, - onCode: vi.fn(), - }) + const pending = run(t, { signal: abort.signal }) await vi.waitFor(() => { - expect(t.request).toHaveBeenCalledWith('account/loginStart', { type: 'deviceCode' }) + expect(t.request).toHaveBeenCalledWith(stuck, params) }) abort.abort() await expect(pending).resolves.toBe('cancelled') @@ -149,7 +178,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 +196,7 @@ describe('Muse Code device sign-in', () => { now: () => 0, signal: abort.signal, onCode: vi.fn(), + log, }) expect(connect).toHaveBeenCalledOnce() abort.abort() @@ -185,6 +215,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() @@ -220,6 +251,7 @@ describe('Muse Code device sign-in', () => { now: () => 0, signal: new AbortController().signal, onCode: vi.fn(), + log, }), ).resolves.toBe('cancelled') expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) @@ -240,9 +272,148 @@ describe('Muse Code device sign-in', () => { }, signal: new AbortController().signal, onCode: vi.fn(), + log, }), ).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): the host's own ending, `account/read` on the open +// host, and the credential file, in that order of trust. +describe('Muse Code device sign-in: how it ends', () => { + it('finishes on a granted outcome the account confirms, with no file change', async () => { + let account = LOGGED_OUT + const t = session(() => account) + const sleep = () => { + account = SIGNED_IN + t.complete({ outcome: 'granted' }) + 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() + }) + + it('waits while the store does not show a granted sign-in yet', async () => { + const accounts = [LOGGED_OUT, LOGGED_OUT, LOGGED_OUT, SIGNED_IN] + const t = session(() => accounts.shift() ?? SIGNED_IN) + let polls = 0 + const sleep = () => { + polls += 1 + t.complete({ outcome: 'granted' }) + return Promise.resolve() + } + await expect(run(t, { sleep })).resolves.toBe('signedIn') + expect(polls).toBe(2) + }) + + it('notices a sign-in by polling account/read when no outcome arrives', async () => { + let account = LOGGED_OUT + const t = session(() => account) + const sleep = () => { + account = SIGNED_IN + return Promise.resolve() + } + await expect(run(t, { sleep })).resolves.toBe('signedIn') + }) + + it('does not take a file a sign-out rewrote for a sign-in', async () => { + const t = session(() => LOGGED_OUT) + let modified = 1 + const sleep = () => { + modified += 1 + return Promise.resolve() + } + await expect(run(t, { sleep, 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') + }) + + it.each(['denied', 'expired', 'failed'] as const)( + 'ends at once on %s, logs the host’s reason, and needs no loginCancel', + async (outcome) => { + const t = session(() => LOGGED_OUT) + const logged = new FakeLogOutputChannel() + const sleep = () => { + t.complete({ outcome, message: `the flow was ${outcome}` }) + return Promise.resolve() + } + await expect(run(t, { sleep, log: logged })).resolves.toBe(outcome) + expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + expect(t.close).toHaveBeenCalledOnce() + expect(logged.info).toHaveBeenCalledWith( + `Muse Code sign-in ended: ${outcome}: the flow was ${outcome}`, + ) + }, + ) + + it.each([ + ['an outcome it does not know, keeping the first ending', ['somethingNew', 'denied']], + ['a malformed ending', [undefined]], + ])('keeps waiting on %s', async (_name, outcomes) => { + const t = session(() => LOGGED_OUT) + const sleep = () => { + for (const outcome of outcomes) { + t.complete(outcome === undefined ? { result: 'denied' } : { outcome }) + } + return Promise.resolve() + } + await expect(run(t, { sleep, step: ONE_POLL })).resolves.toBe('timedOut') + expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) + }) + + it('never logs the account’s label', async () => { + const logged = new FakeLogOutputChannel() + const t = session(() => SIGNED_IN) + const sleep = () => { + t.complete({ outcome: 'granted' }) + return Promise.resolve() + } + await run(t, { sleep, log: logged }) + const everything = [...logged.info.mock.calls, ...logged.warn.mock.calls].flat().join('\n') + expect(everything).not.toContain('person@example.com') + }) +}) diff --git a/test/unit/supportReport.test.ts b/test/unit/supportReport.test.ts index a9641b944..c4a2eaf00 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({ From 1b28b75fd44081a657c19ee41d5585ac20b2b057 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 17:51:34 -0700 Subject: [PATCH 02/25] Record the sign-in detection gate Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 3709c1c78..68a59ab88 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -131,4 +131,16 @@ Run on the working tree (Windows 11, 2026-09-27), before the commit: rule 2): raising it, or a size cut elsewhere in the host bundle, is the owner's decision. -The full `npm run quality` run is reported with the pull request. +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. From 0c13b4d5d1891bcc3764ad8961669714f49eb2b3 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 18:58:31 -0700 Subject: [PATCH 03/25] End the device sign-in on captured endings only, and at once PR #49 review (Codex). P1: the account/loginCompleted handlers were written from the MSP schema's words. P2: a poll awaiting account/read held up Cancel and the host's ending for 30 s when the CLI stopped answering, and loginCancel could take another 30 s. Live capture (owner-authorized; Muse Code 1.4.0-R4302.1, Windows, a throwaway home per run, 0 model attempts, the owner's auth.json stat'ed unchanged): `expired` arrives 600 s after loginStart with a message, and `cancelled` precedes the loginCancel answer. The frames, with the code replaced by its shape, are test/fixtures/msp/account-login-*.json; the unit tests and the fake CLI replay them. `granted`, `denied` and `failed` could not be captured: the Chrome Control extension is disabled in the owner's Chrome, so no code could be approved or declined. - `granted` is neither an ending nor a sign-in: account/read or a new file decides. `expired` keeps its own message; any other outcome ends the flow with one message that shows Muse Code's word (signInEnded, replacing signInDenied and signInNotSaved in all 15 tables). - Each poll, each wait and the pre-start calls race a stop signal (Cancel, or an ending); loginCancel is bounded at 2 s, then the host is closed anyway. - The extension now waits 11 minutes, past the captured code lifetime: at 5 it cancelled codes the browser could still approve, and Muse Code's own `expired` could never arrive. Red drills R to AA recorded in docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 16 +- PLAN.md | 42 +- README.md | 7 +- docs/certification/sign-in-detection.md | 172 +++++++- l10n/ui.cs.json | 3 +- l10n/ui.de.json | 3 +- l10n/ui.es.json | 3 +- l10n/ui.fr.json | 3 +- l10n/ui.hu.json | 3 +- l10n/ui.it.json | 3 +- l10n/ui.ja.json | 3 +- l10n/ui.ko.json | 3 +- l10n/ui.pl.json | 3 +- l10n/ui.pt-br.json | 3 +- l10n/ui.ru.json | 3 +- l10n/ui.tr.json | 3 +- l10n/ui.zh-cn.json | 3 +- l10n/ui.zh-tw.json | 3 +- src/host/auth/authService.ts | 26 +- src/host/auth/deviceSignIn.ts | 195 +++++---- src/shared/constants.ts | 25 +- src/shared/l10n/en.ts | 7 +- test/e2e/cliAccount.e2e.test.ts | 92 ++++- test/e2e/fake-muse/serve.mjs | 84 +++- .../fixtures/msp/account-login-cancelled.json | 237 +++++++++++ test/fixtures/msp/account-login-expired.json | 304 ++++++++++++++ test/unit/authService.test.ts | 38 +- test/unit/deviceSignIn.test.ts | 371 +++++++++++++----- test/unit/helpers/accountLoginCapture.ts | 107 +++++ 29 files changed, 1470 insertions(+), 295 deletions(-) create mode 100644 test/fixtures/msp/account-login-cancelled.json create mode 100644 test/fixtures/msp/account-login-expired.json create mode 100644 test/unit/helpers/accountLoginCapture.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d80917a..0e8277361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,11 +59,17 @@ happened, not what was planned; superseded entries are kept. - **Signing out.** Sign-out uses Muse Code's own `account/logout` and confirms it with `account/read`. `muse logout` in a terminal remains the fallback. -- **Browser sign-in ends at once when it is declined, the code expires, or - Muse Code cannot save it.** The panel says which of the three happened. - Before, it waited out the full five minutes. Success is taken from Muse - Code's own `granted` outcome confirmed by `account/read`, from polling - `account/read`, or from a new credential file. +- **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.** Any other way Muse Code ends the sign-in is shown in + Muse Code's own word. + - **Cancel.** It works at once, even when Muse Code stops answering. + - **Success.** It is taken from Muse Code's `account/read` turning + signed in, or from a new credential file it does not contradict. - **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. - **Muse Spark: Diagnostics** describes the CLI's credential file by its diff --git a/PLAN.md b/PLAN.md index 8dc5a9661..d90528f7d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -872,22 +872,38 @@ action that fails is worse than hiding one that would work. chat host has no `experimentalApi`, and sign-out stops it first). The result is confirmed by `account/read`. The terminal `muse logout` is the fallback, confirmed later by the file or the CLI. -- **M55's device flow ends on:** - - `account/loginCompleted` `granted`, confirmed by `account/read`; +- **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. - `denied`, `expired` and `failed` end it at once with their own messages. - `account/changed` is not relied on: a terminal `muse logout` did not fire - it. This supersedes M55's "accept only after a new credential-file - modification". - + 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).** The endings + were captured on 1.4.0-R4302.1 in throwaway homes: `expired`, 600 s + after `loginStart`, with a message, and `cancelled` + (`test/fixtures/msp/`). + - **`granted`, `denied`, `failed`: not captured.** The owner's Chrome has + the Chrome Control extension disabled, so no code could be approved or + declined. + - **`granted`.** It neither ends the flow nor counts as a sign-in; + `account/read` decides. + - **Every other word.** It ends the flow at once. The captured `expired` + has its own message; any other word is shown as Muse Code sent it + (AGENTS.md rule 13). +- **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. - **Where it is only as good as the schema.** `account/*` stays - experimental, and `granted`, `denied`, `expired` and `failed` are the - schema's words: only `cancelled` was captured. + experimental. - **Owner steps.** A real sign-in on each OS remains an owner step: - the macOS pointer after a 1.4.0 login; - - the success sequence; + - the success sequence and a declined code, once Chrome Control is back + on (`scratchpad/cred-capture/capture.mjs`); - logout of an OAuth slot. - **Not taken.** `TBH_CREDENTIAL_BACKEND=file` is not set. R4302.1 fixed #38/#53 and the launcher updates itself; the README names the switch @@ -5944,8 +5960,10 @@ showed `account/read` logged out, `loginStart {type:"deviceCode"}` returning 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`. (Amended 2026-09-27, D26: -the schema's `granted` counts only when `account/read`, whose shapes were -captured on 2026-09-27, agrees; the file change stays as a third signal.) +`account/read`, whose shapes were captured on 2026-09-27, decides a +sign-in, with the file change as the second signal; the uncaptured +`granted` is not one. The PR #49 capture added `expired`, 600 s after +`loginStart`, to the captured endings.) **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 72a078922..f679c0dda 100644 --- a/README.md +++ b/README.md @@ -191,9 +191,10 @@ 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 Muse Code ends the sign-in another way, the panel + shows Muse Code's word for it. 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 diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 68a59ab88..d2a7fde58 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -39,19 +39,21 @@ made, and no token was read. ## What changed -| Behaviour | Where | Tests | -| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `auth.json` is parsed for its structure only: schema version, any provider named, a Keychain `storage` lane. The schema drops every other field. A file over 64 KiB, a folder or a stat failure is not read. | `src/core/backends/musecode/credentialFile.ts`, `readCredentialFile` in `src/host/auth/cliAccount.ts` | `credentialFile.test.ts` (the captured shapes on each OS, six malformed files); `cliAccount.test.ts` (`readCredentialFile`) | -| No file, or the empty file a sign-out leaves, is signed out without a process. A file holding the credential is signed in. | `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, 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`, `probeAccount` | `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, Diagnostics), never on activation or panel open. | `CliAccount.confirm`; `AuthService.refresh(isUserAction)`; the controller's `retryBackend` | `cliAccount.test.ts`; `authService.test.ts` "when the CLI may be asked"; `conversationController.test.ts` | -| A macOS pointer on Windows or Linux is a named error that gives the file's path; the browser sign-in is refused with the same text instead of starting a host that exits 3. | `AuthService.selectedSnapshot`, `signInWithCli`; `UI_TEXT.cliKeychainElsewhere` | `authService.test.ts` "a credential file Muse Code cannot start with" | -| Sign-out goes through MSP `account/logout`, confirmed by `account/read`. `muse logout` in a terminal is the fallback. The logout hold ends once the file or the CLI shows no sign-in, even though the file stays. | `logOutAccount`; `AuthService.performSignOut`, `refresh` | `accountHost.test.ts`; `authService.test.ts` (the rewritten former 845–853 case, the account/logout path, the fallback, the real-file cases); e2e | -| The device sign-in succeeds on any of three signals: `granted` confirmed by `account/read`; `account/read` turning `accountLogin` while polling; a new file that `account/read` does not contradict. A CLI without `account/read` falls back to the host's word or the file. | `runDeviceSignIn` | `deviceSignIn.test.ts` "how it ends" | -| `denied`, `expired` and `failed` end the flow at once. Each shows its own localized message, and the host's message goes to the log, clipped. An unknown or malformed ending keeps waiting. | `runDeviceSignIn`; `AuthService` (`signInDenied`, `signInExpired`, `signInNotSaved`) | `deviceSignIn.test.ts`; `authService.test.ts` "how Muse Code ends a browser sign-in" | -| A Cancel pressed while the pre-check reads the file is kept: the controller exists before that read, and an aborted flow never starts. | `AuthService.signInWithCli` | `authService.test.ts` "cancels the running device flow" (it caught the regression during this work) | -| The account's `label` (an e-mail address) is dropped by the `account/read` parse and never logged. | `accountStateSchema` | `accountHost.test.ts`; `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). | `renderSupportReport`, `keychainItemPresence`, the Diagnostics command | `supportReport.test.ts`; `credentialFile.test.ts` | +| Behaviour | Where | Tests | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth.json` is parsed for its structure only: schema version, any provider named, a Keychain `storage` lane. The schema drops every other field. A file over 64 KiB, a folder or a stat failure is not read. | `src/core/backends/musecode/credentialFile.ts`, `readCredentialFile` in `src/host/auth/cliAccount.ts` | `credentialFile.test.ts` (the captured shapes on each OS, six malformed files); `cliAccount.test.ts` (`readCredentialFile`) | +| No file, or the empty file a sign-out leaves, is signed out without a process. A file holding the credential is signed in. | `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, 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`, `probeAccount` | `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, Diagnostics), never on activation or panel open. | `CliAccount.confirm`; `AuthService.refresh(isUserAction)`; the controller's `retryBackend` | `cliAccount.test.ts`; `authService.test.ts` "when the CLI may be asked"; `conversationController.test.ts` | +| A macOS pointer on Windows or Linux is a named error that gives the file's path; the browser sign-in is refused with the same text instead of starting a host that exits 3. | `AuthService.selectedSnapshot`, `signInWithCli`; `UI_TEXT.cliKeychainElsewhere` | `authService.test.ts` "a credential file Muse Code cannot start with" | +| Sign-out goes through MSP `account/logout`, confirmed by `account/read`. `muse logout` in a terminal is the fallback. The logout hold ends once the file or the CLI shows no sign-in, even though the file stays. | `logOutAccount`; `AuthService.performSignOut`, `refresh` | `accountHost.test.ts`; `authService.test.ts` (the rewritten former 845–853 case, the account/logout path, the fallback, the real-file cases); 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. `granted` was not captured, so it is not a signal (PR #49 review, below). | `runDeviceSignIn` | `deviceSignIn.test.ts` "how it ends" | +| `account/loginCompleted` ends the flow at once on any outcome but `granted`. The captured `expired` shows its own message. Any other word is shown as Muse Code sent it, in one message (`signInEnded`). The host's message goes to the log, clipped. A malformed ending keeps waiting. | `runDeviceSignIn`; `AuthService` (`signInExpired`, `signInEnded`) | `deviceSignIn.test.ts`; `authService.test.ts` "how Muse Code ends a browser sign-in"; `cliAccount.e2e.test.ts` (the captured frames replayed) | +| Cancel and the host's ending are noticed at once, even while an `account/read` goes unanswered: each poll and each wait races a stop signal. `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"; `cliAccount.e2e.test.ts` (the fake leaves `account/read` unanswered) | +| 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. | `AuthService.signInWithCli` | `authService.test.ts` "cancels the running device flow" (it caught the regression during this work) | +| The account's `label` (an e-mail address) is dropped by the `account/read` parse and never logged. | `accountStateSchema` | `accountHost.test.ts`; `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). | `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 @@ -60,6 +62,103 @@ rewriting that file as the empty one the CLI leaves. `installFakeCredential` writes a stored login's structure (schema 1, one provider, no secret) 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 a capture's `loginCompleted` frame after + `MUSE_FAKE_LOGIN_ENDING_MS`; +- `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. + +The unit tests read the same files through +`test/unit/helpers/accountLoginCapture.ts`. + +## 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. + +**Not captured: `granted`, `denied` and `failed`.** Each needs a click on +the device page in the owner's signed-in Chrome, through Chrome Control. +The Chrome Control extension is disabled in that Chrome profile +(`Default`, `disable_reasons: [1]`, a user action), so the MCP tools could +not attach. Turning it back on changes the owner's browser, which is his +call. So: + +- **No handler keeps a meaning built on those words.** +- **`granted`.** It does not end the flow and is not a sign-in. The flow + goes on until `account/read` turns `accountLogin`, or a new file appears + that it does not contradict. +- **Every other word.** It ends the flow at once. The panel shows one + localized message with the word as Muse Code sent it (`signInEnded`, + "Sign-in ended: {outcome}. …"). The host's message goes to the log. +- **Capturing them later.** Once Chrome Control can attach, the same script + captures them: `node capture.mjs granted|denied|failed-dirfile out`. + `failed-dirfile` puts a file where the config folder goes, so the save + after an approval should fail. A `granted` run signs out through + `account/logout` in the same home before the home is deleted. + +**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. + ## Drills Each drill broke one guard in the working tree and ran the named suites. @@ -87,6 +186,26 @@ seventeen (`scratchpad/cred-fix/drills.mjs`, `drills-result.json`). | 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" | + ## Not proved here A real sign-in is the owner's to give, and each of these needs one: @@ -94,10 +213,13 @@ 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. -- **The success sequence.** The order and timing of - `account/loginCompleted {outcome: "granted"}` against `account/read`. - `granted`, `denied`, `expired` and `failed` are the schema's - `AccountLoginOutcome` words; only `cancelled` was captured live (M55). +- **The success sequence, and `denied` and `failed`.** These are the order + and timing of `account/loginCompleted {outcome: "granted"}` against + `account/read`, `account/changed` and the file, and the frames of a + declined code and a failed save. `cancelled` and `expired` were captured + live (above). The other three need Chrome Control, whose extension is + disabled in the owner's Chrome. Until then they have no handler of their + own. - **Logout of an OAuth login slot.** It should leave the same empty file, and the server-side revoke is unverified. Stored keys were verified. - **Keychain prompts.** Whether one appears after a CLI update (a new @@ -144,3 +266,19 @@ After the fix was joined with M57 (the Model API backend in its own bundle, - 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. diff --git a/l10n/ui.cs.json b/l10n/ui.cs.json index d98b435e1..4ee6cd90b 100644 --- a/l10n/ui.cs.json +++ b/l10n/ui.cs.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "Přihlášení bylo v prohlížeči odmítnuto. Přihlaste se znovu a získejte nový kód.", "signInExpired": "Platnost kódu vypršela dříve, než byl schválen. Přihlaste se znovu a získejte nový kód.", - "signInNotSaved": "Muse Code nemohl přihlášení dokončit. Zkuste to znovu; důvod najdete v protokolu Muse Spark.", + "signInEnded": "Přihlášení skončilo: {outcome}. Přihlaste se znovu a získejte nový kód; důvod najdete v protokolu Muse Spark.", "cliKeychainElsewhere": "Muse Code nelze spustit: jeho soubor přihlášení {path} odkazuje na Klíčenku systému macOS, kterou Muse Code v tomto systému nemůže použít. 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…", diff --git a/l10n/ui.de.json b/l10n/ui.de.json index 0386278d8..fd145cbf1 100644 --- a/l10n/ui.de.json +++ b/l10n/ui.de.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "Die Anmeldung wurde im Browser abgelehnt. Melden Sie sich erneut an, um einen neuen Code zu erhalten.", "signInExpired": "Der Code ist abgelaufen, bevor er bestätigt wurde. Melden Sie sich erneut an, um einen neuen Code zu erhalten.", - "signInNotSaved": "Muse Code konnte die Anmeldung nicht abschließen. Versuchen Sie es erneut; den Grund finden Sie im Muse Spark-Protokoll.", + "signInEnded": "Anmeldung beendet: {outcome}. Melden Sie sich erneut an, um einen neuen Code zu erhalten; den Grund finden Sie im Muse Spark-Protokoll.", "cliKeychainElsewhere": "Muse Code kann nicht starten: Seine Anmeldedatei {path} verweist auf den macOS-Schlüsselbund, den Muse Code auf diesem System nicht nutzen 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…", diff --git a/l10n/ui.es.json b/l10n/ui.es.json index c51ffa69c..897817c53 100644 --- a/l10n/ui.es.json +++ b/l10n/ui.es.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "El inicio de sesión se rechazó en el navegador. Vuelva a iniciar sesión para obtener un código nuevo.", "signInExpired": "El código caducó antes de aprobarse. Vuelva a iniciar sesión para obtener un código nuevo.", - "signInNotSaved": "Muse Code no pudo completar el inicio de sesión. Vuelva a intentarlo; el registro de Muse Spark indica el motivo.", + "signInEnded": "El inicio de sesión terminó: {outcome}. Vuelva a iniciar sesión para obtener un código nuevo; el registro de Muse Spark indica el motivo.", "cliKeychainElsewhere": "Muse Code no puede iniciarse: su archivo de inicio de sesión {path} apunta al llavero de macOS, que Muse Code no puede usar 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…", diff --git a/l10n/ui.fr.json b/l10n/ui.fr.json index 83051db86..db5583d5c 100644 --- a/l10n/ui.fr.json +++ b/l10n/ui.fr.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "La connexion a été refusée dans le navigateur. Reconnectez-vous pour obtenir un nouveau code.", "signInExpired": "Le code a expiré avant d’être approuvé. Reconnectez-vous pour obtenir un nouveau code.", - "signInNotSaved": "Muse Code n’a pas pu terminer la connexion. Réessayez. Le journal de Muse Spark en indique la raison.", + "signInEnded": "Connexion terminée : {outcome}. Reconnectez-vous pour obtenir un nouveau code. Le journal de Muse Spark en indique la raison.", "cliKeychainElsewhere": "Muse Code ne peut pas démarrer : son fichier de connexion {path} pointe vers le trousseau macOS, que Muse Code ne peut pas utiliser 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…", diff --git a/l10n/ui.hu.json b/l10n/ui.hu.json index cfce21cc6..669584e8a 100644 --- a/l10n/ui.hu.json +++ b/l10n/ui.hu.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "A bejelentkezést elutasították a böngészőben. Jelentkezzen be újra, hogy új kódot kapjon.", "signInExpired": "A kód lejárt, mielőtt jóváhagyták volna. Jelentkezzen be újra, hogy új kódot kapjon.", - "signInNotSaved": "A Muse Code nem tudta befejezni a bejelentkezést. Próbálja újra; az okot a Muse Spark naplója tartalmazza.", + "signInEnded": "A bejelentkezés véget ért: {outcome}. Jelentkezzen be újra, hogy új kódot kapjon; az okot a Muse Spark naplója tartalmazza.", "cliKeychainElsewhere": "A Muse Code nem tud elindulni: a bejelentkezési fájlja ({path}) a macOS kulcskarikájára mutat, amelyet a Muse Code ezen a rendszeren nem tud használni. 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…", diff --git a/l10n/ui.it.json b/l10n/ui.it.json index b7e0c325a..b2cfa388e 100644 --- a/l10n/ui.it.json +++ b/l10n/ui.it.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "L’accesso è stato rifiutato nel browser. Accedi di nuovo per ottenere un nuovo codice.", "signInExpired": "Il codice è scaduto prima di essere approvato. Accedi di nuovo per ottenere un nuovo codice.", - "signInNotSaved": "Muse Code non è riuscito a completare l’accesso. Riprova; il log di Muse Spark indica il motivo.", + "signInEnded": "L’accesso si è concluso: {outcome}. Accedi di nuovo per ottenere un nuovo codice; il log di Muse Spark indica il motivo.", "cliKeychainElsewhere": "Muse Code non può avviarsi: il suo file di accesso {path} rimanda al Portachiavi di macOS, che Muse Code non può usare 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…", diff --git a/l10n/ui.ja.json b/l10n/ui.ja.json index c087f7d9e..29caf99d8 100644 --- a/l10n/ui.ja.json +++ b/l10n/ui.ja.json @@ -48,9 +48,8 @@ "apiKeyInvalid": "Model API キーは LLM_ で始まります(古いキーの形式は LLM|| です)。", "signInWaiting": "ブラウザーでのサインインが完了するのを待っています…", "signInTimedOut": "サインインが時間内に完了しませんでした。もう一度お試しください。", - "signInDenied": "ブラウザーでサインインが拒否されました。新しいコードを取得するには、もう一度サインインしてください。", "signInExpired": "承認される前にコードの有効期限が切れました。新しいコードを取得するには、もう一度サインインしてください。", - "signInNotSaved": "Muse Code はサインインを完了できませんでした。もう一度お試しください。理由は Muse Spark のログに記録されています。", + "signInEnded": "サインインが終了しました: {outcome}。新しいコードを取得するには、もう一度サインインしてください。理由は Muse Spark のログに記録されています。", "cliKeychainElsewhere": "Muse Code を起動できません。サインイン ファイル {path} は macOS のキーチェーンを参照していますが、このシステムの Muse Code はキーチェーンを使用できません。このファイルを移動するか名前を変更してから、もう一度サインインしてください。", "hostExited": "Muse Code が予期せず停止しました", "hostStarting": "Muse Code を起動しています…", diff --git a/l10n/ui.ko.json b/l10n/ui.ko.json index bc436078e..95b6970fc 100644 --- a/l10n/ui.ko.json +++ b/l10n/ui.ko.json @@ -48,9 +48,8 @@ "apiKeyInvalid": "Model API 키는 LLM_로 시작합니다(이전 키의 형식은 LLM||입니다).", "signInWaiting": "브라우저 로그인이 완료되기를 기다리는 중…", "signInTimedOut": "로그인이 제시간에 완료되지 않았습니다. 다시 시도하세요.", - "signInDenied": "브라우저에서 로그인이 거부되었습니다. 새 코드를 받으려면 다시 로그인하세요.", "signInExpired": "승인되기 전에 코드가 만료되었습니다. 새 코드를 받으려면 다시 로그인하세요.", - "signInNotSaved": "Muse Code가 로그인을 완료하지 못했습니다. 다시 시도하세요. 이유는 Muse Spark 로그에서 확인할 수 있습니다.", + "signInEnded": "로그인이 종료되었습니다: {outcome}. 새 코드를 받으려면 다시 로그인하세요. 이유는 Muse Spark 로그에서 확인할 수 있습니다.", "cliKeychainElsewhere": "Muse Code를 시작할 수 없습니다. 로그인 파일 {path}이(가) macOS 키체인을 가리키지만 이 시스템의 Muse Code는 키체인을 사용할 수 없습니다. 이 파일을 옮기거나 이름을 바꾼 뒤 다시 로그인하세요.", "hostExited": "Muse Code가 예기치 않게 중지되었습니다", "hostStarting": "Muse Code를 시작하는 중…", diff --git a/l10n/ui.pl.json b/l10n/ui.pl.json index d810abfc2..b14a2e404 100644 --- a/l10n/ui.pl.json +++ b/l10n/ui.pl.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "Logowanie zostało odrzucone w przeglądarce. Zaloguj się ponownie, aby otrzymać nowy kod.", "signInExpired": "Kod wygasł, zanim został zatwierdzony. Zaloguj się ponownie, aby otrzymać nowy kod.", - "signInNotSaved": "Muse Code nie mógł dokończyć logowania. Spróbuj ponownie; przyczynę znajdziesz w dzienniku Muse Spark.", + "signInEnded": "Logowanie zakończone: {outcome}. Zaloguj się ponownie, aby otrzymać nowy kod; przyczynę znajdziesz w dzienniku Muse Spark.", "cliKeychainElsewhere": "Nie można uruchomić Muse Code: jego plik logowania {path} wskazuje na pęk kluczy macOS, z którego Muse Code nie może korzystać 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…", diff --git a/l10n/ui.pt-br.json b/l10n/ui.pt-br.json index 2aab55dc0..01c15307e 100644 --- a/l10n/ui.pt-br.json +++ b/l10n/ui.pt-br.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "A entrada foi recusada no navegador. Entre novamente para receber um novo código.", "signInExpired": "O código expirou antes de ser aprovado. Entre novamente para receber um novo código.", - "signInNotSaved": "O Muse Code não conseguiu concluir a entrada. Tente novamente; o log do Muse Spark mostra o motivo.", + "signInEnded": "A entrada terminou: {outcome}. Entre novamente para receber um novo código; o log do Muse Spark mostra o motivo.", "cliKeychainElsewhere": "O Muse Code não consegue iniciar: o arquivo de entrada {path} aponta para as Chaves do macOS, que o Muse Code não pode usar neste sistema. Mova ou renomeie esse arquivo e entre novamente.", "hostExited": "O Muse Code parou inesperadamente", "hostStarting": "Iniciando o Muse Code…", diff --git a/l10n/ui.ru.json b/l10n/ui.ru.json index a05c3638e..6a71946a6 100644 --- a/l10n/ui.ru.json +++ b/l10n/ui.ru.json @@ -48,9 +48,8 @@ "apiKeyInvalid": "Ключ Model API начинается с LLM_ (старые ключи имеют вид LLM||).", "signInWaiting": "Ожидание завершения входа в браузере…", "signInTimedOut": "Вход не был выполнен вовремя. Повторите попытку.", - "signInDenied": "Вход отклонён в браузере. Войдите снова, чтобы получить новый код.", "signInExpired": "Срок действия кода истёк до подтверждения. Войдите снова, чтобы получить новый код.", - "signInNotSaved": "Muse Code не удалось завершить вход. Повторите попытку; причина указана в журнале Muse Spark.", + "signInEnded": "Вход завершён: {outcome}. Войдите снова, чтобы получить новый код; причина указана в журнале Muse Spark.", "cliKeychainElsewhere": "Muse Code не может запуститься: его файл входа {path} указывает на связку ключей macOS, которую Muse Code не может использовать в этой системе. Переместите или переименуйте этот файл, затем войдите снова.", "hostExited": "Muse Code неожиданно остановился", "hostStarting": "Запуск Muse Code…", diff --git a/l10n/ui.tr.json b/l10n/ui.tr.json index df7aa3ba6..6686edeb6 100644 --- a/l10n/ui.tr.json +++ b/l10n/ui.tr.json @@ -48,9 +48,8 @@ "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.", - "signInDenied": "Oturum açma isteği tarayıcıda reddedildi. Yeni bir kod almak için yeniden oturum açın.", "signInExpired": "Kodun süresi onaylanmadan önce doldu. Yeni bir kod almak için yeniden oturum açın.", - "signInNotSaved": "Muse Code oturum açmayı tamamlayamadı. Yeniden deneyin; nedeni Muse Spark günlüğünde yazıyor.", + "signInEnded": "Oturum açma sona erdi: {outcome}. Yeni bir kod almak için yeniden oturum açın; nedeni Muse Spark günlüğünde yazıyor.", "cliKeychainElsewhere": "Muse Code başlatılamıyor: oturum açma dosyası {path}, Muse Code’un bu sistemde kullanamadığı macOS Anahtar Zinciri’ni gösteriyor. 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…", diff --git a/l10n/ui.zh-cn.json b/l10n/ui.zh-cn.json index 2f035e8c0..08f68f9b3 100644 --- a/l10n/ui.zh-cn.json +++ b/l10n/ui.zh-cn.json @@ -48,9 +48,8 @@ "apiKeyInvalid": "Model API 密钥以 LLM_ 开头(旧密钥的格式类似 LLM||)。", "signInWaiting": "正在等待浏览器登录完成…", "signInTimedOut": "登录未在规定时间内完成。请重试。", - "signInDenied": "已在浏览器中拒绝登录。请重新登录以获取新代码。", "signInExpired": "代码在获得批准前已过期。请重新登录以获取新代码。", - "signInNotSaved": "Muse Code 无法完成登录。请重试;原因见 Muse Spark 日志。", + "signInEnded": "登录已结束:{outcome}。请重新登录以获取新代码;原因见 Muse Spark 日志。", "cliKeychainElsewhere": "Muse Code 无法启动:其登录文件 {path} 指向 macOS 钥匙串,而此系统上的 Muse Code 无法使用钥匙串。请移动或重命名该文件,然后重新登录。", "hostExited": "Muse Code 意外停止", "hostStarting": "正在启动 Muse Code…", diff --git a/l10n/ui.zh-tw.json b/l10n/ui.zh-tw.json index 4d0bd98c7..8264d5bac 100644 --- a/l10n/ui.zh-tw.json +++ b/l10n/ui.zh-tw.json @@ -48,9 +48,8 @@ "apiKeyInvalid": "Model API 金鑰以 LLM_ 開頭(舊金鑰的格式類似 LLM||)。", "signInWaiting": "正在等候瀏覽器登入完成…", "signInTimedOut": "登入未在時限內完成。請再試一次。", - "signInDenied": "已在瀏覽器中拒絕登入。請重新登入以取得新代碼。", "signInExpired": "代碼在獲得核准前已過期。請重新登入以取得新代碼。", - "signInNotSaved": "Muse Code 無法完成登入。請再試一次;原因請見 Muse Spark 記錄。", + "signInEnded": "登入已結束:{outcome}。請重新登入以取得新代碼;原因請見 Muse Spark 記錄。", "cliKeychainElsewhere": "Muse Code 無法啟動:其登入檔案 {path} 指向 macOS 鑰匙圈,但此系統上的 Muse Code 無法使用鑰匙圈。請移動或重新命名該檔案,然後重新登入。", "hostExited": "Muse Code 意外停止", "hostStarting": "正在啟動 Muse Code…", diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 56d140bf7..56f0e3a17 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -22,7 +22,7 @@ import { fill } from '../../shared/l10n/text' import type { AuthStatus, HostToWebviewMessage, SignInMethod } from '../../shared/protocol' import type { Logger } from '../logger' import { isCliSignedIn } from './cliAccount' -import type { DeviceSignInOutcome, DeviceSignInRefusal } from './deviceSignIn' +import type { DeviceSignInEnded, DeviceSignInOutcome } from './deviceSignIn' import type { CredentialStore } from './credentialStore' export interface AuthBackendFacts { @@ -119,19 +119,15 @@ function signInLine(snapshot: AuthSnapshot): string { return `Sign-in state: ${snapshot.status}${backend}${why}` } -/** Why a device sign-in the host ended did not sign in (read when shown, D33). */ -function refusedSignInText(refusal: DeviceSignInRefusal): string { - switch (refusal) { - case 'denied': { - return UI_TEXT.signInDenied - } - case 'expired': { - return UI_TEXT.signInExpired - } - case 'failed': { - return UI_TEXT.signInNotSaved - } - } +/** + * Why a device sign-in the host ended did not sign in (read when shown, D33): + * the captured `expired` in its own words, any other ending as Muse Code + * named it (AGENTS.md rule 13). + */ +function endedSignInText(ending: 'expired' | DeviceSignInEnded): string { + return ending === 'expired' + ? UI_TEXT.signInExpired + : fill(UI_TEXT.signInEnded, { outcome: ending.endedAs }) } export class AuthService { @@ -303,7 +299,7 @@ export class AuthService { return await this.finishFailedCliSignIn( initial, 'signedOut', - refusedSignInText(outcome), + endedSignInText(outcome), 'warning', ) } diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index a6e8c9557..238ccd903 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -2,13 +2,23 @@ // 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 the host says so and -// `account/read` agrees, or 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 without `account/read` falls -// back to the host's word or the file alone. `account/loginCompleted` ends a -// denied, expired or failed flow at once. `account/changed` is not relied on: -// it did not fire for a change made outside the 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`. Only +// `cancelled` and `expired` were captured live (docs/certification/ +// sign-in-detection.md, "Live capture"), so they are the only endings with a +// meaning of their own; any other word is shown as the CLI named it +// (AGENTS.md rule 13). `granted` was not captured, so it is not taken for a +// sign-in: `account/read` or the file decides. `account/changed` is not +// relied on: it fired neither for a change made outside the host nor for an +// expired code. +// +// Cancel and the host's ending 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. import * as z from 'zod/mini' import { clipForLog } from '../../core/logging' @@ -23,6 +33,8 @@ import { 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 { Logger } from '../logger' @@ -32,10 +44,11 @@ const loginStartSchema = z.object({ verificationUrl: z.url(), userCode: z.string().check(z.minLength(1)), }) -// `message` is display text for denied, expired and failed (the schema's -// `AccountLoginCompletedParams`); it goes to the log, clipped, never the panel. +// As captured: `{outcome: "expired", message: "login failed: the request +// expired"}` and `{outcome: "cancelled"}`. The message goes to the log, +// clipped, never the panel. const loginCompletedSchema = z.object({ - outcome: z.string(), + outcome: z.string().check(z.minLength(1)), message: z.optional(z.string()), }) const loginCancelSchema = z.object({ cancelled: z.boolean() }) @@ -50,18 +63,39 @@ export interface DeviceSignInDeps { readonly log: Logger } -/** How the flow ended the host's way: refused in the browser, too late, or not saved. */ -export type DeviceSignInRefusal = 'denied' | 'expired' | 'failed' -export type DeviceSignInOutcome = 'signedIn' | 'cancelled' | 'timedOut' | DeviceSignInRefusal -const CANCELLED_START = Symbol('cancelled device sign-in start') +/** An ending Muse Code named that no capture covers: shown as it came (AGENTS.md rule 13). */ +export interface DeviceSignInEnded { + readonly endedAs: string +} +/** How the host ended a flow that did not sign in. */ +type HostEnding = 'cancelled' | 'expired' | DeviceSignInEnded +export type DeviceSignInOutcome = 'signedIn' | 'timedOut' | HostEnding -const ENDING_OUTCOMES: ReadonlyMap = new Map([ +const STOPPED = Symbol('device sign-in stopped') + +// The endings captured live: `cancelled` (1.3.0, M55; 1.4.0-R4302.1) and +// `expired` (1.4.0-R4302.1, 600 s after `account/loginStart`). +const CAPTURED_ENDINGS: ReadonlyMap = new Map([ [MUSE_LOGIN_OUTCOMES.cancelled, 'cancelled'], - [MUSE_LOGIN_OUTCOMES.denied, 'denied'], [MUSE_LOGIN_OUTCOMES.expired, 'expired'], - [MUSE_LOGIN_OUTCOMES.failed, 'failed'], ]) +/** How `outcome` ends the flow; undefined for `granted`, which `account/read` must bear 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 } { const parsed = loginStartSchema.parse(raw) @@ -72,35 +106,29 @@ export function parseDeviceCode(raw: unknown): { readonly url: string; readonly return { url: url.href, code: parsed.userCode } } -/** What the host said when the flow ended; the first ending counts. */ -interface HostEnding { - outcome: string | undefined -} - /** The signals that a new sign-in landed, as one poll sees them. */ interface SignInSignals { readonly initial: AccountState | undefined + /** Undefined when the host did not answer, or the poll was cut short. */ readonly current: AccountState | undefined - readonly isGranted: boolean readonly isFileWritten: boolean } function isSignedIn(signals: SignInSignals): boolean { - const { initial, current, isGranted, isFileWritten } = signals + const { initial, current, isFileWritten } = signals if (current === undefined) { - // A CLI without `account/read`: the host's word, or a new file. - return isGranted || isFileWritten + return isFileWritten } - // A file written by a sign-out, or a "granted" the store does not show yet. + // A file written by a sign-out. if (current.state === MUSE_ACCOUNT_STATES.loggedOut && current.credentialRequired) { return false } - // `envKey` or a stored key may mask the new login, so the host's word and - // a new file count while the account is anything but signed out. + // `envKey` or a stored key may mask the new login, so a new file counts + // while the account is anything but signed out. const hasSignedIn = current.state === MUSE_ACCOUNT_STATES.accountLogin && initial?.state !== MUSE_ACCOUNT_STATES.accountLogin - return isGranted || isFileWritten || hasSignedIn + return isFileWritten || hasSignedIn } export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { @@ -118,104 +146,105 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise() + const stop = () => { + stopped.resolve(STOPPED) + } + let hostEnding: HostEnding | undefined + let isEnded = false try { session.connection.onNotification((notification) => { - if (notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED || ending.outcome !== undefined) { + if (isEnded || notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED) { return } const result = loginCompletedSchema.safeParse(notification.params) if (!result.success) { return } - ending.outcome = result.data.outcome + // The first ending counts; the host runs one flow. + isEnded = true + hostEnding = endingOf(result.data.outcome) const message = result.data.message === undefined ? '' : `: ${clipForLog(result.data.message)}` - deps.log.info(`Muse Code sign-in ended: ${result.data.outcome}${message}`) + deps.log.info(`Muse Code sign-in ended: ${clipForLog(result.data.outcome)}${message}`) + if (hostEnding !== undefined) { + stop() + } }) + deps.signal.addEventListener('abort', stop, { once: true }) if (isAborted()) { return 'cancelled' } - const cancelledStart = Promise.withResolvers() - const onAbort = () => { - cancelledStart.resolve(CANCELLED_START) - } - deps.signal.addEventListener('abort', onAbort, { once: true }) - let initial: AccountState | undefined - let start: unknown - try { - // The account before the flow, so a sign-in shows as a change. - const read = await Promise.race([ - readAccountState(session.connection), - cancelledStart.promise, - ]) - if (read === CANCELLED_START) { - return 'cancelled' - } - initial = read - 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) + /** `work`, or STOPPED as soon as Cancel or the host's ending arrives. */ + const untilStopped = (work: Promise) => Promise.race([work, stopped.promise]) + // The account before the flow, so a sign-in shows as a change. + const initial = await untilStopped(readAccountState(session.connection)) + if (initial === STOPPED) { + return hostEnding ?? 'cancelled' } - 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 hostEnding ?? 'cancelled' } 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') + } } const hasLanded = async () => { const modified = deps.credentialFileModifiedAt() + const current = await untilStopped(readAccountState(session.connection)) return isSignedIn({ initial, - current: await readAccountState(session.connection), - isGranted: ending.outcome === MUSE_LOGIN_OUTCOMES.granted, + current: current === STOPPED ? undefined : current, isFileWritten: modified !== undefined && modified !== before, }) } - const hostEnding = () => - ending.outcome === undefined ? undefined : ENDING_OUTCOMES.get(ending.outcome) while (deps.now() < deadline) { if (await hasLanded()) { return 'signedIn' } - const ended = hostEnding() - if (ended !== undefined) { - return ended + if (hostEnding !== undefined) { + return hostEnding } if (isAborted()) { await cancelLogin() return 'cancelled' } - await deps.sleep(CREDENTIAL_POLL_INTERVAL_MS) + await untilStopped(deps.sleep(CREDENTIAL_POLL_INTERVAL_MS)) } if (await hasLanded()) { return 'signedIn' } - const ended = hostEnding() - if (ended !== undefined) { - return ended + if (hostEnding !== undefined) { + return hostEnding } await cancelLogin() return isAborted() ? 'cancelled' : 'timedOut' } finally { + deps.signal.removeEventListener('abort', stop) await session.close() } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 97923f66d..7fabaca7b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1403,15 +1403,21 @@ export const MUSE_ACCOUNT_STATES = { accountLogin: 'accountLogin', } as const // `AccountLoginOutcome` (`account/loginCompleted`): how a device sign-in -// ended. Only `cancelled` was captured live; the rest are the schema's words, -// and the schema calls the vocabulary open. +// ended. `cancelled` and `expired` were captured live (1.4.0-R4302.1, +// 2026-09-27; `cancelled` also on 1.3.0). `granted` is the schema's word for +// success, not captured: it is not taken for a sign-in, 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', - denied: 'denied', expired: 'expired', - failed: 'failed', cancelled: 'cancelled', } 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 and whether a provider's `storage` points to the macOS // Keychain. Version 1 holds the credential itself (Windows, Linux, and macOS @@ -1566,11 +1572,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 waiting, -// and how often it asks the sign-in host (`account/read`) and looks at the -// credential file. +// 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 f6b881385..ff5c38fde 100644 --- a/src/shared/l10n/en.ts +++ b/src/shared/l10n/en.ts @@ -78,10 +78,11 @@ 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). - signInDenied: 'The sign-in was declined in the browser. Sign in again to get a new code.', + // How Muse Code ended a browser sign-in (`account/loginCompleted`, D26): + // `expired` 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.', - signInNotSaved: 'Muse Code could not finish the sign-in. Try again; the Muse Spark log says why.', + signInEnded: + 'Sign-in ended: {outcome}. Sign in again to get a new code; the Muse Spark log says why.', // A macOS Keychain pointer on Windows or Linux stops `muse serve` (D26). cliKeychainElsewhere: 'Muse Code cannot start: its sign-in file {path} points to the macOS Keychain, which Muse Code cannot use on this system. Move or rename that file, then sign in again.', diff --git a/test/e2e/cliAccount.e2e.test.ts b/test/e2e/cliAccount.e2e.test.ts index 72409586d..b8aeb5111 100644 --- a/test/e2e/cliAccount.e2e.test.ts +++ b/test/e2e/cliAccount.e2e.test.ts @@ -2,16 +2,21 @@ // 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. No token is in any file. +// 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 } from 'node:fs' +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 { FakeLogOutputChannel } from '../unit/helpers/fakes' import { FAKE_STORED_SIGN_IN, @@ -23,6 +28,10 @@ import { } 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 // A schema no build has written: only the CLI can say what it holds. const UNPLACEABLE = '{"schema_version": 9, "providers": {"meta": {}}}' const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' @@ -32,7 +41,7 @@ const workspaceRoot = mkdtempSync(path.join(tmpdir(), 'fake-muse-ws-')) const configHome = installFakeCredential(UNPLACEABLE) const managers: MuseCodeBackendManager[] = [] -function setup() { +function setup(fakeEnvironment: readonly { name: string; value: string }[] = []) { const log = new FakeLogOutputChannel() const backend = new MuseCodeBackendManager({ log, @@ -44,6 +53,7 @@ function setup() { { 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', @@ -105,3 +115,79 @@ describe('The CLI’s sign-in against a real child process', { timeout: TEST_TIM expect(t.probe).not.toHaveBeenCalled() }) }) + +/** 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]) + const onCode = vi.fn() + 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 } +} + +// 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( + [ + { name: 'MUSE_FAKE_LOGIN_ENDING', value: 'expired' }, + { name: 'MUSE_FAKE_LOGIN_ENDING_MS', value: String(ENDING_AFTER_MS) }, + ], + new AbortController().signal, + ) + 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: login failed: the request expired', + ) + }) + + 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' }, + { name: 'MUSE_FAKE_LOGIN_ENDING', value: 'expired' }, + { name: 'MUSE_FAKE_LOGIN_ENDING_MS', value: String(ENDING_AFTER_MS) }, + ], + new AbortController().signal, + ) + const started = Date.now() + await expect(t.outcome).resolves.toBe('expired') + expect(Date.now() - started).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 13653b870..9b60e2797 100644 --- a/test/e2e/fake-muse/serve.mjs +++ b/test/e2e/fake-muse/serve.mjs @@ -30,12 +30,22 @@ // `account/read` answers from the credential file under XDG_CONFIG_HOME // (a named provider is a login, anything else signed out), and // `account/logout` rewrites that 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 `account/loginCompleted` +// frame follows after MUSE_FAKE_LOGIN_ENDING_MS. `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). import { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { argv, env, exit, 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 @@ -83,6 +93,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) { @@ -394,6 +408,59 @@ function accountState() { : { state: 'loggedOut', 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 +} + +function startLogin() { + state.isLoginStarted = true + const ending = env['MUSE_FAKE_LOGIN_ENDING'] + const timer = + ending === undefined + ? undefined + : setTimeout( + () => { + state.login = undefined + send(capturedEnding(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) @@ -698,6 +765,17 @@ const handlers = { 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 { @@ -712,6 +790,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) { @@ -719,6 +798,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({ diff --git a/test/fixtures/msp/account-login-cancelled.json b/test/fixtures/msp/account-login-cancelled.json new file mode 100644 index 000000000..d3469f50f --- /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-expired.json b/test/fixtures/msp/account-login-expired.json new file mode 100644 index 000000000..590cfe52b --- /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/unit/authService.test.ts b/test/unit/authService.test.ts index 21a9b0aa9..7b16c4494 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -9,6 +9,7 @@ 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 { FakeLogOutputChannel, memorySecrets, unexpectedWarning } from './helpers/fakes' @@ -1041,29 +1042,36 @@ describe('AuthService.signOut and host reports', () => { }) }) +// `expired` 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)', () => { + const DENIED_TEXT = + 'Sign-in ended: denied. Sign in again to get a new code; the Muse Spark log says why.' + it.each([ - ['denied', EN.signInDenied], - ['expired', EN.signInExpired], - ['failed', EN.signInNotSaved], - ] as const)('ends a %s sign-in at once, signed out with its reason', async (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() - }) + ['the captured expired', 'expired', EN.signInExpired], + ['an uncaptured denied', { endedAs: 'denied' }, DENIED_TEXT], + ['an unknown', { endedAs: 'somethingNew' }, fill(EN.signInEnded, { outcome: 'somethingNew' })], + ] 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 denied CLI sign-in, with a notice', async () => { + 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') + h.runDeviceSignIn.mockResolvedValue({ endedAs: 'denied' }) await expect(h.service.signIn('browser')).resolves.toMatchObject({ status: 'signedIn', backend: 'modelApi', }) - expect(h.broadcasts).toContainEqual({ type: 'notice', level: 'warning', text: EN.signInDenied }) + expect(h.broadcasts).toContainEqual({ type: 'notice', level: 'warning', text: DENIED_TEXT }) }) }) diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index 1fbee2477..1f77c69a3 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -1,27 +1,56 @@ import { describe, expect, it, vi } from 'vitest' import type { AccountSession } from '../../src/host/auth/accountHost' -import { CREDENTIAL_POLL_TIMEOUT_MS } from '../../src/shared/constants' +import { + 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_EXPIRED_ENDING, + CAPTURED_LOGGED_OUT, + CAPTURED_LOGIN_START, + endingNamed, +} from './helpers/accountLoginCapture' import { FakeLogOutputChannel } from './helpers/fakes' -const DEVICE_URL = 'https://auth.meta.com/oauth/device/?code=example' +// 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() -// `account/read` as captured on 1.3.0 and 1.4.0 (the label redacted there, -// an e-mail address in real life, and dropped by the parser here). -const LOGGED_OUT = { state: 'loggedOut', credentialRequired: true } +// `account/read` signed in, as captured on 1.3.0 and 1.4.0 (the label +// redacted there, an e-mail address in real life, and dropped by the parser +// here). const SIGNED_IN = { state: 'accountLogin', label: 'person@example.com', credentialRequired: 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. `account` answers `account/read` (undefined: a CLI without - * it, which refuses the method as unknown). + * 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) { - const request = vi.fn((method: string) => { + 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({ verificationUrl: DEVICE_URL, userCode: 'ABCD-EFGH' }) + return Promise.resolve(CAPTURED_LOGIN_START) } if (method === 'account/read') { const answer = account() @@ -29,29 +58,25 @@ function session(account: () => AccountAnswer = () => undefined) { ? Promise.reject(new Error('no handler for account/read')) : Promise.resolve(answer) } - return Promise.resolve({ cancelled: true }) + 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: AccountSession = { connection: { request, onNotification }, close } - const complete = (params: Record) => { - onNotification.mock.calls[0]?.[0]({ - jsonrpc: '2.0', - method: 'account/loginCompleted', - params, - }) - } return { request, onNotification, close, deviceSession, complete } } 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() }) @@ -78,7 +103,7 @@ describe('Muse Code device sign-in', () => { 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() }) @@ -155,12 +180,10 @@ describe('Muse Code device sign-in', () => { ['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(() => LOGGED_OUT) + const t = session(() => CAPTURED_LOGGED_OUT) const answer = t.request.getMockImplementation() t.request.mockImplementation((method) => - method === stuck - ? new Promise>(() => undefined) - : (answer?.(method) ?? Promise.resolve({})), + method === stuck ? never() : (answer?.(method) ?? Promise.resolve({})), ) const abort = new AbortController() const pending = run(t, { signal: abort.signal }) @@ -173,6 +196,32 @@ describe('Muse Code device sign-in', () => { 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) + const answer = t.request.getMockImplementation() + t.request.mockImplementation((method) => + method === 'account/loginStart' ? never() : (answer?.(method) ?? Promise.resolve({})), + ) + const onCode = vi.fn() + const pending = runDeviceSignIn({ + connect: () => Promise.resolve(t.deviceSession), + credentialFileModifiedAt: () => undefined, + sleep: () => Promise.resolve(), + now: () => 0, + signal: new AbortController().signal, + onCode, + log, + }) + await vi.waitFor(() => { + expect(t.request).toHaveBeenCalledWith('account/loginStart', { type: 'deviceCode' }) + }) + 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() + }) + it('reports cancellation while the temporary host is still connecting', async () => { const abort = new AbortController() const close = vi.fn() @@ -230,51 +279,24 @@ 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(), - log, - }), - ).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(), - log, - }), - ).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() }) @@ -310,15 +332,14 @@ function run(t: ReturnType, flow: Flow = {}) { /** A clock that runs out after one poll. */ const ONE_POLL = CREDENTIAL_POLL_TIMEOUT_MS / 2 -// PLAN.md D26 (2026-09-27): the host's own ending, `account/read` on the open -// host, and the credential file, in that order of trust. +// 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('finishes on a granted outcome the account confirms, with no file change', async () => { - let account = LOGGED_OUT + 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 - t.complete({ outcome: 'granted' }) return Promise.resolve() } await expect(run(t, { sleep })).resolves.toBe('signedIn') @@ -327,31 +348,36 @@ describe('Muse Code device sign-in: how it ends', () => { expect(t.close).toHaveBeenCalledOnce() }) - it('waits while the store does not show a granted sign-in yet', async () => { - const accounts = [LOGGED_OUT, LOGGED_OUT, LOGGED_OUT, SIGNED_IN] + // `granted` was not captured: its word neither ends the flow nor signs in. + it('takes an uncaptured granted for nothing: account/read decides', 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({ outcome: 'granted' }) + t.complete(endingNamed('granted')) return Promise.resolve() } await expect(run(t, { sleep })).resolves.toBe('signedIn') expect(polls).toBe(2) }) - it('notices a sign-in by polling account/read when no outcome arrives', async () => { - let account = LOGGED_OUT - const t = session(() => account) + it('keeps waiting after an uncaptured granted the account never shows', async () => { + const t = session(() => CAPTURED_LOGGED_OUT) const sleep = () => { - account = SIGNED_IN + t.complete(endingNamed('granted')) return Promise.resolve() } - await expect(run(t, { sleep })).resolves.toBe('signedIn') + await expect(run(t, { sleep, step: ONE_POLL })).resolves.toBe('timedOut') + expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) }) it('does not take a file a sign-out rewrote for a sign-in', async () => { - const t = session(() => LOGGED_OUT) + const t = session(() => CAPTURED_LOGGED_OUT) let modified = 1 const sleep = () => { modified += 1 @@ -372,48 +398,189 @@ describe('Muse Code device sign-in: how it ends', () => { await expect(run(t, { sleep, modified: () => modified })).resolves.toBe('signedIn') }) - it.each(['denied', 'expired', 'failed'] as const)( - 'ends at once on %s, logs the host’s reason, and needs no loginCancel', + // 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 its message, 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: login failed: the request expired', + ) + }) + + // Rule 13: an ending no capture covers is shown as the CLI named it. + it.each(['denied', 'failed', 'somethingNew'])( + 'ends at once on %s, which no capture covers, as the CLI named it', async (outcome) => { - const t = session(() => LOGGED_OUT) - const logged = new FakeLogOutputChannel() + const t = session(() => CAPTURED_LOGGED_OUT) const sleep = () => { - t.complete({ outcome, message: `the flow was ${outcome}` }) + t.complete(endingNamed(outcome)) return Promise.resolve() } - await expect(run(t, { sleep, log: logged })).resolves.toBe(outcome) + await expect(run(t, { sleep })).resolves.toEqual({ endedAs: outcome }) expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) expect(t.close).toHaveBeenCalledOnce() - expect(logged.info).toHaveBeenCalledWith( - `Muse Code sign-in ended: ${outcome}: the flow was ${outcome}`, - ) }, ) + 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([ - ['an outcome it does not know, keeping the first ending', ['somethingNew', 'denied']], - ['a malformed ending', [undefined]], - ])('keeps waiting on %s', async (_name, outcomes) => { - const t = session(() => LOGGED_OUT) + ['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 = () => { - for (const outcome of outcomes) { - t.complete(outcome === undefined ? { result: 'denied' } : { outcome }) - } + t.complete(frame) return Promise.resolve() } await expect(run(t, { sleep, step: ONE_POLL })).resolves.toBe('timedOut') - expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) }) it('never logs the account’s label', async () => { const logged = new FakeLogOutputChannel() - const t = session(() => SIGNED_IN) - const sleep = () => { - t.complete({ outcome: 'granted' }) - return Promise.resolve() - } - await run(t, { sleep, log: logged }) + const accounts: AccountAnswer[] = [CAPTURED_LOGGED_OUT] + const t = session(() => accounts.shift() ?? SIGNED_IN) + await expect(run(t, { log: logged })).resolves.toBe('signedIn') const everything = [...logged.info.mock.calls, ...logged.warn.mock.calls].flat().join('\n') expect(everything).not.toContain('person@example.com') }) }) + +/** + * 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('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'], + ['an ending no capture covers', endingNamed('denied'), { endedAs: 'denied' }], + ])('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() } + const answer = t.request.getMockImplementation() + t.request.mockImplementation((method) => + method === 'account/loginCancel' ? never() : (answer?.(method) ?? Promise.resolve({})), + ) + 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 000000000..948124661 --- /dev/null +++ b/test/unit/helpers/accountLoginCapture.ts @@ -0,0 +1,107 @@ +// Muse Code 1.4.0-R4302.1's device sign-in as captured live on 2026-09-27 in +// a throwaway home (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. + +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' +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 the first time it was asked `method`. */ +function answerOf(name: CaptureName, method: string): Record { + const frames = framesOf(name) + const asked = frames.find((entry) => entry.dir === 'out' && entry.frame.method === method) + const result = frames.find((entry) => entry.dir === 'in' && entry.frame.id === asked?.frame.id) + ?.frame.result + if (result === undefined) { + throw new Error(`the ${name} capture has no answer to ${method}`) + } + return result +} + +/** 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 +} + +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') +/** 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') +/** `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 (`denied`, + * `failed`, a future one): as the `cancelled` capture, the outcome alone. + */ +export function endingNamed(outcome: string): CapturedNotification { + return { ...CAPTURED_CANCELLED_ENDING, params: { outcome } } +} From 11cc4aceedc0281954b7d88903a4a8ecbcc6dfa2 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 19:17:14 -0700 Subject: [PATCH 04/25] Let Cancel end a sign-in whose pre-flight probe goes unanswered The account/read probe before the device flow ran outside the flow's stop signal, so a CLI that never answered held Cancel and sign-out for the 30-second MSP timeout (the review of PR #49). It now races the flow's abort signal. Drill AB. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 25 +++++++++--------- src/host/auth/authService.ts | 34 ++++++++++++++++++++++--- test/unit/authService.test.ts | 12 +++++++++ 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index d2a7fde58..b8c4fc519 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -193,18 +193,19 @@ 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" | +| 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 | ## Not proved here diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 56f0e3a17..b572d55a0 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -130,6 +130,32 @@ function endedSignInText(ending: 'expired' | DeviceSignInEnded): string { : fill(UI_TEXT.signInEnded, { outcome: ending.endedAs }) } +/** The listener `unlessAborted` replaces once its promise is made. */ +const IGNORE_ABORT = (): 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. + */ +async function unlessAborted(work: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return undefined + } + let onAbort = IGNORE_ABORT + 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) + } +} + export class AuthService { private snapshot: AuthSnapshot = { status: 'checking', detail: undefined } /** The browser sign-in in flight: a second click joins it (PLAN.md D25). */ @@ -260,9 +286,11 @@ export class AuthService { this.deviceAbort = abort this.set({ ...this.snapshot, status: 'signingIn', detail: UI_TEXT.signInWaiting }) try { - // The sign-in host could not start with that file either. - const { isKeychainElsewhere } = await this.cliCredential(false) - if (isKeychainElsewhere) { + // 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?.isKeychainElsewhere === true) { return await this.finishFailedCliSignIn( initial, 'error', diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 7b16c4494..557681dce 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -285,6 +285,18 @@ 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('shows the device code, waits for the credential, and restarts the backend', async () => { const h = harness() h.runDeviceSignIn.mockImplementation((_signal, onCode) => { From 6464c237073a8c8a3bd22ff1df06e19889960dd7 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 19:36:36 -0700 Subject: [PATCH 05/25] Neither rejoin an abandoned probe nor count a file after a stop The review of PR #49: - Sign-out after a cancelled sign-in re-asked the CLI and joined the probe the cancel had left unanswered, waiting out the 30-second MSP deadline. Cancel now abandons it (CliAccount.abandonAsking): later questions start afresh, and its late answer is not remembered. - With a poll unanswered, a stop (Cancel, an ending) let the file-only fallback report a sign-in when the file changed, e.g. an unrelated sign-out rewrite. A stop now decides the flow; a real sign-in is still read from the file afterwards. Drills AC and AD. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 28 +++++++++++++------------ src/extension.ts | 3 +++ src/host/auth/authService.ts | 5 +++++ src/host/auth/cliAccount.ts | 13 +++++++++++- src/host/auth/deviceSignIn.ts | 7 ++++++- test/unit/authService.test.ts | 24 +++++++++++++++++++++ test/unit/cliAccount.test.ts | 23 ++++++++++++++++++++ test/unit/deviceSignIn.test.ts | 20 +++++++++++++++--- 8 files changed, 105 insertions(+), 18 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index b8c4fc519..c8e0ffdca 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -193,19 +193,21 @@ 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 | +| 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 | ## Not proved here diff --git a/src/extension.ts b/src/extension.ts index fbfc952b0..229f6539e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -686,6 +686,9 @@ export async function activate(context: vscode.ExtensionContext): Promise } }, cliSignIn: (isUserAction) => cliAccount.signIn(isUserAction), + abandonCliProbe: () => { + cliAccount.abandonAsking() + }, credentialFilePath: () => backend.credentialFilePath(), hasEnvironmentKey: () => backend.hasEnvironmentKey(), getBackendMode: () => currentSettings().backend, diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index b572d55a0..509dac296 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -36,6 +36,8 @@ export interface AuthBackendFacts { * too (its host reads the Keychain); otherwise it does not. */ readonly cliSignIn: (isUserAction: boolean) => Promise + /** Stops later questions waiting on a probe the CLI has not answered. */ + readonly abandonCliProbe: () => void /** Where the CLI keeps its sign-in, named when the file stops it starting. */ readonly credentialFilePath: () => string readonly hasEnvironmentKey: () => boolean @@ -648,6 +650,9 @@ export class AuthService { 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 (the review of PR #49). + this.deps.backend.abandonCliProbe() } /** One visible installer terminal and one location watch per window. */ diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index aa00e98e6..3fa30317d 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -119,7 +119,10 @@ export class CliAccount { this.asking = asking try { const signIn = await asking.signIn - this.answered = { key, signIn } + // An abandoned probe's late answer is not remembered. + if (this.asking === asking) { + this.answered = { key, signIn } + } return signIn } finally { if (this.asking === asking) { @@ -138,6 +141,14 @@ export class CliAccount { return signIn } + /** + * Leaves an unanswered probe behind (Cancel, sign-out; the review of PR + * #49): later questions start a fresh one instead of waiting on it. + */ + public abandonAsking(): void { + this.asking = 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. diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index 238ccd903..6a5ddd202 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -216,9 +216,14 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { 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 + } return isSignedIn({ initial, - current: current === STOPPED ? undefined : current, + current, isFileWritten: modified !== undefined && modified !== before, }) } diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 557681dce..a26b76a6c 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -27,6 +27,7 @@ interface Harness { backendMode: BackendMode } readonly cliSignIn: ReturnType Promise>> + readonly abandonCliProbe: ReturnType void>> /** `account/logout`: by default it works and leaves the CLI signed out. */ readonly logOutCli: ReturnType Promise>> readonly restartBackend: ReturnType< @@ -58,6 +59,7 @@ function harness(overrides: Partial = {}): Harness { const cliSignIn = vi.fn<(isUserAction: boolean) => Promise>(() => Promise.resolve(facts.cli), ) + const abandonCliProbe = vi.fn<() => void>() const logOutCli = vi.fn<() => Promise>(() => { facts.cli = 'signedOut' return Promise.resolve(true) @@ -80,6 +82,7 @@ function harness(overrides: Partial = {}): Harness { resolveCli: () => facts.cliPresent ? { ok: true, cliPath: '/bin/muse' } : { ok: false, reason: 'missing' }, cliSignIn, + abandonCliProbe, credentialFilePath: () => CREDENTIAL_PATH, hasEnvironmentKey: () => facts.envKey, getBackendMode: () => facts.backendMode, @@ -116,6 +119,7 @@ function harness(overrides: Partial = {}): Harness { broadcasts, facts, cliSignIn, + abandonCliProbe, logOutCli, restartBackend, runInTerminal, @@ -297,6 +301,26 @@ describe('AuthService.signIn', () => { 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() + }) + it('shows the device code, waits for the credential, and restarts the backend', async () => { const h = harness() h.runDeviceSignIn.mockImplementation((_signal, onCode) => { diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index 981229624..f60902370 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -207,4 +207,27 @@ describe('CliAccount', () => { await expect(Promise.all([first, second])).resolves.toEqual(['signedIn', 'signedIn']) expect(probe).toHaveBeenCalledOnce() }) + + it('asks afresh after an unanswered probe is abandoned, and forgets its late answer (the review of PR #49)', async () => { + 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)) + const checker = new CliAccount({ + platform: 'linux', + credentialFilePath: () => home.file, + probe, + log: new FakeLogOutputChannel(), + }) + const abandoned = checker.signIn(true) + checker.abandonAsking() + await expect(checker.signIn(true)).resolves.toBe('signedOut') + expect(probe).toHaveBeenCalledTimes(2) + // The first probe answers late: it settles its own caller only. + stale.resolve(SIGNED_IN) + await expect(abandoned).resolves.toBe('signedIn') + await expect(checker.signIn(false)).resolves.toBe('signedOut') + expect(probe).toHaveBeenCalledTimes(2) + }) }) diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index 1f77c69a3..624984a8e 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -129,7 +129,10 @@ describe('Muse Code device sign-in', () => { expect(t.close).toHaveBeenCalledOnce() }) - it('reports a completed credential write when cancellation races the same poll', async () => { + // 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('keeps a cancellation that races a credential write in the same poll', async () => { const t = session() const abort = new AbortController() let modified: number | undefined @@ -147,8 +150,8 @@ describe('Muse Code device sign-in', () => { onCode: vi.fn(), log, }), - ).resolves.toBe('signedIn') - expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) + ).resolves.toBe('cancelled') + expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) expect(t.close).toHaveBeenCalledOnce() }) @@ -504,6 +507,17 @@ function wedgedSession() { // 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() From 6801b88d845010b6b0f5354cc41a65a9a5eb0116 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 19:39:08 -0700 Subject: [PATCH 06/25] Share the two cancellation cases of the device sign-in test jscpd found them identical once a racing write became a cancellation too. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/unit/deviceSignIn.test.ts | 71 ++++++++++++++-------------------- 1 file changed, 28 insertions(+), 43 deletions(-) diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index 624984a8e..76a7da449 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -108,52 +108,37 @@ describe('Muse Code device sign-in', () => { 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(), - log, - }), - ).resolves.toBe('cancelled') - expect(t.request).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('keeps a cancellation that races a credential write in 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(), - log, - }), - ).resolves.toBe('cancelled') - expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) - expect(t.close).toHaveBeenCalledOnce() - }) + 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() From 934e09de2f6c61b7a1fa96a9fcea4f83d9ada441 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 19:49:50 -0700 Subject: [PATCH 07/25] Give account/read's uncaptured credentialRequired false no meaning Every captured account/read says credentialRequired: true. The code read false as a keyless gateway, so signed in (the review of PR #49, AGENTS.md rule 13). A signed-out answer with it false is now unknown, like any answer we cannot place, and the device flow treats signed out as signed out whatever the flag. Drills AE and AF. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 32 +++++++++++++------------ src/host/auth/cliAccount.ts | 12 ++++++---- src/host/auth/deviceSignIn.ts | 5 ++-- test/unit/cliAccount.test.ts | 4 ++-- test/unit/deviceSignIn.test.ts | 11 +++++++-- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index c8e0ffdca..0f9d31d83 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -193,21 +193,23 @@ 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 | +| 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 | ## Not proved here diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index 3fa30317d..81cceb575 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -69,12 +69,16 @@ export function cliSignInFromAccount(account: AccountState | undefined): CliSign if (account === undefined) { return 'unknown' } - // A keyless gateway needs no credential (the schema's `credentialRequired`). - if (!account.credentialRequired || isStoredSignIn(account)) { + if (isStoredSignIn(account)) { return 'signedIn' } - // `envKey` masks the stored lane, and a future state is not guessed at. - return account.state === MUSE_ACCOUNT_STATES.loggedOut ? 'signedOut' : 'unknown' + // 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 account.state === MUSE_ACCOUNT_STATES.loggedOut && account.credentialRequired + ? 'signedOut' + : 'unknown' } /** What the structure settles alone; a Keychain pointer on macOS and anything unrecognized go to the CLI. */ diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index 6a5ddd202..ed34d1a1c 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -119,8 +119,9 @@ function isSignedIn(signals: SignInSignals): boolean { if (current === undefined) { return isFileWritten } - // A file written by a sign-out. - if (current.state === MUSE_ACCOUNT_STATES.loggedOut && current.credentialRequired) { + // 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 diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index f60902370..3c13ff147 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -91,8 +91,8 @@ describe('cliSignInFromAccount', () => { expect(cliSignInFromAccount(SIGNED_IN)).toBe('signedIn') expect(cliSignInFromAccount({ state: 'apiKey', credentialRequired: true })).toBe('signedIn') expect(cliSignInFromAccount(LOGGED_OUT)).toBe('signedOut') - // A keyless gateway needs no sign-in at all. - expect(cliSignInFromAccount({ state: 'loggedOut', credentialRequired: false })).toBe('signedIn') + // 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( diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index 76a7da449..ddbc4109b 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -364,8 +364,15 @@ describe('Muse Code device sign-in: how it ends', () => { expect(t.request).toHaveBeenCalledWith('account/loginCancel', {}) }) - it('does not take a file a sign-out rewrote for a sign-in', async () => { - const t = session(() => CAPTURED_LOGGED_OUT) + // 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 From ae4f318cdbfb4c6b6914437fbca289f523adae5f Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 20:02:27 -0700 Subject: [PATCH 08/25] Read an empty version-2 credential file as signed out on every OS A signed-out macOS file copied to Windows or Linux names no provider, so it points nowhere; it was read as a Keychain pointer there, which blocked browser sign-in (the review of PR #49). An empty provider map is now signed out before the platform verdict. Drill AG. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 35 ++++++++++---------- src/core/backends/musecode/credentialFile.ts | 20 ++++++----- test/unit/credentialFile.test.ts | 8 +++++ 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 0f9d31d83..47ac9386f 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -193,23 +193,24 @@ 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 | +| 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 | ## Not proved here diff --git a/src/core/backends/musecode/credentialFile.ts b/src/core/backends/musecode/credentialFile.ts index a978fcfb6..149e106f5 100644 --- a/src/core/backends/musecode/credentialFile.ts +++ b/src/core/backends/musecode/credentialFile.ts @@ -68,19 +68,23 @@ export function credentialFileVerdict( } const version = parsed.data.schema_version const providers = Object.values(parsed.data.providers) + const isKnownVersion = + version === MUSE_CREDENTIAL_INLINE_SCHEMA || version === MUSE_CREDENTIAL_POINTER_SCHEMA + if (!isKnownVersion) { + return 'unrecognized' + } + // No provider points anywhere, whatever the version or OS: signed out (the + // review of PR #49; a signed-out macOS file copied elsewhere is no pointer). + if (providers.length === 0) { + return 'empty' + } const isKeychainStyle = version === MUSE_CREDENTIAL_POINTER_SCHEMA || providers.some((provider) => provider.storage === MUSE_CREDENTIAL_KEYCHAIN_STORAGE) - if (isKeychainStyle && platform !== 'darwin') { - return 'keychainElsewhere' - } if (isKeychainStyle) { - return providers.length === 0 ? 'empty' : 'keychain' - } - if (version !== MUSE_CREDENTIAL_INLINE_SCHEMA) { - return 'unrecognized' + return platform === 'darwin' ? 'keychain' : 'keychainElsewhere' } - return providers.length === 0 ? 'empty' : 'inline' + return 'inline' } /** `security find-generic-password` without `-g`/`-w`: 0 found, 44 not found. */ diff --git a/test/unit/credentialFile.test.ts b/test/unit/credentialFile.test.ts index 7480790f3..6df4d462e 100644 --- a/test/unit/credentialFile.test.ts +++ b/test/unit/credentialFile.test.ts @@ -64,6 +64,14 @@ describe('credentialFileVerdict', () => { expect(credentialFileVerdict(SCHEMA_1_POINTER, platform)).toBe('keychainElsewhere') }) + // A signed-out macOS file copied elsewhere names no provider, so points nowhere (the review of PR #49). + it.each(['win32', 'linux'] as const)( + 'reads an empty version-2 file as signed out on %s', + (platform) => { + expect(credentialFileVerdict('{"schema_version":2,"providers":{}}', platform)).toBe('empty') + }, + ) + it.each([ ['not JSON', '{"schema_version": 1, "providers": '], ['a future schema', '{"schema_version": 3, "providers": {"meta": {}}}'], From e97507c7be109955d40dcbb3fa800d0cc3c36748 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 20:15:55 -0700 Subject: [PATCH 09/25] Confirm a logout only on the captured signed-out answer account/logout counted as confirmed on any account/read answer but a stored sign-in, so envKey and the uncaptured credentialRequired false skipped the terminal fallback (the review of PR #49, AGENTS.md rule 13). Only loggedOut with credentialRequired true confirms now; envKey gets its own log line. cliSignInFromAccount shares the check. Drill AH. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 1 + src/host/auth/accountHost.ts | 25 ++++++++++++++++++++----- src/host/auth/cliAccount.ts | 8 +++----- test/unit/accountHost.test.ts | 21 +++++++++++++++------ 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 47ac9386f..133ef0fed 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -211,6 +211,7 @@ captured frames. | 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 | ## Not proved here diff --git a/src/host/auth/accountHost.ts b/src/host/auth/accountHost.ts index 7bbe2091d..a199e0a20 100644 --- a/src/host/auth/accountHost.ts +++ b/src/host/auth/accountHost.ts @@ -48,6 +48,16 @@ export function isStoredSignIn(account: AccountState): boolean { ) } +/** + * 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. */ @@ -111,9 +121,10 @@ export async function probeAccount( /** * MSP `account/logout` on a short-lived host, confirmed by `account/read`: - * `confirmed` when no stored credential is in use afterwards (`META_API_KEY`, - * which no logout can unset, is the caller's to report); `unconfirmed` when - * the host could not start, refused, or still reports a stored credential. + * `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: () => Promise, @@ -126,8 +137,12 @@ export async function logOutAccount( async (connection) => { const answer = await requestAccount(connection, MUSE_ACCOUNT_LOGOUT) const after = answer === undefined ? undefined : await readAccountState(connection) - if (after === undefined || isStoredSignIn(after)) { - log.warn(`Muse Code did not confirm account/logout (${after?.state ?? 'no answer'})`) + 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?.state ?? 'no answer'})`, + ) return 'unconfirmed' } log.info(`Muse Code signed out through account/logout (now ${after.state})`) diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index 81cceb575..cf298b51c 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -16,9 +16,9 @@ import { type CredentialFileVerdict, credentialFileVerdict, } from '../../core/backends/musecode/credentialFile' -import { MUSE_ACCOUNT_STATES, MUSE_CREDENTIAL_FILE_MAX_BYTES } from '../../shared/constants' +import { MUSE_CREDENTIAL_FILE_MAX_BYTES } from '../../shared/constants' import type { Logger } from '../logger' -import { type AccountState, isStoredSignIn } from './accountHost' +import { type AccountState, isCapturedSignedOut, isStoredSignIn } from './accountHost' export interface CredentialFileReading { /** `:`: a write changes it. */ @@ -76,9 +76,7 @@ export function cliSignInFromAccount(account: AccountState | undefined): CliSign // 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 account.state === MUSE_ACCOUNT_STATES.loggedOut && account.credentialRequired - ? 'signedOut' - : 'unknown' + return isCapturedSignedOut(account) ? 'signedOut' : 'unknown' } /** What the structure settles alone; a Keychain pointer on macOS and anything unrecognized go to the CLI. */ diff --git a/test/unit/accountHost.test.ts b/test/unit/accountHost.test.ts index ea6c1e14e..e1723e589 100644 --- a/test/unit/accountHost.test.ts +++ b/test/unit/accountHost.test.ts @@ -105,18 +105,27 @@ describe('logOutAccount', () => { expect(allLogged(log)).not.toContain('person@example.com') }) - it('leaves META_API_KEY to the caller: an environment key afterwards still confirms', async () => { - const envKey = { state: 'envKey', credentialRequired: true } - const t = host({ 'account/logout': envKey, 'account/read': envKey }) - await expect(logOutAccount(t.connect, new FakeLogOutputChannel())).resolves.toBe('confirmed') - }) - 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) { From 2c093c7c05fa1dc4cd921228c7c6864f99ffb711 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 20:16:50 -0700 Subject: [PATCH 10/25] Name the captured account/read states in the sign-in record Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 133ef0fed..65ced09b0 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -30,6 +30,12 @@ under `scratchpad/m140cred/`: 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. From ca73582d3fb293f5b0606d3867f03160edec0749 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 20:33:58 -0700 Subject: [PATCH 11/25] Let no refresh from before a sign-out publish its late answer A refresh blocked in account/read could answer after a sign-out (or a new sign-in) finished and overwrite that state with "Sign-out is in progress" (the review of PR #49). A refresh from an older sign-out epoch now returns the current snapshot; a race with the hold release keeps the hold without publishing. Drill AI. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 1 + src/host/auth/authService.ts | 17 ++++++++++++++--- test/unit/authService.test.ts | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 65ced09b0..a1570e47b 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -218,6 +218,7 @@ captured frames. | 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" | ## Not proved here diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 509dac296..0fc1d63fc 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -718,7 +718,13 @@ export class AuthService { public async refresh(isUserAction = false): Promise { const epoch = this.signOutEpoch const selected = await this.selectedSnapshot(isUserAction) - if (this.isSigningOut || this.signOutEpoch !== epoch) { + // 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 this.set({ ...selected, status: 'error', detail: this.logoutDetail() }) } if (!this.isLogoutHeld) { @@ -727,7 +733,7 @@ export class AuthService { 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() }) @@ -740,7 +746,12 @@ export class AuthService { const hasCurrentCredential = (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 hold stands, its state is its own. + 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() }) } diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index a26b76a6c..cff1b7f41 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -321,6 +321,22 @@ describe('AuthService.signIn', () => { expect(h.abandonCliProbe).toHaveBeenCalled() }) + it('keeps the state a sign-out published when an older refresh answers late (the review of PR #49)', async () => { + const h = harness() + const late = Promise.withResolvers() + h.cliSignIn.mockImplementationOnce(() => late.promise) + const stale = h.service.refresh() + await vi.waitFor(() => { + expect(h.cliSignIn).toHaveBeenCalled() + }) + await expect(h.service.signOut()).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) => { From f2c886d7c74349fe80b790a22cbb5d7ed4890262 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 20:37:13 -0700 Subject: [PATCH 12/25] Let a sign-out speak for the sign-in it cancelled A device sign-in that sign-out cancelled still refreshed and announced "Sign-in cancelled" over the sign-out in progress. It now leaves the state to the sign-out. Found while sweeping for stale publishes after the review of PR #49. Drill AJ. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 1 + src/host/auth/authService.ts | 4 ++++ test/unit/authService.test.ts | 8 ++++++++ 3 files changed, 13 insertions(+) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index a1570e47b..1ed8d3594 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -219,6 +219,7 @@ captured frames. | 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 | ## Not proved here diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 0fc1d63fc..f12405f43 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -375,6 +375,10 @@ export class AuthService { detail: string, noticeLevel: 'info' | 'warning', ): Promise { + // A sign-out that cancelled this sign-in publishes its own state. + if (this.isSigningOut) { + return this.snapshot + } if (this.isLogoutHeld) { const refreshed = await this.refresh(true) this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index cff1b7f41..a77ce0660 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -319,6 +319,14 @@ describe('AuthService.signIn', () => { // 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) }) it('keeps the state a sign-out published when an older refresh answers late (the review of PR #49)', async () => { From 42b7e58f7d5c8373efdbfe6bd8d222e528c53050 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 20:55:13 -0700 Subject: [PATCH 13/25] Look again when the credential file changes while the CLI answers An account/read answer about a file that was rewritten while the question was out (a sign-out, another CLI command) was returned as if about the new file (the review of PR #49). CliAccount now re-reads the file after the answer and decides again, up to three looks, then says unknown. Drill AK. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 43 +++++++++++++------------ src/host/auth/cliAccount.ts | 33 +++++++++++++++---- src/shared/constants.ts | 3 ++ test/unit/cliAccount.test.ts | 18 +++++++++++ 4 files changed, 69 insertions(+), 28 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 1ed8d3594..b9b530e2c 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -199,27 +199,28 @@ 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 | +| 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 | ## Not proved here diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index cf298b51c..5ecf39b01 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -16,7 +16,10 @@ import { type CredentialFileVerdict, credentialFileVerdict, } from '../../core/backends/musecode/credentialFile' -import { MUSE_CREDENTIAL_FILE_MAX_BYTES } from '../../shared/constants' +import { + MUSE_CREDENTIAL_FILE_MAX_BYTES, + MUSE_CREDENTIAL_READ_ATTEMPTS, +} from '../../shared/constants' import type { Logger } from '../logger' import { type AccountState, isCapturedSignedOut, isStoredSignIn } from './accountHost' @@ -143,6 +146,15 @@ export class CliAccount { 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, sign-out; the review of PR * #49): later questions start a fresh one instead of waiting on it. @@ -156,11 +168,18 @@ export class CliAccount { * read the Keychain); elsewhere an ambiguous file is asked about at once. */ public async signIn(isUserAction: boolean): Promise { - const filePath = this.deps.credentialFilePath() - const reading = readCredentialFile(filePath, this.deps.platform) - return reading === undefined - ? 'signedOut' - : (FILE_SIGN_IN[reading.verdict] ?? - (await this.confirm(`${filePath}\n${reading.signature}`, isUserAction))) + 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 (the review of PR #49): look again. + if (this.look().key === look.key) { + return signIn + } + } + return 'unknown' } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7fabaca7b..feda1d1cf 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1427,6 +1427,9 @@ export const MUSE_CREDENTIAL_POINTER_SCHEMA = 2 export const MUSE_CREDENTIAL_KEYCHAIN_STORAGE = 'keychain' /** 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: +// a file rewritten again and again ends as `unknown` (the review of PR #49). +export const MUSE_CREDENTIAL_READ_ATTEMPTS = 3 // 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. diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index 3c13ff147..1a7b07be4 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -208,6 +208,24 @@ describe('CliAccount', () => { expect(probe).toHaveBeenCalledOnce() }) + it('looks again when the file is rewritten while the CLI answers (the review of PR #49)', async () => { + const home = configHome() + home.write(MALFORMED) + const answer = Promise.withResolvers() + const probe = vi.fn(() => answer.promise) + const checker = new CliAccount({ + platform: 'linux', + credentialFilePath: () => home.file, + probe, + log: new FakeLogOutputChannel(), + }) + 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') + }) + it('asks afresh after an unanswered probe is abandoned, and forgets its late answer (the review of PR #49)', async () => { const home = configHome() home.write(MALFORMED) From 37df516df7bfa3d433ed76893f550c735132409f Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 20:59:54 -0700 Subject: [PATCH 14/25] Share the CliAccount test setups jscpd found repeated Co-Authored-By: Claude Opus 5.5 (1M context) --- test/unit/cliAccount.test.ts | 48 +++++++++++++++++------------------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index 1a7b07be4..f2f9163e1 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -57,6 +57,25 @@ function account(platform: NodeJS.Platform, file: string, answers: (AccountState 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 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() @@ -191,16 +210,7 @@ describe('CliAccount', () => { }) it('shares one question among callers asking at once', async () => { - const home = configHome() - home.write(MALFORMED) - const answer = Promise.withResolvers() - const probe = vi.fn(() => answer.promise) - const checker = new CliAccount({ - platform: 'linux', - credentialFilePath: () => home.file, - probe, - log: new FakeLogOutputChannel(), - }) + const { answer, probe, checker } = heldQuestion() const first = checker.signIn(false) const second = checker.signIn(true) answer.resolve(SIGNED_IN) @@ -209,16 +219,7 @@ describe('CliAccount', () => { }) it('looks again when the file is rewritten while the CLI answers (the review of PR #49)', async () => { - const home = configHome() - home.write(MALFORMED) - const answer = Promise.withResolvers() - const probe = vi.fn(() => answer.promise) - const checker = new CliAccount({ - platform: 'linux', - credentialFilePath: () => home.file, - probe, - log: new FakeLogOutputChannel(), - }) + 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) @@ -232,12 +233,7 @@ describe('CliAccount', () => { const stale = Promise.withResolvers() const answers = [stale.promise, Promise.resolve(LOGGED_OUT)] const probe = vi.fn(() => answers.shift() ?? Promise.resolve(undefined)) - const checker = new CliAccount({ - platform: 'linux', - credentialFilePath: () => home.file, - probe, - log: new FakeLogOutputChannel(), - }) + const checker = linuxChecker(home.file, probe) const abandoned = checker.signIn(true) checker.abandonAsking() await expect(checker.signIn(true)).resolves.toBe('signedOut') From ef29944baa50f55f0e7b4859dda6b232b99ee6f5 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 21:21:37 -0700 Subject: [PATCH 15/25] Forget the CLI's answers after a logout A confirmed account/logout that leaves auth.json as it was (a Keychain sign-in, whose vault item is what goes) kept CliAccount's remembered "signed in", so the logout hold never cleared (the review of PR #49). The abandon hook becomes forgetCliAnswers: it drops the unanswered probe and the remembered answer, and sign-out calls it after every logout. Drill AL. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 1 + src/extension.ts | 4 +-- src/host/auth/authService.ts | 12 ++++++--- src/host/auth/cliAccount.ts | 9 ++++--- test/unit/authService.test.ts | 34 +++++++++++++++++++------ test/unit/cliAccount.test.ts | 2 +- 6 files changed, 44 insertions(+), 18 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index b9b530e2c..3a08f374d 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -221,6 +221,7 @@ captured frames. | 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 | ## Not proved here diff --git a/src/extension.ts b/src/extension.ts index 229f6539e..db90c0285 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -686,8 +686,8 @@ export async function activate(context: vscode.ExtensionContext): Promise } }, cliSignIn: (isUserAction) => cliAccount.signIn(isUserAction), - abandonCliProbe: () => { - cliAccount.abandonAsking() + forgetCliAnswers: () => { + cliAccount.forgetAnswers() }, credentialFilePath: () => backend.credentialFilePath(), hasEnvironmentKey: () => backend.hasEnvironmentKey(), diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index f12405f43..9e5108786 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -36,8 +36,8 @@ export interface AuthBackendFacts { * too (its host reads the Keychain); otherwise it does not. */ readonly cliSignIn: (isUserAction: boolean) => Promise - /** Stops later questions waiting on a probe the CLI has not answered. */ - readonly abandonCliProbe: () => void + /** Drops what the CLI said: an unanswered probe, and a remembered answer. */ + readonly forgetCliAnswers: () => void /** Where the CLI keeps its sign-in, named when the file stops it starting. */ readonly credentialFilePath: () => string readonly hasEnvironmentKey: () => boolean @@ -567,7 +567,11 @@ export class AuthService { * Returns false when that terminal could not open. */ private async logOutCli(cliPath: string): Promise { - if (await this.deps.backend.logOutCli()) { + 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 } this.deps.log.warn('Muse Code did not sign out through its account host; running muse logout') @@ -656,7 +660,7 @@ export class AuthService { this.deviceAbort?.abort() // Sign-out and the next sign-in ask afresh rather than wait on a probe // the CLI left unanswered (the review of PR #49). - this.deps.backend.abandonCliProbe() + this.deps.backend.forgetCliAnswers() } /** One visible installer terminal and one location watch per window. */ diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index 5ecf39b01..e35e19c0e 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -156,11 +156,14 @@ export class CliAccount { } /** - * Leaves an unanswered probe behind (Cancel, sign-out; the review of PR - * #49): later questions start a fresh one instead of waiting on it. + * Forgets what the CLI said (Cancel, sign-out; the review of PR #49): an + * unanswered probe is left behind, and a remembered answer is dropped, so + * the next question asks afresh. A logout that leaves the file as it was + * (a Keychain sign-in) would otherwise keep reading as signed in. */ - public abandonAsking(): void { + public forgetAnswers(): void { this.asking = undefined + this.answered = undefined } /** diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index a77ce0660..313a39cd1 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -3,6 +3,7 @@ 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 } from '../../src/host/auth/authService' import { CliAccount } from '../../src/host/auth/cliAccount' import { CredentialStore } from '../../src/host/auth/credentialStore' @@ -27,7 +28,7 @@ interface Harness { backendMode: BackendMode } 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< @@ -59,7 +60,7 @@ function harness(overrides: Partial = {}): Harness { 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) @@ -82,7 +83,7 @@ function harness(overrides: Partial = {}): Harness { resolveCli: () => facts.cliPresent ? { ok: true, cliPath: '/bin/muse' } : { ok: false, reason: 'missing' }, cliSignIn, - abandonCliProbe, + forgetCliAnswers, credentialFilePath: () => CREDENTIAL_PATH, hasEnvironmentKey: () => facts.envKey, getBackendMode: () => facts.backendMode, @@ -119,7 +120,7 @@ function harness(overrides: Partial = {}): Harness { broadcasts, facts, cliSignIn, - abandonCliProbe, + forgetCliAnswers, logOutCli, restartBackend, runInTerminal, @@ -308,7 +309,7 @@ describe('AuthService.signIn', () => { h.cliSignIn.mockImplementation(() => isAbandoned ? Promise.resolve(h.facts.cli) : new Promise(() => undefined), ) - h.abandonCliProbe.mockImplementation(() => { + h.forgetCliAnswers.mockImplementation(() => { isAbandoned = true }) const pending = h.service.signIn('browser') @@ -318,7 +319,7 @@ describe('AuthService.signIn', () => { 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() + expect(h.forgetCliAnswers).toHaveBeenCalled() // Its cancellation never showed over the sign-out's own state or as a notice. expect( h.broadcasts.some( @@ -1204,7 +1205,7 @@ describe('AuthService over the CLI’s real credential file', () => { if (contents !== undefined) { writeFileSync(file, contents) } - const probe = vi.fn(() => Promise.resolve(undefined)) + const probe = vi.fn<() => Promise>(() => Promise.resolve(undefined)) const account = new CliAccount({ platform: 'linux', credentialFilePath: () => file, @@ -1215,7 +1216,13 @@ describe('AuthService over the CLI’s real credential file', () => { const facts = h.deps.backend const service = new AuthService({ ...h.deps, - backend: { ...facts, cliSignIn: (isUserAction) => account.signIn(isUserAction) }, + backend: { + ...facts, + cliSignIn: (isUserAction) => account.signIn(isUserAction), + forgetCliAnswers: () => { + account.forgetAnswers() + }, + }, }) return { h, file, service, probe } } @@ -1229,6 +1236,17 @@ describe('AuthService over the CLI’s real credential file', () => { 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('{"schema_version": 1, "providers": ') + t.probe + .mockResolvedValueOnce({ state: 'accountLogin', credentialRequired: true }) + .mockResolvedValue({ state: 'loggedOut', credentialRequired: true }) + 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) + }) + it('signs out through account/logout: the file stays, empty, and the hold is released', async () => { const t = withFile(STORED_SIGN_IN) t.h.logOutCli.mockImplementation(() => { diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index f2f9163e1..7edbdae15 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -235,7 +235,7 @@ describe('CliAccount', () => { const probe = vi.fn(() => answers.shift() ?? Promise.resolve(undefined)) const checker = linuxChecker(home.file, probe) const abandoned = checker.signIn(true) - checker.abandonAsking() + checker.forgetAnswers() await expect(checker.signIn(true)).resolves.toBe('signedOut') expect(probe).toHaveBeenCalledTimes(2) // The first probe answers late: it settles its own caller only. From 990ee91e5e7a5e62aff415c3a6801a70f20fed47 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 22:38:40 -0700 Subject: [PATCH 16/25] Settle PR #49's third review round on captured sign-in endings The live captures of 2026-09-27 (Muse Code 1.4.0-R4302.1, Windows, throwaway homes, 0 model attempts) join the fixtures: granted, denied and failed. The unit tests and the fake muse serve replay them. Races: - R1: a finished sign-in's confirming questions yield to the flow's signal, so a sign-out never waits on them. - R2: Cancel only abandons an unanswered probe; the remembered answer stands. A failed sign-in's refresh yields to a sign-out. - R3: the sign-out epoch also moves as a sign-out ends. - R4: Check again forgets the CLI's answers before it asks. - R5: with no first account/read, only a new file counts as a sign-in. - R6: a Cancel after the credential file changed lets the file decide. Wire evidence (AGENTS.md rule 13): - W1: only providers.meta speaks for the sign-in; the bundled Slack connector's entry alone is asked of the CLI. - W2: muse serve on Windows exits 3 on an empty version-2 file too (captured); every version-2 file off macOS is now unsupportedHere (renamed from keychainElsewhere, message cliCredentialUnsupported). With META_API_KEY set it starts (captured), so the key still wins. - W3: the tests use the captured file shapes; synthetic ones say so. Failure paths: - C1: a sign-out that keeps the hold publishes error, with Check again. - C2: the device flow fails at once when its host exits. - C3: the window closing closes every account host, cancels the sign-in and waits for it before the backends stop. - C4: denied and failed have their own messages; only expired's and denied's messages are logged (failed's names a profile path). - C5: an unconfirmed logout reads the sign-in again before opening a terminal. - C6: the CLI's terminals get museSpark.environmentVariables. - C7, C8: docs and a comment say what happens on macOS. - C9: a successful device sign-in forgets the CLI's earlier answers. - C10: the e2e "at once" is timed from the code being shown. Drills AM to BJ each broke one guard, failed its suite and were restored by SHA-256. Docs: PLAN.md D26, CHANGELOG, README, PRIVACY, SECURITY, AGENTS.md, CONTRIBUTING, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 12 +- CHANGELOG.md | 53 ++- CONTRIBUTING.md | 5 +- PLAN.md | 89 ++-- README.md | 52 ++- SECURITY.md | 17 +- docs/PRIVACY.md | 35 +- docs/certification/sign-in-detection.md | 263 ++++++++--- l10n/ui.cs.json | 6 +- l10n/ui.de.json | 6 +- l10n/ui.es.json | 6 +- l10n/ui.fr.json | 6 +- l10n/ui.hu.json | 6 +- l10n/ui.it.json | 6 +- l10n/ui.ja.json | 6 +- l10n/ui.ko.json | 6 +- l10n/ui.pl.json | 6 +- l10n/ui.pt-br.json | 6 +- l10n/ui.ru.json | 6 +- l10n/ui.tr.json | 6 +- l10n/ui.zh-cn.json | 6 +- l10n/ui.zh-tw.json | 6 +- src/core/backends/musecode/credentialFile.ts | 67 +-- src/core/backends/musecode/launch.ts | 18 + src/core/support/report.ts | 2 +- src/core/timeouts.ts | 34 +- src/extension.ts | 52 ++- src/host/auth/accountHost.ts | 57 ++- src/host/auth/authService.ts | 312 +++++++++---- src/host/auth/cliAccount.ts | 23 +- src/host/auth/deviceSignIn.ts | 137 ++++-- .../conversation/conversationController.ts | 5 +- src/shared/constants.ts | 24 +- src/shared/l10n/en.ts | 15 +- test/e2e/cliAccount.e2e.test.ts | 92 +++- test/e2e/fake-muse/serve.mjs | 73 ++- test/e2e/fakeMuse.ts | 14 +- test/fixtures/msp/account-login-denied.json | 326 +++++++++++++ test/fixtures/msp/account-login-failed.json | 327 +++++++++++++ test/fixtures/msp/account-login-granted.json | 442 ++++++++++++++++++ test/unit/accountHost.test.ts | 58 ++- test/unit/authService.test.ts | 294 ++++++++++-- test/unit/cliAccount.test.ts | 40 +- test/unit/conversationController.test.ts | 8 +- test/unit/credentialFile.test.ts | 82 ++-- test/unit/deviceSignIn.test.ts | 169 +++++-- test/unit/helpers/accountLoginCapture.ts | 61 ++- test/unit/helpers/credentialShapes.ts | 48 ++ test/unit/launch.test.ts | 24 + 49 files changed, 2861 insertions(+), 553 deletions(-) create mode 100644 test/fixtures/msp/account-login-denied.json create mode 100644 test/fixtures/msp/account-login-failed.json create mode 100644 test/fixtures/msp/account-login-granted.json create mode 100644 test/unit/helpers/credentialShapes.ts diff --git a/AGENTS.md b/AGENTS.md index 90a18cd45..6c4649763 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,15 @@ them, the milestone plan, and the certification checklist. 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, - whether a provider is named, and a Keychain `storage` lane. It also - reads the file's size and modification time. Never a token value. + which providers are named (only `meta` speaks for the sign-in), and a + Keychain `storage` lane. 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) is never kept, logged - or shown. + (PLAN.md D26). Its `label` (an e-mail address) and `avatarUrl` are + never kept, logged or shown. + - **A sign-in's ending.** Only `loginCompleted` messages captured as + naming nothing personal (`expired`, `denied`) reach the log; `failed`'s + names a path under the user's profile. - **Logging.** Log through the `LogOutputChannel`; never `console.log` in the host. 9. **Dependencies are deliberate.** Before adding one: check peer ranges diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8277361..c0879f8dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,33 +49,62 @@ happened, not what was planned; superseded entries are kept. 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, whether it names a provider, and whether it points to - the macOS Keychain. Every other value, the token included, is dropped - as the file is parsed. + schema version, which providers it names, and whether Muse Code's own + (`meta`) points to the macOS Keychain. Every other 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, 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). It keeps the - answer until the file changes. On macOS it asks only after a click in - the panel. + answer until the file changes, or until you sign in, sign out or choose + **Check again**, which always asks afresh. On macOS it asks only when + you act: a click in the panel, or the **Sign Out** or **Diagnostics** + command. - **Signing out.** Sign-out uses Muse Code's own `account/logout` and - confirms it with `account/read`. `muse logout` in a terminal remains - the fallback. + 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. + - **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.** Any other way Muse Code ends the sign-in is shown in - Muse Code's own word. - - **Cancel.** It works at once, even when Muse Code stops answering. + - **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. If + the browser approved just before, the panel follows what Muse Code + saved instead of saying the sign-in was cancelled. - **Success.** It is taken from Muse Code's `account/read` turning signed in, or from a new credential file it does not contradict. + - **A host that exits.** The sign-in fails at once instead of waiting + out the eleven-minute limit. + - **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. - **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, which Muse Code 1.4.0 on + Windows refuses as well. +- **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: - no secret and no prompt. + 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.** When Muse Code could not save a browser sign-in, its message + names a folder in your profile; the log now says only that saving + failed. ## [0.9.1] - 2026-09-27 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd7430559..d89337d9c 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 d90528f7d..c3739a94e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -852,26 +852,41 @@ action that fails is worse than hiding one that would work. 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 three facts are kept: the schema version, whether any provider is + Only three facts are kept: the schema version, which providers are named, and whether a provider's `storage` is the Keychain. The schema - parse drops every other field. It decides: + 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; - an empty file → signed out; - - a file holding the credential → signed in; - - a Keychain pointer off macOS → a named error (`muse serve` exits 3 - there with it). + - a `meta` entry holding the credential → signed in; + - other providers alone → asked of the CLI; + - 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 on + Windows: "unsupported auth schema version 2", captured 2026-09-27 for + an empty file and a pointer). With `META_API_KEY` set it starts and + answers `envKey` (captured the same day), so the key still wins. - **Asking the CLI.** A Keychain pointer on macOS, 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. + - 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. - On macOS the CLI is asked only on a user action (Check again, sign-in, - sign-out, Diagnostics), so a Keychain prompt follows a click. + 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`. The terminal `muse logout` is the - fallback, confirmed later by the file or the CLI. + 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. - **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. @@ -880,31 +895,44 @@ action that fails is worse than hiding one that would work. 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).** The endings - were captured on 1.4.0-R4302.1 in throwaway homes: `expired`, 600 s - after `loginStart`, with a message, and `cancelled` - (`test/fixtures/msp/`). - - **`granted`, `denied`, `failed`: not captured.** The owner's Chrome has - the Chrome Control extension disabled, so no code could be approved or - declined. - - **`granted`.** It neither ends the flow nor counts as a sign-in; - `account/read` decides. - - **Every other word.** It ends the flow at once. The captured `expired` - has its own message; any other word is shown as Muse Code sent it - (AGENTS.md rule 13). +- **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 after the file was written and `account/read` + already said `accountLogin`; it neither ends the flow nor counts on + its own, and `account/read` or the file decides. + - **`denied` and `failed`.** Each ends the flow with its own message. + `failed`'s message names the credential file's path under the user's + profile, so only `expired`'s and `denied`'s messages are logged. + - **Every other word.** It ends the flow at once and is shown as Muse + Code sent it (AGENTS.md rule 13), its message unlogged. + - **With no first `account/read`,** only a file written since the flow + began counts; a sign-in from before would pass for a new one. + - **Cancel after approval.** When the file changed since the flow began, + its structure decides, not the click. + - **A host that exits** fails the flow at once (`connection.closed`). - **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. + 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. - **Where it is only as good as the schema.** `account/*` stays experimental. -- **Owner steps.** A real sign-in on each OS remains an owner step: - - the macOS pointer after a 1.4.0 login; - - the success sequence and a declined code, once Chrome Control is back - on (`scratchpad/cred-capture/capture.mjs`); - - logout of an OAuth slot. +- **Owner steps.** A real sign-in remains an owner step on: + - macOS, for the pointer after a 1.4.0 login; + - 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. @@ -5961,9 +5989,10 @@ 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`. (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 uncaptured -`granted` is not one. The PR #49 capture added `expired`, 600 s after -`loginStart`, to the captured endings.) +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.) **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 f679c0dda..b3573f595 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,11 @@ harness:shots`) against a scripted session, so they match the build. - **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 within ten minutes, before it expires. **Cancel sign-in** stops the temporary CLI - sign-in process. If Muse Code ends the sign-in another way, the panel - shows Muse Code's word for it. Work is billed to your Muse subscription. + 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 @@ -1302,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 @@ -1473,13 +1476,18 @@ stopped and the next message resumes the same session. 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** — sign-out asks - Muse Code to sign itself out (`account/logout`). Only when that cannot - run does it open `muse logout` in a terminal. - - **Waiting for the terminal:** the panel stays gated 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. + 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 @@ -1490,17 +1498,23 @@ stopped and the next message resumes the same session. 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 points - to the macOS Keychain** — the `auth.json` it names was written on a Mac, - where the token lives in the Keychain. Muse Code on Windows or Linux - exits at startup with that file. Move or rename the file, then sign in - again. +- **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 exits at startup with such a file, even an empty one, + and the extension treats Linux the same way. 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 after you click something in the - panel, so a Keychain prompt never appears just because VS Code opened. - Choose **Check again** to have it asked now. **Muse Spark: Diagnostics** - says whether the Keychain item exists; it never reads the secret. + 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. 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 diff --git a/SECURITY.md b/SECURITY.md index e5babfb5d..1f4452470 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,14 +31,17 @@ Only the latest release on the Visual Studio Marketplace receives fixes. 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 extension reads only the structure of the Muse Code CLI's own credential - file, never a token in it: the schema version, whether it names a - provider, and whether it points to the macOS Keychain. When that is not - enough, it asks the CLI (`account/read`) and discards the account label - 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 + file, never a token in it: the schema version, which providers it names + (only `meta` speaks for the sign-in), and whether that one points to the + macOS Keychain. 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 or error. The only page sign-in opens must be on - `https://auth.meta.com`. The log channel redacts + timeout, error or when the window closes; a sign-in whose host exits + fails at once. The only page sign-in opens must be on + `https://auth.meta.com`. A failed sign-in's message, which names a + folder in the user's profile, is not logged. The log channel redacts key-shaped strings, in Meta's current `LLM_…` form and the older `LLM||` one. - **Workspace trust.** In VS Code's Restricted Mode the agent loads no diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 5f172a181..fd9947871 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -199,29 +199,42 @@ fields, never raw configuration or failed-command output. 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, whether it names a - provider, and whether it points to the Keychain. + structure of `auth.json`: its schema version, which providers it names + (only Muse Code's own, `meta`, speaks for the sign-in), and whether that + one points to the Keychain. - Every other 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). It keeps the answer until the file changes. - - The CLI's answer carries your account's e-mail address as a label. The - extension discards it without logging or showing it. - - On macOS it asks only after you click something in the panel, since - the CLI may read the Keychain to answer. + conversation). It keeps the answer until the file changes, or until + you sign in, sign out or choose **Check again**. + - 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 gets the word it ended + with. Its message goes to the log only for a code that expired or was + denied, whose captured messages name nothing personal. When Muse Code + could not save the sign-in, its message names a folder in your profile, + so the log says only that saving failed. - **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 reads no secret and shows no prompt. The report says whether - the item is there. + `-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**) 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 that cannot run, it - runs `muse logout` in a terminal instead. + `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. diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 3a08f374d..c4193070d 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -38,49 +38,81 @@ under `scratchpad/m140cred/`: 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. + 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 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, any provider named, a Keychain `storage` lane. The schema drops every other field. A file over 64 KiB, a folder or a stat failure is not read. | `src/core/backends/musecode/credentialFile.ts`, `readCredentialFile` in `src/host/auth/cliAccount.ts` | `credentialFile.test.ts` (the captured shapes on each OS, six malformed files); `cliAccount.test.ts` (`readCredentialFile`) | -| No file, or the empty file a sign-out leaves, is signed out without a process. A file holding the credential is signed in. | `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, 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`, `probeAccount` | `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, Diagnostics), never on activation or panel open. | `CliAccount.confirm`; `AuthService.refresh(isUserAction)`; the controller's `retryBackend` | `cliAccount.test.ts`; `authService.test.ts` "when the CLI may be asked"; `conversationController.test.ts` | -| A macOS pointer on Windows or Linux is a named error that gives the file's path; the browser sign-in is refused with the same text instead of starting a host that exits 3. | `AuthService.selectedSnapshot`, `signInWithCli`; `UI_TEXT.cliKeychainElsewhere` | `authService.test.ts` "a credential file Muse Code cannot start with" | -| Sign-out goes through MSP `account/logout`, confirmed by `account/read`. `muse logout` in a terminal is the fallback. The logout hold ends once the file or the CLI shows no sign-in, even though the file stays. | `logOutAccount`; `AuthService.performSignOut`, `refresh` | `accountHost.test.ts`; `authService.test.ts` (the rewritten former 845–853 case, the account/logout path, the fallback, the real-file cases); 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. `granted` was not captured, so it is not a signal (PR #49 review, below). | `runDeviceSignIn` | `deviceSignIn.test.ts` "how it ends" | -| `account/loginCompleted` ends the flow at once on any outcome but `granted`. The captured `expired` shows its own message. Any other word is shown as Muse Code sent it, in one message (`signInEnded`). The host's message goes to the log, clipped. A malformed ending keeps waiting. | `runDeviceSignIn`; `AuthService` (`signInExpired`, `signInEnded`) | `deviceSignIn.test.ts`; `authService.test.ts` "how Muse Code ends a browser sign-in"; `cliAccount.e2e.test.ts` (the captured frames replayed) | -| Cancel and the host's ending are noticed at once, even while an `account/read` goes unanswered: each poll and each wait races a stop signal. `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"; `cliAccount.e2e.test.ts` (the fake leaves `account/read` unanswered) | -| 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. | `AuthService.signInWithCli` | `authService.test.ts` "cancels the running device flow" (it caught the regression during this work) | -| The account's `label` (an e-mail address) is dropped by the `account/read` parse and never logged. | `accountStateSchema` | `accountHost.test.ts`; `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). | `renderSupportReport`, `keychainItemPresence`, the Diagnostics command | `supportReport.test.ts`; `credentialFile.test.ts` | +| Behaviour | Where | Tests | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `auth.json` is parsed for its structure only: schema version, which providers are named, a Keychain `storage` lane. 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 file a sign-out leaves, is signed out without a process. A `meta` entry holding the credential is signed in. Other providers alone are asked of the CLI. | `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, 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). | `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. The browser sign-in is refused with the same text instead of starting a host that exits 3. `META_API_KEY` still wins: `muse serve` starts with it. | `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. With no first `account/read`, only a new file counts. | `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`). Only `expired`’s and `denied`’s messages reach the log; `failed`’s names a path, so a fixed line stands in. 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). | `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). | `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). | `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`. It serves `account/logout` by -rewriting that file as the empty one the CLI leaves. -`installFakeCredential` writes a stored login's structure (schema 1, one -provider, no secret) instead of `{"fake": true}`. +credential file under `XDG_CONFIG_HOME`: `providers.meta` is a login, +anything else signed out. It serves `account/logout` by rewriting that +file as the empty one the CLI leaves. `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 a capture's `loginCompleted` frame after - `MUSE_FAKE_LOGIN_ENDING_MS`; +- `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. + 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`. +`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. +- **`granted`.** Captured now, it still needs no handler: it comes after + the file and `account/read` already show the sign-in. ## Live capture of the device sign-in (PR #49 review) @@ -131,25 +163,57 @@ capture. the ending. The code is replaced by its shape, `AAAA-AAAA`; no label, e-mail or token was in any frame. -**Not captured: `granted`, `denied` and `failed`.** Each needs a click on -the device page in the owner's signed-in Chrome, through Chrome Control. -The Chrome Control extension is disabled in that Chrome profile -(`Default`, `disable_reasons: [1]`, a user action), so the MCP tools could -not attach. Turning it back on changes the owner's browser, which is his -call. So: - -- **No handler keeps a meaning built on those words.** -- **`granted`.** It does not end the flow and is not a sign-in. The flow - goes on until `account/read` turns `accountLogin`, or a new file appears - that it does not contradict. -- **Every other word.** It ends the flow at once. The panel shows one - localized message with the word as Muse Code sent it (`signInEnded`, - "Sign-in ended: {outcome}. …"). The host's message goes to the log. -- **Capturing them later.** Once Chrome Control can attach, the same script - captures them: `node capture.mjs granted|denied|failed-dirfile out`. - `failed-dirfile` puts a file where the config folder goes, so the save - after an approval should fail. A `granted` run signs out through - `account/logout` in the same home before the home is deleted. +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 keeps `expired`'s and `denied`'s messages + only: `failed`'s names a path, so the log says "saving the credential + failed". `granted` keeps no handler: it comes after the file and + `account/read` already show the sign-in. **What the wire showed that the code had wrong.** @@ -165,6 +229,45 @@ call. So: 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 is treated + like Windows; it was not probed. +- **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. + ## Drills Each drill broke one guard in the working tree and ran the named suites. @@ -223,6 +326,42 @@ captured frames. | 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" | + ## Not proved here A real sign-in is the owner's to give, and each of these needs one: @@ -230,20 +369,18 @@ 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. -- **The success sequence, and `denied` and `failed`.** These are the order - and timing of `account/loginCompleted {outcome: "granted"}` against - `account/read`, `account/changed` and the file, and the frames of a - declined code and a failed save. `cancelled` and `expired` were captured - live (above). The other three need Chrome Control, whose extension is - disabled in the owner's Chrome. Until then they have no handler of their - own. -- **Logout of an OAuth login slot.** It should leave the same empty file, - and the server-side revoke is unverified. Stored keys were verified. - **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). -- **Windows and Linux R4302.1.** Whether a device-code login persists to - the file. Only the stored-key writer was exercised. +- **Linux R4302.1.** Whether a device-code login persists to the file as + it does on Windows, and whether `muse serve` refuses a version-2 file + there as it does on Windows. The extension treats Linux like Windows. + +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). ## The gate @@ -299,3 +436,21 @@ With the PR #49 review fixes, `npm run quality` on the working tree 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`. diff --git a/l10n/ui.cs.json b/l10n/ui.cs.json index 4ee6cd90b..d299c1866 100644 --- a/l10n/ui.cs.json +++ b/l10n/ui.cs.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "Přihlášení skončilo: {outcome}. Přihlaste se znovu a získejte nový kód; důvod najdete v protokolu Muse Spark.", - "cliKeychainElsewhere": "Muse Code nelze spustit: jeho soubor přihlášení {path} odkazuje na Klíčenku systému macOS, kterou Muse Code v tomto systému nemůže použít. Přesuňte nebo přejmenujte tento soubor a pak se přihlaste znovu.", + "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 fd145cbf1..15d6b8984 100644 --- a/l10n/ui.de.json +++ b/l10n/ui.de.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "Anmeldung beendet: {outcome}. Melden Sie sich erneut an, um einen neuen Code zu erhalten; den Grund finden Sie im Muse Spark-Protokoll.", - "cliKeychainElsewhere": "Muse Code kann nicht starten: Seine Anmeldedatei {path} verweist auf den macOS-Schlüsselbund, den Muse Code auf diesem System nicht nutzen kann. Verschieben Sie diese Datei oder benennen Sie sie um, und melden Sie sich dann erneut an.", + "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 897817c53..3e600b679 100644 --- a/l10n/ui.es.json +++ b/l10n/ui.es.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "El inicio de sesión terminó: {outcome}. Vuelva a iniciar sesión para obtener un código nuevo; el registro de Muse Spark indica el motivo.", - "cliKeychainElsewhere": "Muse Code no puede iniciarse: su archivo de inicio de sesión {path} apunta al llavero de macOS, que Muse Code no puede usar en este sistema. Mueva o cambie el nombre de ese archivo y vuelva a iniciar sesión.", + "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 db5583d5c..bc173da18 100644 --- a/l10n/ui.fr.json +++ b/l10n/ui.fr.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "Connexion terminée : {outcome}. Reconnectez-vous pour obtenir un nouveau code. Le journal de Muse Spark en indique la raison.", - "cliKeychainElsewhere": "Muse Code ne peut pas démarrer : son fichier de connexion {path} pointe vers le trousseau macOS, que Muse Code ne peut pas utiliser sur ce système. Déplacez ou renommez ce fichier, puis reconnectez-vous.", + "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 669584e8a..6e63b8e76 100644 --- a/l10n/ui.hu.json +++ b/l10n/ui.hu.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "A bejelentkezés véget ért: {outcome}. Jelentkezzen be újra, hogy új kódot kapjon; az okot a Muse Spark naplója tartalmazza.", - "cliKeychainElsewhere": "A Muse Code nem tud elindulni: a bejelentkezési fájlja ({path}) a macOS kulcskarikájára mutat, amelyet a Muse Code ezen a rendszeren nem tud használni. Helyezze át vagy nevezze át ezt a fájlt, majd jelentkezzen be újra.", + "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 b2cfa388e..a82d0f213 100644 --- a/l10n/ui.it.json +++ b/l10n/ui.it.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "L’accesso si è concluso: {outcome}. Accedi di nuovo per ottenere un nuovo codice; il log di Muse Spark indica il motivo.", - "cliKeychainElsewhere": "Muse Code non può avviarsi: il suo file di accesso {path} rimanda al Portachiavi di macOS, che Muse Code non può usare su questo sistema. Sposta o rinomina il file, quindi accedi di nuovo.", + "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 29caf99d8..be334b479 100644 --- a/l10n/ui.ja.json +++ b/l10n/ui.ja.json @@ -49,8 +49,10 @@ "signInWaiting": "ブラウザーでのサインインが完了するのを待っています…", "signInTimedOut": "サインインが時間内に完了しませんでした。もう一度お試しください。", "signInExpired": "承認される前にコードの有効期限が切れました。新しいコードを取得するには、もう一度サインインしてください。", - "signInEnded": "サインインが終了しました: {outcome}。新しいコードを取得するには、もう一度サインインしてください。理由は Muse Spark のログに記録されています。", - "cliKeychainElsewhere": "Muse Code を起動できません。サインイン ファイル {path} は macOS のキーチェーンを参照していますが、このシステムの Muse Code はキーチェーンを使用できません。このファイルを移動するか名前を変更してから、もう一度サインインしてください。", + "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 95b6970fc..76764297c 100644 --- a/l10n/ui.ko.json +++ b/l10n/ui.ko.json @@ -49,8 +49,10 @@ "signInWaiting": "브라우저 로그인이 완료되기를 기다리는 중…", "signInTimedOut": "로그인이 제시간에 완료되지 않았습니다. 다시 시도하세요.", "signInExpired": "승인되기 전에 코드가 만료되었습니다. 새 코드를 받으려면 다시 로그인하세요.", - "signInEnded": "로그인이 종료되었습니다: {outcome}. 새 코드를 받으려면 다시 로그인하세요. 이유는 Muse Spark 로그에서 확인할 수 있습니다.", - "cliKeychainElsewhere": "Muse Code를 시작할 수 없습니다. 로그인 파일 {path}이(가) macOS 키체인을 가리키지만 이 시스템의 Muse Code는 키체인을 사용할 수 없습니다. 이 파일을 옮기거나 이름을 바꾼 뒤 다시 로그인하세요.", + "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 b14a2e404..7e0ef0575 100644 --- a/l10n/ui.pl.json +++ b/l10n/ui.pl.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "Logowanie zakończone: {outcome}. Zaloguj się ponownie, aby otrzymać nowy kod; przyczynę znajdziesz w dzienniku Muse Spark.", - "cliKeychainElsewhere": "Nie można uruchomić Muse Code: jego plik logowania {path} wskazuje na pęk kluczy macOS, z którego Muse Code nie może korzystać w tym systemie. Przenieś ten plik lub zmień jego nazwę, a potem zaloguj się ponownie.", + "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 01c15307e..e15830534 100644 --- a/l10n/ui.pt-br.json +++ b/l10n/ui.pt-br.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "A entrada terminou: {outcome}. Entre novamente para receber um novo código; o log do Muse Spark mostra o motivo.", - "cliKeychainElsewhere": "O Muse Code não consegue iniciar: o arquivo de entrada {path} aponta para as Chaves do macOS, que o Muse Code não pode usar neste sistema. Mova ou renomeie esse arquivo e entre novamente.", + "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 6a71946a6..ba78dee0c 100644 --- a/l10n/ui.ru.json +++ b/l10n/ui.ru.json @@ -49,8 +49,10 @@ "signInWaiting": "Ожидание завершения входа в браузере…", "signInTimedOut": "Вход не был выполнен вовремя. Повторите попытку.", "signInExpired": "Срок действия кода истёк до подтверждения. Войдите снова, чтобы получить новый код.", - "signInEnded": "Вход завершён: {outcome}. Войдите снова, чтобы получить новый код; причина указана в журнале Muse Spark.", - "cliKeychainElsewhere": "Muse Code не может запуститься: его файл входа {path} указывает на связку ключей macOS, которую Muse Code не может использовать в этой системе. Переместите или переименуйте этот файл, затем войдите снова.", + "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 6686edeb6..6d216167d 100644 --- a/l10n/ui.tr.json +++ b/l10n/ui.tr.json @@ -49,8 +49,10 @@ "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.", - "signInEnded": "Oturum açma sona erdi: {outcome}. Yeni bir kod almak için yeniden oturum açın; nedeni Muse Spark günlüğünde yazıyor.", - "cliKeychainElsewhere": "Muse Code başlatılamıyor: oturum açma dosyası {path}, Muse Code’un bu sistemde kullanamadığı macOS Anahtar Zinciri’ni gösteriyor. Bu dosyayı taşıyın veya yeniden adlandırın, ardından 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 08f68f9b3..e1eb49cd9 100644 --- a/l10n/ui.zh-cn.json +++ b/l10n/ui.zh-cn.json @@ -49,8 +49,10 @@ "signInWaiting": "正在等待浏览器登录完成…", "signInTimedOut": "登录未在规定时间内完成。请重试。", "signInExpired": "代码在获得批准前已过期。请重新登录以获取新代码。", - "signInEnded": "登录已结束:{outcome}。请重新登录以获取新代码;原因见 Muse Spark 日志。", - "cliKeychainElsewhere": "Muse Code 无法启动:其登录文件 {path} 指向 macOS 钥匙串,而此系统上的 Muse Code 无法使用钥匙串。请移动或重命名该文件,然后重新登录。", + "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 8264d5bac..9e04c9a57 100644 --- a/l10n/ui.zh-tw.json +++ b/l10n/ui.zh-tw.json @@ -49,8 +49,10 @@ "signInWaiting": "正在等候瀏覽器登入完成…", "signInTimedOut": "登入未在時限內完成。請再試一次。", "signInExpired": "代碼在獲得核准前已過期。請重新登入以取得新代碼。", - "signInEnded": "登入已結束:{outcome}。請重新登入以取得新代碼;原因請見 Muse Spark 記錄。", - "cliKeychainElsewhere": "Muse Code 無法啟動:其登入檔案 {path} 指向 macOS 鑰匙圈,但此系統上的 Muse Code 無法使用鑰匙圈。請移動或重新命名該檔案,然後重新登入。", + "signInDenied": "您已在瀏覽器中拒絕登入。", + "signInSaveFailed": "Muse Code 已登入,但無法儲存認證資訊。", + "signInEnded": "登入已結束:{outcome}。請重新登入以取得新代碼。", + "cliCredentialUnsupported": "Muse Code 無法啟動:其登入檔案 {path} 為 macOS 格式,此系統上的 Muse Code 無法讀取。請移動或重新命名該檔案,然後重新登入。", "hostExited": "Muse Code 意外停止", "hostStarting": "正在啟動 Muse Code…", "hostRestartsOnSend": "下一則訊息會重新啟動它並繼續此對話。", diff --git a/src/core/backends/musecode/credentialFile.ts b/src/core/backends/musecode/credentialFile.ts index 149e106f5..5004f4439 100644 --- a/src/core/backends/musecode/credentialFile.ts +++ b/src/core/backends/musecode/credentialFile.ts @@ -1,19 +1,25 @@ // 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 three facts: the schema version, whether any provider is -// named, and whether a provider's `storage` points to the macOS Keychain. -// Every other field, the token included, is dropped by the parse, and -// nothing from the file is stored, logged or passed on. +// schema keeps three facts: the schema version, which providers are named, +// and whether a provider's `storage` points to the macOS Keychain. Every +// other field, the token included, is dropped by the parse, and 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 a provider holding its credential: signed in. +// - Version 1 with `providers.meta` holding its credential: `muse auth set` +// writes `api_key` alone, a browser sign-in the token, the key and the +// account's name. Signed in. +// - 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. // - 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 that file, and with a -// version-1 provider whose storage is the Keychain. +// token in the login Keychain and the file is a pointer. On Windows +// `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. import * as z from 'zod/mini' import { @@ -21,26 +27,27 @@ import { MUSE_CREDENTIAL_INLINE_SCHEMA, MUSE_CREDENTIAL_KEYCHAIN_STORAGE, MUSE_CREDENTIAL_POINTER_SCHEMA, + MUSE_CREDENTIAL_PROVIDER, } from '../../../shared/constants' /** * - `empty`: names no provider; the file a sign-out leaves. * - `inline`: holds the credential itself. * - `keychain`: points to the macOS Keychain (on macOS). - * - `keychainElsewhere`: a macOS pointer on Windows or Linux, where `muse - * serve` cannot start with it. + * - `unsupportedHere`: a macOS file on Windows or Linux (version 2, or the + * Keychain lane), where `muse serve` cannot start with it. * - `unrecognized`: anything else; only the CLI can say. */ export type CredentialFileVerdict = - 'empty' | 'inline' | 'keychain' | 'keychainElsewhere' | 'unrecognized' + '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); `keychainElsewhere` when the file - * stops `muse serve` from starting. + * turn's `authRequired` corrects it); `unsupportedHere` when the file stops + * `muse serve` from starting. */ -export type CliSignIn = 'signedIn' | 'signedOut' | 'unknown' | 'keychainElsewhere' +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' @@ -67,22 +74,30 @@ export function credentialFileVerdict( return 'unrecognized' } const version = parsed.data.schema_version - const providers = Object.values(parsed.data.providers) - const isKnownVersion = - version === MUSE_CREDENTIAL_INLINE_SCHEMA || version === MUSE_CREDENTIAL_POINTER_SCHEMA - if (!isKnownVersion) { + if (version !== MUSE_CREDENTIAL_INLINE_SCHEMA && version !== MUSE_CREDENTIAL_POINTER_SCHEMA) { return 'unrecognized' } - // No provider points anywhere, whatever the version or OS: signed out (the - // review of PR #49; a signed-out macOS file copied elsewhere is no pointer). - if (providers.length === 0) { - return 'empty' + const isMacOs = platform === 'darwin' + // Off macOS the version alone stops `muse serve`, whatever the file holds + // (captured on Windows for a pointer and for an empty file; the review of + // PR #49). + if (version === MUSE_CREDENTIAL_POINTER_SCHEMA && !isMacOs) { + return 'unsupportedHere' } - const isKeychainStyle = + const providers = parsed.data.providers + const muse = Object.hasOwn(providers, MUSE_CREDENTIAL_PROVIDER) + ? providers[MUSE_CREDENTIAL_PROVIDER] + : undefined + if (muse === undefined) { + // No provider at all is signed out; another provider alone (a + // connector's token) says nothing about the sign-in. + return Object.keys(providers).length === 0 ? 'empty' : 'unrecognized' + } + if ( version === MUSE_CREDENTIAL_POINTER_SCHEMA || - providers.some((provider) => provider.storage === MUSE_CREDENTIAL_KEYCHAIN_STORAGE) - if (isKeychainStyle) { - return platform === 'darwin' ? 'keychain' : 'keychainElsewhere' + muse.storage === MUSE_CREDENTIAL_KEYCHAIN_STORAGE + ) { + return isMacOs ? 'keychain' : 'unsupportedHere' } return 'inline' } diff --git a/src/core/backends/musecode/launch.ts b/src/core/backends/musecode/launch.ts index 7367d358b..2808ae3ee 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/support/report.ts b/src/core/support/report.ts index cb53f4ef1..d73cdd37e 100644 --- a/src/core/support/report.ts +++ b/src/core/support/report.ts @@ -71,7 +71,7 @@ export interface SupportFacts { /** * 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), `keychainElsewhere` (a macOS pointer where `muse serve` cannot + * pointer), `unsupportedHere` (a macOS file where `muse serve` cannot * start with it) or `unrecognized`. */ readonly cliCredentialFile: CredentialFileVerdict | 'absent' diff --git a/src/core/timeouts.ts b/src/core/timeouts.ts index e50dff7f2..b40d71cdf 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 db90c0285..7c18ceba9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,7 +8,7 @@ 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 { personalSkillsRoot } from './core/context/skills' @@ -29,7 +29,7 @@ import { rootRelativePath, } from './core/workspaceRoot' import { keychainItemPresence } from './core/backends/musecode/credentialFile' -import { connectAccountSession, logOutAccount, probeAccount } from './host/auth/accountHost' +import { AccountHosts, connectAccountSession } from './host/auth/accountHost' import { AuthService } from './host/auth/authService' import { CliAccount, isCliSignedIn } from './host/auth/cliAccount' import { runDeviceSignIn } from './host/auth/deviceSignIn' @@ -281,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'] @@ -297,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): @@ -602,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({ @@ -624,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()), @@ -663,10 +681,12 @@ export async function activate(context: vscode.ExtensionContext): Promise // 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: () => probeAccount(() => connectAccountHost(new AbortController().signal), log), + probe: () => accountHosts.probe(), log, }) /** The CLI's own credential without a question to the CLI on macOS: META_API_KEY or its sign-in. */ @@ -686,17 +706,19 @@ export async function activate(context: vscode.ExtensionContext): Promise } }, 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 logOutAccount(() => connectAccountHost(new AbortController().signal), log)) === - 'confirmed', + logOutCli: async () => (await accountHosts.logOut()) === 'confirmed', }, credentials, logoutHold: { @@ -704,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, @@ -759,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) => { @@ -1595,7 +1625,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) diff --git a/src/host/auth/accountHost.ts b/src/host/auth/accountHost.ts index a199e0a20..169af6593 100644 --- a/src/host/auth/accountHost.ts +++ b/src/host/auth/accountHost.ts @@ -8,7 +8,7 @@ import { spawnMspConnection, type Connection } from '@muse-code/sdk' import * as z from 'zod/mini' -import { withDeadline } from '../../core/timeouts' +import { unlessAborted, withDeadline } from '../../core/timeouts' import { MSP_CLIENT_NAME, MSP_HANDSHAKE_TIMEOUT_MS, @@ -29,10 +29,14 @@ const accountStateSchema = z.object({ export type AccountState = z.infer export interface AccountSession { - readonly connection: Pick + /** `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. */ @@ -86,22 +90,27 @@ export async function readAccountState( return await requestAccount(connection, MUSE_ACCOUNT_READ) } -/** `use` on one short-lived host, closed afterwards; `fallback` when it cannot start. */ +/** + * `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: () => Promise, + connect: ConnectAccountHost, log: Logger, + ended: AbortSignal, fallback: T, use: (connection: AccountConnection) => Promise, ): Promise { let session: AccountSession try { - session = await connect() + session = await connect(ended) } catch (error: unknown) { log.warn(`The Muse Code account host could not start: ${errorName(error)}`) return fallback } try { - return await use(session.connection) + return (await unlessAborted(use(session.connection), ended)) ?? fallback } finally { try { await session.close() @@ -113,10 +122,11 @@ async function onAccountHost( /** One short-lived host asked `account/read`; undefined when it could not start or say. */ export async function probeAccount( - connect: () => Promise, + connect: ConnectAccountHost, log: Logger, + ended: AbortSignal, ): Promise { - return await onAccountHost(connect, log, undefined, readAccountState) + return await onAccountHost(connect, log, ended, undefined, readAccountState) } /** @@ -127,12 +137,14 @@ export async function probeAccount( * the stored lane), or a state never captured. */ export async function logOutAccount( - connect: () => Promise, + 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) @@ -151,6 +163,33 @@ export async function logOutAccount( ) } +/** + * 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, diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 9e5108786..46e0a6896 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -11,6 +11,7 @@ import { selectBackend } from '../../core/backendSelection' import type { BackendKind } from '../../core/agent/agentBackend' import type { CliSignIn } from '../../core/backends/musecode/credentialFile' +import { unlessAborted } from '../../core/timeouts' import { type BackendMode, MUSE_INSTALL_POLL_INTERVAL_MS, @@ -22,7 +23,7 @@ import { fill } from '../../shared/l10n/text' import type { AuthStatus, HostToWebviewMessage, SignInMethod } from '../../shared/protocol' import type { Logger } from '../logger' import { isCliSignedIn } from './cliAccount' -import type { DeviceSignInEnded, DeviceSignInOutcome } from './deviceSignIn' +import type { CapturedSignInEnding, DeviceSignInEnded, DeviceSignInOutcome } from './deviceSignIn' import type { CredentialStore } from './credentialStore' export interface AuthBackendFacts { @@ -36,10 +37,20 @@ export interface AuthBackendFacts { * too (its host reads the Keychain); otherwise it does not. */ readonly cliSignIn: (isUserAction: boolean) => Promise - /** Drops what the CLI said: an unanswered probe, and a remembered answer. */ + /** + * 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`. */ readonly getBackendMode: () => BackendMode @@ -50,7 +61,10 @@ export interface AuthBackendFacts { readonly restartBackend: (isConversationEnding: boolean) => Promise /** * The CLI's own sign-out, MSP `account/logout` on a short-lived host: true - * once `account/read` shows no stored credential in use. Never rejects. + * 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 } @@ -106,6 +120,7 @@ export type AuthPort = Pick< | 'cancelSignIn' | 'signOut' | 'refresh' + | 'checkAgain' | 'markAuthRequired' | 'markBackendError' > @@ -123,39 +138,40 @@ function signInLine(snapshot: AuthSnapshot): string { /** * Why a device sign-in the host ended did not sign in (read when shown, D33): - * the captured `expired` in its own words, any other ending as Muse Code - * named it (AGENTS.md rule 13). + * each captured ending in its own words, any other as Muse Code named it + * (AGENTS.md rule 13). */ -function endedSignInText(ending: 'expired' | DeviceSignInEnded): string { - return ending === 'expired' - ? UI_TEXT.signInExpired - : fill(UI_TEXT.signInEnded, { outcome: ending.endedAs }) +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 }) + } + } } -/** The listener `unlessAborted` replaces once its promise is made. */ -const IGNORE_ABORT = (): 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. - */ -async function unlessAborted(work: Promise, signal: AbortSignal): Promise { - if (signal.aborted) { - return undefined - } - let onAbort = IGNORE_ABORT - 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) - } +/** 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 } export class AuthService { @@ -166,8 +182,13 @@ 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. */ @@ -219,19 +240,21 @@ export class AuthService { /** * The CLI's own credential as the gate counts it: `META_API_KEY` in its - * environment (no file is looked at then), or its sign-in, including one - * only the CLI could confirm. + * 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 isKeychainElsewhere: boolean }> { + ): Promise<{ readonly hasCliSession: boolean; readonly isUnsupportedFile: boolean }> { if (this.deps.backend.hasEnvironmentKey()) { - return { hasCliSession: true, isKeychainElsewhere: false } + return { hasCliSession: true, isUnsupportedFile: false } } const signIn = await this.deps.backend.cliSignIn(isUserAction) return { hasCliSession: isCliSignedIn(signIn), - isKeychainElsewhere: signIn === 'keychainElsewhere', + isUnsupportedFile: signIn === 'unsupportedHere', } } @@ -243,11 +266,11 @@ export class AuthService { /** The backend selection from the current facts. */ private async choose(isUserAction: boolean) { const cli = this.deps.backend.resolveCli() - const { hasCliSession, isKeychainElsewhere } = await this.cliCredential(isUserAction) + const { hasCliSession, isUnsupportedFile } = await this.cliCredential(isUserAction) return { cli, hasCliSession, - isKeychainElsewhere, + isUnsupportedFile, choice: selectBackend({ setting: this.deps.backend.getBackendMode(), hasCli: cli.ok, @@ -257,9 +280,11 @@ export class AuthService { } } - /** A macOS Keychain pointer on Windows or Linux: `muse serve` exits at startup. */ - private keychainElsewhereText(): string { - return fill(UI_TEXT.cliKeychainElsewhere, { path: this.deps.backend.credentialFilePath() }) + /** 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). */ @@ -276,15 +301,20 @@ export class AuthService { } private async signInWithCli(): Promise { - const initial = this.snapshot - const epoch = this.signOutEpoch - const wasLogoutHeld = this.isLogoutHeld + const flow: CliSignInFlow = { + initial: this.snapshot, + epoch: this.signOutEpoch, + wasLogoutHeld: this.isLogoutHeld, + abort: new AbortController(), + signOut: this.signOutStarts.signal, + fileBefore: this.deps.backend.credentialFileModifiedAt(), + } + 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') } // Cancel and sign-out reach this flow from here on, before any await. - const abort = new AbortController() this.deviceAbort = abort this.set({ ...this.snapshot, status: 'signingIn', detail: UI_TEXT.signInWaiting }) try { @@ -292,11 +322,11 @@ export class AuthService { // 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?.isKeychainElsewhere === true) { + if (credential?.isUnsupportedFile === true) { return await this.finishFailedCliSignIn( - initial, + flow, 'error', - this.keychainElsewhereText(), + this.unsupportedFileText(), 'warning', ) } @@ -310,16 +340,11 @@ export class AuthService { this.set({ ...this.snapshot, verificationUrl: url, userCode: code }) }) if (outcome === 'cancelled') { - return await this.finishFailedCliSignIn( - initial, - 'signedOut', - UI_TEXT.signInCancelled, - 'info', - ) + return await this.finishCancelledCliSignIn(flow) } if (outcome === 'timedOut') { return await this.finishFailedCliSignIn( - initial, + flow, 'signedOut', UI_TEXT.signInTimedOut, 'warning', @@ -327,7 +352,7 @@ export class AuthService { } if (outcome !== 'signedIn') { return await this.finishFailedCliSignIn( - initial, + flow, 'signedOut', endedSignInText(outcome), 'warning', @@ -336,41 +361,85 @@ export class AuthService { if (this.isSigningOut) { return this.snapshot } - // 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 ( - wasLogoutHeld && - (this.deps.backend.hasEnvironmentKey() || - (await this.deps.credentials.getApiKey()) !== undefined || - (await this.deps.backend.cliSignIn(true)) !== 'signedIn') - ) { - return await this.refresh(true) - } - 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(true) - } - const isHoldSaved = await this.setLogoutHold(false) - if (!isHoldSaved) { - return this.set({ ...this.snapshot, status: 'error', detail: UI_TEXT.signOutHoldFailed }) - } - } - return await this.refresh(true) + // 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 + await this.deps.backend.restartBackend(flow.initial.status === 'signedIn') + 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 the file (the review of PR + * #49): when the credential file changed since the flow began (the browser + * approved as Cancel was pressed), its structure says whether the CLI is + * signed in. + */ + private async finishCancelledCliSignIn(flow: CliSignInFlow): Promise { + if (this.isSigningOut) { + return this.snapshot + } + const isFileChanged = this.deps.backend.credentialFileModifiedAt() !== flow.fileBefore + return isFileChanged + ? ((await unlessAborted(this.refresh(), flow.signOut)) ?? this.snapshot) + : await this.finishFailedCliSignIn(flow, 'signedOut', UI_TEXT.signInCancelled, 'info') + } + private async finishFailedCliSignIn( - initial: AuthSnapshot, + flow: CliSignInFlow, status: 'noCli' | 'signedOut' | 'error', detail: string, noticeLevel: 'info' | 'warning', @@ -379,13 +448,14 @@ export class AuthService { if (this.isSigningOut) { return this.snapshot } - if (this.isLogoutHeld) { - const refreshed = await this.refresh(true) - this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) - return refreshed - } - if (initial.status === 'signedIn' && initial.backend === 'modelApi') { - const refreshed = await this.refresh(true) + const { initial } = flow + if (this.isLogoutHeld || (initial.status === 'signedIn' && initial.backend === 'modelApi')) { + // 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 } @@ -505,7 +575,7 @@ export class AuthService { /** Derive from current CLI, setting and SecretStorage facts. */ private async selectedSnapshot(isUserAction: boolean): Promise { - const { cli, hasCliSession, isKeychainElsewhere, choice } = await this.choose(isUserAction) + const { cli, hasCliSession, isUnsupportedFile, choice } = await this.choose(isUserAction) if (choice.kind === undefined) { return { status: 'noCli', @@ -518,10 +588,10 @@ export class AuthService { } // 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 && isKeychainElsewhere + const isBlocked = choice.kind === 'museCode' && cli.ok && isUnsupportedFile return { status: isBlocked ? 'error' : choice.status, - detail: isBlocked ? this.keychainElsewhereText() : undefined, + detail: isBlocked ? this.unsupportedFileText() : undefined, backend: choice.kind, methods: choice.methods, hasCli: cli.ok, @@ -574,7 +644,18 @@ export class AuthService { if (isConfirmed) { return true } - this.deps.log.warn('Muse Code did not sign out through its account host; running muse logout') + // `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 @@ -588,6 +669,8 @@ export class AuthService { this.signOutEpoch += 1 this.admissionGenerationValue += 1 this.isSigningOut = true + this.signOutStarts.abort() + this.signOutStarts = new AbortController() this.set({ ...this.snapshot, status: 'error', @@ -639,12 +722,12 @@ 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, @@ -653,14 +736,41 @@ export class AuthService { }) } 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 (the review of PR #49). + // 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 (the review of PR + * #49). + */ + public async stopSignIn(): Promise { + 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). + */ + public async checkAgain(): Promise { this.deps.backend.forgetCliAnswers() + return await this.refresh(true) } /** One visible installer terminal and one location watch per window. */ diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index e35e19c0e..010200e97 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -86,7 +86,7 @@ export function cliSignInFromAccount(account: AccountState | undefined): CliSign const FILE_SIGN_IN: Partial> = { empty: 'signedOut', inline: 'signedIn', - keychainElsewhere: 'keychainElsewhere', + unsupportedHere: 'unsupportedHere', } /** Signed in, or possibly: the estimate the gate and the backend choice use. */ @@ -156,13 +156,24 @@ export class CliAccount { } /** - * Forgets what the CLI said (Cancel, sign-out; the review of PR #49): an - * unanswered probe is left behind, and a remembered answer is dropped, so - * the next question asks afresh. A logout that leaves the file as it was - * (a Keychain sign-in) would otherwise keep reading as signed in. + * 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 + * not remembered. A remembered answer stands, so what Cancel shows next + * needs no new question. */ - public forgetAnswers(): void { + public abandonProbe(): void { 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 } diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index ed34d1a1c..f987d400f 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -7,18 +7,19 @@ // 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`. Only -// `cancelled` and `expired` were captured live (docs/certification/ -// sign-in-detection.md, "Live capture"), so they are the only endings with a -// meaning of their own; any other word is shown as the CLI named it -// (AGENTS.md rule 13). `granted` was not captured, so it is not taken for a -// sign-in: `account/read` or the file decides. `account/changed` is not -// relied on: it fired neither for a change made outside the host nor for an -// expired code. +// `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 after the file was written and +// `account/read` already said `accountLogin`, so those decide and `granted` +// needs no handler. `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 and the host's ending 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. +// 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. import * as z from 'zod/mini' import { clipForLog } from '../../core/logging' @@ -44,9 +45,10 @@ const loginStartSchema = z.object({ verificationUrl: z.url(), userCode: z.string().check(z.minLength(1)), }) -// As captured: `{outcome: "expired", message: "login failed: the request -// expired"}` and `{outcome: "cancelled"}`. The message goes to the log, -// clipped, never the panel. +// 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. Only what `loggedEnding` +// allows reaches the log; the panel shows none of it. const loginCompletedSchema = z.object({ outcome: z.string().check(z.minLength(1)), message: z.optional(z.string()), @@ -67,20 +69,52 @@ export interface DeviceSignInDeps { 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 = 'cancelled' | 'expired' | DeviceSignInEnded +type HostEnding = CapturedSignInEnding | DeviceSignInEnded export type DeviceSignInOutcome = 'signedIn' | 'timedOut' | HostEnding const STOPPED = Symbol('device sign-in stopped') -// The endings captured live: `cancelled` (1.3.0, M55; 1.4.0-R4302.1) and -// `expired` (1.4.0-R4302.1, 600 s after `account/loginStart`). -const CAPTURED_ENDINGS: ReadonlyMap = new Map([ +/** + * 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'], +]) + +// The endings whose captured message is safe to log: it names no path and +// no account (the review of PR #49). `failed`'s names the credential file's +// path, under the user's profile, so a fixed line stands in for it; a word +// no capture covers is logged without its message. +const LOGGED_MESSAGES: ReadonlySet = new Set([ + MUSE_LOGIN_OUTCOMES.expired, + MUSE_LOGIN_OUTCOMES.denied, ]) -/** How `outcome` ends the flow; undefined for `granted`, which `account/read` must bear out. */ +/** How the log names an ending: the word, and only a message captured as safe. */ +function loggedEnding(outcome: string, message: string | undefined): string { + if (outcome === MUSE_LOGIN_OUTCOMES.failed) { + return `${outcome}: saving the credential failed` + } + const said = + message !== undefined && LOGGED_MESSAGES.has(outcome) ? `: ${clipForLog(message)}` : '' + return `${clipForLog(outcome)}${said}` +} + +/** 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 @@ -108,6 +142,7 @@ export function parseDeviceCode(raw: unknown): { readonly url: string; readonly /** 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 @@ -125,10 +160,14 @@ function isSignedIn(signals: SignInSignals): boolean { 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. + // while the account is anything but signed out. A change of account + // counts only against an account read before the flow: with no first + // answer, a sign-in from before would pass for a new one (the review of + // PR #49). const hasSignedIn = - current.state === MUSE_ACCOUNT_STATES.accountLogin && - initial?.state !== MUSE_ACCOUNT_STATES.accountLogin + initial !== undefined && + initial.state !== MUSE_ACCOUNT_STATES.accountLogin && + current.state === MUSE_ACCOUNT_STATES.accountLogin return isFileWritten || hasSignedIn } @@ -147,13 +186,15 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise() const stop = () => { stopped.resolve(STOPPED) } let hostEnding: HostEnding | undefined let isEnded = false + let isHostGone = false try { session.connection.onNotification((notification) => { if (isEnded || notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED) { @@ -166,23 +207,37 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { + isHostGone = true + stop() + }) deps.signal.addEventListener('abort', stop, { once: true }) if (isAborted()) { return 'cancelled' } - /** `work`, or STOPPED as soon as Cancel or the host's ending arrives. */ + /** `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' + } // The account before the flow, so a sign-in shows as a change. const initial = await untilStopped(readAccountState(session.connection)) if (initial === STOPPED) { - return hostEnding ?? 'cancelled' + return stoppedEarly() } const start = await untilStopped( withDeadline( @@ -194,7 +249,7 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise => { if (hostEnding !== undefined) { return hostEnding } @@ -239,13 +292,27 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise ({ proxy: '', noProxy: [] }), }) managers.push(backend) - const connect = () => - connectAccountSession(backend, '0.0.0-e2e', log, workspaceRoot, new AbortController().signal) - const probe = vi.fn(() => probeAccount(connect, log)) + 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(), @@ -96,7 +101,7 @@ describe('The CLI’s sign-in against a real child process', { timeout: TEST_TIM await expect(t.account.signIn(false)).resolves.toBe('signedIn') expect(t.probe).toHaveBeenCalledOnce() - await expect(logOutAccount(t.connect, t.log)).resolves.toBe('confirmed') + 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) @@ -109,18 +114,31 @@ describe('The CLI’s sign-in against a real child process', { timeout: TEST_TIM }) it('reads a stored sign-in from the file alone', async () => { - writeFakeCredential(configHome, FAKE_STORED_SIGN_IN) + writeFakeCredential(configHome, DEVICE_LOGIN_FILE) const t = setup() await expect(t.account.signIn(true)).resolves.toBe('signedIn') expect(t.probe).not.toHaveBeenCalled() }) + + // 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() + await expect(t.account.signIn(false)).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]) - const onCode = vi.fn() + // 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), @@ -134,20 +152,22 @@ function signIn(fakeEnvironment: readonly { name: string; value: string }[], sig onCode, log: t.log, }) - return { ...t, onCode, outcome } + 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( - [ - { name: 'MUSE_FAKE_LOGIN_ENDING', value: 'expired' }, - { name: 'MUSE_FAKE_LOGIN_ENDING_MS', value: String(ENDING_AFTER_MS) }, - ], - new AbortController().signal, - ) + 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', @@ -162,14 +182,42 @@ describe('The device sign-in against a real child process', { timeout: TEST_TIME const t = signIn( [ { name: 'MUSE_FAKE_ACCOUNT_READ', value: 'silentAfterStart' }, - { name: 'MUSE_FAKE_LOGIN_ENDING', value: 'expired' }, - { name: 'MUSE_FAKE_LOGIN_ENDING_MS', value: String(ENDING_AFTER_MS) }, + ...endingAfterStart('expired'), ], - new AbortController().signal, + OPEN, ) - const started = Date.now() await expect(t.outcome).resolves.toBe('expired') - expect(Date.now() - started).toBeLessThan(PROMPT_MS) + 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') + expect(readCredentialFile(t.backend.credentialFilePath(), process.platform)?.verdict).toBe( + 'inline', + ) + expect(t.log.info).toHaveBeenCalledWith('Muse Code sign-in ended: granted') + expect(everythingLogged(t.log)).not.toContain('person@example.com') + }) + + // `failed`'s captured message names the credential file's path. + it.each([ + ['denied', 'login failed: the request was denied'], + ['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 () => { diff --git a/test/e2e/fake-muse/serve.mjs b/test/e2e/fake-muse/serve.mjs index 9b60e2797..eca933a37 100644 --- a/test/e2e/fake-muse/serve.mjs +++ b/test/e2e/fake-muse/serve.mjs @@ -28,18 +28,22 @@ // 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 the credential file under XDG_CONFIG_HOME -// (a named provider is a login, anything else signed out), and +// (`providers.meta` is a login, anything else signed out), and // `account/logout` rewrites that 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 `account/loginCompleted` -// frame follows after MUSE_FAKE_LOGIN_ENDING_MS. `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_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 { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' @@ -53,6 +57,25 @@ const COMMAND_REJECTED = -32_000 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' +// 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: ' @@ -396,7 +419,8 @@ function hasStoredSignIn() { return false } try { - return Object.keys(JSON.parse(readFileSync(file, 'utf8')).providers ?? {}).length > 0 + const providers = JSON.parse(readFileSync(file, 'utf8')).providers ?? {} + return Object.hasOwn(providers, MUSE_PROVIDER) } catch { return false } @@ -432,16 +456,45 @@ function capturedEnding(name) { )?.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( () => { - state.login = undefined - send(capturedEnding(ending)) + endLogin(ending) }, Number(env['MUSE_FAKE_LOGIN_ENDING_MS'] ?? '0'), ) diff --git a/test/e2e/fakeMuse.ts b/test/e2e/fakeMuse.ts index fabd68a03..f20c6a2ef 100644 --- a/test/e2e/fakeMuse.ts +++ b/test/e2e/fakeMuse.ts @@ -19,6 +19,7 @@ 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') @@ -97,21 +98,16 @@ export function installFakeMuse(): FakeMuseInstall { } /** - * A config home holding the CLI's credential file: a stored login's - * structure as Muse Code writes it (schema 1, one provider), metadata only, - * no secret; or `contents` as given. + * 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 = FAKE_STORED_SIGN_IN): string { +export function installFakeCredential(contents = DEVICE_LOGIN_FILE): string { const configHome = mkdtempSync(path.join(tmpdir(), 'fake-muse-config-')) writeFakeCredential(configHome, contents) return configHome } -export const FAKE_STORED_SIGN_IN = JSON.stringify({ - schema_version: 1, - providers: { meta: { mechanism: 'oauth', obtained_via: 'device_code' } }, -}) - /** 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) diff --git a/test/fixtures/msp/account-login-denied.json b/test/fixtures/msp/account-login-denied.json new file mode 100644 index 000000000..d5ec32335 --- /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-failed.json b/test/fixtures/msp/account-login-failed.json new file mode 100644 index 000000000..90410c47d --- /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 000000000..1af0c8ea2 --- /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/accountHost.test.ts b/test/unit/accountHost.test.ts index e1723e589..c7a6a62d8 100644 --- a/test/unit/accountHost.test.ts +++ b/test/unit/accountHost.test.ts @@ -1,11 +1,13 @@ 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, @@ -26,10 +28,16 @@ function host(answers: Record Answer)>) { return answer instanceof Error ? Promise.reject(answer) : Promise.resolve(answer) }) const close = vi.fn(() => Promise.resolve()) - const session: AccountSession = { connection: { request, onNotification: vi.fn() }, close } + 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') } @@ -43,6 +51,15 @@ describe('readAccountState', () => { }) }) + // 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( @@ -64,14 +81,14 @@ 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)).resolves.toEqual(LOGGED_OUT) + 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)).resolves.toBeUndefined() + 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') }) @@ -80,7 +97,7 @@ describe('probeAccount', () => { const t = host({ 'account/read': LOGGED_OUT }) t.close.mockRejectedValue(new Error('still draining')) const log = new FakeLogOutputChannel() - await expect(probeAccount(t.connect, log)).resolves.toEqual(LOGGED_OUT) + 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') }) }) @@ -96,7 +113,7 @@ describe('logOutAccount', () => { 'account/read': () => state, }) const log = new FakeLogOutputChannel() - await expect(logOutAccount(t.connect, log)).resolves.toBe('confirmed') + await expect(logOutAccount(t.connect, log, OPEN)).resolves.toBe('confirmed') expect(t.request.mock.calls.map(([method]) => method)).toEqual([ 'account/logout', 'account/read', @@ -129,14 +146,37 @@ describe('logOutAccount', () => { ])('is false when %s', async (_name, answers) => { const log = new FakeLogOutputChannel() if (answers === undefined) { - await expect(logOutAccount(() => Promise.reject(new Error('no CLI')), log)).resolves.toBe( - 'unconfirmed', - ) + 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)).resolves.toBe('unconfirmed') + await expect(logOutAccount(t.connect, log, OPEN)).resolves.toBe('unconfirmed') expect(t.close).toHaveBeenCalledOnce() expect(log.warn).toHaveBeenCalled() }) }) + +// 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 313a39cd1..299ec2e13 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -12,6 +12,7 @@ import { MUSE_INSTALL_TIMEOUT_MS, type BackendMode } from '../../src/shared/cons 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_PATH = '/home/u/.config/muse/auth.json' @@ -26,8 +27,11 @@ interface Harness { 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>> @@ -56,10 +60,12 @@ function harness(overrides: Partial = {}): Harness { 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' @@ -83,8 +89,10 @@ function harness(overrides: Partial = {}): Harness { resolveCli: () => facts.cliPresent ? { ok: true, cliPath: '/bin/muse' } : { ok: false, reason: 'missing' }, cliSignIn, + abandonCliProbe, forgetCliAnswers, credentialFilePath: () => CREDENTIAL_PATH, + credentialFileModifiedAt: () => facts.fileModifiedAt, hasEnvironmentKey: () => facts.envKey, getBackendMode: () => facts.backendMode, restartBackend, @@ -120,6 +128,7 @@ function harness(overrides: Partial = {}): Harness { broadcasts, facts, cliSignIn, + abandonCliProbe, forgetCliAnswers, logOutCli, restartBackend, @@ -309,7 +318,7 @@ describe('AuthService.signIn', () => { h.cliSignIn.mockImplementation(() => isAbandoned ? Promise.resolve(h.facts.cli) : new Promise(() => undefined), ) - h.forgetCliAnswers.mockImplementation(() => { + h.abandonCliProbe.mockImplementation(() => { isAbandoned = true }) const pending = h.service.signIn('browser') @@ -319,7 +328,7 @@ describe('AuthService.signIn', () => { 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.forgetCliAnswers).toHaveBeenCalled() + expect(h.abandonCliProbe).toHaveBeenCalled() // Its cancellation never showed over the sign-out's own state or as a notice. expect( h.broadcasts.some( @@ -330,21 +339,31 @@ describe('AuthService.signIn', () => { ).toBe(false) }) - it('keeps the state a sign-out published when an older refresh answers late (the review of PR #49)', async () => { - const h = harness() - const late = Promise.withResolvers() - h.cliSignIn.mockImplementationOnce(() => late.promise) - const stale = h.service.refresh() - await vi.waitFor(() => { - expect(h.cliSignIn).toHaveBeenCalled() - }) - await expect(h.service.signOut()).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) - }) + // 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() @@ -548,6 +567,129 @@ 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 }, + ) + }) +} + +// 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() + 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() + await expect(pending).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 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 + }) +}) + describe('AuthService.installMuseCode', () => { it('reports terminal launch failure without signing out a Model API session', async () => { const h = await signedInModelApi() @@ -900,8 +1042,10 @@ describe('AuthService.signOut and host reports', () => { 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) @@ -914,7 +1058,7 @@ 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() @@ -1045,12 +1189,15 @@ describe('AuthService.signOut and host reports', () => { expect(h.cliSignIn.mock.calls.at(-1)).toEqual([true]) }) + // 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: 'signedOut', + status: 'error', detail: EN.signOutPending, }) expect(h.logOutCli).toHaveBeenCalledOnce() @@ -1103,15 +1250,18 @@ describe('AuthService.signOut and host reports', () => { }) }) -// `expired` as captured live (2026-09-27); any other ending as Muse Code named it. +// `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)', () => { - const DENIED_TEXT = - 'Sign-in ended: denied. Sign in again to get a new code; the Muse Spark log says why.' - it.each([ ['the captured expired', 'expired', EN.signInExpired], - ['an uncaptured denied', { endedAs: 'denied' }, DENIED_TEXT], - ['an unknown', { endedAs: 'somethingNew' }, fill(EN.signInEnded, { outcome: 'somethingNew' })], + ['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) => { @@ -1127,23 +1277,26 @@ describe('AuthService: how Muse Code ends a browser sign-in (D26)', () => { 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({ endedAs: 'denied' }) + 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: DENIED_TEXT }) + 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 Keychain pointer on Windows or Linux instead of offering a dead sign-in', async () => { + it('names a macOS file on Windows or Linux instead of offering a dead sign-in', async () => { const h = harness() - h.facts.cli = 'keychainElsewhere' + h.facts.cli = 'unsupportedHere' const refreshed = await h.service.refresh() expect(refreshed).toMatchObject({ status: 'error', backend: 'museCode', hasCliSession: false }) - expect(refreshed.detail).toContain(CREDENTIAL_PATH) - expect(refreshed.detail).toContain('macOS Keychain') + 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() @@ -1151,7 +1304,7 @@ describe('AuthService: a credential file Muse Code cannot start with (D26)', () it('does not block the Model API key a user already stored', async () => { const h = await signedInModelApi() - h.facts.cli = 'keychainElsewhere' + h.facts.cli = 'unsupportedHere' await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedIn', backend: 'modelApi', @@ -1159,10 +1312,12 @@ describe('AuthService: a credential file Muse Code cannot start with (D26)', () }) }) + // Captured: with META_API_KEY set `muse serve` starts even with a + // version-2 file, and answers `envKey` (probe-v2-serve.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 = 'keychainElsewhere' + h.facts.cli = 'unsupportedHere' await expect(h.service.refresh()).resolves.toMatchObject({ status: 'signedIn' }) expect(h.cliSignIn).not.toHaveBeenCalled() }) @@ -1181,13 +1336,12 @@ describe('AuthService: when the CLI may be asked (macOS Keychain prompts follow }) // 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 write (isolated -// homes, 2026-09-27), no token in any of them. -const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' -const STORED_SIGN_IN = JSON.stringify({ - schema_version: 1, - providers: { meta: { mechanism: 'oauth', obtained_via: 'device_code' } }, -}) +// 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 } describe('AuthService over the CLI’s real credential file', () => { const homes: string[] = [] @@ -1219,9 +1373,13 @@ describe('AuthService over the CLI’s real credential file', () => { backend: { ...facts, cliSignIn: (isUserAction) => account.signIn(isUserAction), + abandonCliProbe: () => { + account.abandonProbe() + }, forgetCliAnswers: () => { account.forgetAnswers() }, + credentialFileModifiedAt: () => statSync(file, { throwIfNoEntry: false })?.mtimeMs, }, }) return { h, file, service, probe } @@ -1238,17 +1396,59 @@ describe('AuthService over the CLI’s real credential file', () => { // 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('{"schema_version": 1, "providers": ') - t.probe - .mockResolvedValueOnce({ state: 'accountLogin', credentialRequired: true }) - .mockResolvedValue({ state: 'loggedOut', credentialRequired: true }) + 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) + }) + + // 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(STORED_SIGN_IN) + const t = withFile(DEVICE_LOGIN_FILE) t.h.logOutCli.mockImplementation(() => { writeFileSync(t.file, LOGOUT_SHELL) return Promise.resolve(true) @@ -1265,7 +1465,7 @@ describe('AuthService over the CLI’s real credential file', () => { }) it('finishes a terminal sign-out once muse logout has rewritten the file', async () => { - const t = withFile(STORED_SIGN_IN) + 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 }) diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index 7edbdae15..6c0c9742b 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -10,12 +10,11 @@ import { readCredentialFile, } from '../../src/host/auth/cliAccount' import { MUSE_CREDENTIAL_FILE_MAX_BYTES } from '../../src/shared/constants' +import { DEVICE_LOGIN_FILE, LOGOUT_SHELL, SLACK_CONNECTOR_ONLY } from './helpers/credentialShapes' import { FakeLogOutputChannel } from './helpers/fakes' -// What Muse Code 1.3.0 and 1.4.0 write (isolated homes, 2026-09-27) and -// macOS's pointer (aonia §2.3); placeholders, never a token. -const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' -const STORED_SIGN_IN = '{"schema_version":1,"providers":{"meta":{"mechanism":"oauth"}}}' +// 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": ' @@ -123,7 +122,7 @@ describe('cliSignInFromAccount', () => { expect(isCliSignedIn('signedIn')).toBe(true) expect(isCliSignedIn('unknown')).toBe(true) expect(isCliSignedIn('signedOut')).toBe(false) - expect(isCliSignedIn('keychainElsewhere')).toBe(false) + expect(isCliSignedIn('unsupportedHere')).toBe(false) }) }) @@ -141,7 +140,7 @@ describe('CliAccount', () => { it('reads a stored sign-in as signed in without asking', async () => { const home = configHome() - home.write(STORED_SIGN_IN) + 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() @@ -151,7 +150,7 @@ describe('CliAccount', () => { const home = configHome() home.write(MAC_POINTER) const t = account('win32', home.file, []) - await expect(t.checker.signIn(true)).resolves.toBe('keychainElsewhere') + await expect(t.checker.signIn(true)).resolves.toBe('unsupportedHere') expect(t.probe).not.toHaveBeenCalled() }) @@ -235,7 +234,7 @@ describe('CliAccount', () => { const probe = vi.fn(() => answers.shift() ?? Promise.resolve(undefined)) const checker = linuxChecker(home.file, probe) const abandoned = checker.signIn(true) - checker.forgetAnswers() + checker.abandonProbe() await expect(checker.signIn(true)).resolves.toBe('signedOut') expect(probe).toHaveBeenCalledTimes(2) // The first probe answers late: it settles its own caller only. @@ -244,4 +243,29 @@ describe('CliAccount', () => { await expect(checker.signIn(false)).resolves.toBe('signedOut') expect(probe).toHaveBeenCalledTimes(2) }) + + // 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 95fe23a0c..8d4793fb7 100644 --- a/test/unit/conversationController.test.ts +++ b/test/unit/conversationController.test.ts @@ -103,6 +103,10 @@ function fakeAuth(status: AuthSnapshot['status'] = 'signedIn'): FakeAuth { calls.push(isUserAction === true ? 'refresh:userAction' : 'refresh') return Promise.resolve(state.snapshot) }, + checkAgain: () => { + calls.push('checkAgain') + return Promise.resolve(state.snapshot) + }, markAuthRequired: (reason: string) => { calls.push(`authRequired:${reason}`) state.snapshot = { status: 'signedOut', detail: reason } @@ -2313,8 +2317,8 @@ describe('ConversationController: other messages', () => { 'installMuseCode', 'cancelSignIn', 'signOut', - // Check again is a click: macOS may ask the CLI (D26). - 'refresh:userAction', + // 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 index 6df4d462e..b4f6509b6 100644 --- a/test/unit/credentialFile.test.ts +++ b/test/unit/credentialFile.test.ts @@ -3,25 +3,16 @@ import { credentialFileVerdict, keychainItemPresence, } from '../../src/core/backends/musecode/credentialFile' +import { + AUTH_SET_FILE, + DEVICE_LOGIN_FILE, + LOGOUT_SHELL, + SLACK_CONNECTOR_ONLY, +} from './helpers/credentialShapes' -// The shapes Muse Code 1.3.0 and 1.4.0-R4302.1 wrote in isolated homes -// (2026-09-27), and the macOS pointer a third party observed on 1.3.0 -// (aonia §2.3). No token in any of them: the values are placeholders. -const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' -const STORED_KEY = JSON.stringify({ - schema_version: 1, - providers: { meta: { mechanism: 'api_key', api_key: '' } }, -}) -const OAUTH_LOGIN = JSON.stringify({ - schema_version: 1, - providers: { - meta: { - mechanism: 'oauth', - obtained_via: 'device_code', - oauth: { access_token: '', refresh_token: '', expires_at: 1 }, - }, - }, -}) +// 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: { @@ -33,10 +24,23 @@ const MAC_POINTER = JSON.stringify({ }, }, }) +// Synthetic, as the probe of 2026-09-27 wrote it: a version-1 `meta` whose +// storage is the Keychain (`muse serve` exits 3 with it on Windows). const SCHEMA_1_POINTER = JSON.stringify({ schema_version: 1, providers: { meta: { storage: 'keychain' } }, }) +// Synthetic: the empty version-2 file the probe of 2026-09-27 gave `muse +// serve`, which exits 3 with it on Windows. +const EMPTY_V2 = '{"schema_version":2,"providers":{}}' + +/** 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)( @@ -47,31 +51,47 @@ describe('credentialFileVerdict', () => { ) it('reads a stored key or login as held in the file', () => { - expect(credentialFileVerdict(STORED_KEY, 'win32')).toBe('inline') - expect(credentialFileVerdict(OAUTH_LOGIN, 'linux')).toBe('inline') - expect(credentialFileVerdict(OAUTH_LOGIN, 'darwin')).toBe('inline') + expect(credentialFileVerdict(AUTH_SET_FILE, 'win32')).toBe('inline') + expect(credentialFileVerdict(DEVICE_LOGIN_FILE, 'win32')).toBe('inline') + expect(credentialFileVerdict(DEVICE_LOGIN_FILE, 'linux')).toBe('inline') + expect(credentialFileVerdict(DEVICE_LOGIN_FILE, 'darwin')).toBe('inline') }) 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('{"schema_version":2,"providers":{}}', 'darwin')).toBe('empty') - }) - - // Both made `muse serve` exit 3 on Windows 1.4.0 (isolated homes). - it.each(['win32', 'linux'] as const)('names a Keychain pointer on %s', (platform) => { - expect(credentialFileVerdict(MAC_POINTER, platform)).toBe('keychainElsewhere') - expect(credentialFileVerdict(SCHEMA_1_POINTER, platform)).toBe('keychainElsewhere') + expect(credentialFileVerdict(EMPTY_V2, 'darwin')).toBe('empty') }) - // A signed-out macOS file copied elsewhere names no provider, so points nowhere (the review of PR #49). + // Each made `muse serve` exit 3 on Windows 1.4.0 (isolated homes), the + // empty version-2 file included: "unsupported auth schema version 2" (the + // review of PR #49). it.each(['win32', 'linux'] as const)( - 'reads an empty version-2 file as signed out on %s', + 'names a macOS file Muse Code cannot start with on %s', (platform) => { - expect(credentialFileVerdict('{"schema_version":2,"providers":{}}', platform)).toBe('empty') + 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', () => { + 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": {}}}'], diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index ddbc4109b..cbfd98ac9 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -10,9 +10,13 @@ import { 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' @@ -22,10 +26,10 @@ const DEVICE_URL = 'https://auth.meta.com/oauth/device/?code=AAAA-AAAA' const DEVICE_CODE = 'AAAA-AAAA' const log = new FakeLogOutputChannel() -// `account/read` signed in, as captured on 1.3.0 and 1.4.0 (the label -// redacted there, an e-mail address in real life, and dropped by the parser -// here). -const SIGNED_IN = { state: 'accountLogin', label: 'person@example.com', credentialRequired: true } +// `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']) type AccountAnswer = Record | undefined type Notify = Parameters[0] @@ -68,8 +72,24 @@ function session(account: () => AccountAnswer = () => undefined) { notify = handler }) const close = vi.fn(() => Promise.resolve()) - const deviceSession: AccountSession = { connection: { request, onNotification }, close } - return { request, onNotification, close, deviceSession, complete } + // 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({})), + ) } describe('Muse Code device sign-in', () => { @@ -169,10 +189,7 @@ describe('Muse Code device sign-in', () => { ['the first account/read', 'account/read', {}], ])('closes promptly when cancelled before %s answers', async (_name, stuck, params) => { const t = session(() => CAPTURED_LOGGED_OUT) - const answer = t.request.getMockImplementation() - t.request.mockImplementation((method) => - method === stuck ? never() : (answer?.(method) ?? Promise.resolve({})), - ) + unanswered(t, stuck) const abort = new AbortController() const pending = run(t, { signal: abort.signal }) await vi.waitFor(() => { @@ -186,10 +203,7 @@ describe('Muse Code device sign-in', () => { it('ends on the host’s ending while loginStart is unanswered, with no code shown', async () => { const t = session(() => CAPTURED_LOGGED_OUT) - const answer = t.request.getMockImplementation() - t.request.mockImplementation((method) => - method === 'account/loginStart' ? never() : (answer?.(method) ?? Promise.resolve({})), - ) + unanswered(t, 'account/loginStart') const onCode = vi.fn() const pending = runDeviceSignIn({ connect: () => Promise.resolve(t.deviceSession), @@ -336,8 +350,9 @@ describe('Muse Code device sign-in: how it ends', () => { expect(t.close).toHaveBeenCalledOnce() }) - // `granted` was not captured: its word neither ends the flow nor signs in. - it('takes an uncaptured granted for nothing: account/read decides', async () => { + // Captured: `granted` came after `account/read` already said + // `accountLogin`, so its word neither ends the flow nor signs in. + it('decides on account/read, which the captured granted follows', async () => { const accounts: AccountAnswer[] = [ CAPTURED_LOGGED_OUT, CAPTURED_LOGGED_OUT, @@ -347,23 +362,45 @@ describe('Muse Code device sign-in: how it ends', () => { let polls = 0 const sleep = () => { polls += 1 - t.complete(endingNamed('granted')) + t.complete(CAPTURED_GRANTED_ENDING) return Promise.resolve() } await expect(run(t, { sleep })).resolves.toBe('signedIn') expect(polls).toBe(2) }) - it('keeps waiting after an uncaptured granted the account never shows', async () => { + it('keeps waiting after a granted the account never shows', async () => { const t = session(() => CAPTURED_LOGGED_OUT) const sleep = () => { - t.complete(endingNamed('granted')) + 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], @@ -415,21 +452,47 @@ describe('Muse Code device sign-in: how it ends', () => { ) }) - // Rule 13: an ending no capture covers is shown as the CLI named it. - it.each(['denied', 'failed', 'somethingNew'])( - 'ends at once on %s, which no capture covers, as the CLI named it', - async (outcome) => { + // 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, 'login failed: the request was denied'], + ['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(endingNamed(outcome)) + t.complete(frame) return Promise.resolve() } - await expect(run(t, { sleep })).resolves.toEqual({ endedAs: outcome }) + 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') + 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 = () => { @@ -466,8 +529,52 @@ describe('Muse Code device sign-in: how it ends', () => { const accounts: AccountAnswer[] = [CAPTURED_LOGGED_OUT] const t = session(() => accounts.shift() ?? SIGNED_IN) await expect(run(t, { log: logged })).resolves.toBe('signedIn') - const everything = [...logged.info.mock.calls, ...logged.warn.mock.calls].flat().join('\n') - expect(everything).not.toContain('person@example.com') + 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() + }) + + 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') }) }) @@ -523,7 +630,8 @@ describe('Muse Code device sign-in: a CLI that stops answering', () => { it.each([ ['the captured expired ending', CAPTURED_EXPIRED_ENDING, 'expired'], - ['an ending no capture covers', endingNamed('denied'), { endedAs: 'denied' }], + ['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) @@ -560,10 +668,7 @@ describe('Muse Code device sign-in: a CLI that stops answering', () => { const { t, polling } = isCancel ? wedgedSession() : { t: session(() => CAPTURED_LOGGED_OUT), polling: () => Promise.resolve() } - const answer = t.request.getMockImplementation() - t.request.mockImplementation((method) => - method === 'account/loginCancel' ? never() : (answer?.(method) ?? Promise.resolve({})), - ) + unanswered(t, 'account/loginCancel') const logged = new FakeLogOutputChannel() const abort = new AbortController() const pending = run(t, { diff --git a/test/unit/helpers/accountLoginCapture.ts b/test/unit/helpers/accountLoginCapture.ts index 948124661..356dd61dc 100644 --- a/test/unit/helpers/accountLoginCapture.ts +++ b/test/unit/helpers/accountLoginCapture.ts @@ -1,9 +1,9 @@ // Muse Code 1.4.0-R4302.1's device sign-in as captured live on 2026-09-27 in -// a throwaway home (test/fixtures/msp/account-login-*.json; +// 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. +// shape, AAAA-AAAA; the account's label and avatar are stand-ins. import { readFileSync } from 'node:fs' import path from 'node:path' @@ -37,7 +37,7 @@ const captureSchema = z.object({ ), }) -type CaptureName = 'expired' | 'cancelled' +type CaptureName = 'expired' | 'cancelled' | 'granted' | 'denied' | 'failed' type CapturedNotification = Parameters[0] function framesOf(name: CaptureName) { @@ -45,16 +45,25 @@ function framesOf(name: CaptureName) { 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 frames = framesOf(name) - const asked = frames.find((entry) => entry.dir === 'out' && entry.frame.method === method) - const result = frames.find((entry) => entry.dir === 'in' && entry.frame.id === asked?.frame.id) - ?.frame.result - if (result === undefined) { + const [first] = answersOf(name, method) + if (first === undefined) { throw new Error(`the ${name} capture has no answer to ${method}`) } - return result + return first } /** The captured `account/loginCompleted` frame, as it arrived. */ @@ -84,23 +93,53 @@ function codeLifetimeMs(): number { 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 (`denied`, - * `failed`, a future one): as the `cancelled` capture, the outcome alone. + * 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 000000000..c37584ad0 --- /dev/null +++ b/test/unit/helpers/credentialShapes.ts @@ -0,0 +1,48 @@ +// 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: '' } }, +}) + +/** + * 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 cc9025968..05e99b694 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', () => { From 886af682fdaacb445f686bec3883fc0a9bd98b0f Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Sun, 27 Sep 2026 23:37:00 -0700 Subject: [PATCH 17/25] Settle PR #49's fourth review round, with Linux captured The re-review of 990ee91e found seven races and five places where the structural read went past the wire evidence (AGENTS.md rule 13). The missing evidence was captured: muse serve 1.4.0-R4302.1 on Windows 11 and the Kubuntu VM, serve only, throwaway homes, a dead proxy, a dummy key, 0 model attempts, nothing left on the VM. Races: - R1: a sign-out forgets the CLI's answers right after the Cancel it makes, so a stale signedOut never skips account/logout. - R2: granted is recorded; with no first account/read, granted borne out by account/read saying accountLogin is the sign-in (granted alone is not). - R3: stopSignIn sets a flag that signIn and the device flow's join check, so a click still in its pre-flight starts nothing after the window closed (chosen over joining the window's signal to the connect, which would reach the click only at the connect). - R4: a refresh that finds the epoch moved holds again only while a sign-out runs; it never puts back a hold a finished sign-out released. - R5: concurrent Check again presses share one in-flight refresh: one question, one host. - R6: a failed or cancelled sign-in publishes its state without the code first, then refreshes (hold on, or a Model API session). - R7: a host that exits after the credential file changed has signed in. Wire evidence: - W1: inline only for a meta entry with no storage lane and api_key or access_token (the parse keeps their presence as true, never a value); any other meta entry is asked of the CLI. - W2: on macOS a version-1 file holding the credential and an empty version-2 file are asked of the CLI (uncaptured there); the version-1 empty file stays signed out everywhere. The constants comment no longer says the Mac writes version 1 with TBH_CREDENTIAL_BACKEND=file. - W3: captured on both systems: empty v2, a v2 pointer and a v1 Keychain lane exit 3 without META_API_KEY and start (envKey) with it. Linux keeps unsupportedHere, now from the capture; "the key wins" names what was captured. - W4: the unsupported-file state is logged in fixed words, without the credential path; the panel keeps the path. - W5: the fake CLI exits 3 with the captured stderr on those files and answers apiKey or accountLogin by shape; the e2e tests follow (a host that exits answers unknown), and the unit test that sent granted before accountLogin is renamed, beside one replaying the captured order. Also here: the e2e Slack-only test asks with signIn(true), and the 15 package.nls tables say museSpark.environmentVariables reach the CLI's terminals. Drills BK to BZ each broke one guard, failed its suite and were restored by SHA-256. Docs: PLAN.md D26, CHANGELOG, README, PRIVACY, SECURITY, AGENTS.md, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 8 +- CHANGELOG.md | 50 +++-- PLAN.md | 76 ++++--- README.md | 15 +- SECURITY.md | 18 +- docs/PRIVACY.md | 14 +- docs/certification/sign-in-detection.md | 205 ++++++++++++++++--- package.nls.cs.json | 2 +- package.nls.de.json | 2 +- package.nls.es.json | 2 +- package.nls.fr.json | 2 +- package.nls.hu.json | 2 +- package.nls.it.json | 2 +- package.nls.ja.json | 2 +- package.nls.json | 2 +- package.nls.ko.json | 2 +- package.nls.pl.json | 2 +- package.nls.pt-br.json | 2 +- package.nls.ru.json | 2 +- package.nls.tr.json | 2 +- package.nls.zh-cn.json | 2 +- package.nls.zh-tw.json | 2 +- src/core/backends/musecode/credentialFile.ts | 83 +++++--- src/host/auth/authService.ts | 92 +++++++-- src/host/auth/cliAccount.ts | 10 +- src/host/auth/deviceSignIn.ts | 65 +++--- src/shared/constants.ts | 12 +- test/e2e/cliAccount.e2e.test.ts | 48 ++++- test/e2e/fake-muse/serve.mjs | 79 +++++-- test/unit/authService.test.ts | 165 ++++++++++++++- test/unit/cliAccount.test.ts | 24 +++ test/unit/credentialFile.test.ts | 66 ++++-- test/unit/deviceSignIn.test.ts | 97 ++++++++- 33 files changed, 927 insertions(+), 230 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c4649763..427514da1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,9 +55,11 @@ them, the milestone plan, and the certification checklist. 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), and a - Keychain `storage` lane. It also reads the file's size and - modification time. Never a token value. + 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index c0879f8dc..b1efea9dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,22 +49,26 @@ happened, not what was planned; superseded entries are kept. 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, and whether Muse Code's own - (`meta`) points to the macOS Keychain. Every other value, the token + 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, is not taken for - a Muse sign-in. + 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). It keeps the - answer until the file changes, or until you sign in, sign out or choose - **Check again**, which always asks afresh. On macOS it asks only when - you act: a click in the panel, or the **Sign Out** or **Diagnostics** - command. + 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. - **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. + 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. @@ -77,21 +81,31 @@ happened, not what was planned; superseded entries are kept. - **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. If - the browser approved just before, the panel follows what Muse Code - saved instead of saying the sign-in was cancelled. + - **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. - **Success.** It is taken from Muse Code's `account/read` turning - signed in, or from a new credential file it does not contradict. + 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 host that exits.** The sign-in fails at once instead of waiting - out the eleven-minute limit. + out the eleven-minute limit, unless the credential file changed first: + then the sign-in went through. - **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. + 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, which Muse Code 1.4.0 on - Windows refuses as well. + 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 diff --git a/PLAN.md b/PLAN.md index c3739a94e..cd37504e7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -852,28 +852,39 @@ action that fails is worse than hiding one that would work. 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 three facts are kept: the schema version, which providers are - named, and whether a provider's `storage` is the Keychain. The schema - 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 + 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; - - an empty file → signed out; - - a `meta` entry holding the credential → signed in; - - other providers alone → asked of the CLI; + - 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 on - Windows: "unsupported auth schema version 2", captured 2026-09-27 for - an empty file and a pointer). With `META_API_KEY` set it starts and - answers `envKey` (captured the same day), so the key still wins. -- **Asking the CLI.** A Keychain pointer on macOS, or a file the read cannot - place, is asked of the CLI: `account/read` on a short-lived - `experimentalApi` host. + 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. + 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. @@ -886,7 +897,8 @@ action that fails is worse than hiding one that would work. 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. + 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. @@ -900,19 +912,25 @@ action that fails is worse than hiding one that would work. `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 after the file was written and `account/read` - already said `accountLogin`; it neither ends the flow nor counts on - its own, and `account/read` or the file decides. + - **`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. `failed`'s message names the credential file's path under the user's profile, so only `expired`'s and `denied`'s messages are logged. - **Every other word.** It ends the flow at once and is shown as Muse Code sent it (AGENTS.md rule 13), its message unlogged. - - **With no first `account/read`,** only a file written since the flow - began counts; a sign-in from before would pass for a new one. + - **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). - **Cancel after approval.** When the file changed since the flow began, - its structure decides, not the click. - - **A host that exits** fails the flow at once (`connection.closed`). + 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. - **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. @@ -922,11 +940,16 @@ action that fails is worse than hiding one that would work. 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. + 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). +- **The log.** The unsupported-file message names the credential file's + full path; the log says so in fixed words and the panel keeps the path. - **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; + - 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 @@ -5992,7 +6015,8 @@ SecretStorage and is never given to `muse serve`. (Amended 2026-09-27, D26: 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.) +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 b3573f595..5f063373b 100644 --- a/README.md +++ b/README.md @@ -1501,17 +1501,18 @@ stopped and the next message resumes the same session. - **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 exits at startup with such a file, even an empty one, - and the extension treats Linux the same way. Move or rename the file, - then sign in again. With `META_API_KEY` set, Muse Code starts anyway and - uses the key. + 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. 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, + **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. diff --git a/SECURITY.md b/SECURITY.md index 1f4452470..5d0fe625d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -32,14 +32,16 @@ Only the latest release on the Visual Studio Marketplace receives fixes. child process, written to settings or logs, or shown in the panel. The 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), and whether that one points to the - macOS Keychain. 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; a sign-in whose host exits - fails at once. The only page sign-in opens must be on + (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. The only page sign-in opens must be on `https://auth.meta.com`. A failed sign-in's message, which names a folder in the user's profile, is not logged. The log channel redacts key-shaped strings, in Meta's current `LLM_…` form and the older diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index fd9947871..c6efde1d7 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -200,14 +200,18 @@ fields, never raw configuration or failed-command output. `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), and whether that - one points to the Keychain. - - Every other value in the file, the token included, is dropped while the - file is parsed. Nothing from it is stored, logged or passed on. + (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). It keeps the answer until the file changes, or until + 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. diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index c4193070d..9a8b9c639 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -42,6 +42,11 @@ under `scratchpad/m140cred/`: - `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 @@ -53,32 +58,39 @@ 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, a Keychain `storage` lane. 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 file a sign-out leaves, is signed out without a process. A `meta` entry holding the credential is signed in. Other providers alone are asked of the CLI. | `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, 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). | `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. The browser sign-in is refused with the same text instead of starting a host that exits 3. `META_API_KEY` still wins: `muse serve` starts with it. | `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. With no first `account/read`, only a new file counts. | `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`). Only `expired`’s and `denied`’s messages reach the log; `failed`’s names a path, so a fixed line stands in. 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). | `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). | `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). | `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` | +| 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`). Only `expired`’s and `denied`’s messages reach the log; `failed`’s names a path, so a fixed line stands in. 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`: `providers.meta` is a login, -anything else signed out. It serves `account/logout` by rewriting that -file as the empty one the CLI leaves. `installFakeCredential` writes the -granted capture's file structure (schema 1, `meta` with the captured keys, -placeholders for every value) instead of `{"fake": true}`. +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`: @@ -110,9 +122,11 @@ Checked in the third review round and left as it was: 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. + 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. + 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) @@ -262,12 +276,85 @@ to `muse serve` itself. 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 is treated - like Windows; it was not probed. + 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. @@ -362,6 +449,38 @@ was. | 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" | + ## Not proved here A real sign-in is the owner's to give, and each of these needs one: @@ -373,14 +492,22 @@ A real sign-in is the owner's to give, and each of these needs one: 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, and whether `muse serve` refuses a version-2 file - there as it does on Windows. The extension treats Linux like Windows. + 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). +`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 @@ -454,3 +581,21 @@ The three new fixtures (`account-login-granted.json`, `-denied.json`, 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. diff --git a/package.nls.cs.json b/package.nls.cs.json index fed09db51..85378da45 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 46a499666..425adaf0d 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 43274f6d4..767df57af 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 792c427c0..0e58741c1 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 67493d0f4..a6863c01c 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 aa4ac1465..da333102b 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 1d8054a44..f1a5a98cc 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 bb08a0918..5485932bf 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 633902a2e..30b2ee7e3 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 247948472..7395b7bd7 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 0ba35fd79..86f9e2006 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 a35a8a2f5..8fdedf0bc 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 948665f67..478914453 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 98efa3e28..6911374aa 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 53edbd285..fcded0dc9 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/backends/musecode/credentialFile.ts b/src/core/backends/musecode/credentialFile.ts index 5004f4439..5d1cb615a 100644 --- a/src/core/backends/musecode/credentialFile.ts +++ b/src/core/backends/musecode/credentialFile.ts @@ -1,25 +1,32 @@ // 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 three facts: the schema version, which providers are named, -// and whether a provider's `storage` points to the macOS Keychain. Every -// other field, the token included, is dropped by the parse, and nothing from -// the file is stored, logged or passed on. +// 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: `muse auth set` -// writes `api_key` alone, a browser sign-in the token, the key and the -// account's name. Signed in. +// - 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. +// 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 -// `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. +// 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 { @@ -31,12 +38,13 @@ import { } from '../../../shared/constants' /** - * - `empty`: names no provider; the file a sign-out leaves. - * - `inline`: holds the credential itself. + * - `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 else; only the CLI can say. + * - `unrecognized`: anything the structure cannot settle here; only the CLI + * can say. */ export type CredentialFileVerdict = 'empty' | 'inline' | 'keychain' | 'unsupportedHere' | 'unrecognized' @@ -52,13 +60,35 @@ 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' -// Unknown keys are dropped by the parse: a provider keeps its storage lane, -// never its key or tokens. +// 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()) })), + 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, @@ -79,8 +109,8 @@ export function credentialFileVerdict( } const isMacOs = platform === 'darwin' // Off macOS the version alone stops `muse serve`, whatever the file holds - // (captured on Windows for a pointer and for an empty file; the review of - // PR #49). + // (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' } @@ -89,9 +119,12 @@ export function credentialFileVerdict( ? providers[MUSE_CREDENTIAL_PROVIDER] : undefined if (muse === undefined) { - // No provider at all is signed out; another provider alone (a - // connector's token) says nothing about the sign-in. - return Object.keys(providers).length === 0 ? 'empty' : 'unrecognized' + // 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 || @@ -99,7 +132,9 @@ export function credentialFileVerdict( ) { return isMacOs ? 'keychain' : 'unsupportedHere' } - return 'inline' + // 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. */ diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 46e0a6896..2cb9ecd5c 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -127,15 +127,21 @@ export type AuthPort = Pick< /** * 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' + /** * 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 @@ -193,9 +199,13 @@ export class AuthService { 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 public constructor(private readonly deps: AuthServiceDeps) { this.isLogoutHeld = deps.logoutHold.get() @@ -218,6 +228,14 @@ 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 + } + private async stopBackendForSignOut(): Promise { try { await this.deps.backend.restartBackend(true) @@ -232,7 +250,11 @@ export class AuthService { const previous = this.snapshot this.snapshot = snapshot 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 @@ -292,6 +314,11 @@ export class AuthService { 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 @@ -449,6 +476,15 @@ export class AuthService { return this.snapshot } 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, + }) if (this.isLogoutHeld || (initial.status === 'signedIn' && initial.backend === 'modelApi')) { // 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). @@ -459,13 +495,7 @@ export class AuthService { this.deps.broadcast({ type: 'notice', level: noticeLevel, text: detail }) return refreshed } - return this.set({ - ...this.snapshot, - status, - detail, - verificationUrl: undefined, - userCode: undefined, - }) + return ended } private async refreshAfterCliDiscovery(epoch: number): Promise { @@ -679,6 +709,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 { @@ -752,10 +786,15 @@ export class AuthService { /** * The window is closing: the browser sign-in is cancelled and waited - * for, so its host is closed before the backends stop (the review of PR - * #49). + * 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) { @@ -766,11 +805,21 @@ export class AuthService { /** * 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). + * 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() - return await this.refresh(true) + 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. */ @@ -865,8 +914,13 @@ export class AuthService { (await this.hasCliCredential(isUserAction)) || (await this.deps.credentials.getApiKey()) !== undefined if (this.signOutEpoch !== epoch) { - // A sign-out raced this release: its hold stands, its state is its own. - await this.setLogoutHold(true) + // 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') { @@ -877,7 +931,7 @@ export class AuthService { } public async signIn(method: SignInMethod): Promise { - if (this.isSigningOut) { + if (this.isSigningOut || this.isStopped) { return this.snapshot } const epoch = this.signOutEpoch diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index 010200e97..8bc100c22 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -3,10 +3,12 @@ // 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 a file holding the credential signed in. -// - A macOS Keychain pointer or a file the structure cannot place is 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. +// 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. diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index f987d400f..373d4f23b 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -11,15 +11,19 @@ // 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 after the file was written and -// `account/read` already said `accountLogin`, so those decide and `granted` -// needs no handler. `account/changed` is not relied on: it fired neither for -// a change made outside the host nor for an expired, denied or failed code. +// (AGENTS.md rule 13). `granted` came 205 ms after the file was written and +// `account/read` said `accountLogin`, so those decide. It is remembered for +// one case only: with no first `account/read` there is no account to +// compare, and `granted` with `account/read` saying `accountLogin` is then +// the sign-in (a Keychain sign-in may leave the file as it was). `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. +// flow. A host that exits after the credential file changed has signed in. import * as z from 'zod/mini' import { clipForLog } from '../../core/logging' @@ -147,10 +151,12 @@ interface SignInSignals { /** 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 } = signals + const { initial, current, isFileWritten, isGranted } = signals if (current === undefined) { return isFileWritten } @@ -160,15 +166,17 @@ function isSignedIn(signals: SignInSignals): boolean { 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. A change of account - // counts only against an account read before the flow: with no first - // answer, a sign-in from before would pass for a new one (the review of - // PR #49). - const hasSignedIn = - initial !== undefined && - initial.state !== MUSE_ACCOUNT_STATES.accountLogin && - current.state === MUSE_ACCOUNT_STATES.accountLogin - return isFileWritten || hasSignedIn + // while the account is anything but signed out. + if (isFileWritten) { + return true + } + const isAccountLogin = current.state === MUSE_ACCOUNT_STATES.accountLogin + // With no first answer, a sign-in from before would pass for a new one: + // only the host's own `granted`, borne out by `account/read`, counts then + // (the review of PR #49). `granted` alone never does. + return initial === undefined + ? isGranted && isAccountLogin + : initial.state !== MUSE_ACCOUNT_STATES.accountLogin && isAccountLogin } export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { @@ -194,7 +202,12 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { + const modified = deps.credentialFileModifiedAt() + return modified !== undefined && modified !== before + } try { session.connection.onNotification((notification) => { if (isEnded || notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED) { @@ -206,6 +219,7 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { - const modified = deps.credentialFileModifiedAt() + const isWritten = isFileWritten() 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 - } - return isSignedIn({ - initial, - current, - isFileWritten: modified !== undefined && modified !== before, - }) + return ( + current !== STOPPED && isSignedIn({ initial, current, isFileWritten: isWritten, isGranted }) + ) } /** How a flow that has not landed ends now; undefined while it goes on. */ - const endedAs = async (): Promise => { + const endedAs = async (): Promise => { if (hostEnding !== undefined) { return hostEnding } @@ -293,6 +302,12 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise' } }, +}) +// 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 @@ -113,18 +122,47 @@ describe('The CLI’s sign-in against a real child process', { timeout: TEST_TIM expect(everythingLogged(t.log)).not.toContain('person@example.com') }) - it('reads a stored sign-in from the file alone', async () => { + // 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).not.toHaveBeenCalled() + 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() - await expect(t.account.signIn(false)).resolves.toBe('signedOut') + // 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() }) }) diff --git a/test/e2e/fake-muse/serve.mjs b/test/e2e/fake-muse/serve.mjs index eca933a37..a1b1475a8 100644 --- a/test/e2e/fake-muse/serve.mjs +++ b/test/e2e/fake-muse/serve.mjs @@ -25,11 +25,21 @@ // 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 the credential file under XDG_CONFIG_HOME -// (`providers.meta` is a login, anything else signed out), and -// `account/logout` rewrites that file as the empty one the CLI leaves. +// `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 @@ -47,7 +57,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' -import { argv, env, exit, stderr, stdin, stdout } from 'node:process' +import { argv, env, exit, platform, stderr, stdin, stdout } from 'node:process' import { createInterface } from 'node:readline' import { clearTimeout, setImmediate, setTimeout } from 'node:timers' @@ -59,6 +69,19 @@ const LOGOUT_SHELL = '{\n "schema_version": 1,\n "providers": {}\n}' 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({ @@ -100,6 +123,8 @@ 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 } */ @@ -413,23 +438,53 @@ function credentialFile() { return configHome === undefined ? undefined : path.join(configHome, 'muse', 'auth.json') } -function hasStoredSignIn() { +/** 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 false + return {} } try { - const providers = JSON.parse(readFileSync(file, 'utf8')).providers ?? {} - return Object.hasOwn(providers, MUSE_PROVIDER) + return JSON.parse(readFileSync(file, 'utf8')) ?? {} } catch { - return false + 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() { - return hasStoredSignIn() - ? { state: 'accountLogin', label: ACCOUNT_LABEL, credentialRequired: true } - : { state: 'loggedOut', credentialRequired: true } + 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. */ diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 299ec2e13..b44bc5cb2 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -145,6 +145,16 @@ function withLogoutFallback(h: Harness): Harness { 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') @@ -672,6 +682,100 @@ describe('AuthService: sign-in, sign-out and Cancel racing', () => { 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 + }) + // 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() @@ -964,11 +1068,7 @@ describe('AuthService.signOut and host reports', () => { }) it('recovers a held old CLI sign-in through an explicit fresh device approval', async () => { - const h = withLogoutFallback(harness()) - h.facts.cli = 'signedIn' - await h.service.refresh() - await h.service.signOut() - expect(h.logoutHoldState.isHeld).toBe(true) + const h = await heldCliSignIn() h.runDeviceSignIn.mockResolvedValue('signedIn') await expect(h.service.signIn('browser')).resolves.toMatchObject({ status: 'signedIn', @@ -1312,8 +1412,24 @@ describe('AuthService: a credential file Muse Code cannot start with (D26)', () }) }) - // Captured: with META_API_KEY set `muse serve` starts even with a - // version-2 file, and answers `envKey` (probe-v2-serve.json). + // 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 @@ -1414,6 +1530,41 @@ describe('AuthService over the CLI’s real credential file', () => { 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 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 () => { diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index 6c0c9742b..4f57f6d38 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -146,6 +146,30 @@ describe('CliAccount', () => { 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) diff --git a/test/unit/credentialFile.test.ts b/test/unit/credentialFile.test.ts index b4f6509b6..f106809b9 100644 --- a/test/unit/credentialFile.test.ts +++ b/test/unit/credentialFile.test.ts @@ -24,16 +24,21 @@ const MAC_POINTER = JSON.stringify({ }, }, }) -// Synthetic, as the probe of 2026-09-27 wrote it: a version-1 `meta` whose -// storage is the Keychain (`muse serve` exits 3 with it on Windows). +// 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 probe of 2026-09-27 gave `muse -// serve`, which exits 3 with it on Windows. +// 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({ @@ -50,22 +55,55 @@ describe('credentialFileVerdict', () => { }, ) - it('reads a stored key or login as held in the file', () => { - expect(credentialFileVerdict(AUTH_SET_FILE, 'win32')).toBe('inline') - expect(credentialFileVerdict(DEVICE_LOGIN_FILE, 'win32')).toBe('inline') - expect(credentialFileVerdict(DEVICE_LOGIN_FILE, 'linux')).toBe('inline') - expect(credentialFileVerdict(DEVICE_LOGIN_FILE, 'darwin')).toBe('inline') + 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') }) + // 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('empty') + 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 Windows 1.4.0 (isolated homes), the - // empty version-2 file included: "unsupported auth schema version 2" (the - // review of PR #49). + // 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) => { @@ -83,7 +121,7 @@ describe('credentialFileVerdict', () => { }, ) - it('decides on meta beside another provider, and on meta’s storage only', () => { + it('decides on meta beside another provider, and on meta’s storage only, off macOS', () => { expect(credentialFileVerdict(besideConnector({ api_key: '' }), 'win32')).toBe( 'inline', ) diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index cbfd98ac9..224a86a3b 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -92,6 +92,39 @@ function unanswered(t: ReturnType, method: string): void { ) } +/** 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(CAPTURED_LOGIN_START)).toEqual({ url: DEVICE_URL, code: DEVICE_CODE }) @@ -350,9 +383,9 @@ describe('Muse Code device sign-in: how it ends', () => { expect(t.close).toHaveBeenCalledOnce() }) - // Captured: `granted` came after `account/read` already said - // `accountLogin`, so its word neither ends the flow nor signs in. - it('decides on account/read, which the captured granted follows', async () => { + // 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, @@ -369,6 +402,44 @@ describe('Muse Code device sign-in: how it ends', () => { 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', {}) + }) + + // `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 = () => { @@ -553,6 +624,26 @@ describe('Muse Code device sign-in: a host that exits', () => { 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', + ) + }) + it('fails at once when the host exits before loginStart answers', async () => { const t = session(() => CAPTURED_LOGGED_OUT) unanswered(t, 'account/loginStart') From 1ae3604f504520b8a2056ee8ed60fe9d1d542835 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 00:45:08 -0700 Subject: [PATCH 18/25] Settle Codex's review of 886af682, and every sibling of each class Codex raised three P2s on PR #49. Each is fixed, and each class was swept across src/host/auth and src/core/backends/musecode (plus the two places that log Muse Code's stderr) in the same pass. Stale answers: - A probe that Cancel, a sign-out or Check again abandoned gives its late answer to no caller: the one that started it and every one that joined it get OBSOLETE and look again, finding the newer answer. - Every refresh takes a ticket as it starts and publishes only if nothing newer has, so a slower, older refresh never replaces a newer state. - While the browser sign-in waits on its runner, a refresh leaves the code on screen. A host exit read as success: - An answered account/read that says signed out refutes the write it saw. Neither a host exit nor a later unanswered account/read turns that write into a sign-in. Free text in the log (the redactor catches only key-shaped strings): - Captured sign-in endings are logged in fixed words, never the message. - wireWordForLog logs a protocol word only in its shape: an uncovered ending, the account/read state (CliAccount, logOutAccount), and markAuthRequired's reason. - failureForLog names an MSP failure by kind and code, in MuseCodeHost's three failure logs and the backend manager's close errors. - stderrForLog names muse serve and muse skills stderr by the captured lines in fixed words, any other line by its length. Drills CA to CR each broke one guard, failed its suite and were restored by SHA-256. Docs: PLAN.md D26, CHANGELOG, PRIVACY, SECURITY, AGENTS.md rule 8, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 9 +- CHANGELOG.md | 20 ++- PLAN.md | 34 ++++- SECURITY.md | 13 +- docs/PRIVACY.md | 16 +- docs/certification/sign-in-detection.md | 165 ++++++++++++++++++++- src/core/backends/musecode/MuseCodeHost.ts | 9 +- src/core/backends/musecode/logText.ts | 64 ++++++++ src/core/logging.ts | 11 ++ src/host/auth/accountHost.ts | 5 +- src/host/auth/authService.ts | 115 +++++++++++--- src/host/auth/cliAccount.ts | 51 +++++-- src/host/auth/deviceSignIn.ts | 75 ++++++---- src/host/backend/museCodeBackendManager.ts | 10 +- src/host/commands/skillsCommands.ts | 5 +- src/shared/constants.ts | 6 +- test/e2e/cliAccount.e2e.test.ts | 7 +- test/e2e/museCode.e2e.test.ts | 15 +- test/unit/MuseCodeHost.test.ts | 3 +- test/unit/accountHost.test.ts | 15 ++ test/unit/authService.test.ts | 69 +++++++++ test/unit/cliAccount.test.ts | 55 +++++-- test/unit/deviceSignIn.test.ts | 83 ++++++++++- test/unit/logText.test.ts | 76 ++++++++++ test/unit/skillsCommands.test.ts | 6 + 25 files changed, 808 insertions(+), 129 deletions(-) create mode 100644 src/core/backends/musecode/logText.ts create mode 100644 test/unit/logText.test.ts diff --git a/AGENTS.md b/AGENTS.md index 427514da1..dd26d997b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,9 +63,12 @@ them, the milestone plan, and the certification checklist. - **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. - - **A sign-in's ending.** Only `loginCompleted` messages captured as - naming nothing personal (`expired`, `denied`) reach the log; `failed`'s - names a path under the user's profile. + - **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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b1efea9dd..8d84eea9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,11 @@ happened, not what was planned; superseded entries are kept. 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. + - **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 @@ -92,7 +97,9 @@ happened, not what was planned; superseded entries are kept. sign-in that leaves the file as it was is seen. - **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. + 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 @@ -116,9 +123,14 @@ happened, not what was planned; superseded entries are kept. 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.** When Muse Code could not save a browser sign-in, its message - names a folder in your profile; the log now says only that saving - failed. +- **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 diff --git a/PLAN.md b/PLAN.md index cd37504e7..3f28930e4 100644 --- a/PLAN.md +++ b/PLAN.md @@ -916,10 +916,12 @@ action that fails is worse than hiding one that would work. `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. - `failed`'s message names the credential file's path under the user's - profile, so only `expired`'s and `denied`'s messages are logged. + 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), its message unlogged. + 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 @@ -930,7 +932,9 @@ action that fails is worse than hiding one that would work. 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. + 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. @@ -943,8 +947,26 @@ action that fails is worse than hiding one that would work. 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). -- **The log.** The unsupported-file message names the credential file's - full path; the log says so in fixed words and the panel keeps the path. +- **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: diff --git a/SECURITY.md b/SECURITY.md index 5d0fe625d..12ab00dfc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,11 +41,14 @@ Only the latest release on the Visual Studio Marketplace receives fixes. 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. The only page sign-in opens must be on - `https://auth.meta.com`. A failed sign-in's message, which names a - folder in the user's profile, is not logged. The log channel redacts - key-shaped strings, in Meta's current `LLM_…` form and the older - `LLM||` one. + 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 c6efde1d7..978815e4b 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -220,11 +220,17 @@ fields, never raw configuration or failed-command output. 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 gets the word it ended - with. Its message goes to the log only for a code that expired or was - denied, whose captured messages name nothing personal. When Muse Code - could not save the sign-in, its message names a folder in your profile, - so the log says only that saving failed. +- 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 diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 9a8b9c639..4f22dfbbe 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -68,7 +68,7 @@ made, and no token was read. | 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`). Only `expired`’s and `denied`’s messages reach the log; `failed`’s names a path, so a fixed line stands in. 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) | +| `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" | @@ -224,10 +224,11 @@ They were captured later the same evening (below). 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 keeps `expired`'s and `denied`'s messages - only: `failed`'s names a path, so the log says "saving the credential - failed". `granted` keeps no handler: it comes after the file and - `account/read` already show the sign-in. + 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.** @@ -481,6 +482,138 @@ constants comment (W2). | 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" | + +## 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: @@ -599,3 +732,25 @@ With the fourth round's fixes, `npm run quality` on the working tree `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). diff --git a/src/core/backends/musecode/MuseCodeHost.ts b/src/core/backends/musecode/MuseCodeHost.ts index 3eff7663f..27a116872 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/logText.ts b/src/core/backends/musecode/logText.ts new file mode 100644 index 000000000..30b9eb14c --- /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 a8d21a066..50ca4dc10 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/host/auth/accountHost.ts b/src/host/auth/accountHost.ts index 169af6593..b774a86fa 100644 --- a/src/host/auth/accountHost.ts +++ b/src/host/auth/accountHost.ts @@ -8,6 +8,7 @@ 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, @@ -149,11 +150,13 @@ export async function logOutAccount( 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?.state ?? 'no answer'})`, + : `Muse Code did not confirm account/logout (${after === undefined ? 'no answer' : wireWordForLog(after.state)})`, ) return 'unconfirmed' } diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 2cb9ecd5c..48a739355 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -11,6 +11,7 @@ import { 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, @@ -142,6 +143,9 @@ function signInLine(snapshot: AuthSnapshot, loggedDetail: string | undefined): s 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 @@ -206,6 +210,15 @@ export class AuthService { 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 public constructor(private readonly deps: AuthServiceDeps) { this.isLogoutHeld = deps.logoutHold.get() @@ -246,9 +259,26 @@ export class AuthService { } } - private set(snapshot: AuthSnapshot): AuthSnapshot { + 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 publishRefresh(ticket: number, snapshot: AuthSnapshot): AuthSnapshot { + return ticket < this.publishedTicket || this.isDeviceFlowWaiting + ? this.snapshot + : this.set(snapshot, ticket) + } + + private set(snapshot: AuthSnapshot, ticket = this.nextTicket()): AuthSnapshot { const previous = this.snapshot this.snapshot = snapshot + this.publishedTicket = ticket if (previous.status !== snapshot.status || previous.backend !== snapshot.backend) { const isUnsupportedFile = snapshot.detail !== undefined && snapshot.detail === this.unsupportedFileText() @@ -327,6 +357,38 @@ 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 + } + this.set({ ...this.snapshot, verificationUrl: url, userCode: code }) + }) + } finally { + this.isDeviceFlowWaiting = false + } + } + private async signInWithCli(): Promise { const flow: CliSignInFlow = { initial: this.snapshot, @@ -345,11 +407,8 @@ export class AuthService { this.deviceAbort = abort this.set({ ...this.snapshot, status: 'signingIn', detail: UI_TEXT.signInWaiting }) 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) { + const outcome = await this.awaitDeviceRunner(flow) + if (outcome === UNSUPPORTED_FILE) { return await this.finishFailedCliSignIn( flow, 'error', @@ -357,15 +416,6 @@ export class AuthService { 'warning', ) } - // A cancel or sign-out during that look ends the flow before it starts. - const outcome: DeviceSignInOutcome = abort.signal.aborted - ? 'cancelled' - : await this.deps.runDeviceSignIn(abort.signal, (url, code) => { - if (abort.signal.aborted) { - return - } - this.set({ ...this.snapshot, verificationUrl: url, userCode: code }) - }) if (outcome === 'cancelled') { return await this.finishCancelledCliSignIn(flow) } @@ -884,6 +934,9 @@ export class AuthService { */ public async refresh(isUserAction = false): Promise { const epoch = this.signOutEpoch + // 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 @@ -892,10 +945,14 @@ export class AuthService { return this.snapshot } if (this.isSigningOut) { - return this.set({ ...selected, status: 'error', detail: this.logoutDetail() }) + return this.publishRefresh(ticket, { + ...selected, + status: 'error', + detail: this.logoutDetail(), + }) } if (!this.isLogoutHeld) { - return this.set(selected) + return this.publishRefresh(ticket, selected) } const hasCliCredential = await this.hasCliCredential(isUserAction) const hasStoredKey = (await this.deps.credentials.getApiKey()) !== undefined @@ -903,11 +960,19 @@ export class AuthService { return this.snapshot } if (hasCliCredential || hasStoredKey) { - return this.set({ ...selected, status: 'error', detail: this.logoutDetail() }) + return this.publishRefresh(ticket, { + ...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 this.publishRefresh(ticket, { + ...selected, + status: 'error', + detail: UI_TEXT.signOutHoldFailed, + }) } const current = await this.selectedSnapshot(isUserAction) const hasCurrentCredential = @@ -925,9 +990,13 @@ export class AuthService { } if (hasCurrentCredential || current.status === 'signedIn') { await this.setLogoutHold(true) - return this.set({ ...current, status: 'error', detail: this.logoutDetail() }) + return this.publishRefresh(ticket, { + ...current, + status: 'error', + detail: this.logoutDetail(), + }) } - return this.set(current) + return this.publishRefresh(ticket, current) } public async signIn(method: SignInMethod): Promise { @@ -991,7 +1060,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/cliAccount.ts b/src/host/auth/cliAccount.ts index 8bc100c22..6aad15784 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -18,6 +18,7 @@ import { type CredentialFileVerdict, credentialFileVerdict, } from '../../core/backends/musecode/credentialFile' +import { wireWordForLog } from '../../core/logging' import { MUSE_CREDENTIAL_FILE_MAX_BYTES, MUSE_CREDENTIAL_READ_ATTEMPTS, @@ -104,13 +105,25 @@ export interface CliAccountDeps { readonly log: Logger } +/** 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 } | undefined - private asking: { readonly key: string; readonly signIn: Promise } | undefined + private asking: Probe | undefined public constructor(private readonly deps: CliAccountDeps) {} - private async confirm(key: string, isUserAction: boolean): Promise { + private async confirm(key: string, isUserAction: boolean): Promise { const answered = this.answered // A remembered "could not say" is asked again when the user acts. if (answered?.key === key && (!isUserAction || answered.signIn !== 'unknown')) { @@ -120,16 +133,19 @@ export class CliAccount { return 'unknown' } if (this.asking?.key === key) { - return await this.asking.signIn + const joined = this.asking + const signIn = await joined.signIn + return joined.isAbandoned ? OBSOLETE : signIn } - const asking = { key, signIn: this.ask() } + 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 not remembered. - if (this.asking === asking) { - this.answered = { key, signIn } + // An abandoned probe's late answer is neither remembered nor given. + if (asking.isAbandoned) { + return OBSOLETE } + this.answered = { key, signIn } return signIn } finally { if (this.asking === asking) { @@ -141,10 +157,10 @@ export class CliAccount { private async ask(): Promise { const account = await this.deps.probe() const signIn = cliSignInFromAccount(account) - // The state word only: the account's label is an e-mail address. - this.deps.log.info( - `Muse Code sign-in confirmed by account/read: ${account?.state ?? 'no answer'} (${signIn})`, - ) + // 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 } @@ -160,10 +176,14 @@ export class CliAccount { /** * 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 - * not remembered. A remembered answer stands, so what Cancel shows next - * needs no new question. + * 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 } @@ -191,8 +211,9 @@ export class CliAccount { } 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 (the review of PR #49): look again. - if (this.look().key === look.key) { + // 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 } } diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index 373d4f23b..6e4ed8468 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -23,10 +23,12 @@ // 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. +// 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 * as z from 'zod/mini' -import { clipForLog } from '../../core/logging' +import { wireWordForLog } from '../../core/logging' import { withDeadline } from '../../core/timeouts' import { CREDENTIAL_POLL_INTERVAL_MS, @@ -51,11 +53,11 @@ const loginStartSchema = z.object({ }) // 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. Only what `loggedEnding` -// allows reaches the log; the panel shows none of it. +// 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)), - message: z.optional(z.string()), }) const loginCancelSchema = z.object({ cancelled: z.boolean() }) @@ -99,23 +101,23 @@ const CAPTURED_ENDINGS: ReadonlyMap = new Map([ [MUSE_LOGIN_OUTCOMES.failed, 'failed'], ]) -// The endings whose captured message is safe to log: it names no path and -// no account (the review of PR #49). `failed`'s names the credential file's -// path, under the user's profile, so a fixed line stands in for it; a word -// no capture covers is logged without its message. -const LOGGED_MESSAGES: ReadonlySet = new Set([ - MUSE_LOGIN_OUTCOMES.expired, - MUSE_LOGIN_OUTCOMES.denied, +// 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: the word, and only a message captured as safe. */ -function loggedEnding(outcome: string, message: string | undefined): string { - if (outcome === MUSE_LOGIN_OUTCOMES.failed) { - return `${outcome}: saving the credential failed` - } - const said = - message !== undefined && LOGGED_MESSAGES.has(outcome) ? `: ${clipForLog(message)}` : '' - return `${clipForLog(outcome)}${said}` +/** 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)` + ) } /** How `outcome` ends the flow; undefined for `granted`, which `account/read` bears out. */ @@ -204,10 +206,15 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { - const modified = deps.credentialFileModifiedAt() - return modified !== undefined && modified !== before - } + // 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 (isEnded || notification.method !== MUSE_ACCOUNT_LOGIN_COMPLETED) { @@ -221,9 +228,7 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { - const isWritten = isFileWritten() + // 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). - return ( - current !== STOPPED && isSignedIn({ initial, current, isFileWritten: isWritten, isGranted }) - ) + if (current === STOPPED) { + return false + } + 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 => { @@ -303,7 +313,8 @@ export async function runDeviceSignIn(deps: DeviceSignInDeps): Promise { - // 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 @@ -227,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 } @@ -418,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 bdab50b55..cba8dbb87 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/shared/constants.ts b/src/shared/constants.ts index 0fb52bc28..254e7278b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1437,8 +1437,10 @@ export const MUSE_CREDENTIAL_KEYCHAIN_STORAGE = 'keychain' 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: -// a file rewritten again and again ends as `unknown` (the review of PR #49). +// 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 // 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 diff --git a/test/e2e/cliAccount.e2e.test.ts b/test/e2e/cliAccount.e2e.test.ts index 7c5fdd08f..933554561 100644 --- a/test/e2e/cliAccount.e2e.test.ts +++ b/test/e2e/cliAccount.e2e.test.ts @@ -212,7 +212,7 @@ describe('The device sign-in against a real child process', { timeout: TEST_TIME 'AAAA-AAAA', ) expect(t.log.info).toHaveBeenCalledWith( - 'Muse Code sign-in ended: expired: login failed: the request expired', + 'Muse Code sign-in ended: expired: the code expired before it was approved', ) }) @@ -240,9 +240,10 @@ describe('The device sign-in against a real child process', { timeout: TEST_TIME expect(everythingLogged(t.log)).not.toContain('person@example.com') }) - // `failed`'s captured message names the credential file's path. + // The log keeps fixed words; `failed`'s captured message names the + // credential file's path. it.each([ - ['denied', 'login failed: the request was denied'], + ['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) diff --git a/test/e2e/museCode.e2e.test.ts b/test/e2e/museCode.e2e.test.ts index 78351363d..fc4f922ab 100644 --- a/test/e2e/museCode.e2e.test.ts +++ b/test/e2e/museCode.e2e.test.ts @@ -41,6 +41,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) @@ -425,7 +433,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') @@ -455,9 +464,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/unit/MuseCodeHost.test.ts b/test/unit/MuseCodeHost.test.ts index 8c7ef8bab..84bfcb99a 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 index c7a6a62d8..4379364b7 100644 --- a/test/unit/accountHost.test.ts +++ b/test/unit/accountHost.test.ts @@ -156,6 +156,21 @@ describe('logOutAccount', () => { 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). diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index b44bc5cb2..f4ccd6341 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -776,6 +776,41 @@ describe('AuthService: sign-in, sign-out and Cancel racing', () => { 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() @@ -1324,6 +1359,20 @@ describe('AuthService.signOut and host reports', () => { 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.cli = 'signedIn' @@ -1550,6 +1599,26 @@ describe('AuthService over the CLI’s real credential file', () => { 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). diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index 4f57f6d38..0ed27f7a9 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -66,6 +66,19 @@ function linuxChecker(file: string, probe: () => Promise() + const answers = [stale.promise, Promise.resolve(LOGGED_OUT)] + const probe = vi.fn(() => answers.shift() ?? Promise.resolve(undefined)) + return { 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() @@ -250,24 +263,48 @@ describe('CliAccount', () => { await expect(pending).resolves.toBe('signedOut') }) - it('asks afresh after an unanswered probe is abandoned, and forgets its late answer (the review of PR #49)', async () => { - 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)) - const checker = linuxChecker(home.file, probe) + // 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) - // The first probe answers late: it settles its own caller only. stale.resolve(SIGNED_IN) - await expect(abandoned).resolves.toBe('signedIn') + 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) + }) + + // 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 () => { diff --git a/test/unit/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index 224a86a3b..52f69e41d 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -491,6 +491,23 @@ describe('Muse Code device sign-in: how it ends', () => { ) }) + // 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 @@ -508,7 +525,7 @@ describe('Muse Code device sign-in: how it ends', () => { expect(CREDENTIAL_POLL_TIMEOUT_MS).toBeGreaterThan(CAPTURED_CODE_LIFETIME_MS) }) - it('ends at once on the captured expired ending, logs its message, and sends no loginCancel', async () => { + 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 = () => { @@ -519,14 +536,36 @@ describe('Muse Code device sign-in: how it ends', () => { expect(t.request).not.toHaveBeenCalledWith('account/loginCancel', {}) expect(t.close).toHaveBeenCalledOnce() expect(logged.info).toHaveBeenCalledWith( - 'Muse Code sign-in ended: expired: login failed: the request expired', + '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, 'login failed: the request was denied'], + ['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', @@ -560,7 +599,23 @@ describe('Muse Code device sign-in: how it ends', () => { } 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') + 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') }) @@ -644,6 +699,26 @@ describe('Muse Code device sign-in: a host that exits', () => { ) }) + // 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') diff --git a/test/unit/logText.test.ts b/test/unit/logText.test.ts new file mode 100644 index 000000000..f18353d5b --- /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 c92e4c00d..89ab96645 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) From 3b359e566a187811bc8f718b28949f07cef4b62e Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 01:36:53 -0700 Subject: [PATCH 19/25] Switch backends through one helper that ends the old conversations Codex on 1ae3604f: with the Model API signed in, a Cancel pressed just after the browser approved let the credential file decide, and 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. AuthService.publishSelection is now the one way a state that may sign in on another backend than conversations run on is published: it opens a new admission generation, shows checking, ends the running backend's conversations (restartBackend(true)) and then publishes, if nothing newer has. Every refresh goes through it (Check again, a Cancel that landed a sign-in, a failed sign-in's refresh, a device sign-in's confirmation, CLI discovery after an install, whose own copy of this logic is gone, and a changed museSpark.backend), and so do key activation and a failed install. liveBackend records the backend of the last signed-in state; any restart that ends conversations clears it, so none is ended twice. Drills CS to CY each broke one guard, failed its suite and were restored by SHA-256. Docs: PLAN.md D26, CHANGELOG, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 8 ++ PLAN.md | 10 ++ docs/certification/sign-in-detection.md | 68 +++++++++++ src/host/auth/authService.ts | 146 ++++++++++++++++-------- test/unit/authService.test.ts | 115 +++++++++++++++++-- 5 files changed, 290 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d84eea9e..51f7482d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,14 @@ happened, not what was planned; superseded entries are kept. 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. - **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 diff --git a/PLAN.md b/PLAN.md index 3f28930e4..41fbd1858 100644 --- a/PLAN.md +++ b/PLAN.md @@ -947,6 +947,16 @@ action that fails is worse than hiding one that would work. 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 diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 4f22dfbbe..4438dd8ed 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -523,6 +523,61 @@ pre-drill value after every drill and after all eighteen: | 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" | + +## 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, @@ -754,3 +809,16 @@ tree (Windows 11, 2026-09-28, after drills CA to CR, before the commit): `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/src/host/auth/authService.ts b/src/host/auth/authService.ts index 48a739355..021904d89 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -219,6 +219,13 @@ export class AuthService { 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 public constructor(private readonly deps: AuthServiceDeps) { this.isLogoutHeld = deps.logoutHold.get() @@ -249,9 +256,21 @@ export class AuthService { 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 { + await this.deps.backend.restartBackend(isConversationEnding) + if (isConversationEnding) { + 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') @@ -259,6 +278,59 @@ export class AuthService { } } + /** 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') + return this.set({ + ...this.snapshot, + status: 'error', + detail: UI_TEXT.signOutStopFailed, + installState: + this.snapshot.installState === 'running' ? 'failed' : this.snapshot.installState, + }) + } + return ticket < this.publishedTicket || !isCurrent() ? this.snapshot : this.set(next, ticket) + } + private nextTicket(): number { this.tickets += 1 return this.tickets @@ -269,16 +341,23 @@ export class AuthService { * 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 publishRefresh(ticket: number, snapshot: AuthSnapshot): AuthSnapshot { + private async publishRefresh( + ticket: number, + epoch: number, + snapshot: AuthSnapshot, + ): Promise { return ticket < this.publishedTicket || this.isDeviceFlowWaiting ? this.snapshot - : this.set(snapshot, ticket) + : 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 + } if (previous.status !== snapshot.status || previous.backend !== snapshot.backend) { const isUnsupportedFile = snapshot.detail !== undefined && snapshot.detail === this.unsupportedFileText() @@ -477,7 +556,7 @@ export class AuthService { } } this.admissionGenerationValue += 1 - await this.deps.backend.restartBackend(flow.initial.status === 'signedIn') + await this.restartHosts(flow.initial.status === 'signedIn') if (abort.signal.aborted) { return await this.finishCancelledCliSignIn(flow) } @@ -549,36 +628,9 @@ export class AuthService { } private async refreshAfterCliDiscovery(epoch: number): Promise { - const selected = await this.selectedSnapshot(true) - if (epoch !== this.signOutEpoch) { - return this.snapshot - } - if ( - this.snapshot.status === 'signedIn' && - this.snapshot.backend === 'modelApi' && - selected.status === 'signedIn' && - selected.backend === 'museCode' - ) { - // 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) { - return this.snapshot - } - } - return await this.refresh(true) + // 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 { @@ -639,10 +691,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', @@ -692,7 +745,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 @@ -705,10 +758,13 @@ 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) - return epoch === this.signOutEpoch ? this.set(selected) : this.snapshot + // 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 } /** @@ -945,14 +1001,14 @@ export class AuthService { return this.snapshot } if (this.isSigningOut) { - return this.publishRefresh(ticket, { + return await this.publishRefresh(ticket, epoch, { ...selected, status: 'error', detail: this.logoutDetail(), }) } if (!this.isLogoutHeld) { - return this.publishRefresh(ticket, selected) + return await this.publishRefresh(ticket, epoch, selected) } const hasCliCredential = await this.hasCliCredential(isUserAction) const hasStoredKey = (await this.deps.credentials.getApiKey()) !== undefined @@ -960,7 +1016,7 @@ export class AuthService { return this.snapshot } if (hasCliCredential || hasStoredKey) { - return this.publishRefresh(ticket, { + return await this.publishRefresh(ticket, epoch, { ...selected, status: 'error', detail: this.logoutDetail(), @@ -968,7 +1024,7 @@ export class AuthService { } const isHoldSaved = await this.setLogoutHold(false) if (!isHoldSaved) { - return this.publishRefresh(ticket, { + return await this.publishRefresh(ticket, epoch, { ...selected, status: 'error', detail: UI_TEXT.signOutHoldFailed, @@ -990,13 +1046,13 @@ export class AuthService { } if (hasCurrentCredential || current.status === 'signedIn') { await this.setLogoutHold(true) - return this.publishRefresh(ticket, { + return await this.publishRefresh(ticket, epoch, { ...current, status: 'error', detail: this.logoutDetail(), }) } - return this.publishRefresh(ticket, current) + return await this.publishRefresh(ticket, epoch, current) } public async signIn(method: SignInMethod): Promise { diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index f4ccd6341..9b92f4e01 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -4,7 +4,11 @@ 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 } from '../../src/host/auth/authService' +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' @@ -595,6 +599,25 @@ function aborted(signal: AbortSignal): Promise { }) } +/** + * 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 +} + // 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', () => { @@ -651,18 +674,10 @@ describe('AuthService: sign-in, sign-out and Cancel racing', () => { // 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() - 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() + await expect(cancelAfterApproval(h)).resolves.toMatchObject({ + status: 'signedIn', + backend: 'museCode', }) - h.service.cancelSignIn() - await expect(pending).resolves.toMatchObject({ status: 'signedIn', backend: 'museCode' }) }) // `account/logout` cleared the file, but META_API_KEY made `account/read` @@ -829,6 +844,82 @@ describe('AuthService: sign-in, sign-out and Cancel racing', () => { }) }) +// 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 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() From 19e74b0749c9bb72f60c2e4f7cbeb0c23636d74c Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 02:14:17 -0700 Subject: [PATCH 20/25] Expect each OS's own reading of the credential file in the e2e tests CI on 1ae3604f failed on macos-latest only: two e2e tests read a real credential file on the host OS and expected inline, which macOS reads as 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. capturedInlineVerdict(platform) in the test helpers gives each OS's verdict for the captured inline shapes, and credentialFile.test.ts pins it for all three OSes against the read. The granted e2e now reads the file the sign-in left as every OS reads it, so each 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. Drill CZ puts the old expectation back and fails on Windows with CI's own message; DA (the table drifts) and DB (the macOS branch regresses) fail on Windows too. Each restored by SHA-256. Docs: docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/certification/sign-in-detection.md | 48 +++++++++++++++++++++++++ test/e2e/cliAccount.e2e.test.ts | 13 +++++-- test/e2e/museCode.e2e.test.ts | 8 +++-- test/unit/cliAccount.test.ts | 16 ++++++++- test/unit/credentialFile.test.ts | 19 ++++++++++ test/unit/helpers/credentialShapes.ts | 15 ++++++++ 6 files changed, 113 insertions(+), 6 deletions(-) diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 4438dd8ed..c4a18d297 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -538,6 +538,54 @@ same way, on the final tree, from `scratchpad/cred-capture/drills5.mjs` | 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" | + +## 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 diff --git a/test/e2e/cliAccount.e2e.test.ts b/test/e2e/cliAccount.e2e.test.ts index 933554561..6560a7ecd 100644 --- a/test/e2e/cliAccount.e2e.test.ts +++ b/test/e2e/cliAccount.e2e.test.ts @@ -19,8 +19,10 @@ import { MuseCodeBackendManager } from '../../src/host/backend/museCodeBackendMa 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' @@ -233,9 +235,14 @@ describe('The device sign-in against a real child process', { timeout: TEST_TIME it('signs in on the captured granted sequence', async () => { const t = signIn(endingAfterStart('granted'), OPEN) await expect(t.outcome).resolves.toBe('signedIn') - expect(readCredentialFile(t.backend.credentialFilePath(), process.platform)?.verdict).toBe( - 'inline', - ) + // 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') }) diff --git a/test/e2e/museCode.e2e.test.ts b/test/e2e/museCode.e2e.test.ts index fc4f922ab..7e2c15f8b 100644 --- a/test/e2e/museCode.e2e.test.ts +++ b/test/e2e/museCode.e2e.test.ts @@ -23,6 +23,7 @@ import { UI_TEXT, } from '../../src/shared/constants' import { FakeLogOutputChannel } from '../unit/helpers/fakes' +import { capturedInlineVerdict } from '../unit/helpers/credentialShapes' import { installFakeCredential, installFakeMuse, removeTestFolders } from './fakeMuse' const TURN_TIMEOUT_MS = 10_000 @@ -168,10 +169,13 @@ afterAll(() => { 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.credentialFileVerdict()).toBe('inline') + // 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 inline,')) + 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}`) diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index 0ed27f7a9..c2df8fe46 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -10,7 +10,13 @@ import { readCredentialFile, } from '../../src/host/auth/cliAccount' import { MUSE_CREDENTIAL_FILE_MAX_BYTES } from '../../src/shared/constants' -import { DEVICE_LOGIN_FILE, LOGOUT_SHELL, SLACK_CONNECTOR_ONLY } from './helpers/credentialShapes' +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 @@ -103,6 +109,14 @@ describe('readCredentialFile', () => { 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) diff --git a/test/unit/credentialFile.test.ts b/test/unit/credentialFile.test.ts index f106809b9..86158addc 100644 --- a/test/unit/credentialFile.test.ts +++ b/test/unit/credentialFile.test.ts @@ -5,8 +5,10 @@ import { } from '../../src/core/backends/musecode/credentialFile' import { AUTH_SET_FILE, + capturedInlineVerdict, DEVICE_LOGIN_FILE, LOGOUT_SHELL, + SHIPPED_PLATFORMS, SLACK_CONNECTOR_ONLY, } from './helpers/credentialShapes' @@ -70,6 +72,23 @@ describe('credentialFileVerdict', () => { 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') diff --git a/test/unit/helpers/credentialShapes.ts b/test/unit/helpers/credentialShapes.ts index c37584ad0..1f864338e 100644 --- a/test/unit/helpers/credentialShapes.ts +++ b/test/unit/helpers/credentialShapes.ts @@ -26,6 +26,21 @@ export const SLACK_CONNECTOR_ONLY = JSON.stringify({ 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 From 86d6365204391c34f9b6438b4287d8896f84eeee Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 05:40:40 -0700 Subject: [PATCH 21/25] Guard shared state written after an await against newer writes Codex on 19e74b07: restartHosts(true) cleared liveBackend after awaiting the restart, so a signed-in state a newer refresh published meanwhile was erased and a later switch skipped ending its conversations. set() now moves liveVersion each time it records the backend; restartHosts clears only if it is unchanged (not before the await, so a failed restart keeps the record of conversations still running). The sweep of every field written after an await in AuthService, CliAccount and runDeviceSignIn found two more: - isLogoutPersistenceFailed: an older logout-hold write that failed late marked the hold unsaved after newer writes were saved, shutting admission. Only the latest write's outcome counts (holdWrites). - CliAccount.answered: a probe about an older version of the file could overwrite the newer version's remembered answer. Starting a probe now abandons the one it replaces. The rest are single-writer by construction, identity-guarded, or only increase. Drills DC to DE each broke one guard, failed its suite and were restored by SHA-256. The local gate did not pass: four runs exited 1 in untouched real-process suites and a11y under load from other worktrees' gates (see the cert record); the touched suites, typecheck, lint, l10n and jscpd passed. The full gate is CI's three-OS run on the pushed commit. Docs: CHANGELOG, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +- docs/certification/sign-in-detection.md | 72 +++++++++++++++++++++++++ src/host/auth/authService.ts | 25 +++++++-- src/host/auth/cliAccount.ts | 6 +++ test/unit/authService.test.ts | 43 +++++++++++++++ test/unit/cliAccount.test.ts | 16 +++++- 6 files changed, 162 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7482d9..d9066e4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,10 @@ happened, not what was planned; superseded entries are kept. 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. + 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. - **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 diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index c4a18d297..3356b34a3 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -549,6 +549,78 @@ They ran the same way on this Windows machine, from | 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" | + +## 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 diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 021904d89..5b67a6243 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -226,6 +226,10 @@ export class AuthService { * 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() @@ -233,12 +237,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 } @@ -262,8 +275,13 @@ export class AuthService { * 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) { + if (isConversationEnding && live === this.liveVersion) { this.liveBackend = undefined } } @@ -357,6 +375,7 @@ export class AuthService { this.publishedTicket = ticket if (snapshot.status === 'signedIn') { this.liveBackend = snapshot.backend + this.liveVersion += 1 } if (previous.status !== snapshot.status || previous.backend !== snapshot.backend) { const isUnsupportedFile = diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index 6aad15784..8e2af17bb 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -137,6 +137,12 @@ export class CliAccount { 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 { diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 9b92f4e01..a4c7814f9 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -908,6 +908,49 @@ describe('AuthService: a switch of backends ends the running one’s conversatio 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 = 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() + }) + 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 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 () => { diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index c2df8fe46..ebb8a8e34 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -82,7 +82,7 @@ function staleThenLoggedOut() { const stale = Promise.withResolvers() const answers = [stale.promise, Promise.resolve(LOGGED_OUT)] const probe = vi.fn(() => answers.shift() ?? Promise.resolve(undefined)) - return { stale, probe, checker: linuxChecker(home.file, probe) } + return { home, stale, probe, checker: linuxChecker(home.file, probe) } } /** An ambiguous file on Linux whose CLI question waits until the test answers it. */ @@ -305,6 +305,20 @@ describe('CliAccount', () => { 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 () => { From 55b9e24cd7811fa93619d46394a58a7ee730b0b2 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 06:14:04 -0700 Subject: [PATCH 22/25] Publish error paths after an await under the success paths' guards Codex on 86d63652: when publishSelection's restart rejected late, its catch published signOutStopFailed with a new ticket, skipping the ticket and isCurrent guards the success path uses, so a stale attempt overwrote a newer refresh or a sign-out and closed admission. The failure is now published under the same guards, with the refresh's own ticket. The re-sweep of every catch, finally and error path after an await in AuthService, CliAccount, runDeviceSignIn and accountHost found two more: - confirmCliSignIn: a finished sign-in's rejected restart reached finishFailedCliSignIn and published signInFailed over a refresh made meanwhile; a newer state now stands. - installFailed, the install's error path, published the selection it awaited over a newer state; it now takes a ticket before asking. The other catches publish nothing or belong to the one operation that owns them; the finally blocks reset single-writer or identity-guarded fields. Drills DF to DH each broke one guard, failed its suite and were restored by SHA-256. Checks: authService.test.ts (103), typecheck, lint, l10n, jscpd, format; not the full gate (machine loaded by other worktrees' gates). The full gate is CI's three-OS run on the pushed commit. Docs: CHANGELOG, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +- docs/certification/sign-in-detection.md | 57 +++++++++++++++++ src/host/auth/authService.ts | 41 +++++++++--- test/unit/authService.test.ts | 84 ++++++++++++++++++++++--- 4 files changed, 167 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9066e4f5..eb833aa00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,8 @@ happened, not what was planned; superseded entries are kept. 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. + 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 diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 3356b34a3..1293df3f2 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -559,6 +559,63 @@ Drills DC to DE cover Codex's review of `19e74b07` (below), from | 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" | + +## 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 diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 5b67a6243..186851f04 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -338,13 +338,21 @@ export class AuthService { await this.restartHosts(true) } catch { this.deps.log.warn('The running backend could not stop before switching backends') - return this.set({ - ...this.snapshot, - status: 'error', - detail: UI_TEXT.signOutStopFailed, - installState: - this.snapshot.installState === 'running' ? 'failed' : this.snapshot.installState, - }) + // 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) } @@ -575,7 +583,19 @@ export class AuthService { } } this.admissionGenerationValue += 1 - await this.restartHosts(flow.initial.status === 'signedIn') + // 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) } @@ -697,8 +717,11 @@ export class AuthService { } private async installFailed(detail: string, epoch: number): Promise { + // 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) { + if (epoch !== this.signOutEpoch || this.isSigningOut || ticket < this.publishedTicket) { return this.snapshot } if (this.isLogoutHeld) { diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index a4c7814f9..918ef8f39 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -618,6 +618,23 @@ async function cancelAfterApproval(h: Harness): Promise { 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', () => { @@ -912,14 +929,7 @@ describe('AuthService: a switch of backends ends the running one’s conversatio // 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 = 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() - }) + const { h, stopping, switching } = await switchHeldInRestart() await expect(h.service.refresh()).resolves.toMatchObject({ backend: 'museCode' }) stopping.resolve(undefined) await switching @@ -928,6 +938,64 @@ describe('AuthService: a switch of backends ends the running one’s conversatio 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). From 2a324d48c9fab7e119b1d87a87347f6cfe4de5d1 Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 07:49:34 -0700 Subject: [PATCH 23/25] Ask the CLI afresh on a Cancel after the device code was shown Codex on 55b9e24c: 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, the flow reported signedOut and kept the CLI's answer from before it, although the login had landed. A Cancel after the code was shown, with the file unchanged, now forgets the CLI's answers; the cancellation shows at once, then a user-action refresh asks account/read afresh (bounded by the account host's deadline and the sign-out signal) and publishes through publishSelection's guards, with the cancellation as a notice. A Cancel before any code ends as before. Sibling: when the file changed as Cancel was pressed, the refresh was passive, so macOS estimated the new file instead of asking; a Cancel is a click, so it asks now. Drills DI to DK each broke one guard, failed its suite and were restored by SHA-256. Checks: authService, cliAccount, conversationController and the cliAccount e2e (389), typecheck, lint, l10n, jscpd, format; not the full gate (machine loaded). Docs: CHANGELOG, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +- docs/certification/sign-in-detection.md | 43 +++++++++++++++ src/host/auth/authService.ts | 45 +++++++++++++--- test/unit/authService.test.ts | 72 ++++++++++++++++++++++++- 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb833aa00..1d8b5d85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,7 +101,8 @@ happened, not what was planned; superseded entries are kept. - **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. + 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. - **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 diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 1293df3f2..5efdb07d4 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -570,6 +570,49 @@ pre-drill SHA-256 afterwards. | 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 `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 diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 186851f04..6c0a92aae 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -182,6 +182,8 @@ interface CliSignInFlow { 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 { @@ -488,6 +490,7 @@ export class AuthService { if (abort.signal.aborted) { return } + flow.isCodeShown = true this.set({ ...this.snapshot, verificationUrl: url, userCode: code }) }) } finally { @@ -503,6 +506,7 @@ export class AuthService { abort: new AbortController(), signOut: this.signOutStarts.signal, fileBefore: this.deps.backend.credentialFileModifiedAt(), + isCodeShown: false, } const { abort } = flow const cli = this.deps.backend.resolveCli() @@ -618,26 +622,47 @@ export class AuthService { } /** - * Cancel decides the flow, but not against the file (the review of PR - * #49): when the credential file changed since the flow began (the browser - * approved as Cancel was pressed), its structure says whether the CLI is - * signed in. + * 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 - return isFileChanged - ? ((await unlessAborted(this.refresh(), flow.signOut)) ?? this.snapshot) - : await this.finishFailedCliSignIn(flow, 'signedOut', UI_TEXT.signInCancelled, 'info') + 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( flow: CliSignInFlow, status: 'noCli' | 'signedOut' | 'error', detail: string, noticeLevel: 'info' | 'warning', + isAskingAfresh = false, ): Promise { // A sign-out that cancelled this sign-in publishes its own state. if (this.isSigningOut) { @@ -653,7 +678,11 @@ export class AuthService { verificationUrl: undefined, userCode: undefined, }) - if (this.isLogoutHeld || (initial.status === 'signedIn' && initial.backend === 'modelApi')) { + if ( + isAskingAfresh || + this.isLogoutHeld || + (initial.status === 'signedIn' && initial.backend === 'modelApi') + ) { // 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) diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 918ef8f39..7c00a3b6d 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -1709,6 +1709,10 @@ describe('AuthService: when the CLI may be asked (macOS Keychain prompts follow 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[] = [] @@ -1718,7 +1722,7 @@ describe('AuthService over the CLI’s real credential file', () => { } }) - function withFile(contents: string | undefined) { + 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') @@ -1728,7 +1732,7 @@ describe('AuthService over the CLI’s real credential file', () => { } const probe = vi.fn<() => Promise>(() => Promise.resolve(undefined)) const account = new CliAccount({ - platform: 'linux', + platform, credentialFilePath: () => file, probe, log: new FakeLogOutputChannel(), @@ -1752,6 +1756,70 @@ describe('AuthService over the CLI’s real credential file', () => { 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({ From 328efb52f6a8f4bd55373e69aecb193c6c949a4e Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 08:33:39 -0700 Subject: [PATCH 24/25] Ask the CLI afresh on every macOS click, unless the same click asked Codex on 2a324d48: on macOS a sign-in or sign-out made elsewhere may change only the Keychain, the file's path, size and mtime as they were, and CliAccount.confirm reused a remembered definitive answer even for a user action, so Diagnostics could report a stale sign-in. Fixed in the class: on macOS a user action 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. A literal "never reuse" would ask, and maybe prompt for the Keychain, up to four times per click. Off macOS the file speaks for the sign-in, as before. Diagnostics, Check again, Cancel, the sign-in button, sign-out and the Sign Out command all ask as a user action; the sign-in's pre-check stays passive so nothing prompts before the code is shown. Drills DL and DM each broke the rule, failed its suite and were restored by SHA-256. Checks: cliAccount, authService, supportReport and the cliAccount e2e (153), typecheck, lint, l10n, jscpd, format; not the full gate (machine loaded). Docs: CHANGELOG, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 ++- docs/certification/sign-in-detection.md | 32 +++++++++++++++++++++ src/host/auth/cliAccount.ts | 31 ++++++++++++++++++--- src/shared/constants.ts | 6 ++++ test/unit/cliAccount.test.ts | 37 ++++++++++++++++++++++++- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d8b5d85f..53bb2f5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,9 @@ happened, not what was planned; superseded entries are kept. 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. + 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 diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 5efdb07d4..54ef6cd1d 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -581,6 +581,38 @@ pre-drill SHA-256 afterwards. | 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 `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 diff --git a/src/host/auth/cliAccount.ts b/src/host/auth/cliAccount.ts index 8e2af17bb..182d8deeb 100644 --- a/src/host/auth/cliAccount.ts +++ b/src/host/auth/cliAccount.ts @@ -22,6 +22,7 @@ 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' @@ -103,6 +104,8 @@ export interface CliAccountDeps { /** `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. */ @@ -118,15 +121,35 @@ interface Probe { const OBSOLETE = Symbol('the answer of an abandoned probe') export class CliAccount { - private answered: { readonly key: string; readonly signIn: CliSignIn } | undefined + 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 - // A remembered "could not say" is asked again when the user acts. - if (answered?.key === key && (!isUserAction || answered.signIn !== 'unknown')) { + if (answered?.key === key && this.mayReuse(answered, isUserAction)) { return answered.signIn } if (!isUserAction && this.deps.platform === 'darwin') { @@ -151,7 +174,7 @@ export class CliAccount { if (asking.isAbandoned) { return OBSOLETE } - this.answered = { key, signIn } + this.answered = { key, signIn, at: this.now() } return signIn } finally { if (this.asking === asking) { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 254e7278b..6016b5f46 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1442,6 +1442,12 @@ export const MUSE_CREDENTIAL_FILE_MAX_BYTES = 64 * 1024 // 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. diff --git a/test/unit/cliAccount.test.ts b/test/unit/cliAccount.test.ts index ebb8a8e34..66fb609db 100644 --- a/test/unit/cliAccount.test.ts +++ b/test/unit/cliAccount.test.ts @@ -9,7 +9,10 @@ import { isCliSignedIn, readCredentialFile, } from '../../src/host/auth/cliAccount' -import { MUSE_CREDENTIAL_FILE_MAX_BYTES } from '../../src/shared/constants' +import { + MUSE_CREDENTIAL_FILE_MAX_BYTES, + MUSE_USER_ACTION_ANSWER_REUSE_MS, +} from '../../src/shared/constants' import { capturedInlineVerdict, DEVICE_LOGIN_FILE, @@ -222,6 +225,38 @@ describe('CliAccount', () => { ) }) + // 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) From 5184f26986283cfcacd64da8628c76a5e062a4ff Mon Sep 17 00:00:00 2001 From: Randy Northrup Date: Mon, 28 Sep 2026 08:47:39 -0700 Subject: [PATCH 25/25] Ask the CLI nothing when Model API is forced; count granted on reauth Codex on 328efb52, two P2s: - With museSpark.backend forced to modelApi, the gate's refresh and host admission still asked the CLI for its sign-in, so an ambiguous file could keep a user with a stored key in checking until the MSP deadline. isCliSignInConsulted(setting) now says the choice needs no CLI answer: AuthService.choose and readBackendChoice (host admission in extension.ts) ask nothing. The logout hold's checks after a sign-out still look at the CLI. - With the logout hold keeping an accountLogin, a macOS re-sign-in to the same account may replace only the Keychain item, so neither a state change nor a file write showed it and the flow waited out its limit. The captured success, granted borne out by accountLogin, now counts whatever came before; granted alone still never does. Drills DN, DO and DP each broke one guard, failed its suite and were restored by SHA-256. Checks: backendSelection, authService, deviceSignIn, cliAccount and the cliAccount e2e (207), typecheck, lint, l10n, jscpd, format; not the full gate (machine loaded). Docs: PLAN.md D26, CHANGELOG, docs/certification/sign-in-detection.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 ++++ PLAN.md | 8 +++++- docs/certification/sign-in-detection.md | 33 +++++++++++++++++++++ src/core/backendSelection.ts | 25 ++++++++++++++++ src/extension.ts | 9 +++--- src/host/auth/authService.ts | 14 ++++++--- src/host/auth/deviceSignIn.ts | 26 ++++++++++------- test/unit/authService.test.ts | 17 +++++++++++ test/unit/backendSelection.test.ts | 38 +++++++++++++++++++++++-- test/unit/deviceSignIn.test.ts | 14 +++++++++ 10 files changed, 168 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53bb2f5dc..bf6b2fe44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,12 @@ happened, not what was planned; superseded entries are kept. 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 diff --git a/PLAN.md b/PLAN.md index d94ae6929..8a27360b2 100644 --- a/PLAN.md +++ b/PLAN.md @@ -930,7 +930,13 @@ action that fails is worse than hiding one that would work. 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). + 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. diff --git a/docs/certification/sign-in-detection.md b/docs/certification/sign-in-detection.md index 54ef6cd1d..339f619a0 100644 --- a/docs/certification/sign-in-detection.md +++ b/docs/certification/sign-in-detection.md @@ -581,6 +581,39 @@ pre-drill SHA-256 afterwards. | 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 diff --git a/src/core/backendSelection.ts b/src/core/backendSelection.ts index bdc1c70df..729e6add1 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/extension.ts b/src/extension.ts index 7c18ceba9..b0260bfca 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,7 +10,7 @@ import * as z from 'zod/mini' import type { AgentHost, BackendKind } from './core/agent/agentBackend' 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' @@ -1086,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: await hasCliSession(), - hasStoredKey: (await credentials.getApiKey()) !== undefined, + hasCliSession, + hasStoredKey: async () => (await credentials.getApiKey()) !== undefined, }), async (kind): Promise => kind === 'modelApi' ? await modelApi.ensureHost() : await backend.ensureHost(), diff --git a/src/host/auth/authService.ts b/src/host/auth/authService.ts index 6c0a92aae..8a3f083bc 100644 --- a/src/host/auth/authService.ts +++ b/src/host/auth/authService.ts @@ -8,7 +8,7 @@ // `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' @@ -423,16 +423,22 @@ export class AuthService { return hasCliSession } - /** The backend selection from the current facts. */ + /** + * 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, isUnsupportedFile } = await this.cliCredential(isUserAction) + 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, diff --git a/src/host/auth/deviceSignIn.ts b/src/host/auth/deviceSignIn.ts index 6e4ed8468..7c474390e 100644 --- a/src/host/auth/deviceSignIn.ts +++ b/src/host/auth/deviceSignIn.ts @@ -12,11 +12,11 @@ // 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 decide. It is remembered for -// one case only: with no first `account/read` there is no account to -// compare, and `granted` with `account/read` saying `accountLogin` is then -// the sign-in (a Keychain sign-in may leave the file as it was). `granted` -// alone never signs in. `account/changed` is not relied on: it fired neither +// `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. // @@ -173,12 +173,16 @@ function isSignedIn(signals: SignInSignals): boolean { return true } const isAccountLogin = current.state === MUSE_ACCOUNT_STATES.accountLogin - // With no first answer, a sign-in from before would pass for a new one: - // only the host's own `granted`, borne out by `account/read`, counts then - // (the review of PR #49). `granted` alone never does. - return initial === undefined - ? isGranted && isAccountLogin - : initial.state !== MUSE_ACCOUNT_STATES.accountLogin && isAccountLogin + // 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 { diff --git a/test/unit/authService.test.ts b/test/unit/authService.test.ts index 7c00a3b6d..aca548d05 100644 --- a/test/unit/authService.test.ts +++ b/test/unit/authService.test.ts @@ -253,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', diff --git a/test/unit/backendSelection.test.ts b/test/unit/backendSelection.test.ts index 4b0694625..55e786469 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/deviceSignIn.test.ts b/test/unit/deviceSignIn.test.ts index 52f69e41d..a695c2367 100644 --- a/test/unit/deviceSignIn.test.ts +++ b/test/unit/deviceSignIn.test.ts @@ -424,6 +424,20 @@ describe('Muse Code device sign-in: how it ends', () => { 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`).