Back to blog
FILE 0x91·LOGGING A CLI INTO OAUTH WITH NOBODY AT THE KEYBOARD

Logging a CLI into OAuth with nobody at the keyboard

August 21, 2026 · automation, oauth, playwright

A coding CLI on one of my machines died with Login expired. Please run /login. Nobody was sitting at that machine. I wanted it back without driving over to it, and "run /login" is exactly the instruction you can't follow remotely.

The credentials file explained it in one field:

{
  "expiresAt":             1786480928177,
  "refreshTokenExpiresAt": 1787174487177
}

The access token had been dead for days, and so had the refresh token — two days past, which is why the CLI stopped silently renewing itself and started asking for a human. Worth noting that the tool's own auth status cheerfully reported loggedIn: true the whole time; it was reading the stale file, not testing it.

The paths that don't work

Copy a good credentials file from another machine. Tempting, and wrong. The first refresh rotates the token pair, which would silently break the machine I stole it from. Not a fix, just a relocation of the outage.

Drive the interactive TUI over SSH. No tmux on the box. The screen build is 4.00.03 from 2006 — no -Logfile, and screen -X stuff accepted my keystrokes and delivered exactly none of them to the prompt. I burned twenty minutes proving that.

Click the emailed magic link in the local browser. The login flow emails a magic-link#<token> URL. I opened it in the browser on the same machine — same tab, then a new tab — and got We were unable to verify you with this link both times. The link only verifies inside the browser context that requested it, and that context was a Playwright instance on another host entirely.

Also: there is no code in that email. Grepping the HTML for a six-digit code returns 141413, which is a hex color from the stylesheet. Ask me how I know.

The shape that works

Three moving parts, each doing the one thing it's good at.

**expect holds the CLI's prompt open.** It spawns the login, scrapes the authorize URL out of the output, writes it to a file, then blocks on a second file appearing — the code I'll deliver later.

spawn env PATH=$env(HOME)/.local/bin:$env(PATH) cli auth login
expect {
  -re {https://example\.com/oauth/authorize\?[^\s\x1b]+} {
     set f [open /tmp/login/url.txt w]; puts $f $expect_out(0,string); close $f
  }
  timeout { exit 1 }
}
for {set i 0} {$i < 300} {incr i} {
  if {[file exists /tmp/login/code.txt]} break
  sleep 2
}
set f [open /tmp/login/code.txt r]; set code [string trim [read $f]]; close $f
send -- "$code\r"

One trap here cost me a round trip: the captured URL comes out doubled. Modern CLIs emit clickable links as OSC-8 escape sequences, where the URL appears once in the escape payload and again as the visible text. My regex spanned both. Cut at the second occurrence of the scheme.

A headless browser does the sign-in. Playwright, one persistent context, driven over a small HTTP wrapper. Navigate to the authorize URL, fill the email field, submit.

My own mail pipeline supplies the magic link. This is the part that only exists because I built it months ago for unrelated reasons: all my mail lands in a capture mailbox I can read over IMAP. So instead of waiting on a human to forward me a code, I poke the fetcher and read the message directly.

M = imaplib.IMAP4_SSL(host, 993); M.login(user, password)
M.select("INBOX", readonly=True)
_, d = M.search(None, '(SINCE "21-Aug-2026" FROM "provider")')
# ... walk the newest message, pull https://.../magic-link#<token>

Then the trick that unlocked the whole thing: open the magic link in the same browser context, as a second navigation. Because it isn't the tab that started the flow, the site doesn't sign you in — it renders a six-digit verification code and says enter this where you first tried to sign in. Go back, type it into the field that was waiting there the entire time, submit. That "failure mode" is the feature.

Read the URL, not the page

After consent, I was scraping the confirmation page for the code with a regex over every div and span. Fragile, and it broke on a Unicode glyph in the copy button.

The code was in the address bar the whole time:

https://.../oauth/code/callback?code=qrDhQn...&state=RScgDh...

Paste string is code#state. One GET /url against the browser wrapper, parse the query string, done — no DOM scraping, nothing to break when they restyle the page. When an OAuth flow hands you a redirect, the redirect is the payload. I should have looked there first.

Write that string to the file expect is blocking on and the CLI prints Login successful. End to end, no human, no keyboard.

What I'd do differently

Check refreshTokenExpiresAt on a schedule. This whole exercise is a ~30-day timer that fires with no warning and no alert, and I only found out when something failed downstream. A cron that reads one integer out of a JSON file and complains a week early would have turned an hour of archaeology into a five-minute re-auth at a time of my choosing.

The other lesson is cheaper: when a flow says unable to verify, stop re-running it in a different tab. That's the same hypothesis twice. The second attempt taught me nothing the first hadn't; the thing that actually moved was changing which browser held the session.