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-71 → waitForGuestUpgrade, 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.
hookdeck loginon 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 fromhookdeck listen, plus an existing Hookdeck account).All of these live in the guest-upgrade branch of
Login(pkg/login/client_login.go:53-71→waitForGuestUpgrade,client_login.go:242), which pollsGET /cli-auth/validateevery 2s waiting foruser_is_guestto 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_guestonly flips viaUserAuthService.signupGuest, and that is only reached when the browser signup resolves to a brand-new user:core/server/services/UserAuth.ts:203-227:if (!user) { … signupGuest }. An existing Google/GitHub identity skips the guest entirely.core/server/api/passport.ts:334-338: an existing email returnsEmail 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_idset, soPOST /cli-auth/claimrejects it withAPIUnclaimableCliKey(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 incore/clients/apps/dashboard/tests/acceptance/guest-cli-login-intents.spec.ts:181and mentioned inpkg/cmd/login.go:38— but the CLI never tells the user this at the moment they need it.Also worth noting:
/signin/guest?token=callsreq.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.Browserusesexec.Command(...).Start()(pkg/open/open.go:14-30) — it returnsnilas soon as the child is spawned, soxdg-open/openfailing afterwards is undetectable.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_CLIENTare 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.waitForGuestUpgradealso never checksstdinIsTerminal()(unlike the CI branch atclient_login.go:71-77), so with non-TTY stdinFscanlnreturns 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
RefreshGuestSigninLinkfalls back to the savedProfile.GuestURLon any API error (pkg/login/guest_link.go:16-23). Auth tokens have a 1-hour TTL (core/server/models/AuthToken.ts:16, enforced inalterQuery), so a link saved during an earlierlistensession givesLink is invalid or expiredin the browser (core/server/api/passport.ts:156) while the CLI keeps polling, unaware.POST /cli/guestalso 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
climints a different guestIf
POST /cli/guestdoes not resolveauthentication_method === 'cli', the server creates a new guest user and a new CLI key (core/server/api/routes/platform/cli.ts:417-427).RefreshGuestSigninLinkreads onlylinkfrom the response and discardskey/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/validatebut not onPOST /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/idfrom 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.