/** * Login broker — runs a harness's own login command in a PTY and relays it. * * The broker knows nothing about harness APIs. It spawns the login command * (`claude auth login`, `codex login --device-auth`) in a very wide PTY, * normalizes the screen and applies the harness's rule set (login-rules.ts) * to find the sign-in URL, a one-time code, a "paste the code" prompt, * success and failure. Front ends read the resulting {@link LoginSession} * and send the user's reply back with {@link LoginBrokerService.input}. * * Front ends are consumers of the same events: the REST API polls * {@link LoginBrokerService.get}, the CLI awaits * {@link LoginBrokerService.waitForCompletion}, and Phase 2 (re-login over * Slack DM) subscribes to the `update` / `finished` events. * * Credentials: the broker never captures or stores one. Claude Code's own * login (`claude auth login`, with `CLAUDE_CONFIG_DIR` set for a named * account) stores the login itself, in its config dir or the keychain; the * broker only relays the sign-in link and the one-time code the owner pastes * back. Anthropic's rule: sign-in must complete through its own flow, and * developers may not collect, store or intermediate Claude.ai credentials. * Any credential-shaped text is still masked on every exposed screen. * * State machine: * `starting` → `awaiting_user` (URL / code / prompt seen) → `verifying` * (input sent, or the command reported success and is being confirmed) → * `succeeded` | `failed`; any live state → `timed_out` (15 min) | `cancelled`. * A prompt that reappears after input returns the session to `awaiting_user`. * * Harnesses that save their own credentials and exit (Codex) are never killed * on a failure line: the broker waits for the exit (bounded by * FAILURE_EXIT_GRACE_MS), and a non-zero exit is checked with the harness's * status command before it is reported as failed. On 2026-09-29/30 the owner * finished the device login four times on steamfun-ops (Codex logged * "oauth token exchange succeeded"), every attempt was reported `failed`, and * each retry's `codex login` revoked the previous one. The failure message is * now logged (redacted) so the next such report says why. * * @module services/harness/login-broker.service */ import { EventEmitter } from 'events'; import { type HarnessId, type HarnessLogger, type LoginSession } from './harness.types.js'; /** The slice of a PTY the broker uses (node-pty's IPty satisfies it). */ export interface BrokerPty { onData(listener: (data: string) => void): unknown; onExit(listener: (event: { exitCode: number; signal?: number; }) => void): unknown; write(data: string): void; kill(signal?: string): void; } /** Spawns a PTY. */ export type BrokerPtySpawner = (file: string, args: string[], options: { cols: number; rows: number; cwd: string; env: Record; }) => BrokerPty; /** Events emitted by the broker. Listeners receive a {@link LoginSession} (never a secret). */ export declare const LOGIN_BROKER_EVENTS: { /** Any change to a session */ readonly UPDATE: "update"; /** A session reached a terminal state */ readonly FINISHED: "finished"; }; /** Error codes the REST layer maps to HTTP statuses. */ export type LoginBrokerErrorCode = 'unknown_harness' | 'unsupported_method' | 'not_installed' | 'spawn_failed' | 'not_found' | 'not_active'; /** Broker error with a machine-readable code. */ export declare class LoginBrokerError extends Error { readonly code: LoginBrokerErrorCode; /** * @param code - Machine-readable code * @param message - Human-readable message (never contains a secret) */ constructor(code: LoginBrokerErrorCode, message: string); } /** Injectable dependencies. */ export interface LoginBrokerDeps { spawnPty?: BrokerPtySpawner; /** PATH lookup for the login command */ resolveCommand?: (command: string, envPath: string) => string | null; env?: NodeJS.ProcessEnv; homeDir?: string; /** * Confirms a login with a real check: Codex's status command, or a real * Claude turn on the login's own config dir (`account`: a named Claude * Code account; absent = the default login). */ verify?: (harnessId: HarnessId, account?: string) => Promise; /** Runs after a Claude Code login is verified (migrates a token Crewly stored earlier); never throws into the login */ afterClaudeLogin?: (account: string | null) => Promise; /** Records Claude's first-run answers after a Claude login */ prepareClaudeConfig?: () => void; /** Creates one of the owner's other Claude Code accounts' config dir; returns it */ ensureAccountDir?: (account: string) => string; /** Records Claude's first-run answers in an account's config dir after its login */ prepareAccount?: (account: string) => void; now?: () => number; idFactory?: () => string; logger?: HarnessLogger; timeoutMs?: number; /** Pause before Enter after typed input (ms); 0 types both in one write (tests) */ submitDelayMs?: number; /** Second Enter when the login printed nothing after the first (ms); 0 disables */ submitRetryMs?: number; } /** Runs harness login commands in PTYs and tracks their sessions. */ export declare class LoginBrokerService extends EventEmitter { private readonly spawnPty; private readonly resolveCommand; private readonly env; private readonly homeDir; private readonly verify; private readonly afterClaudeLogin; private readonly prepareClaudeConfig; private readonly ensureAccountDir; private readonly prepareAccount; private readonly now; private readonly idFactory; private readonly logger; private readonly timeoutMs; private readonly sessions; /** Sessions whose verification is in flight. */ private readonly verifying; private readonly submitDelayMs; private readonly submitRetryMs; /** * @param deps - Injectable dependencies (all optional) */ constructor(deps?: LoginBrokerDeps); /** * Environment for the login PTY: the harness PATH, no owner API token, no * nested-Claude-session markers, and a no-op BROWSER so the harness does * not try to open a browser on the server. * * @returns String-only env map */ buildEnv(): Record; /** * Start a login session, or return the live one for this harness (and account). * * With `options.account` (Claude Code only, issue #942) the login is for * one of the owner's other Claude Code accounts: it runs with that * account's config dir (`CLAUDE_CONFIG_DIR`) and none of the default * login's credentials; Claude Code stores that login in the config dir * itself. * * @param harnessId - Harness id * @param method - Broker login method (`subscription`, `device`) * @param options - `account`: one of the owner's other Claude Code accounts (validated name) * @returns The session * @throws LoginBrokerError unknown_harness | unsupported_method | not_installed | spawn_failed */ start(harnessId: string, method: string, options?: { account?: string; }): LoginSession; /** * Read a session. * * @param sessionId - Session id * @returns The session * @throws LoginBrokerError not_found */ get(sessionId: string): LoginSession; /** * The live (non-terminal) session for a harness (and account), if any. * * @param harnessId - Harness id * @param account - One of the owner's other Claude Code accounts; absent = the default login * @returns The session, or null */ getActiveSession(harnessId: string, account?: string): LoginSession | null; /** * Type the user's reply into the PTY, followed by Enter. * * @param sessionId - Session id * @param text - What the user typed (e.g. the code from platform.claude.com) * @returns The session * @throws LoginBrokerError not_found | not_active */ input(sessionId: string, text: string): LoginSession; /** * Cancel a session (kills the PTY). Cancelling a finished session is a no-op. * * @param sessionId - Session id * @returns The session * @throws LoginBrokerError not_found */ cancel(sessionId: string): LoginSession; /** * Resolve when the session reaches a terminal state. * * @param sessionId - Session id * @returns The final session * @throws LoginBrokerError not_found */ waitForCompletion(sessionId: string): Promise; /** Cancel every live session (backend shutdown, CLI exit). */ shutdown(): void; /** * Session record or a not_found error. * * @param sessionId - Session id * @returns The record * @throws LoginBrokerError not_found */ private requireRecord; /** * Append PTY output and re-evaluate the rules. * * @param record - Session record * @param data - Output chunk */ private handleData; /** * Apply the rule set to the current screen and move the state machine. * * @param record - Session record * @param final - The process has exited */ private evaluate; /** * A failure line was printed by a harness that exits by itself: give it * FAILURE_EXIT_GRACE_MS to exit before failing the session. * * @param record - Session record * @param message - The failure line */ private awaitExitAfterFailure; /** * Confirm a login with the harness's status command. * * @param record - Session record */ private confirm; /** * A Claude Code login was verified with a real turn: record Claude's * first-run answers in the login's config dir and let the migration retire * a token Crewly stored for it earlier. Failures only log: the login itself * worked. * * @param record - Session record */ private onLoginVerified; /** * The login command exited. * * @param record - Session record * @param exitCode - Process exit code */ private handleExit; /** * Move a session to a terminal state and release the PTY. * * @param record - Session record * @param state - Terminal state * @param message - Message for the user */ private finish; /** * Apply a patch, bump `updatedAt` and emit `update`. * * @param record - Session record * @param patch - Fields to change */ private update; /** * A user-facing message made safe for logs: secrets redacted, one line, bounded. * * @param record - Session record * @param message - Message * @returns Redacted message */ private safeMessage; /** * Tail of the screen, secrets redacted. * * @param record - Session record * @param text - Normalized text * @returns Exposed screen text */ private exposedScreen; /** Forget finished sessions older than the retention window. */ private prune; } //# sourceMappingURL=login-broker.service.d.ts.map