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}