Skip to content

Stop the CLI misdiagnosing why a login failed - #7

Merged
adhikjoshi merged 1 commit into
mainfrom
fix/auth-diagnostics-and-credential-safety
Sep 9, 2026
Merged

Stop the CLI misdiagnosing why a login failed#7
adhikjoshi merged 1 commit into
mainfrom
fix/auth-diagnostics-and-credential-safety

Conversation

@adhikjoshi

@adhikjoshi adhikjoshi commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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/--password can never work for them (OAuth signups get Hash::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, so auth login --browser never 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:

Error: {"data":null,"error":{"code":"invalid_credentials","message":"Email or password is incorrect.","details":[]},"meta":{"request_id":"886edf14-..."}}

  Check your email and password.
  Run: modelslab auth forgot-password

After:

Error: Email or password is incorrect.

  Check your email and password.
  Signed up with Google or GitHub? That account has no password — run: modelslab auth login --browser
  Otherwise run: modelslab auth forgot-password
  • parseAPIError never understood the control-plane envelope. {"data":null,"error":{...},"meta":{}} puts the message one level down, and error is an object, not a string — both top-level lookups missed, so the raw JSON body became the message. Now parses message, machine code, and the per-field details bag. Unrecognised bodies truncate on a rune boundary; an HTML proxy page reduces to its <title> (502 Bad Gateway (HTTP 502)).
  • Hints now come from the code. email_not_verified offers resend-verification; validation_error prints the fields the server actually rejected instead of blaming --email.
  • An empty code is no longer folded in with invalid_credentials. A 502 during a deploy used to print "Check your email and password" and send the user to forgot-password — precisely the multi-day rabbit hole in this ticket. Verified against a fake nginx 502.
  • Exit codes apply to every command. main.go hardcoded os.Exit(1), so only auth login used APIError.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."
  • 429 honours Retry-After. Only X-RateLimit-Reset was read, and only under 30s; Laravel's throttle sends Retry-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.toml was a token-exfiltration vector. It is read from the current working directory and merged wholesale, so a committed base_url = "http://attacker/" in any repo you git clone and cd into redirected the next modelslab command — with the stored bearer token in the Authorization header — to that host. No prompt, no warning. Reproduced end to end by the reviewer. base_url, api_key and token are now stripped from project-level config; model/output preferences still merge. Use --base-url or MODELSLAB_BASE_URL, which the user types themselves.
  • auth login printed "Logged in" even when it stored nothing. StoreToken/StoreEmail/StoreAPIKey return 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."
  • The MCP server's auth-login never authenticated the session. The client is built once in mcp serve and 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 for api-keys-create. Both now apply the credentials and persist them.

Prompts and the browser flow

  • fmt.Scanln replaced. It stops at the first space (auth signup recorded "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.
  • Non-interactive login works. term.ReadPassword fails with inappropriate ioctl for device when stdin is a pipe, so the only non-interactive option was --password on the command line — shell history, ps, CI logs. Now falls back to reading the line when stdin is not a TTY.
  • The authorize URL is always printed. openBrowser uses exec.Start(), which returns nil the moment the child is spawned, so xdg-open with 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 for windows/amd64 and linux/amd64. New coverage: the control-plane envelope and its details bag, rune-safe truncation, HTML <title> summarisation, Retry-After handling, every loginFailureHints branch (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

  • credentialFilePath ignores the os.UserHomeDir() error; unreachable today because config.Init() errors on the same call first. Latent.
  • storeToFile is a non-atomic read-modify-write per field; a crash mid-write loses all three credentials rather than one.
  • No CLI command for 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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
@adhikjoshi
adhikjoshi merged commit 5504b29 into main Sep 9, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant