1use std::{
31 collections::BTreeMap,
32 path::{Path, PathBuf},
33};
34
35use anyhow::{Context, bail};
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38
39pub use crate::admin_cli::{config_dir, write_private};
40use crate::auth::TOKEN_PREFIX;
41
42pub const PROFILES_FILE: &str = "profiles.toml";
43pub const PROJECT_FILE: &str = ".acs.toml";
44pub const BASE_KEY: &str = "_base";
46pub const DEFAULT_MCP_URL: &str = "http://localhost:8787/mcp";
48
49const MAX_ASCENT: usize = 64;
52
53#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
58pub struct Profile {
59 pub url: String,
62 pub team: String,
65 pub agent: String,
67 pub tokens: String,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub key: Option<String>,
75}
76
77#[derive(Clone, Debug, Default, Deserialize, Serialize)]
78pub struct Profiles {
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub default: Option<String>,
82 #[serde(default)]
83 pub profiles: BTreeMap<String, Profile>,
84}
85
86fn profiles_path(dir: &Path) -> PathBuf {
87 dir.join(PROFILES_FILE)
88}
89
90pub fn validate_name(what: &str, raw: &str) -> anyhow::Result<String> {
93 let name = raw.trim();
94 let ok = !name.is_empty()
95 && name.len() <= 64
96 && name
97 .chars()
98 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
99 && !name.starts_with('.');
100 if !ok {
101 bail!(
102 "{what} '{raw}' is not valid: use letters, digits, '-', '_' and '.' \
103 (up to 64, not starting with '.')"
104 );
105 }
106 Ok(name.to_owned())
107}
108
109pub fn validate_tokens_ref(raw: &str) -> anyhow::Result<String> {
112 let name = raw.trim();
113 if name.is_empty()
114 || name.contains(['/', '\\'])
115 || name == "."
116 || name == ".."
117 || name.starts_with('.')
118 {
119 bail!(
120 "tokens file '{raw}' must be a bare file name inside the configuration \
121 directory, such as tokens-acme"
122 );
123 }
124 Ok(name.to_owned())
125}
126
127pub fn load_profiles(dir: &Path) -> anyhow::Result<Profiles> {
128 let path = profiles_path(dir);
129 let text = match std::fs::read_to_string(&path) {
130 Ok(t) => t,
131 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Profiles::default()),
132 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
133 };
134 let parsed: Profiles =
135 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
136 for (name, p) in &parsed.profiles {
137 validate_name("profile name", name)?;
138 validate_tokens_ref(&p.tokens)?;
139 }
140 Ok(parsed)
141}
142
143pub fn with_config_lock<T>(dir: &Path, f: impl FnOnce() -> anyhow::Result<T>) -> anyhow::Result<T> {
147 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
148 let lock_path = dir.join(".lock");
149 let lock = std::fs::OpenOptions::new()
150 .create(true)
151 .truncate(false)
152 .write(true)
153 .open(&lock_path)
154 .with_context(|| format!("opening {}", lock_path.display()))?;
155 lock.lock()
156 .with_context(|| format!("locking {}", lock_path.display()))?;
157 let out = f();
158 let _ = lock.unlock();
159 out
160}
161
162pub fn save_profiles(dir: &Path, profiles: &Profiles) -> anyhow::Result<PathBuf> {
163 let path = profiles_path(dir);
164 let text = toml::to_string_pretty(profiles).context("serialising profiles")?;
165 let header = "# ai-crew-sync connection profiles — no secrets here; tokens live in the\n\
166 # tokens-<team> files this refers to. Edit with `ai-crew-sync context profile`.\n";
167 write_private(&path, &format!("{header}{text}"))?;
168 Ok(path)
169}
170
171pub fn update_profiles(
173 dir: &Path,
174 f: impl FnOnce(&mut Profiles) -> anyhow::Result<()>,
175) -> anyhow::Result<PathBuf> {
176 with_config_lock(dir, || {
177 let mut profiles = load_profiles(dir)?;
178 f(&mut profiles)?;
179 save_profiles(dir, &profiles)
180 })
181}
182
183#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
192#[serde(deny_unknown_fields)]
193pub struct ProjectConfig {
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub profile: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub project: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub channel: Option<String>,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub key: Option<String>,
206}
207
208const FORBIDDEN_PROJECT_KEYS: [&str; 6] =
212 ["url", "endpoint", "token", "tokens", "bearer", "secret"];
213
214pub fn parse_project_file(text: &str, path: &Path) -> anyhow::Result<ProjectConfig> {
215 let table: toml::Table =
216 toml::from_str(text).with_context(|| format!("parsing {}", path.display()))?;
217 for key in FORBIDDEN_PROJECT_KEYS {
218 if table.contains_key(key) {
219 bail!(
220 "{} sets '{key}', which a repository may not do: endpoints and credentials \
221 come from your local profiles only (`ai-crew-sync context profile add`). \
222 Remove the key",
223 path.display()
224 );
225 }
226 }
227 let cfg: ProjectConfig = table.try_into().with_context(|| {
228 format!(
229 "{} has a key this version does not accept. A project file may only set \
230 profile, project, channel and key — never an endpoint or a credential",
231 path.display()
232 )
233 })?;
234 if let Some(p) = &cfg.profile {
235 validate_name("profile name", p)?;
236 }
237 if let Some(p) = &cfg.project {
238 validate_name("project name", p)?;
239 }
240 if let Some(k) = &cfg.key {
241 validate_name("token key", k)?;
242 }
243 Ok(cfg)
244}
245
246pub fn find_project(start: &Path) -> anyhow::Result<Option<(PathBuf, ProjectConfig)>> {
251 let start = start
252 .canonicalize()
253 .with_context(|| format!("resolving {}", start.display()))?;
254 let mut dir: Option<&Path> = Some(&start);
255 let mut worktree_main: Option<PathBuf> = None;
256 for _ in 0..MAX_ASCENT {
257 let Some(d) = dir else { break };
258 let candidate = d.join(PROJECT_FILE);
259 if candidate.is_file() {
260 let text = std::fs::read_to_string(&candidate)
261 .with_context(|| format!("reading {}", candidate.display()))?;
262 return Ok(Some((
263 d.to_path_buf(),
264 parse_project_file(&text, &candidate)?,
265 )));
266 }
267 let dot_git = d.join(".git");
270 if dot_git.is_file() && worktree_main.is_none() {
271 worktree_main = main_worktree_of(&dot_git);
272 }
273 if dot_git.is_dir() {
274 break;
275 }
276 if dot_git.is_file() {
277 break;
278 }
279 dir = d.parent();
280 }
281 if let Some(main) = worktree_main {
282 let candidate = main.join(PROJECT_FILE);
283 if candidate.is_file() {
284 let text = std::fs::read_to_string(&candidate)
285 .with_context(|| format!("reading {}", candidate.display()))?;
286 return Ok(Some((main, parse_project_file(&text, &candidate)?)));
287 }
288 }
289 Ok(None)
290}
291
292fn main_worktree_of(dot_git_file: &Path) -> Option<PathBuf> {
295 let text = std::fs::read_to_string(dot_git_file).ok()?;
296 let gitdir = text.trim().strip_prefix("gitdir:")?.trim();
297 let gitdir = {
298 let p = Path::new(gitdir);
299 if p.is_absolute() {
300 p.to_path_buf()
301 } else {
302 dot_git_file.parent()?.join(p)
303 }
304 };
305 let common = std::fs::read_to_string(gitdir.join("commondir")).ok()?;
306 let common_dir = gitdir.join(common.trim()).canonicalize().ok()?;
307 common_dir.parent().map(Path::to_path_buf)
309}
310
311#[derive(Debug, PartialEq, Eq)]
313pub enum LocalOutcome {
314 Added(PathBuf),
316 AlreadyExcluded(PathBuf),
318 NotARepository,
320}
321
322fn git_common_dir(start: &Path) -> Option<PathBuf> {
325 let mut dir = Some(start);
326 while let Some(d) = dir {
327 let dot_git = d.join(".git");
328 if dot_git.is_dir() {
329 return Some(dot_git);
330 }
331 if dot_git.is_file() {
332 let text = std::fs::read_to_string(&dot_git).ok()?;
333 let gitdir = text.trim().strip_prefix("gitdir:")?.trim();
334 let gitdir = if Path::new(gitdir).is_absolute() {
335 PathBuf::from(gitdir)
336 } else {
337 d.join(gitdir)
338 };
339 return match std::fs::read_to_string(gitdir.join("commondir")) {
340 Ok(common) => gitdir.join(common.trim()).canonicalize().ok(),
341 Err(_) => Some(gitdir),
343 };
344 }
345 dir = d.parent();
346 }
347 None
348}
349
350pub fn keep_project_file_local(root: &Path) -> anyhow::Result<LocalOutcome> {
358 let Some(common) = git_common_dir(root) else {
359 return Ok(LocalOutcome::NotARepository);
360 };
361 let exclude = common.join("info").join("exclude");
362 let text = match std::fs::read_to_string(&exclude) {
363 Ok(t) => t,
364 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
365 Err(e) => return Err(e).with_context(|| format!("reading {}", exclude.display())),
366 };
367 let listed = text.lines().any(|l| {
368 let l = l.trim();
369 l == PROJECT_FILE || l == format!("/{PROJECT_FILE}") || l == format!("**/{PROJECT_FILE}")
370 });
371 if listed {
372 return Ok(LocalOutcome::AlreadyExcluded(exclude));
373 }
374 if let Some(parent) = exclude.parent() {
375 std::fs::create_dir_all(parent)
376 .with_context(|| format!("creating {}", parent.display()))?;
377 }
378 let mut add = String::new();
379 if !text.is_empty() && !text.ends_with('\n') {
380 add.push('\n');
381 }
382 add.push_str(PROJECT_FILE);
383 add.push('\n');
384 use std::io::Write as _;
385 std::fs::OpenOptions::new()
386 .create(true)
387 .append(true)
388 .open(&exclude)
389 .and_then(|mut f| f.write_all(add.as_bytes()))
390 .with_context(|| format!("writing {}", exclude.display()))?;
391 Ok(LocalOutcome::Added(exclude))
392}
393
394pub fn project_file_is_tracked(root: &Path) -> Option<bool> {
397 std::process::Command::new("git")
398 .arg("-C")
399 .arg(root)
400 .args(["ls-files", "--error-unmatch", "--", PROJECT_FILE])
401 .stdout(std::process::Stdio::null())
402 .stderr(std::process::Stdio::null())
403 .status()
404 .ok()
405 .map(|s| s.success())
406}
407
408pub fn write_project_file(root: &Path, cfg: &ProjectConfig) -> anyhow::Result<PathBuf> {
409 let path = root.join(PROJECT_FILE);
410 let text = toml::to_string_pretty(cfg).context("serialising project defaults")?;
411 let header = "# ai-crew-sync project defaults — names only, never a credential or an endpoint.\n\
412 # `profile` must exist in each teammate's local profiles.\n";
413 let dir = path.parent().context("project root has no parent")?;
416 let tmp = dir.join(format!(".{PROJECT_FILE}.{}.tmp", std::process::id()));
417 std::fs::write(&tmp, format!("{header}{text}"))
418 .with_context(|| format!("writing {}", tmp.display()))?;
419 std::fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
420 Ok(path)
421}
422
423#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
431#[serde(rename_all = "kebab-case")]
432pub enum Origin {
433 Flag,
435 Environment,
437}
438
439impl Origin {
440 pub fn of(env_name: &str, value: &str) -> Origin {
448 match std::env::var(env_name) {
449 Ok(v) if v == value => Origin::Environment,
450 _ => Origin::Flag,
451 }
452 }
453
454 pub fn describe(self, env_name: &str, flag: &str) -> String {
457 match self {
458 Origin::Environment => format!("{env_name} (environment)"),
459 Origin::Flag => format!("{flag} (flag)"),
460 }
461 }
462}
463
464#[derive(Clone, Debug, Default)]
465pub struct Inputs {
466 pub config_dir: PathBuf,
467 pub explicit_url: Option<String>,
469 pub url_origin: Option<Origin>,
471 pub explicit_token: Option<String>,
473 pub token_origin: Option<Origin>,
475 pub explicit_session: Option<String>,
477 pub profile: Option<String>,
479 pub project_dir: Option<PathBuf>,
482 pub host_session: Option<String>,
488}
489
490pub fn session_for_host(host_id: &str) -> String {
494 let digest = Sha256::digest(host_id.trim().as_bytes());
495 format!("s-{}", &hex::encode(digest)[..12])
496}
497
498pub fn binding_key(host_id: &str) -> String {
500 hex::encode(Sha256::digest(host_id.trim().as_bytes()))
501}
502
503#[derive(Clone, Debug, Default, Deserialize)]
506pub struct Binding {
507 pub session: Option<String>,
508 pub profile: Option<String>,
509 pub project: Option<String>,
510 pub role: Option<String>,
511 pub agent: Option<String>,
512 pub team: Option<String>,
513 pub mcp_url: Option<String>,
515 pub session_token: Option<String>,
519 pub session_id: Option<String>,
520 pub epoch: Option<i64>,
523 pub expires_at: Option<String>,
524 pub closed_at: Option<String>,
525 #[serde(default)]
531 pub binding: Option<String>,
532}
533
534pub const CODEX_THREAD_BINDING: &str = "request-meta";
536
537pub const BINDINGS_DIR: &str = "sessions";
541
542pub fn binding_path(dir: &Path, host_id: &str) -> PathBuf {
543 dir.join(BINDINGS_DIR)
544 .join(format!("{}.json", binding_key(host_id)))
545}
546
547pub fn write_binding_file(path: &Path, content: &str) -> anyhow::Result<()> {
549 if let Some(dir) = path.parent() {
550 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
551 #[cfg(unix)]
552 {
553 use std::os::unix::fs::PermissionsExt;
554 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
557 }
558 }
559 write_private(path, content)
560}
561
562pub fn read_binding(dir: &Path, host_id: &str) -> Option<Binding> {
563 let text = std::fs::read_to_string(binding_path(dir, host_id)).ok()?;
564 serde_json::from_str(&text).ok()
565}
566
567#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
569#[serde(rename_all = "kebab-case")]
570pub enum Source {
571 Explicit,
573 ProfileFlag,
575 ProjectDefault,
577 UserDefault,
580}
581
582#[derive(Clone, Debug)]
585pub struct Resolved {
586 pub mcp_url: String,
587 pub token: String,
588 pub source: Source,
589 pub profile: Option<String>,
590 pub expected: Option<(String, String)>,
593 pub tokens_file: Option<PathBuf>,
594 pub token_key: Option<String>,
595 pub project: Option<String>,
596 pub channel: Option<String>,
597 pub project_root: Option<PathBuf>,
598 pub session: Option<String>,
599 pub token_origin: Option<Origin>,
602 pub url_origin: Option<Origin>,
604 pub warnings: Vec<String>,
609}
610
611impl Resolved {
612 pub fn credential_provenance(&self) -> String {
615 match self.source {
616 Source::Explicit => self
617 .token_origin
618 .unwrap_or(Origin::Flag)
619 .describe("BUS_TOKEN", "--token"),
620 _ => format!(
621 "entry '{}' of {} (profile '{}', {})",
622 self.token_key.as_deref().unwrap_or("?"),
623 self.tokens_file
624 .as_ref()
625 .map(|p| p.display().to_string())
626 .unwrap_or_default(),
627 self.profile.as_deref().unwrap_or("?"),
628 match self.source {
629 Source::ProfileFlag => "selected by --profile / BUS_PROFILE",
630 Source::ProjectDefault => "named by the project's .acs.toml",
631 _ => "the user default, which applies wherever no .acs.toml names a profile",
632 }
633 ),
634 }
635 }
636
637 pub fn url_provenance(&self) -> String {
640 match (self.url_origin, self.source) {
641 (Some(o), _) => o.describe("BUS_URL", "--url"),
642 (None, Source::Explicit) => "the built-in default".to_owned(),
643 (None, _) => format!("profile '{}'", self.profile.as_deref().unwrap_or("?")),
644 }
645 }
646
647 pub fn redacted(&self) -> serde_json::Value {
650 serde_json::json!({
651 "mcp_url": self.mcp_url,
652 "url_from": self.url_provenance(),
653 "token_prefix": format!("{}…", crate::auth::token_prefix(&self.token)),
654 "token_from": self.credential_provenance(),
655 "source": self.source,
656 "profile": self.profile,
657 "expected_team": self.expected.as_ref().map(|e| e.0.clone()),
658 "expected_agent": self.expected.as_ref().map(|e| e.1.clone()),
659 "tokens_file": self.tokens_file.as_ref().map(|p| p.display().to_string()),
660 "token_key": self.token_key,
661 "project": self.project,
662 "channel": self.channel,
663 "project_root": self.project_root.as_ref().map(|p| p.display().to_string()),
664 "session": self.session,
665 "warnings": self.warnings,
666 })
667 }
668}
669
670fn mcp_url_of(base: &str) -> anyhow::Result<String> {
671 let base = crate::admin_cli::normalize_base_url(base)?;
672 Ok(format!("{base}/mcp"))
673}
674
675fn read_token_entry(path: &Path, key: &str) -> anyhow::Result<Option<String>> {
677 let text = match std::fs::read_to_string(path) {
678 Ok(t) => t,
679 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
680 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
681 };
682 for line in text.lines() {
683 let line = line.trim();
684 if line.is_empty() || line.starts_with('#') {
685 continue;
686 }
687 if let Some((k, v)) = line.split_once('=')
688 && k.trim() == key
689 {
690 let v = v.trim();
691 if v.is_empty() {
692 return Ok(None);
693 }
694 return Ok(Some(v.to_owned()));
695 }
696 }
697 Ok(None)
698}
699
700fn none_if_blank(v: Option<String>) -> Option<String> {
701 v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
702}
703
704pub fn resolve(inputs: &Inputs) -> anyhow::Result<Resolved> {
706 let explicit_url = none_if_blank(inputs.explicit_url.clone());
707 let explicit_token = none_if_blank(inputs.explicit_token.clone());
708 let explicit_session = none_if_blank(inputs.explicit_session.clone());
709 let mut profile_flag = none_if_blank(inputs.profile.clone());
710 let host_session = none_if_blank(inputs.host_session.clone());
711
712 let binding = host_session
717 .as_deref()
718 .and_then(|id| read_binding(&inputs.config_dir, id));
719 let session = match (&explicit_session, &host_session) {
720 (Some(s), _) => Some(s.clone()),
721 (None, Some(id)) => Some(
722 binding
723 .as_ref()
724 .and_then(|b| b.session.clone())
725 .unwrap_or_else(|| session_for_host(id)),
726 ),
727 (None, None) => None,
728 };
729 if profile_flag.is_none()
730 && inputs.explicit_token.is_none()
731 && let Some(p) = binding.as_ref().and_then(|b| b.profile.clone())
732 {
733 profile_flag = Some(p);
734 }
735
736 let start = match &inputs.project_dir {
740 Some(d) => d.clone(),
741 None => std::env::current_dir().context("reading the current directory")?,
742 };
743 let project = find_project(&start)?;
744 let (project_root, project_cfg) = match &project {
745 Some((root, cfg)) => (Some(root.clone()), cfg.clone()),
746 None => (None, ProjectConfig::default()),
747 };
748
749 if let Some(token) = explicit_token {
752 if let Some(p) = none_if_blank(inputs.profile.clone()).as_ref() {
753 bail!(
754 "both explicit credentials (--token / BUS_TOKEN) and a profile ('{p}', from \
755 --profile / BUS_PROFILE) were given; drop one so it is clear which identity \
756 this window uses"
757 );
758 }
759 let token_origin = inputs.token_origin;
765 let mut warnings = Vec::new();
766 if token_origin == Some(Origin::Environment) {
767 let shadowed = match project_cfg.profile.as_deref() {
768 Some(p) => Some((p.to_owned(), "the project's .acs.toml names")),
769 None => load_profiles(&inputs.config_dir)
772 .ok()
773 .and_then(|p| p.default)
774 .map(|p| (p, "the user default is")),
775 };
776 if let Some((name, how)) = shadowed {
777 warnings.push(format!(
778 "BUS_TOKEN (environment) is overriding profile '{name}' ({how} it): this \
779 window authenticates with the environment token, not the profile. Unset \
780 BUS_TOKEN and BUS_URL to use the profile, or drop the profile if the \
781 override is intended. `ai-crew-sync context verify` shows who each one is."
782 ));
783 }
784 }
785 let mcp_url = match explicit_url {
786 Some(u) => mcp_url_of(&u)?,
787 None => DEFAULT_MCP_URL.to_owned(),
788 };
789 return Ok(Resolved {
790 mcp_url,
791 token,
792 source: Source::Explicit,
793 profile: None,
794 expected: None,
795 tokens_file: None,
796 token_key: None,
797 project: project_cfg.project,
798 channel: project_cfg.channel,
799 project_root,
800 session,
801 token_origin,
802 url_origin: inputs.url_origin,
803 warnings,
804 });
805 }
806
807 let profiles = load_profiles(&inputs.config_dir)?;
808 let (name, source) = if let Some(name) = profile_flag {
809 (name, Source::ProfileFlag)
810 } else if let Some(name) = project_cfg.profile.clone() {
811 (name, Source::ProjectDefault)
812 } else if let Some(name) = profiles.default.clone() {
813 (name, Source::UserDefault)
814 } else {
815 bail!(
816 "no credentials: pass --token / set BUS_TOKEN, select a profile with --profile / \
817 BUS_PROFILE, add `profile = \"<name>\"` to {PROJECT_FILE} at the project root, or \
818 set a default with `ai-crew-sync context profile default <name>` \
819 (profiles: `ai-crew-sync context profile add`)"
820 );
821 };
822 let name = validate_name("profile name", &name)?;
823 let Some(profile) = profiles.profiles.get(&name) else {
824 let where_from = match source {
825 Source::ProfileFlag => "selected with --profile / BUS_PROFILE".to_owned(),
826 Source::ProjectDefault => format!(
827 "named by {} — a repository may only reference profiles you approved locally",
828 project_root
829 .as_ref()
830 .map(|r| r.join(PROJECT_FILE).display().to_string())
831 .unwrap_or_else(|| PROJECT_FILE.to_owned())
832 ),
833 _ => "set as the user default".to_owned(),
834 };
835 let known: Vec<&String> = profiles.profiles.keys().collect();
836 bail!(
837 "profile '{name}' does not exist ({where_from}). Known profiles: {}. Create it with \
838 `ai-crew-sync context profile add --name {name} --url <bus> --team <team> \
839 --agent <agent> --tokens tokens-<team>`",
840 if known.is_empty() {
841 "none".to_owned()
842 } else {
843 known
844 .iter()
845 .map(|k| k.as_str())
846 .collect::<Vec<_>>()
847 .join(", ")
848 }
849 );
850 };
851
852 let mut warnings = Vec::new();
855 let url_origin = explicit_url.is_some().then_some(()).and(inputs.url_origin);
856 if let (Some(_), Some(Origin::Environment)) = (&explicit_url, inputs.url_origin) {
857 warnings.push(format!(
858 "BUS_URL (environment) is overriding profile '{name}'s endpoint: the profile's \
859 token will be presented to a different bus. Unset BUS_URL to use the profile's \
860 endpoint, or pass --url if the override is intended."
861 ));
862 }
863 let mcp_url = match explicit_url {
864 Some(u) => mcp_url_of(&u)?,
865 None => mcp_url_of(&profile.url)?,
866 };
867 let tokens_file = inputs
868 .config_dir
869 .join(validate_tokens_ref(&profile.tokens)?);
870 let mut candidates: Vec<String> = Vec::new();
874 for c in [
875 project_cfg.key.clone(),
876 project_cfg.project.clone(),
877 profile.key.clone(),
878 ]
879 .into_iter()
880 .flatten()
881 {
882 if !candidates.contains(&c) {
883 candidates.push(c);
884 }
885 }
886 candidates.push(BASE_KEY.to_owned());
887 let mut found = None;
888 for key in &candidates {
889 if let Some(token) = read_token_entry(&tokens_file, key)? {
890 found = Some((key.clone(), token));
891 break;
892 }
893 }
894 let Some((token_key, token)) = found else {
895 bail!(
896 "profile '{name}': no entry {} in {}. Issue one with `ai-crew-sync admin token issue \
897 --team {} --agent {} --save --repo {}`",
898 candidates
899 .iter()
900 .map(|c| format!("'{c}'"))
901 .collect::<Vec<_>>()
902 .join(" or "),
903 tokens_file.display(),
904 profile.team,
905 profile.agent,
906 candidates.first().map(String::as_str).unwrap_or(BASE_KEY)
907 );
908 };
909 if !token.starts_with(TOKEN_PREFIX) {
910 bail!(
911 "profile '{name}': entry '{token_key}' in {} is not an agent token (expected the \
912 {TOKEN_PREFIX} prefix)",
913 tokens_file.display()
914 );
915 }
916
917 Ok(Resolved {
918 mcp_url,
919 token,
920 source,
921 profile: Some(name),
922 expected: Some((profile.team.clone(), profile.agent.clone())),
923 tokens_file: Some(tokens_file),
924 token_key: Some(token_key),
925 project: project_cfg.project,
926 channel: project_cfg.channel,
927 project_root,
928 session,
929 token_origin: None,
930 url_origin,
931 warnings,
932 })
933}
934
935#[derive(Clone, Debug, Serialize)]
937pub struct Verified {
938 pub agent: String,
939 pub team: String,
940}
941
942pub async fn verify(resolved: &Resolved) -> anyhow::Result<Verified> {
946 let (agent, team) = crate::admin_cli::whoami_on_mcp(&resolved.mcp_url, &resolved.token)
950 .await
951 .with_context(|| {
952 let provenance = format!(
953 "endpoint {} came from {}; the credential came from {}",
954 resolved.mcp_url,
955 resolved.url_provenance(),
956 resolved.credential_provenance()
957 );
958 match &resolved.profile {
959 Some(p) => format!(
960 "profile '{p}': the bus at {} did not accept the token. {provenance}. \
961 It may be revoked; issue a new one with `admin token issue --save`",
962 resolved.mcp_url
963 ),
964 None => format!(
965 "the bus at {} did not accept the token. {provenance}",
966 resolved.mcp_url
967 ),
968 }
969 })?;
970 if let Some((exp_team, exp_agent)) = &resolved.expected
971 && (&agent != exp_agent || &team != exp_team)
972 {
973 bail!(
974 "profile '{}' expects {exp_agent}@{exp_team} but the token authenticates as \
975 {agent}@{team}. The entry '{}' of {} belongs to someone else; fix the profile \
976 or replace the entry",
977 resolved.profile.as_deref().unwrap_or("?"),
978 resolved.token_key.as_deref().unwrap_or("?"),
979 resolved
980 .tokens_file
981 .as_ref()
982 .map(|p| p.display().to_string())
983 .unwrap_or_default()
984 );
985 }
986 Ok(Verified { agent, team })
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 fn tmp(name: &str) -> PathBuf {
994 let dir = std::env::temp_dir().join(format!("acs-ctx-{name}-{}", uuid::Uuid::new_v4()));
995 std::fs::create_dir_all(&dir).unwrap();
996 dir
997 }
998
999 fn seed(dir: &Path) {
1000 save_profiles(
1001 dir,
1002 &Profiles {
1003 default: Some("acme".into()),
1004 profiles: BTreeMap::from([
1005 (
1006 "acme".into(),
1007 Profile {
1008 url: "https://acme.example:8443".into(),
1009 team: "acme".into(),
1010 agent: "joaquin".into(),
1011 tokens: "tokens-acme".into(),
1012 key: None,
1013 },
1014 ),
1015 (
1016 "other".into(),
1017 Profile {
1018 url: "https://other.example".into(),
1019 team: "other".into(),
1020 agent: "joaquin".into(),
1021 tokens: "tokens-other".into(),
1022 key: None,
1023 },
1024 ),
1025 ]),
1026 },
1027 )
1028 .unwrap();
1029 std::fs::write(
1030 dir.join("tokens-acme"),
1031 "_base=acs_base00000000\napi=acs_api000000000\n",
1032 )
1033 .unwrap();
1034 std::fs::write(dir.join("tokens-other"), "_base=acs_other0000000\n").unwrap();
1035 }
1036
1037 fn inputs(dir: &Path, project: &Path) -> Inputs {
1038 Inputs {
1039 config_dir: dir.to_path_buf(),
1040 project_dir: Some(project.to_path_buf()),
1041 ..Default::default()
1042 }
1043 }
1044
1045 #[test]
1046 fn explicit_credentials_win_and_keep_project_metadata() {
1047 let dir = tmp("explicit");
1048 seed(&dir);
1049 let repo = dir.join("repo");
1050 std::fs::create_dir_all(&repo).unwrap();
1051 std::fs::write(
1052 repo.join(PROJECT_FILE),
1053 "profile = \"other\"\nproject = \"api\"\nchannel = \"api\"\n",
1054 )
1055 .unwrap();
1056 let mut i = inputs(&dir, &repo);
1057 i.explicit_token = Some("acs_explicit".into());
1058 i.explicit_url = Some("https://x.example/mcp".into());
1059 let r = resolve(&i).unwrap();
1060 assert_eq!(r.source, Source::Explicit);
1061 assert_eq!(r.token, "acs_explicit");
1062 assert_eq!(r.mcp_url, "https://x.example/mcp");
1063 assert!(r.expected.is_none(), "explicit credentials promise nothing");
1064 assert_eq!(r.project.as_deref(), Some("api"));
1065 i.profile = Some("acme".into());
1066 let err = resolve(&i).unwrap_err().to_string();
1067 assert!(err.contains("drop one"), "two explicit selections: {err}");
1068 assert_eq!(r.channel.as_deref(), Some("api"));
1069 assert_eq!(
1070 r.project_root.as_deref(),
1071 Some(repo.canonicalize().unwrap().as_path())
1072 );
1073 }
1074
1075 #[test]
1076 fn environment_shadowing_a_profile_is_warned_never_reordered() {
1077 let dir = tmp("shadow");
1078 seed(&dir);
1079 let repo = dir.join("repo");
1080 std::fs::create_dir_all(&repo).unwrap();
1081
1082 let mut i = inputs(&dir, &repo);
1085 i.explicit_token = Some("acs_leftover".into());
1086 i.token_origin = Some(Origin::Environment);
1087 let r = resolve(&i).unwrap();
1088 assert_eq!(r.source, Source::Explicit, "precedence must not move");
1089 assert_eq!(r.token, "acs_leftover");
1090 assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
1091 assert!(r.warnings[0].contains("BUS_TOKEN (environment)"));
1092 assert!(
1093 r.warnings[0].contains("profile 'acme'"),
1094 "{}",
1095 r.warnings[0]
1096 );
1097 assert!(
1098 !r.warnings[0].contains("acs_leftover"),
1099 "a warning never carries the secret"
1100 );
1101
1102 let mut i = inputs(&dir, &repo);
1105 i.explicit_token = Some("acs_leftover".into());
1106 i.token_origin = Some(Origin::Flag);
1107 assert!(resolve(&i).unwrap().warnings.is_empty());
1108
1109 std::fs::write(repo.join(PROJECT_FILE), "profile = \"other\"\n").unwrap();
1111 let mut i = inputs(&dir, &repo);
1112 i.explicit_token = Some("acs_leftover".into());
1113 i.token_origin = Some(Origin::Environment);
1114 let r = resolve(&i).unwrap();
1115 assert!(
1116 r.warnings[0].contains("profile 'other'"),
1117 "{}",
1118 r.warnings[0]
1119 );
1120
1121 let bare = tmp("shadow-bare");
1123 let mut i = inputs(&bare, &repo.join("..")); i.explicit_token = Some("acs_leftover".into());
1125 i.token_origin = Some(Origin::Environment);
1126 assert!(resolve(&i).unwrap().warnings.is_empty());
1127 }
1128
1129 #[test]
1130 fn bus_url_from_the_environment_over_a_profile_is_warned() {
1131 let dir = tmp("shadow-url");
1132 seed(&dir);
1133 let repo = dir.join("repo");
1134 std::fs::create_dir_all(&repo).unwrap();
1135
1136 let mut i = inputs(&dir, &repo);
1137 i.explicit_url = Some("https://elsewhere.example".into());
1138 i.url_origin = Some(Origin::Environment);
1139 let r = resolve(&i).unwrap();
1140 assert_eq!(r.mcp_url, "https://elsewhere.example/mcp");
1141 assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
1142 assert!(r.warnings[0].contains("BUS_URL (environment)"));
1143 assert!(
1144 r.warnings[0].contains("profile 'acme'"),
1145 "{}",
1146 r.warnings[0]
1147 );
1148 assert!(r.url_provenance().contains("BUS_URL (environment)"));
1149
1150 let mut i = inputs(&dir, &repo);
1152 i.explicit_url = Some("https://elsewhere.example".into());
1153 i.url_origin = Some(Origin::Flag);
1154 assert!(resolve(&i).unwrap().warnings.is_empty());
1155 }
1156
1157 #[test]
1158 fn provenance_names_the_source_without_the_secret() {
1159 let dir = tmp("provenance");
1160 seed(&dir);
1161 let repo = dir.join("repo");
1162 std::fs::create_dir_all(&repo).unwrap();
1163
1164 let r = resolve(&inputs(&dir, &repo)).unwrap();
1166 let p = r.credential_provenance();
1167 assert!(p.contains("entry '_base'"), "{p}");
1168 assert!(p.contains("tokens-acme"), "{p}");
1169 assert!(p.contains("profile 'acme'"), "{p}");
1170 assert!(p.contains("user default"), "{p}");
1171 assert!(!p.contains("acs_base00000000"), "never the secret: {p}");
1172 assert!(r.url_provenance().contains("profile 'acme'"));
1173
1174 let secret = "acs_explicit0secret0secret0secret0secret";
1178 let mut i = inputs(&dir, &repo);
1179 i.explicit_token = Some(secret.into());
1180 i.token_origin = Some(Origin::Environment);
1181 let r = resolve(&i).unwrap();
1182 assert_eq!(r.credential_provenance(), "BUS_TOKEN (environment)");
1183 assert_eq!(r.url_provenance(), "the built-in default");
1184
1185 let view = r.redacted();
1187 assert_eq!(view["token_from"], "BUS_TOKEN (environment)");
1188 assert!(!view.to_string().contains(secret));
1189 }
1190
1191 #[test]
1192 fn origin_of_an_unset_variable_is_the_flag() {
1193 assert_eq!(
1194 Origin::of("ACS_TEST_UNSET_VARIABLE_187", "acs_x"),
1195 Origin::Flag
1196 );
1197 assert_eq!(
1198 Origin::Environment.describe("BUS_TOKEN", "--token"),
1199 "BUS_TOKEN (environment)"
1200 );
1201 assert_eq!(Origin::Flag.describe("BUS_URL", "--url"), "--url (flag)");
1202 }
1203
1204 #[test]
1205 fn profile_flag_beats_project_which_beats_user_default() {
1206 let dir = tmp("precedence");
1207 seed(&dir);
1208 let repo = dir.join("repo");
1209 let nested = repo.join("src").join("deep");
1210 std::fs::create_dir_all(&nested).unwrap();
1211
1212 let r = resolve(&inputs(&dir, &nested)).unwrap();
1214 assert_eq!(r.source, Source::UserDefault);
1215 assert_eq!(r.profile.as_deref(), Some("acme"));
1216 assert_eq!(r.token_key.as_deref(), Some(BASE_KEY));
1217 assert_eq!(r.mcp_url, "https://acme.example:8443/mcp");
1218
1219 std::fs::write(
1222 repo.join(PROJECT_FILE),
1223 "profile = \"acme\"\nproject = \"api\"\n",
1224 )
1225 .unwrap();
1226 let r = resolve(&inputs(&dir, &nested)).unwrap();
1227 assert_eq!(r.source, Source::ProjectDefault);
1228 assert_eq!(r.token_key.as_deref(), Some("api"));
1229 assert_eq!(r.token, "acs_api000000000");
1230 assert_eq!(r.project.as_deref(), Some("api"));
1231
1232 let mut i = inputs(&dir, &nested);
1234 i.profile = Some("other".into());
1235 let r = resolve(&i).unwrap();
1236 assert_eq!(r.source, Source::ProfileFlag);
1237 assert_eq!(r.profile.as_deref(), Some("other"));
1238 assert_eq!(r.token, "acs_other0000000");
1239 assert_eq!(
1240 std::fs::read_to_string(repo.join(PROJECT_FILE)).unwrap(),
1241 "profile = \"acme\"\nproject = \"api\"\n",
1242 "per-invocation selection never rewrites project defaults"
1243 );
1244 }
1245
1246 #[test]
1247 fn a_missing_profile_is_an_error_never_another_team() {
1248 let dir = tmp("missing");
1249 seed(&dir);
1250 let repo = dir.join("repo");
1251 std::fs::create_dir_all(&repo).unwrap();
1252 std::fs::write(repo.join(PROJECT_FILE), "profile = \"stranger\"\n").unwrap();
1253 let err = resolve(&inputs(&dir, &repo)).unwrap_err().to_string();
1254 assert!(err.contains("'stranger' does not exist"), "{err}");
1255 assert!(err.contains("approved locally"), "{err}");
1256 assert!(err.contains("acme, other"), "{err}");
1257
1258 let mut i = inputs(&dir, &repo);
1259 i.profile = Some("nope".into());
1260 let err = resolve(&i).unwrap_err().to_string();
1261 assert!(err.contains("--profile"), "{err}");
1262
1263 std::fs::write(dir.join("tokens-other"), "web=acs_web\n").unwrap();
1265 i.profile = Some("other".into());
1266 let err = resolve(&i).unwrap_err().to_string();
1267 assert!(err.contains("no entry '_base'"), "{err}");
1268 assert!(err.contains("admin token issue"), "{err}");
1269 }
1270
1271 #[test]
1272 fn a_repository_may_not_carry_an_endpoint_or_a_credential() {
1273 let dir = tmp("malicious");
1274 seed(&dir);
1275 let repo = dir.join("repo");
1276 std::fs::create_dir_all(&repo).unwrap();
1277 for evil in [
1278 "profile = \"acme\"\nurl = \"https://evil.example\"\n",
1279 "profile = \"acme\"\ntoken = \"acs_stolen\"\n",
1280 "profile = \"acme\"\ntokens = \"../../etc/passwd\"\n",
1281 "profile = \"acme\"\ntoken_file = \"~/.ssh/id_rsa\"\n",
1285 "profile = \"acme\"\nmcp_url = \"https://evil.example/mcp\"\n",
1286 ] {
1287 std::fs::write(repo.join(PROJECT_FILE), evil).unwrap();
1288 let err = format!("{:#}", resolve(&inputs(&dir, &repo)).unwrap_err());
1289 assert!(
1290 err.contains("may not do") || err.contains("does not accept"),
1291 "{evil}: {err}"
1292 );
1293 }
1294 std::fs::write(
1297 dir.join(PROFILES_FILE),
1298 "[profiles.bad]\nurl = \"https://x\"\nteam = \"t\"\nagent = \"a\"\ntokens = \"../secrets\"\n",
1299 )
1300 .unwrap();
1301 let err = load_profiles(&dir).unwrap_err().to_string();
1302 assert!(err.contains("bare file name"), "{err}");
1303 }
1304
1305 #[test]
1306 fn a_linked_worktree_inherits_the_main_worktrees_project_file() {
1307 let dir = tmp("worktree");
1308 seed(&dir);
1309 let main = dir.join("main");
1310 let wt = dir.join("wt");
1311 std::fs::create_dir_all(main.join(".git").join("worktrees").join("wt")).unwrap();
1312 std::fs::create_dir_all(wt.join("src")).unwrap();
1313 std::fs::write(
1314 main.join(PROJECT_FILE),
1315 "profile = \"acme\"\nproject = \"api\"\n",
1316 )
1317 .unwrap();
1318 std::fs::write(
1319 wt.join(".git"),
1320 format!("gitdir: {}\n", main.join(".git/worktrees/wt").display()),
1321 )
1322 .unwrap();
1323 std::fs::write(main.join(".git/worktrees/wt/commondir"), "../..\n").unwrap();
1324 let (root, cfg) = find_project(&wt.join("src"))
1325 .unwrap()
1326 .expect("found via worktree");
1327 assert_eq!(root, main.canonicalize().unwrap());
1328 assert_eq!(cfg.project.as_deref(), Some("api"));
1329
1330 std::fs::write(
1332 wt.join(PROJECT_FILE),
1333 "profile = \"acme\"\nproject = \"wt\"\n",
1334 )
1335 .unwrap();
1336 let (root, cfg) = find_project(&wt.join("src")).unwrap().unwrap();
1337 assert_eq!(root, wt.canonicalize().unwrap());
1338 assert_eq!(cfg.project.as_deref(), Some("wt"));
1339
1340 let other = dir.join("solo");
1342 std::fs::create_dir_all(other.join(".git")).unwrap();
1343 std::fs::write(dir.join(PROJECT_FILE), "profile = \"acme\"\n").unwrap();
1344 assert!(find_project(&other).unwrap().is_none());
1345 }
1346
1347 #[test]
1348 fn concurrent_profile_updates_never_interleave() {
1349 let dir = tmp("concurrent");
1350 seed(&dir);
1351 let handles: Vec<_> = (0..16)
1352 .map(|i| {
1353 let dir = dir.clone();
1354 std::thread::spawn(move || {
1355 update_profiles(&dir, |p| {
1356 p.profiles.insert(
1357 format!("p{i}"),
1358 Profile {
1359 url: "https://x.example".into(),
1360 team: "t".into(),
1361 agent: "a".into(),
1362 tokens: "tokens-t".into(),
1363 key: None,
1364 },
1365 );
1366 Ok(())
1367 })
1368 .unwrap();
1369 })
1370 })
1371 .collect();
1372 for h in handles {
1373 h.join().unwrap();
1374 }
1375 let p = load_profiles(&dir).unwrap();
1376 assert_eq!(
1377 p.profiles.len(),
1378 2 + 16,
1379 "every update landed, none was lost"
1380 );
1381 #[cfg(unix)]
1382 {
1383 use std::os::unix::fs::PermissionsExt;
1384 let mode = std::fs::metadata(dir.join(PROFILES_FILE))
1385 .unwrap()
1386 .permissions()
1387 .mode()
1388 & 0o777;
1389 assert_eq!(mode, 0o600);
1390 }
1391 }
1392}