/** * Cleo Nexus device enrolment: the device half of `cleo login nexus` and the * one-time upgrade of a 9.24 bearer session (cleo-nexus device contract v2.9, * §3.3 steps 4 to 10, §3.4). Everything here runs only with device * credentials on ({@link isNexusDeviceEnabled}: the default since T12904); * with `CLEO_NEXUS_DEVICE=0` the CLI keeps the 9.24 session flow in * `nexus-auth.ts` untouched. * * Lock discipline (v2.9). `nexus-device.json`'s lock is held only around local * read-modify-writes, never across a network call: * * 1. under the lock: re-read, check, persist the identity and the E1 intent; * 2. release the lock, send the request with a plain timeout; * 3. re-take the lock and re-check with a compare-and-swap before storing. * * Racing logins on one home (step 7). When the file already holds a different * credential for the same (origin, user), E2 (`/v1/whoami`) is asked about * each credential and the one that answers 200 is kept; the other is dropped * with no E9 and no `pendingSignOut` (the server already refused it). Both * 200: the later `createdAt` wins and a `W_NEXUS_LOGIN_RACE_BOTH_LIVE` * warning is added. Both 401: neither is kept and a login is required. A * network error or 5xx leaves the file as it is. * * No function here logs, no result or error carries a token or key, and no * guarded entry is ever spread: every entry change goes through the store's * `apply*` transitions. * * @task T12868 * @epic T12323 */ import type { NexusLoginResult } from '@cleocode/contracts'; import type { DeviceCodeStartResponse } from '../llm/oauth/device-code.js'; import { type FetchLike } from './http.js'; import { type NexusTokenStore } from './nexus-credentials.js'; import { NexusDeviceStore, type SealedNexusDevice } from './nexus-device.js'; import type { NexusVaultState } from './nexus-vault-state.js'; /** * E1's request timeout. An upgrade intent older than this is stale: the * process that wrote it has given up or died (§3.4 step 1). */ export declare const NEXUS_ENROL_TIMEOUT_MS = 30000; /** Timeout of one E2 (`/v1/whoami`) call. */ export declare const NEXUS_WHOAMI_TIMEOUT_MS = 15000; /** How long an upgrade waits for another process's live upgrade intent (the ~60 s lock budget). */ export declare const NEXUS_UPGRADE_WAIT_MS = 60000; /** Interval between locked re-reads while waiting for another process's upgrade. */ export declare const NEXUS_UPGRADE_POLL_MS = 250; /** Slack added to the stale age of an E1 intent, over its owner's E2 and E1 timeouts. */ export declare const NEXUS_INTENT_MARGIN_MS = 15000; /** * Age after which another process's E1 intent is stale (§3.4 step 1). A live * owner can be E2 plus E1 past `startedAt` (it also refreshes `startedAt` * right before E1), so the threshold is derived from both timeouts plus a * margin, never E1's timeout alone (security review M1). */ export declare const NEXUS_INTENT_STALE_MS: number; /** Warning code: two racing logins both held a live credential (§3.3 step 7, ruling A). */ export declare const W_NEXUS_LOGIN_RACE_BOTH_LIVE = "W_NEXUS_LOGIN_RACE_BOTH_LIVE"; /** Warning code: the chosen device name is this machine's hostname (L8). */ export declare const W_NEXUS_DEVICE_NAME_IS_HOSTNAME = "W_NEXUS_DEVICE_NAME_IS_HOSTNAME"; /** * STAGING ONLY: environment variable holding a better-auth session bearer * minted by the staging test-signup endpoint (`POST /v1/test/users` on * {@link NEXUS_STAGING_API_ORIGIN}). With it, {@link loginToNexusDevice} * skips the browser device-code step, for browserless end-to-end tests. It is * honoured only when the login targets exactly {@link NEXUS_STAGING_API_ORIGIN}. */ export declare const NEXUS_TEST_BEARER_ENV = "CLEO_NEXUS_TEST_BEARER"; /** The only API origin that honours {@link NEXUS_TEST_BEARER_ENV}. */ export declare const NEXUS_STAGING_API_ORIGIN = "https://api.staging.cleocode.dev"; /** Warning code: {@link NEXUS_TEST_BEARER_ENV} is set but the login does not target staging, so it was ignored. */ export declare const W_NEXUS_TEST_BEARER_IGNORED = "W_NEXUS_TEST_BEARER_IGNORED"; /** Dependencies of the device flows; all optional (tests inject them). */ export interface NexusDeviceFlowOptions { /** API URL; defaults to `$CLEO_NEXUS_API_URL`, then production. */ apiUrl?: string; /** `fetch` override. */ fetch?: FetchLike; /** The device store; defaults to `/nexus-device.json`. */ deviceStore?: NexusDeviceStore; /** The 9.24 session store (`nexus-credentials.json`). */ store?: NexusTokenStore; /** Clock (intent ages, `createdAt`). */ now?: () => Date; /** Wait between locked re-reads (tests). */ sleep?: (ms: number) => Promise; /** E1 timeout; also the staleness threshold of an upgrade intent. */ enrolTimeoutMs?: number; /** E2 timeout. */ whoamiTimeoutMs?: number; /** Budget for waiting on another process's upgrade. */ upgradeWaitMs?: number; /** Interval between re-reads while waiting. */ upgradePollMs?: number; /** Margin over E2 + E1 timeouts before an intent is stale; default {@link NEXUS_INTENT_MARGIN_MS}. */ intentMarginMs?: number; /** CLEO version sent as `cliVersion`; defaults to the installed one. */ cliVersion?: string; } /** Options for {@link loginToNexusDevice}. */ export interface NexusDeviceLoginOptions extends NexusDeviceFlowOptions { /** Enrol with the `read-only` profile (`--read-only`). */ readOnly?: boolean; /** Device name (`--name`); defaults to a generic name without the hostname. */ name?: string; /** Called once with the user code and verification URL to show the user. */ onCode?: (code: DeviceCodeStartResponse) => void; /** Called on every pending poll with elapsed and total seconds. */ onPending?: (elapsed: number, expiresIn: number) => void; /** Cancels polling. */ signal?: AbortSignal; /** Wait override for device-code polling (tests). */ pollSleep?: (ms: number) => Promise; /** Vault state store the account setup records signer trust in; defaults to `/nexus-vault.json`. */ vaultState?: NexusVaultState; } /** Result of {@link upgradeNexusSession}. Holds no secret. */ export interface NexusSessionUpgradeResult { /** * - `upgraded`: this process enrolled the device from the 9.24 session; * - `already-enrolled`: a device credential already existed (another * process upgraded, or a login ran); the leftover session was removed; * - `no-session`: there was no 9.24 session for the origin. */ readonly outcome: 'upgraded' | 'already-enrolled' | 'no-session'; /** The device entry now holding the credential, or `null` for `no-session`. */ readonly device: SealedNexusDevice | null; /** Non-fatal problems. */ readonly warnings: readonly string[]; } /** Result of {@link ensureNexusDeviceCredential}. Holds no secret in any enumerable field. */ export interface NexusDeviceCredentialHandle { /** The entry; `currentBearer()` builds the `Authorization` header. */ readonly device: SealedNexusDevice; /** `true` when a 9.24 session was upgraded to reach it. */ readonly upgraded: boolean; /** Non-fatal problems. */ readonly warnings: readonly string[]; } /** * Map an API failure to the CLI's error codes (device contract §4.0.4). The * reasons handled internally by the enrolment and rotation flows * (`device-revoked` and `device-other-account` on E1, `device-id-taken`, * `device-keys-changed`, `project-id-taken`) are mapped by their callers * first; anything reaching this function is reported. * * @param err - The thrown error. * @returns A {@link NexusAccountError} with a stable code, or `err` itself * when it is not an API failure. */ export declare function nexusApiErrorToAccountError(err: unknown): Error; /** * The default device name (§3.2): ` · `. The hostname is never used. * * @param deviceId - The Nexus device id. * @param platform - `process.platform`. * @param arch - `process.arch`. * @returns For example `macOS arm64 · 01f3`. */ export declare function defaultNexusDeviceName(deviceId: string, platform?: NodeJS.Platform, arch?: string): string; /** * `cleo login nexus` with device credentials (contract §3.3): the device-code * login gives a session held in memory only; the session enrols this machine * as a device (E1), is signed out, and only the scoped device credential is * stored, sealed, in `nexus-device.json`. `nexus-credentials.json` is never * written. * * Then, on that new device credential, the account is made ready for * encrypted backups (onboarding A, T13100): the escrowed account key is read, * or minted and escrowed on the account's first device, and this device is * certified under it ({@link NexusLoginResult.account}). That setup never * fails the login; a problem is a warning naming the step and the remedy. * * Environment: * * - `CLEO_NEXUS_API_URL`: the API origin when `opts.apiUrl` is not given. * - `CLEO_NEXUS_TEST_BEARER` ({@link NEXUS_TEST_BEARER_ENV}), STAGING ONLY: a * better-auth session bearer minted by the staging test-signup endpoint * (`POST /v1/test/users` on {@link NEXUS_STAGING_API_ORIGIN}). When the * resolved origin is exactly {@link NEXUS_STAGING_API_ORIGIN}, the * device-code step is skipped and that bearer runs the same E2 (whoami), * E1 (enrol) and sign-out path as a browser login. For any other origin it * is ignored with a {@link W_NEXUS_TEST_BEARER_IGNORED} warning. Its value * never appears in a result, warning, error or log. * * @param opts - API URL, profile, name, UI hooks and test overrides. * @returns The secret-free login result, with the device and its scopes. * @throws {NexusAccountError} `E_NEXUS_REVOKE_PENDING` while a revoke of this * device is unconfirmed; `E_NEXUS_UNREACHABLE`; the §4.0.4 mappings. */ export declare function loginToNexusDevice(opts?: NexusDeviceLoginOptions): Promise; /** * Upgrade a 9.24 session to a device credential, once (contract §3.4). * * Under `nexus-device.json`'s lock, then `nexus-credentials.json`'s (always in * that order), both files are re-read: * * - the device file already holds a credential for (origin, user): another * process upgraded; the leftover session is removed and E1 is not called; * - the session is gone from the v1 file: another process consumed it; * - another process's upgrade intent is live: the locks are released and * this process waits for it (re-reading under the lock), never calling E1; * - an intent older than E1's timeout is stale, and is taken over only when * this locked re-read finds no credential and that same intent. * * Otherwise the identity and the upgrade intent are persisted, the locks are * released, and steps 4 to 9 run with the stored session. E1 accepts it once * (the auto-upgrade exemption) and deletes it, so a lost answer is reported * as needing a browser login, and the session is removed so no later command * retries it. * * When E1 answers 401 `session-used` (v2.12: a concurrent E1 consumed the * session first), the session is removed and `nexus-device.json` is re-read * every `upgradePollMs` for up to `upgradeWaitMs`: once another process has * stored a credential there, the outcome is `already-enrolled`; if none * appears, `E_NEXUS_SESSION_EXPIRED` asks for a browser login. * * @param opts - API URL, stores and test overrides. * @returns What happened; never a secret. * @throws {NexusAccountError} `E_NEXUS_SESSION_EXPIRED` (browser login * needed), `E_NEXUS_BUSY`, `E_NEXUS_REVOKE_PENDING`, or a §4.0.4 mapping. */ export declare function upgradeNexusSession(opts?: NexusDeviceFlowOptions): Promise; /** Options for {@link ensureNexusDeviceCredential}. */ export interface NexusDeviceCredentialOptions extends NexusDeviceFlowOptions { /** * The Nexus user whose device credential to use. Without it, the origin * must hold exactly one account's credential (security review L2). */ userId?: string; } /** * The device credential for commands that need one (with device credentials (the default; `CLEO_NEXUS_DEVICE=0` turns them off)). * * - A 9.24 session still in `nexus-credentials.json` is upgraded first, once * (§3.4); a leftover session next to an existing credential is signed out * and removed. * - The account is `opts.userId`, else the upgraded one, else the only one * on the origin; with several, it refuses and lists them. * - A racing login's parked credential is settled with E2 first (§3.3 step * 7); without an answer the current credential is used and a warning added. * * @param opts - API URL, account, stores and test overrides. * @returns The sealed device entry and whether an upgrade ran. * @throws {NexusAccountError} `E_NEXUS_NOT_SIGNED_IN` with no usable * credential, `E_NEXUS_ACCOUNT_AMBIGUOUS`, or the upgrade's errors. */ export declare function ensureNexusDeviceCredential(opts?: NexusDeviceCredentialOptions): Promise; //# sourceMappingURL=nexus-enrol.d.ts.map