Playbook: claude-runner OAuth Rotation (Claude Max)

Trigger

  • Scheduled credential rotation (every ~90 days)
  • .env.local or credentials file exposure incident
  • claude -p say-ok returns 401 / auth error on vps-i1 or bms-4

Confirm the Problem

# Test each server (should return "ok")
ssh root@217.154.82.162 'runuser -l claude-runner /tmp/test_claude.sh'
ssh root@54.36.123.110  'runuser -l claude-runner /tmp/test_claude.sh'

If neither server is broken, rotation is precautionary only — still valid.

Prerequisites

  • auth_v4.py and run_auth_v4.sh are present on vps-i1 at /tmp/
  • auth_v4_bms4.py and run_auth_v4_bms4.sh are present on bms-4 at /tmp/
  • Playwright MCP is available in the Claude Code session
  • Claude Max account (radieu@gmail.com) is accessible

Files are in the session scratchpad. If missing, re-create them from this playbook’s appendix.

Steps

1. Start auth processes on both servers

# vps-i1 — delete stale code file (root-owned), then start
ssh root@217.154.82.162 'rm -f /tmp/auth_code_v3 /tmp/auth_v4_log; nohup runuser -l claude-runner /tmp/run_auth_v4.sh >/tmp/auth_v4_nohup.log 2>&1 &'
 
# bms-4 — same
ssh root@54.36.123.110 'rm -f /tmp/auth_code_v3_bms4 /tmp/auth_v4_log_bms4; nohup runuser -l claude-runner /tmp/run_auth_v4_bms4.sh >/tmp/auth_v4_nohup_bms4.log 2>&1 &'

2. Wait 18 seconds, get auth URLs

Start-Sleep 18
ssh root@217.154.82.162 'cat /tmp/auth_v4_log'     # note the https://claude.com/cai/oauth/... URL
ssh root@54.36.123.110  'cat /tmp/auth_v4_log_bms4' # same for bms-4

Each URL is unique per run (new PKCE challenge). Do not reuse old URLs.

3. Authorize via Playwright browser (Claude Code session)

For vps-i1:

mcp__playwright__browser_navigate(vps_i1_url)
# If not logged in to Google: fill email, wait for password, user types in browser
# If Google session active: wait for Authorize screen
mcp__playwright__browser_click("Authorize button")
# Page redirects to platform.claude.com/oauth/code/callback?code=CODE&state=STATE
# Note the full CODE#STATE string from page snapshot

Repeat for bms-4 URL.

4. Inject codes (run as claude-runner, not root)

The code file is owned by claude-runner (sentinel was written by auth_v4.py). Root cannot overwrite it. Use a wrapper:

# vps-i1
$code = "CODE#STATE"  # from step 3
$script = "with open('/tmp/auth_code_v3', 'w') as f: f.write('$code' + chr(10))"
[System.IO.File]::WriteAllText("C:\tmp\inject_vps1.py", $script, [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText("C:\tmp\inject_wrap_vps1.sh", "#!/bin/bash`npython3 /tmp/inject_vps1.py`n", [System.Text.UTF8Encoding]::new($false))
scp inject_vps1.py root@217.154.82.162:/tmp/inject_vps1.py
scp inject_wrap_vps1.sh root@217.154.82.162:/tmp/inject_wrap_vps1.sh
ssh root@217.154.82.162 'chmod +x /tmp/inject_wrap_vps1.sh; runuser -l claude-runner /tmp/inject_wrap_vps1.sh'
 
# bms-4 — same with auth_code_v3_bms4 and inject_bms4.py

5. Verify success

ssh root@217.154.82.162 'cat /tmp/auth_v4_log'     # should end with "Login successful." Exit: 0
ssh root@54.36.123.110  'cat /tmp/auth_v4_log_bms4'

Then test:

ssh root@217.154.82.162 'runuser -l claude-runner /tmp/test_claude.sh'  # expect: ok
ssh root@54.36.123.110  'runuser -l claude-runner /tmp/test_claude.sh'  # expect: ok

Critical Bug to Avoid

Stale code file problem: auth_v4.py writes a sentinel (__waiting__) to the code file at startup. If the file already exists from a previous run (and is owned by claude-runner), a fresh root-level rm -f is needed before starting the process. Without this:

  • auth_v4.py tries os.remove() as claude-runner — fails if file is root-owned
  • Process crashes immediately with PermissionError
  • No auth URL is generated

Always rm -f /tmp/auth_code_v3 as root before starting. The scripts already handle the sentinel pattern internally.

Double-quote stripping in PowerShell SSH: Never use ssh host 'cmd "quoted"' — double quotes inside single-quoted strings are stripped by PowerShell before reaching bash. Use wrapper .sh scripts instead.

|| and && in PowerShell SSH: PowerShell intercepts these as pipeline chain operators. Use ; or separate SSH calls.

2>/dev/null in PowerShell SSH: PowerShell may intercept 2> as stderr redirection. Move to a wrapper script.

Escalation

If Login successful but claude -p still returns 401:

  1. Check /home/claude-runner/.claude/.credentials.json timestamp — should be updated
  2. Check if claude-proxy is intercepting (should not be running on either server)
  3. Try claude auth status as claude-runner to inspect token state

If Google 2FA is required:

  • Push notification goes to the Motorola moto g84 5G
  • Tap “Yes” on the phone when prompted

Appendix: auth_v4.py script

import subprocess, select, os, time, sys
 
SENTINEL = "__waiting__"
CODE_FILE = "/tmp/auth_code_v3"
 
try:
    with open(CODE_FILE, "w") as f:
        f.write(SENTINEL)
except Exception as e:
    with open("/tmp/auth_v4_log", "w") as f:
        f.write(f"Failed to write sentinel: {e}\n")
    sys.exit(1)
 
env = dict(os.environ)
env["HOME"] = "/home/claude-runner"
env["USER"] = "claude-runner"
 
proc = subprocess.Popen(
    ["claude", "auth", "login"],
    stdin=subprocess.PIPE, stdout=subprocess.PIPE,
    stderr=subprocess.STDOUT, env=env
)
 
buf = b""
deadline = time.time() + 15
while time.time() < deadline:
    r, _, _ = select.select([proc.stdout], [], [], 0.2)
    if r:
        chunk = proc.stdout.read1(4096)
        if not chunk: break
        buf += chunk
        if b"Paste code" in buf: break
 
with open("/tmp/auth_v4_log", "w") as f:
    f.write("PRE-CODE OUTPUT:\n" + buf.decode(errors="replace") + "\n[WAITING]\n")
 
code = None
for i in range(300):
    try:
        content = open(CODE_FILE).read().strip()
        if content and content != SENTINEL:
            code = content; break
    except: pass
    time.sleep(1)
 
if code:
    proc.stdin.write((code + "\n").encode())
    proc.stdin.flush()
else:
    proc.kill()
    with open("/tmp/auth_v4_log", "a") as f:
        f.write("\nTIMEOUT\n")
    sys.exit(1)
 
buf2 = b""
deadline2 = time.time() + 120
while time.time() < deadline2:
    r, _, _ = select.select([proc.stdout], [], [], 0.5)
    if r:
        chunk = proc.stdout.read1(4096)
        if not chunk: break
        buf2 += chunk
        if proc.poll() is not None: break
    elif proc.poll() is not None: break
 
try: proc.wait(timeout=5)
except: proc.kill()
 
with open("/tmp/auth_v4_log", "a") as f:
    f.write("\nPOST-CODE OUTPUT:\n" + buf2.decode(errors="replace"))
    f.write(f"\nExit: {proc.returncode}\n[DONE]\n")

For bms-4: same script with CODE_FILE = "/tmp/auth_code_v3_bms4" and log "/tmp/auth_v4_log_bms4".