fix(onboarding) [BRNS-DESK-024]: declining sign-in writes nothing to the claude config - #86
fix(onboarding) [BRNS-DESK-024]: declining sign-in writes nothing to the claude config#86sebastian-ssvlabs wants to merge 1 commit into
Conversation
…the claude config "Skip for now" navigated to the connect stage, whose $effect ran setup immediately, and only the middle step there was token-gated. So declining enabled brains@brains in the user's live ~/.claude/settings.json with no credentials, registered the marketplace, and wrote the Mission Control skill into ~/.claude/skills and ~/.agents/skills — breaking their own CLI sessions, and surviving an app uninstall. Two independent guards, both required: - ensureBrains() resolves the token first and returns before touching anything external, so ensureBrainsPlugin() and installMissionControlSkill() are behind the same check that already guarded provisionBrainsPlugin(). The Codex analog and the boot-time skill refresh were already token-gated. - runConnectSetup() stops after the read-only vendor probe when there is no token, so landing on the connect screen can no longer provision by navigation. Adds brains_revert_orphaned_setup to heal machines already polluted in the field: at boot with no token, an enabled-but-tokenless plugin entry is reverted (settings.json keys, plugin, marketplace, both skill dirs) and logged. A provisioned install never matches that signature.
nir-ssvlabs
left a comment
There was a problem hiding this comment.
Both halves check out. The gate in ensureBrains() sits ahead of all three external writes — ensureBrainsPlugin, provisionBrainsPlugin and installMissionControlSkill are now unreachable without a token, which is the right place for it given the middle step was the only one previously gated.
The heal is the part that worried me, since it mutates a user's ~/.claude/settings.json unprompted at boot, and is_orphaned_decline_artifact is correctly narrow: plugin_enabled && token.is_none(). A provisioned install carries a token so it can't match; a disabled plugin can't match; an untouched config can't match. The one shape I went looking for — someone signed out of the desktop app but with a working CLI setup — still has the token in pluginConfigs, so it reads as provisioned and is left alone. The signature only fires on state that is genuinely non-functional.
Checked: the guard's position relative to each write, the heal's signature against the provisioned / disabled / untouched cases, and that save_cli_config is only reached behind a match. Not read: the Rust-side second guard's own callers — the signature check stands on its own regardless of who reaches it.
Merge: ✅ into main.
Summary
brains@brainswas enabled in their live~/.claude/settings.jsonwith no credentials, the brains marketplace was registered, and the Mission Control skill was written into~/.claude/skillsand~/.agents/skills.claudesessions then loaded a brains MCP that could only fail to authenticate, on every session — and uninstalling the desktop app never undid it.Which writes happened, from which unguarded path
runConnectSetup()fires from an$effectthe instantstage === "connect", andskipLogin()sets exactly that — so merely landing on the second screen ran setup. InsideensureBrains()only the middle step was token-gated:ensureBrainsPlugin()claude plugin marketplace add+install+enable→enabledPlugins["brains@brains"] = trueprovisionBrainsPlugin()pluginConfigs["brains@brains"].options.{endpoint,token}if (tok)installMissionControlSkill()~/.claude/skills/brains-mission-control/,~/.agents/skills/brains-mission-control/Net result of declining:
enabled: true,config: undefined—brains_is_provisioned()returnedfalse, so the app knew the state was incomplete after having already mutated the user's config.The two guards
ensureBrains()resolves the token first and returns before touching anything external, so both unguarded steps now sit behind the same check that already guarded provisioning. Theif (tok)conditional and thetok ? … : …error branch collapse away, since past that point a token is guaranteed. The other twoinstallMissionControlSkill()call sites were already covered and are untouched:ensureBrainsCodex()returns on!tokabove its call, andbootRoute()'s idempotent refresh sits insideif (ready), below the!brainsTokearly return.runConnectSetup()stops after the read-only vendor probe when there is no token, so navigation alone can no longer provision.refreshVendor()only reads CLI/auth state.Deliberately narrower than "explicit user action only": the connect screen's own CTA is
finishConnect, which is gated on setup already being complete, so wiring setup to it would deadlock, and a returning signed-in user must still auto-resume the step they left. The token — minted only by an explicit brains sign-in — is the consent signal, and guard 1 makes the writer itself refuse regardless of caller.The visible end state of declining is unchanged: today's decline path already ends on the brains card in
errorwith "Sign in to brains first, then finish setup." — it now reaches that state without the writes. No copy or label changes, so no overlap with the in-flight #27 / #28.Rollback / heal
New
brains_revert_orphaned_setupcommand, called fire-and-forget frombootRoute()only on the no-token branch. It acts on one signature —enabledPlugins["brains@brains"] === truewith no token inpluginConfigs— which a provisioned install, a disabled plugin, and an untouched config can never match. On a match it removes bothsettings.jsonkeys and both skill dirs (Rust), then best-effort unwinds the plugin and its marketplace through the sameclaudeCLI commands that registered them, and logs everything reverted viaclient_log. Empty result = clean machine, nothing runs.Two consequences worth flagging for review:
brains_deprovision_pluginscrubs the token but leaves the plugin enabled), so a logged-out user's next launch reverts it too. That is the same broken state by any other name, and re-login re-installs it — but it is a behaviour change beyond the literal ticket.lib.rsonly, not inweb_server/dispatch.rs— deliberately, to avoid colliding with PR fix(onboarding) [BRNS-DESK-019]: the brains step completes remotely, and stops leaking method names #63, which owns that table. Over a remote transport the call rejects withunknown methodand the heal is skipped; the frontend swallows that.Overlap with PR #63
#63 (
fix/desk-019) fixes the transport side of the same area and states it does not touchroutes/+page.svelte. This PR touches+page.svelte,lib/brains-setup.ts,commands/brains_setup.rsand onelib.rsline — no shared hunks, and nothing here re-introduces a raw RPC name into the UI (the tokenless path sets a fixed human string, never an error body). #63's repro note — "installMissionControlSkill()is the first WS-missing call becauseskipLoginsetsstage = "connect"with no brains token" — describes the very call this PR stops making on that path; its parity fix still stands for the signed-in path, which does call it.Verification
npx vitest run src/lib/brains-setup.test.ts— 14 passed (3 new); full suitenpx vitest run— 82 files / 1886 passed (the one error is pre-existing:print-sanitize.test.tswants thejsdomenv, not installed locally)install_mission_control_intoand is a no-op the second timenpx prettier --check,npx eslinton all changed files — clean (0 errors; the 35 warnings are pre-existing unused-symbol ones)cargo fmt --check— clean;svelte-check— 0 errors (129 pre-existing warnings) via the pre-commit hook, which passed in fullcargo build/npm run build/ GUI launch. The Rust command's real-$HOMEIO path, theclaude plugin uninstall/marketplace removeexit behaviour on a polluted machine, and the pre-polluted self-heal are reasoned from the code, not observed. Worth a manual pass on a real machine before merge.