Durable Provider Auth Gate

One phase. Fix all auth bugs. Clean seam for future providers.

Date
2026-07-15
Commit
776772e
Consultants
Codex gpt-5.5 (3 rounds)
Status
Final — reviewed & debated

The Problem

Beast Computer's Claude OAuth flow has 5 silent break points in the golden path. Any one of them leaves the user staring at a spinner forever with no actionable error.

Break PointWhereWhat HappensSeverity
In-memory auth stateweb_auth_flow.go:44Server restart loses all auth progress. User must start over.Critical
ANSI URL corruptionweb_auth_flow.go:141OSC hyperlinks and split PTY reads corrupt OAuth URL extraction. User sees "Getting your login link..." forever.Critical
Settings merge bail-outtemplate.go:346mergeSettingsHooks() returns if any hooks key exists. Beast Stop hook never installed. Chat responses never reach web UI.Critical
Success inferred from PTYweb_auth_flow.go:186Auth success inferred from cmd.Wait() exit code + checkClaudeAuth(). If check fails (systemd noise, JSON parse), state=failed even though user IS logged in.High
No auth gate on Start()manager.go:284Claude launches but hits 5+ onboarding prompts. User sees "Online" but messages go to prompts, not Claude.High

Adversarial Review Codex gpt-5.5, 3 rounds

Codex read all project MD files (IDEA, ARCHITECTURE, EXECUTION, COMPUTER) plus the full auth codebase, then reviewed the original 5-phase design against 7 adversarial questions.

Round 1 Verdict

"The diagnosis is right, but the proposed architecture is bloated. Fix the current Claude flow, add a thin provider seam, and stop before building a second supervisor/state machine."

#FindingResolution
113 states is too many — mixes auth, setup, and runtime. starting_computer and running already belong to Manager.Fixed
2OnboardingRules catalog is a trap — Claude prompts aren't a stable API. Complete() will rot.Fixed
3Prepare/Complete don't belong in AuthProvider — hook/config merging belongs in template.go.Fixed
4Interface too wide — Status/Verify duplicate. Start with 5 methods max.Fixed
5Reconcile-on-every-poll will hurt — CLI exec on every 8s HTMX poll is wasteful.Fixed
6Missing edge cases: double-tab, cancel/submit race, stale PTY, duplicate code, CLI upgrade, provider hang, API key leak, corrupted JSON, disk full, DB lock.Fixed
7Phase granularity wrong — don't bundle TerminalDriver onboarding automation with core auth fix.Fixed

Round 2: Codex designed "Durable Provider Auth Gate" — one shipping phase that fixes all bugs with a clean interface seam. No onboarding automation, no runtime state mixing, no per-poll CLI exec.

What Was Rejected

Original Design (bloated)

13-state auth+runtime mega-state machine
AuthProvider.Prepare() / Complete()
OnboardingRules catalog
10-method AuthProvider interface
CLI exec on every HTMX poll
5 separate phases (A/B/C/D/E)
TerminalDriver Expect/Answer framework

Final Design (lean)

7 auth-only states, runtime stays in Manager
Workspace config stays in template.go
No onboarding automation
5-method interface
HTMX reads DB only, CLI on EnsureReady()
One phase ships together
Small PTY helper for ANSI stripping only

Architecture

┌──────────────────────────────────────────────────────────────────────┐ │ Web Layer (HTMX + API) │ │ │ │ GET /c/{name}/.beast/ui/auth → reads auth_sessions DB │ │ POST /c/{name}/.beast/ui/auth/start → AuthService.BeginAuth() │ │ POST /c/{name}/.beast/ui/auth/code → AuthService.SubmitAuth() │ │ POST /c/{name}/.beast/ui/auth/cancel → AuthService.CancelAuth() │ │ │ │ Polling NEVER shells out — reads controldb only │ └────────────────────────────────────┬─────────────────────────────────┘ │ ┌────────────────────────────────────▼─────────────────────────────────┐ │ AuthService (orchestration) │ │ │ │ • Provider registry: map[string]AuthProvider │ │ • Durable state: auth_sessions table (controldb) │ │ • TTL cache: DB read first, CLI exec only when stale │ │ • Lease-based: prevents double-start from multiple tabs │ │ • Submit dedup: hash(code+sessionID) rejects duplicate │ │ • EnsureReady(): gate for Manager.Start() │ └───────┬──────────────────────────────────────────────┬───────────────┘ │ │ ┌───────▼──────────────────┐ ┌─────────▼───────────────┐ │ ClaudeAuthProvider │ │ (future providers) │ │ ships now │ │ CodexAuthProvider │ │ │ │ APIKeyProvider │ │ 5 methods: │ │ │ │ Metadata / Status │ │ Same 5 methods. │ │ Begin / Submit / Cancel │ │ No UI rewrite. │ │ │ │ No new DB tables. │ │ Uses: terminal_driver │ │ │ └──────────────────────────┘ └─────────────────────────┘

AuthProvider Interface 5 methods, nothing else

Go interface definition
type AuthProvider interface {
    Metadata() AuthProviderMetadata
    Status(ctx context.Context, c *Computer) (*AuthStatus, error)
    Begin(ctx context.Context, c *Computer, s *AuthSession) (*AuthChallenge, error)
    Submit(ctx context.Context, c *Computer, s *AuthSession, input AuthInput) (*AuthStatus, error)
    Cancel(ctx context.Context, c *Computer, s *AuthSession) error
}

type AuthProviderMetadata struct {
    ID          string `json:"id"`           // "claude"
    DisplayName string `json:"display_name"` // "Claude"
    Method      string `json:"method"`       // "oauth_code"
}

No Prepare(). No Complete(). No Probe(). No Verify(). Workspace config stays in template.go. Auth does auth.

Auth types (AuthState, AuthStatus, AuthChallenge, AuthInput)
type AuthState string

const (
    AuthUnknown          AuthState = "unknown"
    AuthUnauthenticated  AuthState = "unauthenticated"
    AuthChallengePending AuthState = "challenge_pending"
    AuthVerifying        AuthState = "verifying"
    AuthAuthenticated    AuthState = "authenticated"
    AuthBlocked          AuthState = "blocked"
    AuthFailed           AuthState = "failed"
)

type AuthStatus struct {
    State     AuthState `json:"state"`
    Provider  string    `json:"provider"`
    Detail    string    `json:"detail,omitempty"`
    CheckedAt int64     `json:"checked_at"`
    ExpiresAt int64     `json:"expires_at,omitempty"`
    ErrorCode string    `json:"error_code,omitempty"`
    ErrorText string    `json:"error_text,omitempty"`
}

type AuthChallenge struct {
    URL        string `json:"url,omitempty"`
    ExpiresAt  int64  `json:"expires_at,omitempty"`
    SubmitHint string `json:"submit_hint,omitempty"` // "paste_code"
}

type AuthInput struct {
    Code string `json:"code,omitempty"`
}

State Machine auth-only, 7 states

unknown → unauthenticated cached/checked and not logged in → authenticated cached/checked and logged in → blocked provider unavailable, CLI missing, env issue unauthenticated → challenge_pending Begin() produced login URL → blocked environment problem → failed Begin() error challenge_pending → verifying Submit(code) → unauthenticated Cancel() → failed timeout / PTY exit / no URL verifying → authenticated Status confirms provider login → unauthenticated invalid/expired code → failed PTY/provider error → blocked environment problem authenticated → unauthenticated later Status says logged out → blocked provider command unavailable blocked → unauthenticated environment fixed, Status succeeds → authenticated environment fixed, already logged in failed → challenge_pending Begin() retry → unauthenticated Cancel()

Runtime states (starting, running, stopped) stay in Computer.State / Manager. Auth and runtime never mix.

Control DB Schema migration 5

auth_sessions table + indexes
CREATE TABLE IF NOT EXISTS auth_sessions (
    id TEXT PRIMARY KEY,
    computer TEXT NOT NULL,
    provider TEXT NOT NULL,
    state TEXT NOT NULL,
    challenge_url TEXT NOT NULL DEFAULT '',
    challenge_expires_at INTEGER,
    status_checked_at INTEGER,
    status_expires_at INTEGER,
    lease_owner TEXT NOT NULL DEFAULT '',
    lease_until INTEGER,
    attempt INTEGER NOT NULL DEFAULT 1,
    submit_hash TEXT NOT NULL DEFAULT '',
    error_code TEXT NOT NULL DEFAULT '',
    error_text TEXT NOT NULL DEFAULT '',
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL,
    completed_at INTEGER,
    cancelled_at INTEGER
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_active
ON auth_sessions(computer, provider)
WHERE state IN ('challenge_pending', 'verifying');

CREATE INDEX IF NOT EXISTS idx_auth_lookup
ON auth_sessions(computer, provider, updated_at);
Partial unique index
Prevents multiple active auth sessions per computer+provider — solves double-tab start
Lease columns
lease_owner + lease_until prevent concurrent Begin() from two server instances
Submit hash
hash(code+sessionID) rejects duplicate code submission
TTL via status_expires_at
HTMX polls read DB; only EnsureReady() does CLI exec when TTL is expired

EnsureReady() Gate

Design The only place that shells out

Manager.Start() calls AuthService.EnsureReady(ctx, c) before launching tmux. This is the sole path that may run claude auth status.

EnsureReady implementation sketch
func (s *AuthService) EnsureReady(ctx context.Context, c *Computer) error {
    // 1. Read latest auth_session from DB
    session := s.db.LatestAuthStatus(c.Name, providerID)

    // 2. If authenticated and TTL valid → return nil (cached)
    if session.State == AuthAuthenticated && session.StatusExpiresAt > now {
        return nil
    }

    // 3. Stale or unknown → run provider.Status() with short timeout
    status, err := s.provider.Status(ctx, c)
    if err != nil {
        return fmt.Errorf("auth check failed: %w", err)
    }

    // 4. Update DB with fresh status + new TTL
    s.db.UpdateAuthSession(session.ID, func(r *AuthSessionRecord) {
        r.State = status.State
        r.StatusCheckedAt = now
        r.StatusExpiresAt = now + TTL
    })

    // 5. If not authenticated → return error (blocks Start)
    if status.State != AuthAuthenticated {
        return fmt.Errorf("auth required: state=%s", status.State)
    }
    return nil
}

Also called by: supervisor recovery, chat handler (before first message), ResumeComputer().

UI Flow

Key Principle

HTMX polling reads auth_sessions table from controldb. Never shells out. All CLI exec happens in EnsureReady() with TTL caching.

EndpointActionState Shown
GET /ui/authRead DB rowRender based on auth_sessions.state
POST /auth/startCreate/reuse sessionPartial unique index prevents double-tab
POST /auth/codeSubmit via providerHash dedup rejects duplicate submit
POST /auth/cancelCancel active flowBack to unauthenticated
DB StateUI Renders
unknown / unauthenticated"Connect your AI" button
challenge_pendingLogin URL + code input form
verifying"Connecting..." spinner
authenticatedHide onboarding, trigger start
failedError message + "Try again" button
blockedSpecific guidance (CLI missing, env issue)

File Changes one phase

FileActionWhat Changes
auth_provider.goNewAuthProvider interface (5 methods), AuthState, AuthStatus, AuthChallenge, AuthInput types
auth_service.goNewAuthService: EnsureReady, BeginAuth, SubmitAuth, CancelAuth, CachedStatus. Owns TTL, DB writes, leases, dedup, events
auth_claude.goNewClaudeAuthProvider: 5 methods. PTY via terminal_driver. ANSI-stripped URL extraction. claude auth status --json for verification
terminal_driver.goNewSmall PTY helper: bounded buffer, ANSI strip, URL extract, write-line with mutex, context timeout, kill process group
controldb/auth.goNewAuthSessionRecord, CurrentAuthSession, InsertAuthSession, UpdateAuthSession, LatestAuthStatus, ExpireStale
controldb/migrate.goModifyAdd migration 5: auth_sessions table + indexes
manager.goModifyAdd auth *AuthService. Gate Start() and ResumeComputer() on EnsureReady()
web_auth_flow.goModifyReplace global authFlows map. In-process PTY handles keyed by session ID. All visible state from DB
template.goFixDeep-merge Beast Stop hook (insert/dedup), corrupted JSON recovery (backup + rewrite), atomic write (temp+fsync+rename)
web_api.goModifyhandleUIStart returns auth fragment if not ready (not "Could not wake up")
web_dashboard.goModifyAuth HTMX renders from DB state. Provider-agnostic labels

Edge Cases

Edge CaseSolution
Multiple tabs click "Connect"Partial unique index on (computer, provider) WHERE state IN active. Second tab gets existing session.
Cancel races with SubmitDB state checked before PTY write. If cancelled, Submit returns error.
Duplicate code submissionsubmit_hash column. hash(code+sessionID) rejects duplicate.
Server restart mid-authDurable state in controldb. PTY gone, state persists. EnsureReady() runs fresh Status().
Claude CLI upgraded during flowBegin() re-probes CLI flags each time (no cached probe).
Provider command hangsContext timeout on all provider calls. Kill process group on timeout.
Corrupted settings.jsontemplate.go backup as .beast-bad-{ts}, install clean settings, atomic write.
Disk full during authAtomic write detects fsync failure. State stays in DB, retryable.
Computer removed during authAuthService checks computer exists before each state transition.
API keys in transcriptsterminal_driver never logs auth codes. Auth input redacted in events.

Long-Term Extension Points

Future Adding a new provider

// In manager.go initialization — one line:
authProviders["codex"] = NewCodexAuthProvider(cfg.Agent.CodexBin, m.runtime)

// CodexAuthProvider implements same 5 methods:
// Metadata() → {ID:"codex", Method:"api_key"}
// Status()   → check OPENAI_API_KEY env/file
// Begin()    → return challenge with SubmitHint:"paste_api_key"
// Submit()   → validate key, write to env
// Cancel()   → cleanup

No UI rewrite. No manager rewrite. No new DB table. Same auth_sessions, same state machine. Codex validates the seam is clean.

Not Building Yet

Future options (when production data demands it)

Final Design

One phase ships together. Fixes all 5 break points:

  1. In-memory auth state → durable auth_sessions in controldb
  2. Settings merge bail-out → deep-merge with insert/dedup, corrupted JSON recovery
  3. ANSI-corrupted URL extraction → terminal_driver with proper ANSI stripping
  4. Success inferred from PTY exit → authoritative claude auth status --json
  5. No auth gate on Start()EnsureReady() blocks Manager.Start()

Clean 5-method interface seam. Codex validates it later — it's the test, not the reason to over-engineer now.

Research artifact for Beast Computer auth architecture. Produced through 3 rounds of adversarial review with Codex gpt-5.5. All project MD files (IDEA, ARCHITECTURE, EXECUTION, COMPUTER) read and incorporated. Prior version (5-phase design with 13-state machine) was rejected after review.