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