Skip to content

hookdeck login: guest-upgrade flow can hang on "Waiting for account creation…" with no way out #373

Description

@leggetter

hookdeck login on a guest profile has several ways to hang on "Waiting for account creation…" with no recoverable path. Found while investigating a support report (user had a guest CLI profile from hookdeck listen, plus an existing Hookdeck account).

All of these live in the guest-upgrade branch of Login (pkg/login/client_login.go:53-71waitForGuestUpgrade, client_login.go:242), which polls GET /cli-auth/validate every 2s waiting for user_is_guest to flip on the same user row, giving up after ~4 min (client_login.go:288-299).

Filing this to capture the failure modes and make sure each has a solution. Scenario 1 is the one hit in support; the others are latent.


1. A user who already has a Hookdeck account can never complete the upgrade

is_guest only flips via UserAuthService.signupGuest, and that is only reached when the browser signup resolves to a brand-new user:

  • OAuth — core/server/services/UserAuth.ts:203-227: if (!user) { … signupGuest }. An existing Google/GitHub identity skips the guest entirely.
  • Email — core/server/api/passport.ts:334-338: an existing email returns Email already exists; the user signs in instead, which never touches the guest.

In both cases the browser looks successful (the user lands in their real dashboard) while the CLI keeps polling a guest that will never be upgraded, then dies with exceeded max attempts waiting for guest account creation.

There is no browser-side rescue: the guest CLI key already has user_id/team_id set, so POST /cli-auth/claim rejects it with APIUnclaimableCliKey (core/server/api/routes/platform/cli.ts:167-172).

The only supported path for an existing account is hookdeck logout && hookdeck login — encoded as Journey B in core/clients/apps/dashboard/tests/acceptance/guest-cli-login-intents.spec.ts:181 and mentioned in pkg/cmd/login.go:38 — but the CLI never tells the user this at the moment they need it.

Also worth noting: /signin/guest?token= calls req.logIn, which silently signs the user out of their real dashboard session in that browser and into the guest.

Proposed fix: detect the dead end and fail fast with actionable text instead of a 4-minute silent timeout — e.g. poll for "the sign-in link was consumed but resolved to a different, non-guest user" and print That email already has a Hookdeck account. Run 'hookdeck logout && hookdeck login' to connect this CLI to it (the guest sandbox will be left behind). Better still, give the dashboard signup page a "sign in and attach this CLI to my account" path so the sandbox is not the price of already having an account.

2. The sign-in URL is never printed, so a failed browser open is invisible

  • fmt.Printf("Press Enter to open the browser (^C to quit)") (client_login.go:253) has no trailing newline, so the spinner erases that line the moment it starts.
  • open.Browser uses exec.Command(...).Start() (pkg/open/open.go:14-30) — it returns nil as soon as the child is spawned, so xdg-open/open failing afterwards is undetectable.
  • The URL is printed only when Start() errors (client_login.go:260).

On WSL, in a container, under VS Code Remote, or over SSH via tmux/mosh where SSH_TTY/SSH_CONNECTION/SSH_CLIENT are unset, isSSH() is false, no browser appears, and the user is left with a bare spinner and no link — exactly what the support screenshot showed.

waitForGuestUpgrade also never checks stdinIsTerminal() (unlike the CI branch at client_login.go:71-77), so with non-TTY stdin Fscanln returns instantly and blows straight past the prompt.

Proposed fix: always print the URL on its own line before starting the spinner, whether or not the browser opens. Same applies to waitForLoginSession (client_login.go:108-118).

3. Stale guest sign-in link is handed out silently

RefreshGuestSigninLink falls back to the saved Profile.GuestURL on any API error (pkg/login/guest_link.go:16-23). Auth tokens have a 1-hour TTL (core/server/models/AuthToken.ts:16, enforced in alterQuery), so a link saved during an earlier listen session gives Link is invalid or expired in the browser (core/server/api/passport.ts:156) while the CLI keeps polling, unaware.

POST /cli/guest also 401s when the key's user is no longer a guest (core/server/api/routes/platform/cli.ts:412-416), triggering the same stale fallback.

Proposed fix: treat a refresh failure as fatal for this flow rather than falling back to a URL that is probably dead, or at minimum print a warning naming the fallback.

4. A refresh that is not authenticated as cli mints a different guest

If POST /cli/guest does not resolve authentication_method === 'cli', the server creates a new guest user and a new CLI key (core/server/api/routes/platform/cli.ts:417-427). RefreshGuestSigninLink reads only link from the response and discards key/id, so the browser would upgrade the new guest while the CLI polls the old one — a permanent hang.

Reaching this requires the key to validate on GET /cli-auth/validate but not on POST /cli/guest (key invalidated mid-flow, a proxy stripping the auth header), so it is an edge case, but it fails completely silently.

Proposed fix: adopt key/id from the response when they differ from the current profile, or error out when they do.


Cross-repo

Items 1, 3 and 4 have server-side halves in hookdeck/core (server/services/UserAuth.ts, server/api/routes/platform/cli.ts, server/api/passport.ts, server/models/AuthToken.ts). Happy to split out a core issue if that is easier to track.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions