Skip to main content

link_assistant_router/cli/
auth_ops.rs

1//! `auth` and `tls` command definitions.
2//!
3//! Split from `cli.rs` to keep that file within the repository's 1000-line
4//! limit.
5
6use clap::Subcommand;
7
8use super::{AuthFlow, CLAUDE_AUTH_FLOWS, CODEX_AUTH_FLOWS, auth_flow_parser};
9
10/// A login this machine already holds that the router can adopt.
11///
12/// Not `SubscriptionProvider`: that enum is about subscriptions the proxy
13/// serves models from, and GitHub is a credential the router presents upstream
14/// rather than a subscription. Import spans both, so it needs a name for the
15/// union (issue #278).
16#[derive(Clone, Copy, Debug, Eq, PartialEq, clap::ValueEnum)]
17pub enum ImportProvider {
18    #[value(name = "claude", alias = "anthropic")]
19    Claude,
20    #[value(name = "codex", alias = "chatgpt")]
21    Codex,
22    #[value(name = "gemini", alias = "google")]
23    Gemini,
24    #[value(name = "qwen", alias = "qwen-code")]
25    Qwen,
26    #[value(name = "gh", alias = "github")]
27    Gh,
28}
29
30impl ImportProvider {
31    /// The built-in credential this name refers to, if any.
32    ///
33    /// `auth clear` takes a free-form name so it can also withdraw an API-key
34    /// provider added through `providers add` (issue #561). The built-in names
35    /// keep their exact spellings and aliases, so this resolves them before a
36    /// name is looked up in the provider store.
37    #[must_use]
38    pub fn from_name(value: &str) -> Option<Self> {
39        match value.trim().to_ascii_lowercase().as_str() {
40            "claude" | "anthropic" => Some(Self::Claude),
41            "codex" | "chatgpt" => Some(Self::Codex),
42            "gemini" | "google" => Some(Self::Gemini),
43            "qwen" | "qwen-code" => Some(Self::Qwen),
44            "gh" | "github" => Some(Self::Gh),
45            _ => None,
46        }
47    }
48}
49
50/// Provider authorization operations.
51#[derive(Debug, Subcommand)]
52pub enum AuthOp {
53    /// Adopt a login this machine already has, without a browser.
54    ///
55    /// Authorizing means "go get a new credential, interactively"; importing
56    /// means "adopt one that already exists". They differ in prerequisites, in
57    /// side effects, and in whether a human has to be present — which decides
58    /// whether a headless deployment can be provisioned at all (issue #278).
59    ///
60    /// Runs on the deployment being provisioned: it installs into the
61    /// credential home of the machine executing it, and no router accepts a
62    /// credential over HTTP. With another router selected this refuses and
63    /// names it, rather than answering about the local home (issue #291); use
64    /// `auth claude` or `auth codex` to authorize a remote deployment.
65    ///
66    /// The per-provider flags on the authorize commands keep working.
67    Import {
68        /// Which login to adopt. Omit with `--all`.
69        #[arg(
70            value_enum,
71            required_unless_present_any = ["all", "resume"],
72            conflicts_with = "resume"
73        )]
74        provider: Option<ImportProvider>,
75        /// Where to read it from. A named directory is read exactly as given.
76        ///
77        /// Omitted, it defaults to the vendor client's conventional directory —
78        /// `~/.claude`, `~/.codex`, `~/.config/gh` — and there, on macOS for
79        /// Claude, the login Keychain is consulted too and wins when it holds
80        /// the newer credential. Naming a directory says *this* credential from
81        /// *there*, so the machine-wide store is left out of it (issue #285).
82        ///
83        /// `$CLAUDE_CODE_HOME` and `$CODEX_HOME` are deliberately *not* the
84        /// source: in a deployment they name this router's own credential
85        /// directory — the destination — so reading the source through them
86        /// would make every unqualified import refuse itself (issue #307). Pass
87        /// the directory to read from another location.
88        #[arg(requires = "provider", conflicts_with = "resume")]
89        dir: Option<String>,
90        /// Adopt every login this machine has.
91        ///
92        /// The case that motivates a verb: provisioning a deployment from a
93        /// machine already logged in to several providers, without knowing each
94        /// flag name and default path. Run it on that deployment — import
95        /// writes the executing machine's credential home (issue #291).
96        #[arg(long, conflicts_with_all = ["provider", "resume"])]
97        all: bool,
98        /// Install only if no recognized credential exists after taking the
99        /// shared refresh/login lock.
100        #[arg(long, conflicts_with = "all")]
101        if_absent: bool,
102        /// Assert support for non-destructive access-token validation and an
103        /// atomic reference to one writable vendor-owned credential file.
104        /// Older Router versions reject this spelling, allowing deployment
105        /// tooling to fail closed before importing a credential.
106        ///
107        /// The internal field keeps its historical name for source
108        /// compatibility; this flag never bypasses positive validation.
109        #[arg(long = "safe-refresh-chain-import-v1")]
110        force: bool,
111        /// Require that the credential be *followed* rather than copied.
112        ///
113        /// A refresh token is a rotating series, not a value: whoever redeems a
114        /// link invalidates it for every other holder. So a deployment holding
115        /// its own copy and the vendor CLI beside it are two refreshers of one
116        /// chain, and whichever loses the race is left with `invalid_grant` —
117        /// which looks exactly like a revocation from the losing side (issue
118        /// #574). Following installs a reference to the vendor client's own
119        /// credential file instead, so both advance one chain: a rotation by
120        /// either is seen by the other, with no re-import and no restart.
121        ///
122        /// This is already what an import does when it can. The flag makes it a
123        /// requirement: if a reference cannot be established — the credential
124        /// lives only in the platform keychain, names no writable source, or its
125        /// directory cannot be written atomically — the import refuses and says
126        /// which, rather than silently falling back to a copy that will drift.
127        #[arg(long, conflicts_with = "snapshot")]
128        follow: bool,
129        /// Take a one-time copy instead of following the source.
130        ///
131        /// The historical behaviour for callers that want a credential frozen at
132        /// import time, and a deployment that must not write to the source's
133        /// directory at all. A copy drifts: the vendor client will rotate past
134        /// it, so this is the shape that eventually needs a re-import.
135        #[arg(long)]
136        snapshot: bool,
137        /// Emit one stable JSON result envelope instead of human-readable
138        /// progress. Operational failures are represented in the envelope and
139        /// still produce a non-zero exit status.
140        #[arg(long)]
141        json: bool,
142        /// Retry one retained refresh-chain transaction by its opaque ID.
143        ///
144        /// Router resolves the private candidate directory; callers never need
145        /// to discover or construct an internal filesystem path.
146        #[arg(
147            long,
148            value_name = "TRANSACTION_ID",
149            conflicts_with_all = ["provider", "dir", "all"]
150        )]
151        resume: Option<String>,
152        #[command(flatten)]
153        target: AuthTarget,
154    },
155    /// Remove a stored login from this deployment.
156    ///
157    /// Withdrawal is the most destructive thing this tool does and had no
158    /// name: it was four flags, the widest of them attached to a command
159    /// called `status`, so `auth --help` said nothing about it at all. The
160    /// per-command `--clear` flags keep working (issue #305).
161    ///
162    /// Removes credentials on the machine it runs on. No router accepts a
163    /// withdrawal over HTTP, so with another router selected this refuses and
164    /// names it — silently rewriting "there" as "here" is unrecoverable for an
165    /// OAuth credential, which then needs a fresh browser login on a machine
166    /// that may not have a browser.
167    Clear {
168        /// Which login to remove: `claude`, `codex`, `gemini`, `qwen`, `gh`, or
169        /// the name of a provider added through `providers add`. Omit with
170        /// `--all`.
171        ///
172        /// A free-form name rather than a fixed enum: an API key stored by
173        /// `providers add` authorizes this deployment against an upstream
174        /// vendor exactly as an OAuth login does, and refusing to name one here
175        /// left `auth` unable to withdraw a credential it reports (issue #561).
176        #[arg(required_unless_present = "all")]
177        provider: Option<String>,
178        /// Remove every login this deployment holds.
179        #[arg(long, conflicts_with = "provider")]
180        all: bool,
181        /// Confirm removing more than one credential without a prompt.
182        #[arg(long)]
183        yes: bool,
184        #[command(flatten)]
185        target: AuthTarget,
186    },
187    /// Authorize an Anthropic Claude subscription.
188    Claude {
189        /// Supply the copied code without prompting on stdin.
190        #[arg(long)]
191        code: Option<String>,
192        /// Force an OAuth flow instead of automatic selection.
193        #[arg(long, value_parser = auth_flow_parser(&CLAUDE_AUTH_FLOWS), default_value = "auto")]
194        flow: AuthFlow,
195        /// Scope set to request: `full` (Claude Code `/login` equivalent) or
196        /// `setup-token` for `user:inference` only. Defaults to what
197        /// `LOGIN_CLI_ARGS` selects, then `full`.
198        #[arg(long)]
199        mode: Option<String>,
200        /// Adopt an existing Claude login instead of authorizing.
201        ///
202        /// Reads the credential a vendor client already holds and installs it as
203        /// this deployment's (issue #274). Default: `~/.claude`, where on macOS
204        /// the login Keychain is consulted as well and wins when it is the live
205        /// one. A directory named explicitly is read as given (issue #285).
206        #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = "")]
207        from_claude_home: Option<String>,
208        /// Remove the stored credential instead of authorizing.
209        #[arg(long, conflicts_with_all = ["code", "mode", "from_claude_home"])]
210        clear: bool,
211        #[command(flatten)]
212        target: AuthTarget,
213    },
214    /// Authorize an `OpenAI` Codex / `ChatGPT` subscription.
215    Codex {
216        /// Force an OAuth flow instead of automatic selection.
217        #[arg(long, value_parser = auth_flow_parser(&CODEX_AUTH_FLOWS), default_value = "auto")]
218        flow: AuthFlow,
219        /// Local callback port registered for the Codex OAuth client.
220        #[arg(long, default_value_t = 1455)]
221        port: u16,
222        /// Adopt an existing Codex login instead of authorizing.
223        ///
224        /// Default: `~/.codex` (issue #274).
225        #[arg(long, value_name = "DIR", num_args = 0..=1, default_missing_value = "")]
226        from_codex_home: Option<String>,
227        /// Remove the stored credential instead of authorizing.
228        #[arg(long, conflicts_with = "from_codex_home")]
229        clear: bool,
230        #[command(flatten)]
231        target: AuthTarget,
232    },
233    /// Store the GitHub credential the proxy presents upstream.
234    ///
235    /// The router mediates GitHub traffic on behalf of callers, so it needs an
236    /// operator credential of its own. Reading it from a mounted `gh` config
237    /// means a deployment can reuse an existing login instead of minting a
238    /// separate token (issue #263).
239    Gh {
240        /// Read the credential from a mounted `gh` configuration directory
241        /// (default: `$GH_CONFIG_DIR`, else `~/.config/gh`).
242        #[arg(long, value_name = "DIR")]
243        from_gh_config: Option<String>,
244        /// Read the credential as one line from standard input instead.
245        #[arg(long, conflicts_with = "from_gh_config")]
246        token_stdin: bool,
247        /// Report what is currently stored without changing it.
248        #[arg(long, conflicts_with_all = ["from_gh_config", "token_stdin"])]
249        status: bool,
250        /// Remove the stored credential instead of storing one.
251        #[arg(long, conflicts_with_all = ["from_gh_config", "token_stdin", "status"])]
252        clear: bool,
253        #[command(flatten)]
254        target: AuthTarget,
255    },
256    /// Report whether each provider credential is usable, expired, or absent.
257    Status {
258        /// Remove every stored credential, for decommissioning a deployment.
259        ///
260        /// Withdraws each provider's credential and the GitHub one in a single
261        /// step, so an operator tearing down a test deployment does not have to
262        /// know three separate paths (issue #268).
263        /// `router auth clear --all` is the same operation with a name.
264        #[arg(long = "clear-all")]
265        clear_all: bool,
266        /// Confirm removing more than one credential without a prompt.
267        ///
268        /// An OAuth login cannot be put back without a browser, and this is
269        /// the widest blast radius in the tool — five credentials in one call,
270        /// on a command called `status` (issue #305).
271        #[arg(long, requires = "clear_all")]
272        yes: bool,
273        #[command(flatten)]
274        target: AuthTarget,
275    },
276}
277
278/// What `auth gh` may do when a router other than this machine is selected.
279///
280/// A GitHub credential is read from the router's own data directory at startup
281/// and no endpoint accepts one over HTTP, so there is nothing to store
282/// remotely. Acting locally under a success message is what left a workstation
283/// holding a token it never needed while the targeted deployment had none
284/// (issue #283), so storing refuses and only the read-only query answers.
285#[derive(Debug, Clone, Copy, Eq, PartialEq)]
286pub enum RemoteGh {
287    /// Report this machine's credential, saying whose it is.
288    DescribeLocal,
289    /// Refuse: the credential cannot reach the selected router from here.
290    Refuse,
291}
292
293impl AuthOp {
294    /// What this `auth gh` invocation may do against a selected router.
295    ///
296    /// `None` for anything that is not `auth gh`.
297    #[must_use]
298    pub const fn remote_gh(&self) -> Option<RemoteGh> {
299        match self {
300            Self::Gh { status: true, .. } => Some(RemoteGh::DescribeLocal),
301            Self::Gh { .. } => Some(RemoteGh::Refuse),
302            _ => None,
303        }
304    }
305}
306
307/// Whether an `auth import` invocation may act on the machine running it.
308///
309/// Import installs into the credential home of the executing machine, and no
310/// router accepts a credential document over HTTP, so an import aimed at a
311/// different deployment has nothing it can do. Deciding that here — beside the
312/// flags it reads — keeps the rule unit-testable rather than reachable only by
313/// spawning the binary against a live server (issue #291).
314///
315/// `None` for anything that is not an `import`: the per-provider
316/// `--from-*-home` flags carry no target of their own.
317#[derive(Debug, Clone, Copy, Eq, PartialEq)]
318pub enum ImportTarget {
319    /// Act here: `--local`, `--managed`, or no selection at all.
320    Local,
321    /// A different router was named or selected; resolve it and refuse.
322    Remote,
323}
324
325impl AuthOp {
326    /// Whether this `auth import` may install into the local credential home.
327    ///
328    /// Answers from the flags alone. A bare invocation is [`Self::may_be_remote`]
329    /// because a *persisted* selection also counts as naming a target, which
330    /// only resolution can determine.
331    #[must_use]
332    pub const fn import_target(&self) -> Option<ImportTarget> {
333        match self {
334            Self::Import { target, .. } => {
335                if target.local || target.managed {
336                    Some(ImportTarget::Local)
337                } else {
338                    Some(ImportTarget::Remote)
339                }
340            }
341            _ => None,
342        }
343    }
344
345    /// Whether this invocation must resolve a target before importing.
346    ///
347    /// `false` short-circuits resolution entirely, so `--local` never contacts
348    /// a server and never fails because one is unreachable.
349    #[must_use]
350    pub const fn may_be_remote(&self) -> bool {
351        matches!(self.import_target(), Some(ImportTarget::Remote))
352    }
353}
354
355/// Which router an `auth` command acts on.
356///
357/// `auth` used to always write a local credential even when a server was
358/// selected, so the obvious `server use` → `auth` → `with` sequence left the
359/// targeted router unauthorized and failed later as a 401 (issue #246). The
360/// default now follows the selection, exactly as `with` does; these make the
361/// choice explicit when the default is not what is wanted.
362#[derive(Debug, Clone, Default, clap::Args)]
363pub struct AuthTarget {
364    /// Act on this machine even when a server is selected.
365    #[arg(long, conflicts_with = "server")]
366    pub local: bool,
367    /// Act on this router instead of the selected one.
368    #[arg(long, value_name = "URL", conflicts_with = "local")]
369    pub server: Option<String>,
370    /// Private management origin when it differs from the inference origin.
371    #[arg(long, value_name = "URL", conflicts_with = "local")]
372    pub management_server: Option<String>,
373    /// Start a disposable managed container even if a router is already
374    /// listening locally (issue #250).
375    ///
376    /// Accepted by the commands that can use one — `with`, `configure` and
377    /// `auth`. The families that only read or change router state refuse it
378    /// and name `--local`, because there it started nothing and quietly meant
379    /// `--local` anyway (issue #315).
380    #[arg(long, conflicts_with_all = ["local", "server", "management_server"])]
381    pub managed: bool,
382}
383
384/// TLS subcommands.
385///
386/// The artefact `ca` prints is a trust anchor, so answering for the wrong
387/// machine does not produce a wrong report — it produces trust in the wrong
388/// key. `tls` therefore takes the same target flags as every other
389/// state-touching family, and says so when it cannot answer for the target
390/// (issue #308).
391#[derive(Debug, Subcommand)]
392pub enum TlsOp {
393    /// Print the generated certificate in PEM form.
394    ///
395    /// A client that must trust a self-signed router reads it from here, so a
396    /// private-network deployment can distribute trust without a CA (issue
397    /// #263).
398    Ca {
399        #[command(flatten)]
400        target: AuthTarget,
401    },
402    /// Generate the self-signed certificate without starting the server.
403    Generate {
404        /// Names the certificate is valid for, comma-separated. A sidecar is
405        /// reached by its network alias, so that name must be present.
406        #[arg(long, value_name = "NAMES", default_value = "localhost")]
407        dns: String,
408        #[command(flatten)]
409        target: AuthTarget,
410    },
411}
412
413impl TlsOp {
414    /// Which router this certificate operation acts on.
415    #[must_use]
416    pub const fn target(&self) -> &AuthTarget {
417        match self {
418            Self::Ca { target } | Self::Generate { target, .. } => target,
419        }
420    }
421}