1use std::{
17 io::{Read, Write},
18 path::{Path, PathBuf},
19};
20
21use anyhow::{Context, bail};
22use rmcp::{
23 ServiceExt,
24 model::{CallToolRequestParams, ClientConfig},
25 transport::{
26 StreamableHttpClientTransport, streamable_http_client::StreamableHttpClientTransportConfig,
27 },
28};
29use serde_json::{Value, json};
30use uuid::Uuid;
31
32use crate::auth::ADMIN_TOKEN_PREFIX;
33
34pub const CONFIG_FILE: &str = "admin";
37
38pub fn config_dir() -> anyhow::Result<PathBuf> {
42 if let Some(dir) = std::env::var_os("BUS_CONFIG_DIR").filter(|v| !v.is_empty()) {
43 return Ok(PathBuf::from(dir));
44 }
45 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
46 return Ok(PathBuf::from(xdg).join("ai-crew-sync"));
47 }
48 let home = std::env::var_os("HOME")
49 .filter(|v| !v.is_empty())
50 .context("neither BUS_CONFIG_DIR, XDG_CONFIG_HOME nor HOME is set")?;
51 Ok(PathBuf::from(home).join(".config").join("ai-crew-sync"))
52}
53
54#[derive(Clone, Debug)]
56pub struct AdminConfig {
57 pub url: String,
59 pub token: String,
60}
61
62impl AdminConfig {
63 pub fn mcp_url(&self) -> String {
64 format!("{}/mcp", self.url)
65 }
66}
67
68pub fn normalize_base_url(raw: &str) -> anyhow::Result<String> {
71 let mut url = raw.trim().trim_end_matches('/').to_owned();
72 for suffix in ["/mcp", "/admin"] {
73 if let Some(stripped) = url.strip_suffix(suffix) {
74 url = stripped.trim_end_matches('/').to_owned();
75 }
76 }
77 if !(url.starts_with("http://") || url.starts_with("https://")) {
78 bail!("--url must start with http:// or https:// (got '{raw}')");
79 }
80 if url.len() <= "https://".len() {
81 bail!("--url has no host (got '{raw}')");
82 }
83 Ok(url)
84}
85
86fn config_path(dir: &Path) -> PathBuf {
87 dir.join(CONFIG_FILE)
88}
89
90pub fn load_config(dir: &Path) -> anyhow::Result<AdminConfig> {
93 let env_url = std::env::var("BUS_ADMIN_URL")
94 .ok()
95 .filter(|v| !v.trim().is_empty());
96 let env_token = std::env::var("BUS_ADMIN_TOKEN")
97 .ok()
98 .filter(|v| !v.trim().is_empty());
99 if let (Some(url), Some(token)) = (&env_url, &env_token) {
100 return Ok(AdminConfig {
101 url: normalize_base_url(url)?,
102 token: token.trim().to_owned(),
103 });
104 }
105
106 let path = config_path(dir);
107 let text = match std::fs::read_to_string(&path) {
108 Ok(t) => t,
109 Err(e) if e.kind() == std::io::ErrorKind::NotFound => bail!(
110 "not logged in: run `ai-crew-sync admin login --url <bus>` first \
111 (or set BUS_ADMIN_URL and BUS_ADMIN_TOKEN)"
112 ),
113 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
114 };
115 let mut url = None;
116 let mut token = None;
117 for line in text.lines() {
118 let line = line.trim();
119 if line.is_empty() || line.starts_with('#') {
120 continue;
121 }
122 match line.split_once('=') {
123 Some(("url", v)) => url = Some(v.trim().to_owned()),
124 Some(("token", v)) => token = Some(v.trim().to_owned()),
125 _ => {}
126 }
127 }
128 let url = env_url
129 .or(url)
130 .with_context(|| format!("{} has no url= line; log in again", path.display()))?;
131 let token = env_token
132 .or(token)
133 .with_context(|| format!("{} has no token= line; log in again", path.display()))?;
134 Ok(AdminConfig {
135 url: normalize_base_url(&url)?,
136 token,
137 })
138}
139
140pub fn write_private(path: &Path, content: &str) -> anyhow::Result<()> {
143 let dir = path.parent().context("path has no parent directory")?;
144 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
145 let tmp = dir.join(format!(
146 ".{}.{}.tmp",
147 path.file_name().and_then(|n| n.to_str()).unwrap_or("file"),
148 std::process::id()
149 ));
150 let mut options = std::fs::OpenOptions::new();
151 options.write(true).create(true).truncate(true);
152 #[cfg(unix)]
153 {
154 use std::os::unix::fs::OpenOptionsExt;
155 options.mode(0o600);
156 }
157 let result = (|| -> anyhow::Result<()> {
158 let mut f = options
159 .open(&tmp)
160 .with_context(|| format!("creating {}", tmp.display()))?;
161 f.write_all(content.as_bytes())?;
162 f.sync_all()?;
163 #[cfg(unix)]
164 {
165 use std::os::unix::fs::PermissionsExt;
166 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
169 }
170 std::fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
171 Ok(())
172 })();
173 if result.is_err() {
174 let _ = std::fs::remove_file(&tmp);
175 }
176 result
177}
178
179pub fn save_config(dir: &Path, cfg: &AdminConfig) -> anyhow::Result<PathBuf> {
180 let path = config_path(dir);
181 write_private(
182 &path,
183 &format!(
184 "# ai-crew-sync administrative credential — written by `admin login`\nurl={}\ntoken={}\n",
185 cfg.url, cfg.token
186 ),
187 )?;
188 Ok(path)
189}
190
191pub fn remove_config(dir: &Path) -> anyhow::Result<bool> {
192 match std::fs::remove_file(config_path(dir)) {
193 Ok(()) => Ok(true),
194 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
195 Err(e) => Err(e.into()),
196 }
197}
198
199const MAX_THROTTLE_RETRIES: u32 = 5;
204const MAX_THROTTLE_WAIT_SECS: u64 = 5;
205
206pub struct Api {
208 http: reqwest::Client,
209 cfg: AdminConfig,
210}
211
212impl Api {
213 pub fn new(cfg: AdminConfig) -> Self {
214 Self {
215 http: reqwest::Client::new(),
216 cfg,
217 }
218 }
219
220 pub fn config(&self) -> &AdminConfig {
221 &self.cfg
222 }
223
224 async fn call(
225 &self,
226 method: reqwest::Method,
227 path: &str,
228 body: Option<Value>,
229 ) -> anyhow::Result<Value> {
230 let url = format!("{}/admin{path}", self.cfg.url);
231 for attempt in 0..MAX_THROTTLE_RETRIES {
236 let mut req = self
237 .http
238 .request(method.clone(), &url)
239 .header("Authorization", format!("Bearer {}", self.cfg.token));
240 if let Some(body) = &body {
241 req = req.json(body);
242 }
243 let resp = req
244 .send()
245 .await
246 .with_context(|| format!("could not reach {url}"))?;
247 let status = resp.status();
248 if status == reqwest::StatusCode::TOO_MANY_REQUESTS
249 && attempt + 1 < MAX_THROTTLE_RETRIES
250 {
251 let wait = resp
252 .headers()
253 .get(reqwest::header::RETRY_AFTER)
254 .and_then(|v| v.to_str().ok())
255 .and_then(|v| v.parse::<u64>().ok())
256 .unwrap_or(1)
257 .clamp(1, MAX_THROTTLE_WAIT_SECS);
258 tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
259 continue;
260 }
261 let text = resp.text().await.unwrap_or_default();
262 let value: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
263 if status.is_success() {
264 return Ok(value);
265 }
266 let msg = value["error"]
267 .as_str()
268 .map(str::to_owned)
269 .unwrap_or_else(|| text.trim().to_owned());
270 bail!("{method} {path} failed ({status}): {msg}");
271 }
272 unreachable!("the retry loop returns on its last attempt")
273 }
274
275 pub async fn whoami(&self) -> anyhow::Result<Value> {
276 self.call(reqwest::Method::GET, "/whoami", None).await
277 }
278 pub async fn list_teams(&self) -> anyhow::Result<Value> {
279 self.call(reqwest::Method::GET, "/teams", None).await
280 }
281 pub async fn create_team(&self, slug: &str, name: Option<&str>) -> anyhow::Result<Value> {
282 self.call(
283 reqwest::Method::POST,
284 "/teams",
285 Some(json!({ "slug": slug, "name": name })),
286 )
287 .await
288 }
289 pub async fn list_agents(&self, team: &str) -> anyhow::Result<Value> {
290 self.call(reqwest::Method::GET, &format!("/teams/{team}/agents"), None)
291 .await
292 }
293 pub async fn create_agent(
294 &self,
295 team: &str,
296 name: &str,
297 display_name: Option<&str>,
298 ) -> anyhow::Result<Value> {
299 self.call(
300 reqwest::Method::POST,
301 &format!("/teams/{team}/agents"),
302 Some(json!({ "name": name, "display_name": display_name })),
303 )
304 .await
305 }
306 pub async fn list_tokens(&self, team: &str) -> anyhow::Result<Value> {
307 self.call(reqwest::Method::GET, &format!("/teams/{team}/tokens"), None)
308 .await
309 }
310 pub async fn issue_token(
311 &self,
312 team: &str,
313 agent: &str,
314 label: Option<&str>,
315 ) -> anyhow::Result<Issued> {
316 let v = self
317 .call(
318 reqwest::Method::POST,
319 &format!("/teams/{team}/tokens"),
320 Some(json!({ "agent": agent, "label": label })),
321 )
322 .await?;
323 let t = &v["token"];
324 let field = |k: &str| {
325 t[k].as_str()
326 .map(str::to_owned)
327 .with_context(|| format!("server response has no token.{k}"))
328 };
329 Ok(Issued {
330 id: field("id")?.parse().context("token.id is not a UUID")?,
331 token: field("token")?,
332 agent: field("agent")?,
333 team: field("team")?,
334 })
335 }
336 pub async fn revoke_token(&self, team: &str, id: Uuid) -> anyhow::Result<Value> {
337 self.call(
338 reqwest::Method::DELETE,
339 &format!("/teams/{team}/tokens/{id}"),
340 None,
341 )
342 .await
343 }
344 pub async fn list_credentials(&self) -> anyhow::Result<Value> {
345 self.call(reqwest::Method::GET, "/credentials", None).await
346 }
347 pub async fn grant_credential(
348 &self,
349 team: Option<&str>,
350 label: Option<&str>,
351 ) -> anyhow::Result<Value> {
352 self.call(
353 reqwest::Method::POST,
354 "/credentials",
355 Some(json!({ "team": team, "label": label })),
356 )
357 .await
358 }
359 pub async fn revoke_credential(&self, id: Uuid) -> anyhow::Result<Value> {
360 self.call(reqwest::Method::DELETE, &format!("/credentials/{id}"), None)
361 .await
362 }
363}
364
365#[derive(Clone, Debug)]
367pub struct Issued {
368 pub id: Uuid,
369 pub token: String,
370 pub agent: String,
371 pub team: String,
372}
373
374pub fn read_credential(from_stdin: bool) -> anyhow::Result<String> {
379 let raw = if from_stdin {
380 let mut s = String::new();
381 std::io::stdin()
382 .read_to_string(&mut s)
383 .context("reading the credential from stdin")?;
384 s
385 } else {
386 rpassword::prompt_password("Administrative credential (acsa_…): ")
387 .context("reading the credential from the terminal")?
388 };
389 let token = raw.trim().to_owned();
390 if token.is_empty() {
391 bail!("no credential given");
392 }
393 if !token.starts_with(ADMIN_TOKEN_PREFIX) {
394 bail!(
395 "that is not an administrative credential (expected the {ADMIN_TOKEN_PREFIX} \
396 prefix). Agent tokens cannot administer the bus; mint a credential with \
397 `ai-crew-sync admin bootstrap` next to Postgres, or ask a global administrator \
398 for `admin grant`"
399 );
400 }
401 Ok(token)
402}
403
404pub async fn login(dir: &Path, url: &str, token: String) -> anyhow::Result<(Value, PathBuf)> {
407 let cfg = AdminConfig {
408 url: normalize_base_url(url)?,
409 token,
410 };
411 let me = Api::new(cfg.clone())
412 .whoami()
413 .await
414 .context("the bus did not accept this credential; nothing was saved")?;
415 let path = save_config(dir, &cfg)?;
416 Ok((me, path))
417}
418
419pub async fn whoami_on_mcp(mcp_url: &str, token: &str) -> anyhow::Result<(String, String)> {
424 let mut config = StreamableHttpClientTransportConfig::with_uri(mcp_url.to_owned());
425 config.auth_header = Some(token.to_owned());
426 config.allow_stateless = true;
427 let transport = StreamableHttpClientTransport::from_config(config);
428 let client = ClientConfig::default()
429 .serve(transport)
430 .await
431 .context("the new token could not open an MCP session on the bus")?;
432 let outcome = async {
433 let result = client
434 .call_tool(CallToolRequestParams::new("whoami"))
435 .await
436 .context("whoami failed with the new token")?;
437 let v = result
438 .structured_content
439 .context("whoami returned no structured content")?;
440 let agent = v["agent"]
441 .as_str()
442 .context("whoami has no agent")?
443 .to_owned();
444 let team = v["team"].as_str().context("whoami has no team")?.to_owned();
445 Ok::<_, anyhow::Error>((agent, team))
446 }
447 .await;
448 let _ = client.cancel().await;
449 outcome
450}
451
452#[derive(Clone, Debug)]
455pub struct SaveTarget {
456 pub dir: PathBuf,
457 pub repo: String,
458}
459
460pub fn validate_repo_name(raw: &str) -> anyhow::Result<String> {
463 let name = raw.trim();
464 if name == "_base" {
465 return Ok(name.to_owned());
466 }
467 let ok = !name.is_empty()
468 && name.len() <= 128
469 && name
470 .chars()
471 .next()
472 .is_some_and(|c| c.is_ascii_alphanumeric())
473 && name
474 .chars()
475 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
476 if !ok {
477 bail!(
478 "--repo '{raw}' is not a valid entry name: use letters, digits, '-', '_' and '.', \
479 starting with a letter or digit (or exactly `_base` for the fallback entry)"
480 );
481 }
482 Ok(name.to_owned())
483}
484
485pub fn tokens_file(dir: &Path, team: &str) -> PathBuf {
486 dir.join(format!("tokens-{team}"))
487}
488
489pub fn upsert_token_entry(path: &Path, name: &str, token: &str) -> anyhow::Result<()> {
495 let existing = match std::fs::read_to_string(path) {
496 Ok(t) => t,
497 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
498 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
499 };
500 let mut out = Vec::new();
501 let mut replaced = false;
502 for line in existing.lines() {
503 let is_entry = line.split_once('=').is_some_and(|(k, _)| k.trim() == name);
504 if is_entry {
505 if !replaced {
506 out.push(format!("{name}={token}"));
507 replaced = true;
508 }
509 continue;
510 }
511 out.push(line.to_owned());
512 }
513 if !replaced {
514 out.push(format!("{name}={token}"));
515 }
516 let mut content = out.join("\n");
517 content.push('\n');
518 write_private(path, &content)
519}
520
521pub async fn finish_issue(
526 api: &Api,
527 issued: &Issued,
528 expected_agent: &str,
529 expected_team: &str,
530 save: Option<&SaveTarget>,
531) -> anyhow::Result<Option<PathBuf>> {
532 let expected_agent = expected_agent.trim().to_lowercase();
533 let expected_team = expected_team.trim().to_lowercase();
534 let mismatch = |what: String| {
538 let revoke_in = expected_team.clone();
539 async move {
540 let cleanup = match api.revoke_token(&revoke_in, issued.id).await {
541 Ok(_) => "the token has been revoked".to_owned(),
542 Err(e) => format!(
543 "and revoking it FAILED ({e}); revoke token {} by hand",
544 issued.id
545 ),
546 };
547 anyhow::anyhow!("{what}; {cleanup}. Nothing was saved or printed.")
548 }
549 };
550
551 let (agent, team) = match whoami_on_mcp(&api.config().mcp_url(), &issued.token).await {
552 Ok(identity) => identity,
553 Err(e) => return Err(mismatch(format!("could not verify the new token: {e:#}")).await),
554 };
555 if agent != expected_agent || team != expected_team {
556 return Err(mismatch(format!(
557 "the new token authenticates as {agent}@{team}, not {expected_agent}@{expected_team}"
558 ))
559 .await);
560 }
561 if issued.agent != agent || issued.team != team {
562 return Err(mismatch(format!(
563 "the server reported the token as {}@{} but it authenticates as {agent}@{team}",
564 issued.agent, issued.team
565 ))
566 .await);
567 }
568
569 match save {
570 Some(target) => {
571 let path = tokens_file(&target.dir, &team);
572 if let Err(e) = upsert_token_entry(&path, &target.repo, &issued.token) {
573 return Err(mismatch(format!(
576 "the token was verified but could not be saved to {}: {e:#}",
577 path.display()
578 ))
579 .await);
580 }
581 Ok(Some(path))
582 }
583 None => Ok(None),
584 }
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn base_url_is_normalised_whatever_path_was_pasted() {
593 for raw in [
594 "https://crew.example.com:8443",
595 "https://crew.example.com:8443/",
596 "https://crew.example.com:8443/mcp",
597 "https://crew.example.com:8443/admin/",
598 " https://crew.example.com:8443/mcp/ ",
599 ] {
600 assert_eq!(
601 normalize_base_url(raw).unwrap(),
602 "https://crew.example.com:8443",
603 "{raw}"
604 );
605 }
606 assert!(normalize_base_url("crew.example.com").is_err());
607 assert!(normalize_base_url("https://").is_err());
608 }
609
610 #[test]
611 fn repo_names_are_one_safe_word() {
612 assert_eq!(validate_repo_name(" backend ").unwrap(), "backend");
613 assert_eq!(validate_repo_name("_base").unwrap(), "_base");
614 assert_eq!(validate_repo_name("0dte-api.v2").unwrap(), "0dte-api.v2");
615 for bad in ["", "_other", "-x", "a b", "a=b", "../etc", "a/b", "ünicode"] {
616 assert!(validate_repo_name(bad).is_err(), "{bad:?}");
617 }
618 }
619
620 #[test]
621 fn upsert_keeps_every_other_line_and_collapses_duplicates() {
622 let dir = std::env::temp_dir().join(format!("acs-upsert-{}", Uuid::new_v4()));
623 let path = dir.join("tokens-acme");
624 upsert_token_entry(&path, "backend", "acs_1").unwrap();
625 assert_eq!(std::fs::read_to_string(&path).unwrap(), "backend=acs_1\n");
626
627 std::fs::write(
628 &path,
629 "# hand-written\n_base=acs_base\nbackend=acs_old\n\nweb=acs_web\nbackend=acs_dup\n",
630 )
631 .unwrap();
632 upsert_token_entry(&path, "backend", "acs_new").unwrap();
633 assert_eq!(
634 std::fs::read_to_string(&path).unwrap(),
635 "# hand-written\n_base=acs_base\nbackend=acs_new\n\nweb=acs_web\n"
636 );
637 upsert_token_entry(&path, "docs", "acs_docs").unwrap();
638 assert!(
639 std::fs::read_to_string(&path)
640 .unwrap()
641 .ends_with("web=acs_web\ndocs=acs_docs\n")
642 );
643 #[cfg(unix)]
644 {
645 use std::os::unix::fs::PermissionsExt;
646 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
647 assert_eq!(mode, 0o600);
648 assert!(
649 std::fs::read_dir(&dir).unwrap().count() == 1,
650 "no temp file left"
651 );
652 }
653 let _ = std::fs::remove_dir_all(&dir);
654 }
655}