/** * Seat credential PROBE (crew#630, crew#645): ask the seat's own CLI whether it can authenticate, * under the seat's own configuration home, instead of inferring it from a file. * * # The defect * * `seat-signin.ts` reads presence: claude's `/claude/.claude.json` with an * `oauthAccount` reads signed in. Claude Code keeps the OAuth token itself in the OS keychain, under * a service name keyed by a hash of `CLAUDE_CONFIG_DIR` — so a moved or renamed worker home keeps * the file and loses the token, and an expired login keeps both. The roster said `signed_in`, * launch trusted it, and every ballot / unit on the seat failed "Not logged in" (crew#630) or "OAuth * session expired" (crew#645) after the run was already planned. * * # The probe * * The CLI's own auth-status command, where one exists, run with the SAME configuration-home variable * the engine sets on that seat's workers (`wicked_apps_core::spawn::seat_config_for`): * * claude — `claude auth status --json` with `CLAUDE_CONFIG_DIR=/claude` → `loggedIn`. * It resolves the keychain entry for THAT config dir, so the moved-home case reads false. * codex — `codex login status` with `CODEX_HOME=/codex` → exit 0 = signed in. * * # A status command is not a session (crew#645, reopened) * * Both status commands read what is STORED; neither asks the provider. An OAuth login whose access * token expired and whose refresh is refused ("OAuth session expired and could not be refreshed") * still reads `loggedIn: true`. So when the status command says signed in, the probe makes ONE * live, authenticated round-trip on the seat's cheapest path ({@link verifyCommand}), which is the * only thing that observes a refused refresh: * * - exit 0 → signed in, `check: 'live'`; * - its output says the seat has no credential (the same {@link isAuthRefusal} words ballots are * read with) → signed OUT, with the seat's own words as the evidence; * - anything else (timeout, quota, network) cannot tell → the status answer stands as * `check: 'status'`, and the roster says the login was not verified live. * * Seats with no status command (pi, copilot, opencode, agy) are not probed: their reading stays the * credential-file heuristic plus the seat's own refusal (`seat-health.ts`), and the roster says the * login is unverified (`seat-standing.ts`). * * # Cost * * The roster route is read on every studio poll, so the probe NEVER runs on the request path: * `read()` answers from a cache synchronously and, when the entry is older than its TTL, starts * one refresh in the background (deduplicated per seat, one probe process at a time host-wide * for this daemon). A signed-in answer is kept for 5 minutes (so a live check costs at most one * one-word request per seat per 5 minutes); a signed-out or unknown one for 30 s, so a seat signed * in from the Agents page turns green on the next poll or two. The cache is keyed by (seat, worker * home): changing `worker_config_root` discards every answer at once. A launch awaits a missing or * stale answer, bounded, before it routes ({@link SeatProbe.ensureFresh}). */ import { type SeatAccount } from './seat-scrub.js'; /** A probe process's outcome, as the runner reports it. `error` is set when it never ran to exit * (not installed, timed out, killed). */ export interface ProbeOutput { code: number | null; stdout: string; stderr: string; error?: string; } /** Runs one probe command — injectable so tests never spawn a CLI. */ export type ProbeRunner = (cmd: string, args: string[], env: NodeJS.ProcessEnv, timeoutMs: number) => Promise; /** How far a probe's answer was checked: `live` — an authenticated request answered (or was * refused for want of a credential); `status` — only the CLI's status command answered. */ export type ProbeCheck = 'live' | 'status'; /** One seat's probe answer. */ export interface SeatProbeReading { /** `true` the seat authenticates, `false` it does not, `null` the probe could not tell. */ signedIn: boolean | null; /** How far the answer was checked (see {@link ProbeCheck}). */ check: ProbeCheck; /** ISO-8601 of the probe. */ probedAt: string; /** A bounded excerpt of what the CLI said (its own words for a `false`), with every account * identifier dropped (A8, `seat-scrub.ts`): it rides the roster's `auth_evidence` / `login_note`. */ detail: string; /** (A2) The account KIND the status command stated, when it stated one — never an identifier. */ account?: SeatAccount; /** Present on the container floor (`roster-standing.ts`): the reading is the credential broker's, * and `check: 'live'` means the seat's last step there answered (the floor sends no live request * of its own). `'no_answer'` — its last step did not answer, which the roster warns about; * `'broker'` — no step ran on it in the window, so only the broker answered. */ via?: 'step' | 'no_answer' | 'broker'; } /** How long an answer is trusted before a background refresh (the stale answer is served meanwhile). */ export declare const PROBE_TTL_SIGNED_IN_MS: number; export declare const PROBE_TTL_OTHER_MS: number; /** A status command is local and fast (~1 s); past this it is not answering. */ export declare const PROBE_TIMEOUT_MS: number; /** The live check is one model request (a CLI start plus one short reply). */ export declare const VERIFY_TIMEOUT_MS: number; /** How long a definite answer (signed in or out) outlives later probes that could not tell. A status * command that times out on a loaded host is not news about the login: the last answer stands, and * is re-checked on the short TTL, until this long after it was given. */ export declare const PROBE_KEEP_ANSWER_MS: number; /** How long a launch waits for missing or stale answers before it routes on what it has. */ export declare const LAUNCH_PROBE_WAIT_MS: number; type Command = { cmd: string; args: string[]; env: NodeJS.ProcessEnv; }; /** The probe command for a seat, or `null` when the seat's CLI has no auth-status command. */ export declare function probeCommand(seatKey: string, workerRoot: string | undefined, env?: NodeJS.ProcessEnv, home?: string): Command | null; /** The live check for a seat whose status command said signed in (see the module doc). */ export declare function verifyCommand(seatKey: string, workerRoot: string | undefined, env?: NodeJS.ProcessEnv, home?: string): Command | null; /** Read a probe's outcome: `null` when it did not run to an answer (missing CLI, timeout). */ export declare function classifyProbe(seatKey: string, out: ProbeOutput): boolean | null; /** Read the live check: `true` it answered, `false` it was refused for want of a credential, * `null` it could not tell (timeout, quota, network, an unknown failure). */ export declare function classifyVerify(out: ProbeOutput): boolean | null; /** The production runner: `execFile` with a timeout, never a shell, stdin closed (claude `-p` * otherwise waits for piped input). Runs in the OS temp dir, so no project context is loaded. */ export declare const execProbe: ProbeRunner; export interface SeatProbeOptions { run?: ProbeRunner; now?: () => number; env?: NodeJS.ProcessEnv; home?: string; } /** The per-daemon probe cache (see the module doc). */ export declare class SeatProbe { /** `atMs` — when the answer was given. When a later probe could not tell and the answer was kept * ({@link PROBE_KEEP_ANSWER_MS}): `retryMs` — when that probe ran, and `inconclusive` — what it * said, served once the kept answer is too old. */ private readonly cache; private readonly inflight; /** One probe process at a time: each refresh chains onto this. */ private queue; private readonly run; private readonly now; private readonly env; private readonly home; constructor(opts?: SeatProbeOptions); /** Whether this seat has a probe at all. */ probes(seatKey: string): boolean; /** * The cached answer for `seatKey` under `workerRoot` (`undefined` before the first probe * finishes, or for a seat with no probe). Never waits: a missing or stale answer starts one * background refresh. */ read(seatKey: string, workerRoot: string | undefined): SeatProbeReading | undefined; /** * Before a launch routes: probe every probed seat whose answer is missing or stale and wait for * the answers, at most `waitMs` (a probe that is slower keeps running in the background, and the * seat reads as it would have without the wait). */ ensureFresh(seatKeys: string[], workerRoot: string | undefined, waitMs?: number): Promise; /** Probe now (deduplicated per seat and worker home); resolves to the fresh answer. */ refresh(seatKey: string, workerRoot: string | undefined): Promise; /** * A3b: probe AGAIN, after any probe already running for this seat has finished. A running probe * may have started before the operator signed in, so its answer cannot be the fresh one. */ refreshFresh(seatKey: string, workerRoot: string | undefined): Promise; /** The status command, then — when it says signed in — the live check. */ private probeOnce; private reading; private stale; } export {}; //# sourceMappingURL=seat-probe.d.ts.map