/** * remote Command - expose this server to a phone and pair it with a QR code * Issue #1937 (R9). Design: `docs/design/remote-qr-pairing-1937.md` §5, §6.2. * * commandmate remote # up (default): start, publish, show the QR * commandmate remote status # Provider / URL / expiry / pairing state * commandmate remote stop # close the door, keeping the server running * * commandmate remote --auth remote-only # only the phone's route authenticates * * ## What lives here rather than in a Provider (§6.2) * * Two things, and both for the same reason — they are decisions about an * IRREVERSIBLE act (putting this machine on a network it was not on), and a * decision buried in a probe helper is a decision nobody reviews: * * 1. **Which Provider runs.** `detectRemoteProviders()` returns every Provider * in preference order and picks none. "Tailscale first, and never fall * through to a public tunnel on its own" is a rule about selection, so it * is written here, once, as {@link selectProvider}. * 2. **Whether a public tunnel may be created at all.** A prompt inside a * Provider would have to re-derive interactive-vs-not per Provider, and its * answer would be invisible to the caller that has to honour `--yes`. * * ## How far the token reaches (Issue #2489) * * `--auth all` (the default) authenticates every listener, which is what #1937 * shipped. `--auth remote-only` runs a SECOND loopback listener, points the * Provider at that one alone, and exempts the original from authentication — so * the phone still pairs and the PC in front of the machine keeps working. The * exemption is decided by which listener a request arrived on, never by its * source address: a Provider's upstream IS 127.0.0.1, so every IP- and * header-based answer here fails open. See `src/lib/ws-server.ts`'s ingress * section for the full argument. * * ## What this command deliberately does not have * * - **`--token`.** `remote` is the side that MINTS a token; one supplied from * outside has no hash on the server to match (§5.1). * - **`--auto-yes` in any form.** Auto-Yes state is an in-memory map that is * empty at server start, so a freshly started server has it off for every * worktree. Not offering a flag is what keeps it that way (§5.5); a test * pins that the launch env carries no Auto-Yes key either. * - **Anything that stops the server on expiry.** `--expires` closes the * outside door only. A `commandmate stop` on a timer would take the user's * local session down along with the remote one (§5.3). */ import { Command } from 'commander'; import { ExitCode } from '../types'; import type { RemoteOptions } from '../types'; import { type RemoteAuthScope } from '../utils/remote-state'; import { type ProviderCandidate, type RemoteProviderId } from '../../lib/remote'; /** Default remote-session TTL. Follows `parseDuration`'s 1h-30d range (§5.1). */ export declare const DEFAULT_REMOTE_EXPIRES = "8h"; /** Default pairing-code TTL (§5.1). See {@link parsePairingDuration}. */ export declare const DEFAULT_PAIRING_EXPIRES = "10m"; /** Shortest pairing window: below a minute the QR cannot be scanned in time. */ export declare const MIN_PAIRING_TTL_MS: number; /** * Longest pairing window. * * The handoff file holds the plaintext session token until the code is used * (§7.2), so this bound is the maximum time that plaintext sits on disk. It is * deliberately far below `--expires`' 30-day ceiling. */ export declare const MAX_PAIRING_TTL_MS: number; /** What the user may type after `--provider`, and what it resolves to. */ export declare const REMOTE_PROVIDER_ALIASES: Readonly>; /** * Providers that publish to the open internet rather than to a private network. * * Membership here is what triggers the explicit approval below, so this list — * not a Provider's own opinion of itself — is the thing to check when a * Provider is added. */ export declare const PUBLIC_TUNNEL_PROVIDERS: readonly RemoteProviderId[]; /** * Every environment variable `remote` adds to the server it starts, and no * other (§9.1). * * All three are already-existing keys or a path: * * - `CM_AUTH_TOKEN_HASH` / `CM_AUTH_EXPIRE` — what `start --auth` sets. * - `CM_REMOTE_PAIRING_FILE` — a PATH, not a secret. The plaintext token and * the pairing hash live in the 0600 file it names, because a pane spawned by * `src/lib/tmux/**` inherits the server's environment wholesale, so anything * put here would be readable by the very agents CommandMate is driving * (§7.2). * * - `CM_AUTH_SCOPE` — the `--auth` value (#2489). Not a secret and not a path: * the string `all` or `remote-only`, which is what `middleware.ts` and * `ws-server.ts` read to decide whether the local listener may skip the * token. Always exported, including for `all`, so the server is told what * was chosen rather than inferring it from a missing variable. * * `tests/unit/cli/commands/remote-launch-env-1937.test.ts` holds the measured * set to this declaration in BOTH directions, the way * `agent-launch-plan-secrets-1933.test.ts` does for `prepareLaunch`. Adding a * key without adding it here goes red; so does removing one. */ export declare const REMOTE_LAUNCH_ENV_KEYS: readonly ["CM_AUTH_TOKEN_HASH", "CM_AUTH_EXPIRE", "CM_REMOTE_PAIRING_FILE", "CM_AUTH_SCOPE"]; /** * The measured set for `--auth remote-only` (Issue #2489). * * One key more than {@link REMOTE_LAUNCH_ENV_KEYS}, and declared separately * rather than as an "optional extra" so the exact-set measurement keeps working * in BOTH directions for BOTH modes. A conditional key checked against a single * list would be a list that describes neither mode. */ export declare const REMOTE_ONLY_LAUNCH_ENV_KEYS: readonly ["CM_AUTH_TOKEN_HASH", "CM_AUTH_EXPIRE", "CM_REMOTE_PAIRING_FILE", "CM_AUTH_SCOPE", "CM_REMOTE_INGRESS_PORT"]; /** Inputs of {@link buildRemoteLaunchEnv}. */ export interface RemoteLaunchEnvInput { /** SHA-256 of the session token. The token itself never reaches the env. */ authTokenHash: string; /** The `--expires` duration string, verbatim, for `computeExpireAt()`. */ authExpire: string; /** Absolute path of the 0600 pairing handoff file. */ pairingFilePath: string; /** Issue #2489: how far authentication reaches. */ authScope: RemoteAuthScope; /** * Issue #2489: the loopback port the server opens for the Provider. * * Required when `authScope` is `remote-only` and meaningless otherwise — the * server refuses `remote-only` without it and falls back to authenticating * every listener, so passing one mode's value with the other mode's scope * cannot silently produce a half-open server. */ remoteIngressPort?: number; } /** Pairing state as reported by `remote status` (§5.4). */ export type PairingState = 'unused' | 'consumed' | 'expired'; /** * Build the exact environment `remote` contributes to the server it starts. * * Returned as a plain map rather than written straight into `process.env` so * the contribution is a value a test can compare against * {@link REMOTE_LAUNCH_ENV_KEYS} — "what did remote add" is otherwise only * observable by diffing a global. * * @param input - Hash, expiry, handoff path and auth scope * @returns Those variables, and nothing else */ export declare function buildRemoteLaunchEnv(input: RemoteLaunchEnvInput): Record; /** * Copy the launch env into `process.env`, where `daemon.ts` reads it. * * `runStart` -> `DaemonManager.start()` builds the child environment from * `process.env` plus `.env`, exactly as `start --auth` does, so this is the * hand-off point. Note what is NOT here: `CM_BIND` is neither read nor written * by `remote`, so a user already running `CM_BIND=0.0.0.0` keeps that setting * and a user on the 127.0.0.1 default keeps that one (§9.1). * * @param env - Output of {@link buildRemoteLaunchEnv} * @returns A function restoring the previous values, used on the rollback paths */ export declare function applyRemoteLaunchEnv(env: Record): () => void; /** * Parse a `--pairing-expires` duration. * * `parseDuration()` from `auth-config` is NOT reused here even though `remote` * uses it for `--expires`: its floor is 1 hour, and the pairing default is ten * minutes. Raising that floor would loosen `CM_AUTH_EXPIRE` for every caller of * a shared function, so the short-lived case gets its own bounds instead. * * @param value - Duration string (`Nm` or `Nh`) * @returns Duration in milliseconds * @throws Error when the format is wrong or the value is out of range */ export declare function parsePairingDuration(value: string): number; /** * What the user may type after `--auth`, and the default when they type nothing. * * Issue #2489. `all` is the default deliberately: `remote-only` leaves every * process on this machine — including the agents CommandMate itself runs in tmux * — able to reach the API without a token, which is the same exposure as a * CommandMate started without `--auth` at all, and weaker than `all`. Opting * into that is the user's call to make, not a default to inherit. */ export declare const REMOTE_AUTH_SCOPES: readonly RemoteAuthScope[]; /** Auth scope applied when `--auth` is not given (Issue #2489). */ export declare const DEFAULT_REMOTE_AUTH_SCOPE: RemoteAuthScope; /** * Where the `remote-only` ingress listener lives (Issue #2489). * * The literal loopback address, matching `REMOTE_INGRESS_BIND` in `server.ts`: * both Providers dial `http://127.0.0.1:`, and the listener must not be * reachable from anywhere else. */ export declare const REMOTE_INGRESS_HOST = "127.0.0.1"; /** * Parse an `--auth` value. * * @param value - Raw flag value; undefined means the flag was not given * @returns The scope to use * @throws Error naming the valid values, for CONFIG_ERROR */ export declare function parseAuthScope(value: string | undefined): RemoteAuthScope; /** * The bind address the server this session starts will actually listen on. * * Read through `loadEffectiveEnv()` rather than `process.env` because that is * what `daemon.start()` hands the child: it layers `.env` OVER the exported * environment, so a `CM_BIND=0.0.0.0` written in `~/.commandmate/.env` is * invisible to `process.env` here and decisive for the server. Checking the * wrong one is how this guard would pass while the thing it guards against * happened anyway. * * This is the one place `remote` reads `CM_BIND`, and only in `remote-only` * mode — §9.1's "remote neither reads nor writes CM_BIND" still holds for every * other path, and `remote` never writes it. * * @returns The effective `CM_BIND`, defaulted the way `server.ts` defaults it */ export declare function resolveEffectiveBind(): string; /** * @param bind - An effective `CM_BIND` value * @returns true when nothing outside this machine can reach that address */ export declare function isLoopbackBind(bind: string): boolean; /** Outcome of applying the selection rule to a probe result. */ export interface ProviderSelection { /** The Provider to use, when there is one. */ candidate?: ProviderCandidate; /** Why there is none. Mutually exclusive with `candidate`. */ error?: { exitCode: ExitCode; message: string; /** One line per Provider, so the user sees what was tried and why it failed. */ details: string[]; }; } /** * Apply the Provider selection rule (§6.2). * * The rule, in full: * * - `--provider` names exactly one Provider. An unusable one is an error, not * a reason to try another: the user asked for that one. * - Without `--provider`, the first READY Provider in preference order wins. * Preference order puts Tailscale (private tailnet) ahead of the Cloudflare * Quick Tunnel (public internet). * - Being chosen is never enough to be started. A Provider in * {@link PUBLIC_TUNNEL_PROVIDERS} still has to clear the explicit approval * in {@link approvePublicTunnel}, which is what makes "Tailscale failed, so * it silently published me to the internet" impossible rather than merely * unlikely. * - No Provider ready is `DEPENDENCY_ERROR`, never a fallback. * * Pure: it neither probes nor prompts, so the rule can be tested without a * Provider being installed. * * @param candidates - `detectRemoteProviders()` output, in preference order * @param requested - The raw `--provider` value, if any * @returns The chosen candidate, or the error to exit with */ export declare function selectProvider(candidates: readonly ProviderCandidate[], requested?: string): ProviderSelection; /** * Run the `up` flow: start, publish, pair (§5.3, steps 1-9). * * @param options - Parsed command options * @returns The exit code the caller should terminate with */ export declare function runRemoteUp(options: RemoteOptions): Promise; /** * @param url - Public URL the Provider published * @param code - Plaintext pairing code * @returns The `/login#code=` URL the QR encodes */ export declare function buildPairingUrl(url: string, code: string): string; /** * Decide what to report on the `Pairing:` line (§5.4). * * The absence of the handoff file IS the consumed flag (§7.2) - the route * unlinks it between verifying the code and setting the cookie - so absence is * reported as `consumed` rather than being guessed at from the clock. * * @param handoffPresent - Whether the handoff file still exists * @param pairingExpiresAt - Epoch ms the code dies at * @param now - Epoch ms, injectable for tests * @returns The state to display */ export declare function derivePairingState(handoffPresent: boolean, pairingExpiresAt: number, now?: number): PairingState; /** * Format a millisecond delta the way `remote status` shows expiry. * * @param ms - Milliseconds remaining; zero or less reads as expired * @returns e.g. `in 6h 12m`, or `expired` */ export declare function formatRemaining(ms: number): string; /** * Run the `status` flow (§5.4). * * This is also where `--expires` is enforced in Phase 1: `up` starts the server * as a daemon and returns, so no `remote` process survives to hold a timer. * §5.3 allows either, and "the expiry is judged when `remote status` runs" is * the half that works without a resident process. It closes the Provider only - * the server keeps running, because the local user is still using it. * * @param options - Parsed command options * @returns The exit code the caller should terminate with */ export declare function runRemoteStatus(options: RemoteOptions): Promise; /** * Run the `stop` flow (§6.3-4). * * With no readable state file this exits SUCCESS having done nothing. That is * the point, not a shortcut: the alternative is inferring a Provider and tearing * down whatever configuration it currently holds, and for Tailscale Serve that * configuration can be the user's own, with no way to restore it. `stop.ts` * treats a stale PID file with the same restraint. * * @param options - Parsed command options * @returns The exit code the caller should terminate with */ export declare function runRemoteStop(options: RemoteOptions): Promise; /** * Dispatch one `remote` invocation and terminate. * * The exit-free `run*` functions above are what the tests drive and what a * future caller (a `quickstart --remote`, say) would compose, exactly as * `runStart` / `startCommand` are split (#1195). * * @param action - `up` (default), `status` or `stop` * @param options - Parsed command options */ export declare function remoteCommand(action: string | undefined, options: RemoteOptions): Promise; /** * Create the remote command. * [DR1-08] Factory pattern for addCommand() registration, as `createSyncCommand()` * and `createInstancesCommand()` do. `instances`' shape is followed too - a * default action with the verb as an optional positional - the only difference * being that the default here is `up` rather than a listing. * * @returns The configured commander command */ export declare function createRemoteCommand(): Command; //# sourceMappingURL=remote.d.ts.map