Playbook: claude-runner OAuth Rotation (Claude Max)
Trigger
- Scheduled credential rotation (every ~90 days)
.env.localor credentials file exposure incidentclaude -p say-okreturns 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.pyandrun_auth_v4.share present on vps-i1 at/tmp/auth_v4_bms4.pyandrun_auth_v4_bms4.share 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-4Each 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.py5. 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: okCritical 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:
- Check
/home/claude-runner/.claude/.credentials.jsontimestamp — should be updated - Check if claude-proxy is intercepting (should not be running on either server)
- Try
claude auth statusas 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".