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
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
//! Permanent client setup: one name, one targeting rule, one reversal.
//!
//! `configure` answers the question `clients setup` and `with --global`
//! answered differently. It acts on the router this machine is pointed at,
//! rather than on this CLI's own `--host`/`--port` default; it stores the
//! credential it minted from that router, so the client works when the command
//! returns rather than after the operator sets an environment variable; and it
//! is reversed by its own name, through the hash-verified restore that was the
//! better of the two mechanisms already present (issue #296).
use std::process::ExitCode;
use crate::cli::ConfigureArgs;
use crate::clients::{ClientKind, ClientManager, ManagedCredential, TokenSource};
use crate::managed_server::{ResolvedServer, prepare_run_credential, resolve};
type AnyError = Box<dyn std::error::Error + Send + Sync>;
/// Why a client cannot be pointed at the router by writing a file.
///
/// Reported and skipped rather than treated as a failure: a workstation being
/// pointed at a deployment wants the clients that can be configured, and being
/// told which ones cannot is more useful than stopping at the first.
fn unconfigurable(client: ClientKind) -> Option<String> {
match client {
ClientKind::Cursor | ClientKind::GeminiCli => Some(
client
.setup_limitation()
.unwrap_or("this client cannot be configured through a file")
.to_string(),
),
_ => None,
}
}
/// Whether this client is configured only by its shell environment.
///
/// Grok CLI has no persistent base-URL setting, so the credential file is the
/// whole configuration. Refusing it outright, as `with --global` did, withheld
/// the half that does work.
const fn environment_only(client: ClientKind) -> bool {
matches!(client, ClientKind::GrokCli)
}
/// Run one `router configure` invocation.
pub async fn run(args: &ConfigureArgs) -> ExitCode {
match run_inner(args).await {
Ok(code) => code,
Err(error) => {
eprintln!(
"error: {}",
crate::login_url::redact_secrets(&error.to_string())
);
ExitCode::from(1)
}
}
}
async fn run_inner(args: &ConfigureArgs) -> Result<ExitCode, AnyError> {
let manager = ClientManager::from_env()?;
if args.undo {
return undo(args, &manager).await;
}
let explicit_token = if args.token_stdin {
Some(crate::server_command::read_token()?)
} else {
args.token.clone()
};
let server = target(args, explicit_token).await?;
println!("router: {} (from {})", server.base_url, server.source);
let mut configured = 0_usize;
let mut skipped = Vec::new();
let mut failed = Vec::new();
for client in args.clients() {
if let Some(reason) = unconfigurable(client) {
skipped.push((client, reason));
continue;
}
if args.all && !manager.status(client).is_ok_and(|status| status.installed) {
skipped.push((client, "not installed on this machine".to_string()));
continue;
}
match configure_one(args, &manager, &server, client).await {
Ok(()) => configured += 1,
// `--all` reports every client rather than stopping at the first
// that refuses: a failure on one says nothing about the rest.
Err(error) if args.all => failed.push((client, error.to_string())),
Err(error) => return Err(error),
}
}
for (client, reason) in &skipped {
println!("skipped {}: {reason}", client.display_name());
}
for (client, error) in &failed {
eprintln!("error: {}: {error}", client.display_name());
}
if args.all {
println!("configured {configured} client(s); undo: router configure --undo <CLIENT>");
}
Ok(if failed.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
})
}
/// Which router the client is pointed at.
///
/// The rule settled in issue #294: act on the router this machine is pointed
/// at. `--local` declines a remote selection and uses the router running here,
/// `--server` names one, and the default follows the selection — which is what
/// `clients setup` did not do, writing this CLI's own listen address into a
/// client while a different router was selected.
async fn target(
args: &ConfigureArgs,
explicit_token: Option<String>,
) -> Result<ResolvedServer, AnyError> {
if args.target.local {
let mut server = crate::managed_server::discovered_local_router()
.await
.ok_or("no router is listening on this machine; start one with `router serve`, or drop --local to use the selected server")?;
if let Some(token) = explicit_token {
server.token = Some(token);
}
return Ok(server);
}
let server = resolve(
args.target.server.as_deref(),
explicit_token,
None,
args.target.managed,
)
.await?;
if server.source == "managed local container" {
crate::managed_server::start_managed()?;
}
Ok(server)
}
async fn configure_one(
args: &ConfigureArgs,
manager: &ClientManager,
server: &ResolvedServer,
client: ClientKind,
) -> Result<(), AnyError> {
// Minted from the target, by the rule `clients setup` already used, and
// stored outside the client's configuration at 0600. `with --global`
// stopped short of this and told the user to set a variable themselves,
// which means the command did half its job (issue #296).
let credential =
prepare_run_credential(server, &format!("configure-{client}"), args.ttl_hours).await?;
let record = ManagedCredential {
client: client.to_string(),
// Minted only when this command actually issued one: a token the
// operator supplied is often shared with other machines, so `--undo`
// must not take it away from them.
//
// Asked of the credential rather than of the arguments. `--token
// <admin>` still *mints* a year-long run token, which the argument
// test recorded as supplied and leaked; and a non-admin token reaching
// the resolver from the environment or a persisted selection is reused
// verbatim, which the same test recorded as minted and would have
// revoked out from under every other machine sharing it.
source: if credential.was_minted() {
TokenSource::Minted
} else {
TokenSource::Supplied
},
token_id: credential.id(),
label: Some(format!("configure-{client}")),
issued_at: Some(chrono::Utc::now().timestamp()),
router: Some(server.base_url.clone()),
};
if !environment_only(client) {
let models = crate::clients::usable_models(client, credential.models());
let path = crate::client_global::apply(client, &server.base_url, &models)?;
println!("configured {} in {}", client.display_name(), path.display());
}
let environment = manager.write_environment(client, &server.base_url, &credential.token)?;
manager.write_credential_metadata(client, &record)?;
println!("credentials: {} (mode 0600)", environment.display());
if environment_only(client) {
println!(
"{} has no persistent base-URL setting, so the exports above are the whole \
configuration; source them from your shell profile",
client.display_name()
);
}
println!("undo: router configure --undo {client}");
Ok(())
}
async fn undo(args: &ConfigureArgs, manager: &ClientManager) -> Result<ExitCode, AnyError> {
let mut restored = 0_usize;
let mut failed = Vec::new();
for client in args.clients() {
// The same check `configure` itself makes. Reversal used to report
// success for a client whose configuration the router can never have
// written (issue #303).
if let Some(reason) = unconfigurable(client) {
if args.all {
continue;
}
return Err(reason.into());
}
match undo_one(args, manager, client).await {
Ok(true) => restored += 1,
Ok(false) => {}
// `--all` reverses every client rather than stopping at the first
// that refuses. A hash-verified restore declining one hand-edited
// config is an expected outcome, and letting it abort the loop left
// every later client still pointed at the router with its
// credential in place, and said nothing about which.
Err(error) if args.all => failed.push((client, error.to_string())),
Err(error) => return Err(error),
}
}
for (client, error) in &failed {
eprintln!("error: {}: {error}", client.display_name());
}
if args.all {
println!("restored {restored} client(s)");
}
Ok(if failed.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
})
}
/// Reverse one client, reporting whether anything was actually restored.
async fn undo_one(
args: &ConfigureArgs,
manager: &ClientManager,
client: ClientKind,
) -> Result<bool, AnyError> {
let record = manager.credential_metadata(client).ok().flatten();
// The configuration comes back first. A hash-verified restore can
// refuse — an edit made after `configure` is preserved rather than
// overwritten — and revoking there would strip the credential from a
// setup that is still in place and still working.
let config = crate::client_global::undo(client)?;
// Then the token, before the file that holds it is deleted. Deleting
// first leaves a live credential nobody can name any more, which is
// the regression from issue #190 — and `configure` mints for a year,
// so the window is not a short one.
if let Some(record) = record
.as_ref()
.filter(|record| record.revocable_by_default())
{
report_revocation(args, record).await;
}
let environment = manager.environment_path(client);
let had_credential = environment.exists();
if had_credential {
std::fs::remove_file(&environment)?;
}
let metadata = manager.credential_metadata_path(client);
if metadata.exists() {
std::fs::remove_file(&metadata)?;
}
match config {
Some(path) => {
println!("restored {} exactly", path.display());
Ok(true)
}
None if had_credential => {
println!(
"removed the stored credential for {}",
client.display_name()
);
Ok(true)
}
None if !args.all => Err(format!(
"no configuration saved by `configure` exists for {client}; nothing was restored"
)
.into()),
None => Ok(false),
}
}
/// Revoke the credential this client was configured with, or say why not.
///
/// A failure here is reported rather than fatal, and always names the token
/// and the router holding it: the local files still have to come off, and an
/// operator left without the id cannot finish the job by hand.
async fn report_revocation(args: &ConfigureArgs, record: &ManagedCredential) {
let (Some(router), Some(id)) = (record.router.as_deref(), record.token_id.as_deref()) else {
return;
};
let Some(admin) = admin_token_for(args, router) else {
println!(
"note: token {id} on {router} was left in place; revoke it with \
`router tokens revoke {id} --server {router}`"
);
return;
};
match crate::managed_server::revoke(router, &admin, id).await {
Ok(()) => println!("revoked token {id} on {router}"),
Err(error) => {
eprintln!("warning: {error}");
println!(
"note: token {id} on {router} is still live; revoke it with \
`router tokens revoke {id} --server {router}`"
);
}
}
}
/// A credential able to revoke on `router`, without starting anything.
///
/// Undo removes local files and must keep working offline, so this never
/// resolves a target: it asks only what is already to hand.
fn admin_token_for(args: &ConfigureArgs, router: &str) -> Option<String> {
if let Some(token) = args.token.clone() {
return Some(token);
}
if let Ok(token) = std::env::var("LINK_ASSISTANT_ROUTER_TOKEN")
.or_else(|_| std::env::var("LINK_ASSISTANT_TOKEN"))
{
return Some(token);
}
crate::managed_server::load_persisted()
.ok()
.flatten()
.filter(|persisted| persisted.server.trim_end_matches('/') == router.trim_end_matches('/'))
.and_then(|persisted| persisted.token)
}
#[cfg(test)]
#[path = "configure_tests.rs"]
mod tests;