link_assistant_router/remote_command.rs
1//! One targeting rule for every command that reads or changes router state.
2//!
3//! Targeting used to be decided per command family, so what "the router" meant
4//! depended on which subcommand was typed: `auth` and `with` followed the
5//! selected server while `tokens`, `accounts`, `providers`, `logs` and `doctor`
6//! were local-only and refused to start without a local `TOKEN_SECRET` — even
7//! with a selected server reachable and answering in the same second. That
8//! turned one predictable behaviour into a table an operator had to memorise
9//! (issue #294).
10//!
11//! The rule is now stated once, here: act on the router this machine is
12//! pointed at, and where an operation genuinely has no remote form, say so and
13//! name the target rather than silently answering about local state.
14//!
15//! `TOKEN_SECRET` belongs to the deployment that signs tokens. A workstation
16//! driving a remote router authenticates with an admin token instead, so the
17//! signing secret has no reason to be there — requiring it pushed operators
18//! toward copying it off the host, which is the opposite of what the
19//! admin-token design is for.
20
21use std::process::ExitCode;
22
23use crate::cli::{AuthTarget, Command};
24use crate::managed_server::ResolvedServer;
25
26/// Which router a command acts on.
27///
28/// Deliberately not `Debug`: [`ResolvedServer`] holds the admin credential,
29/// and a derived formatter is the easiest way for one to reach a log.
30pub enum Target {
31 /// No selection, or one declined with `--local` / `--managed`.
32 Local,
33 /// A selected or explicitly named deployment.
34 Remote(Box<ResolvedServer>),
35}
36
37/// Resolve the router `target` names, reporting a resolution failure itself.
38///
39/// `Err` carries the exit code to return: an unreachable *named* target is an
40/// error in its own right, because quietly falling back to local state is the
41/// surprise this exists to prevent.
42///
43/// # Errors
44///
45/// Returns the process exit code when a named target cannot be resolved.
46pub async fn resolve(target: &AuthTarget) -> Result<Target, ExitCode> {
47 match crate::auth_remote::target_for(
48 target.local,
49 target.managed,
50 target.server.as_deref(),
51 target.management_server.as_deref(),
52 )
53 .await
54 {
55 Ok(Some(server)) => Ok(Target::Remote(Box::new(server))),
56 Ok(None) => Ok(Target::Local),
57 Err(error) => {
58 eprintln!("error: {error}");
59 Err(ExitCode::from(1))
60 }
61 }
62}
63
64/// The target flags a command carries, when it has them.
65///
66/// Reading them off `Command` rather than threading them through each family's
67/// dispatch keeps the rule in one place, which is the point of issue #294.
68#[must_use]
69pub const fn target_of(command: &Command) -> Option<&AuthTarget> {
70 match command {
71 Command::Tokens { op } => Some(op.target()),
72 Command::Accounts { op } => Some(op.target()),
73 Command::Providers { op } => Some(op.target()),
74 Command::Logs { op } => Some(op.target()),
75 Command::Doctor { target } => Some(target),
76 Command::Usage(args) => Some(&args.target),
77 Command::Tls { op } => Some(op.target()),
78 _ => None,
79 }
80}
81
82/// Whether this invocation may need a router other than the local one.
83///
84/// Answers from the flags alone, so `--local` and `--managed` never contact a
85/// server and never fail because one is unreachable.
86#[must_use]
87pub const fn may_be_remote(command: &Command) -> bool {
88 match target_of(command) {
89 Some(target) => !target.local && !target.managed,
90 None => false,
91 }
92}
93
94/// Refuse `--managed` where nothing can start a container.
95///
96/// The flag says "start a disposable managed container". Only `with` and
97/// `configure` do — they launch or point a client at a router, and starting one
98/// is part of that. Everywhere else it started nothing and quietly meant
99/// `--local`: a second, undocumented synonym whose own description promised
100/// something it never did (issue #315). Saying so beats a silent second
101/// meaning, and `--local` is the flag that was wanted.
102#[must_use]
103pub fn refuse_managed(command: &Command) -> Option<ExitCode> {
104 let name = match command {
105 Command::Tokens { .. } => "tokens",
106 Command::Accounts { .. } => "accounts",
107 Command::Providers { .. } => "providers",
108 Command::Logs { .. } => "logs",
109 Command::Doctor { .. } => "doctor",
110 Command::Tls { .. } => "tls",
111 Command::Usage { .. } => "usage",
112 // `auth` was exempt and did exactly what #315 condemned: nothing on
113 // the auth path ever starts a container, so `--managed` silently meant
114 // `--local` while its own help listed `auth` as a command that uses
115 // one. The same silent second meaning, in the one flag this work
116 // declared fixed.
117 Command::Auth { .. } => "auth",
118 _ => return None,
119 };
120 if !target_of(command).is_some_and(|target| target.managed) {
121 return None;
122 }
123 eprintln!(
124 "error: `--managed` starts a disposable managed container, which only `with` and \
125 `configure` do; `{name}` cannot use one."
126 );
127 eprintln!("note: pass --local to act on this machine, or --server <URL> to name a router.");
128 Some(ExitCode::from(2))
129}
130
131/// Whether this invocation named the local state it wants acted on.
132///
133/// Without a selection, `auth` adopts a router already listening here, because
134/// authorizing locally while a live router is one port away lands the
135/// credential where that router cannot see it (issue #250). That reasoning
136/// does not carry to a command handed `--data-dir` or `--claude-code-home`:
137/// those name *this machine's* state explicitly, and redirecting them to a
138/// discovered router would answer about a different deployment than the one
139/// the operator pointed at — the same wrong-target failure this work exists to
140/// remove.
141///
142/// An explicit `--server` still wins, so naming a router remains the way to
143/// ask for one.
144#[must_use]
145pub const fn names_local_state(cli: &crate::cli::Cli) -> bool {
146 cli.data_dir.is_some() || cli.claude_code_home.is_some() || cli.home.is_some()
147}
148
149/// Let every command that does not serve start without `TOKEN_SECRET`.
150///
151/// The secret signs this machine's tokens and encrypts its provider keys.
152/// Requiring it per *command family* refused to start for commands that sign
153/// nothing — a read-only listing, a certificate, a diagnostic — and the check
154/// was satisfied by any value, so it only taught operators to keep a
155/// deployment's signing secret exported in their shell (issue #308). Worse,
156/// the relaxation was attached to "might be remote", so `--local` — the only
157/// way to state "this machine" out loud — was the spelling that broke.
158///
159/// The requirement now lives where the secret is used: signing, validating and
160/// encrypting all refuse a stand-in and give the ordinary error (issue #300).
161/// That is what makes relaxing here safe rather than merely convenient — the
162/// stand-in cannot be mistaken for a key, so a command that needs one still
163/// fails, and one that does not simply runs.
164///
165/// A secret the operator did supply is never overwritten.
166#[must_use]
167pub fn relax_token_secret_for_cli(mut cli: crate::cli::Cli) -> crate::cli::Cli {
168 let serves = matches!(cli.command, None | Some(Command::Serve));
169 if !serves && cli.token_secret.as_deref().is_none_or(str::is_empty) {
170 cli.token_secret = Some(crate::token_secret::placeholder("cli-command"));
171 }
172 cli
173}
174
175/// Say that an operation has no remote form, naming the router it cannot reach.
176///
177/// The shape issue #284 gave `auth gh`: an error that names the real target is
178/// honest, where one describing local state as though it were the target is
179/// not. `alternative` says what *can* be done instead, because a refusal that
180/// leaves the operator without a next step is only half an answer.
181#[must_use]
182pub fn no_remote_form(command: &str, server: &ResolvedServer, alternative: &str) -> Vec<String> {
183 vec![
184 format!(
185 "error: `{command}` reports on the machine it runs on, so it cannot answer for {} \
186 from here.",
187 server.base_url
188 ),
189 format!("note: {alternative}"),
190 String::from("note: pass --local to report on this machine instead."),
191 ]
192}
193
194/// Print a refusal and return its exit code.
195#[must_use]
196pub fn refuse(lines: Vec<String>) -> ExitCode {
197 for line in lines {
198 eprintln!("{line}");
199 }
200 ExitCode::from(1)
201}
202
203#[cfg(test)]
204#[path = "remote_command_tests.rs"]
205mod tests;