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