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
311pub fn write_project_file(root: &Path, cfg: &ProjectConfig) -> anyhow::Result<PathBuf> {
312 let path = root.join(PROJECT_FILE);
313 let text = toml::to_string_pretty(cfg).context("serialising project defaults")?;
314 let header = "# ai-crew-sync project defaults — names only, never a credential or an endpoint.\n\
315 # `profile` must exist in each teammate's local profiles.\n";
316 let dir = path.parent().context("project root has no parent")?;
319 let tmp = dir.join(format!(".{PROJECT_FILE}.{}.tmp", std::process::id()));
320 std::fs::write(&tmp, format!("{header}{text}"))
321 .with_context(|| format!("writing {}", tmp.display()))?;
322 std::fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
323 Ok(path)
324}
325
326#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
334#[serde(rename_all = "kebab-case")]
335pub enum Origin {
336 Flag,
338 Environment,
340}
341
342impl Origin {
343 pub fn of(env_name: &str, value: &str) -> Origin {
351 match std::env::var(env_name) {
352 Ok(v) if v == value => Origin::Environment,
353 _ => Origin::Flag,
354 }
355 }
356
357 pub fn describe(self, env_name: &str, flag: &str) -> String {
360 match self {
361 Origin::Environment => format!("{env_name} (environment)"),
362 Origin::Flag => format!("{flag} (flag)"),
363 }
364 }
365}
366
367#[derive(Clone, Debug, Default)]
368pub struct Inputs {
369 pub config_dir: PathBuf,
370 pub explicit_url: Option<String>,
372 pub url_origin: Option<Origin>,
374 pub explicit_token: Option<String>,
376 pub token_origin: Option<Origin>,
378 pub explicit_session: Option<String>,
380 pub profile: Option<String>,
382 pub project_dir: Option<PathBuf>,
385 pub host_session: Option<String>,
391}
392
393pub fn session_for_host(host_id: &str) -> String {
397 let digest = Sha256::digest(host_id.trim().as_bytes());
398 format!("s-{}", &hex::encode(digest)[..12])
399}
400
401pub fn binding_key(host_id: &str) -> String {
403 hex::encode(Sha256::digest(host_id.trim().as_bytes()))
404}
405
406#[derive(Clone, Debug, Default, Deserialize)]
409pub struct Binding {
410 pub session: Option<String>,
411 pub profile: Option<String>,
412 pub project: Option<String>,
413 pub role: Option<String>,
414 pub agent: Option<String>,
415 pub team: Option<String>,
416 pub mcp_url: Option<String>,
418 pub session_token: Option<String>,
422 pub session_id: Option<String>,
423 pub epoch: Option<i64>,
426 pub expires_at: Option<String>,
427 pub closed_at: Option<String>,
428}
429
430pub const BINDINGS_DIR: &str = "sessions";
434
435pub fn binding_path(dir: &Path, host_id: &str) -> PathBuf {
436 dir.join(BINDINGS_DIR)
437 .join(format!("{}.json", binding_key(host_id)))
438}
439
440pub fn write_binding_file(path: &Path, content: &str) -> anyhow::Result<()> {
442 if let Some(dir) = path.parent() {
443 std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
444 #[cfg(unix)]
445 {
446 use std::os::unix::fs::PermissionsExt;
447 let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
450 }
451 }
452 write_private(path, content)
453}
454
455pub fn read_binding(dir: &Path, host_id: &str) -> Option<Binding> {
456 let text = std::fs::read_to_string(binding_path(dir, host_id)).ok()?;
457 serde_json::from_str(&text).ok()
458}
459
460#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
462#[serde(rename_all = "kebab-case")]
463pub enum Source {
464 Explicit,
466 ProfileFlag,
468 ProjectDefault,
470 UserDefault,
472}
473
474#[derive(Clone, Debug)]
477pub struct Resolved {
478 pub mcp_url: String,
479 pub token: String,
480 pub source: Source,
481 pub profile: Option<String>,
482 pub expected: Option<(String, String)>,
485 pub tokens_file: Option<PathBuf>,
486 pub token_key: Option<String>,
487 pub project: Option<String>,
488 pub channel: Option<String>,
489 pub project_root: Option<PathBuf>,
490 pub session: Option<String>,
491 pub token_origin: Option<Origin>,
494 pub url_origin: Option<Origin>,
496 pub warnings: Vec<String>,
501}
502
503impl Resolved {
504 pub fn credential_provenance(&self) -> String {
507 match self.source {
508 Source::Explicit => self
509 .token_origin
510 .unwrap_or(Origin::Flag)
511 .describe("BUS_TOKEN", "--token"),
512 _ => format!(
513 "entry '{}' of {} (profile '{}', {})",
514 self.token_key.as_deref().unwrap_or("?"),
515 self.tokens_file
516 .as_ref()
517 .map(|p| p.display().to_string())
518 .unwrap_or_default(),
519 self.profile.as_deref().unwrap_or("?"),
520 match self.source {
521 Source::ProfileFlag => "selected by --profile / BUS_PROFILE",
522 Source::ProjectDefault => "named by the project's .acs.toml",
523 _ => "the user default",
524 }
525 ),
526 }
527 }
528
529 pub fn url_provenance(&self) -> String {
532 match (self.url_origin, self.source) {
533 (Some(o), _) => o.describe("BUS_URL", "--url"),
534 (None, Source::Explicit) => "the built-in default".to_owned(),
535 (None, _) => format!("profile '{}'", self.profile.as_deref().unwrap_or("?")),
536 }
537 }
538
539 pub fn redacted(&self) -> serde_json::Value {
542 serde_json::json!({
543 "mcp_url": self.mcp_url,
544 "url_from": self.url_provenance(),
545 "token_prefix": format!("{}…", crate::auth::token_prefix(&self.token)),
546 "token_from": self.credential_provenance(),
547 "source": self.source,
548 "profile": self.profile,
549 "expected_team": self.expected.as_ref().map(|e| e.0.clone()),
550 "expected_agent": self.expected.as_ref().map(|e| e.1.clone()),
551 "tokens_file": self.tokens_file.as_ref().map(|p| p.display().to_string()),
552 "token_key": self.token_key,
553 "project": self.project,
554 "channel": self.channel,
555 "project_root": self.project_root.as_ref().map(|p| p.display().to_string()),
556 "session": self.session,
557 "warnings": self.warnings,
558 })
559 }
560}
561
562fn mcp_url_of(base: &str) -> anyhow::Result<String> {
563 let base = crate::admin_cli::normalize_base_url(base)?;
564 Ok(format!("{base}/mcp"))
565}
566
567fn read_token_entry(path: &Path, key: &str) -> anyhow::Result<Option<String>> {
569 let text = match std::fs::read_to_string(path) {
570 Ok(t) => t,
571 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
572 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
573 };
574 for line in text.lines() {
575 let line = line.trim();
576 if line.is_empty() || line.starts_with('#') {
577 continue;
578 }
579 if let Some((k, v)) = line.split_once('=')
580 && k.trim() == key
581 {
582 let v = v.trim();
583 if v.is_empty() {
584 return Ok(None);
585 }
586 return Ok(Some(v.to_owned()));
587 }
588 }
589 Ok(None)
590}
591
592fn none_if_blank(v: Option<String>) -> Option<String> {
593 v.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty())
594}
595
596pub fn resolve(inputs: &Inputs) -> anyhow::Result<Resolved> {
598 let explicit_url = none_if_blank(inputs.explicit_url.clone());
599 let explicit_token = none_if_blank(inputs.explicit_token.clone());
600 let explicit_session = none_if_blank(inputs.explicit_session.clone());
601 let mut profile_flag = none_if_blank(inputs.profile.clone());
602 let host_session = none_if_blank(inputs.host_session.clone());
603
604 let binding = host_session
609 .as_deref()
610 .and_then(|id| read_binding(&inputs.config_dir, id));
611 let session = match (&explicit_session, &host_session) {
612 (Some(s), _) => Some(s.clone()),
613 (None, Some(id)) => Some(
614 binding
615 .as_ref()
616 .and_then(|b| b.session.clone())
617 .unwrap_or_else(|| session_for_host(id)),
618 ),
619 (None, None) => None,
620 };
621 if profile_flag.is_none()
622 && inputs.explicit_token.is_none()
623 && let Some(p) = binding.as_ref().and_then(|b| b.profile.clone())
624 {
625 profile_flag = Some(p);
626 }
627
628 let start = match &inputs.project_dir {
632 Some(d) => d.clone(),
633 None => std::env::current_dir().context("reading the current directory")?,
634 };
635 let project = find_project(&start)?;
636 let (project_root, project_cfg) = match &project {
637 Some((root, cfg)) => (Some(root.clone()), cfg.clone()),
638 None => (None, ProjectConfig::default()),
639 };
640
641 if let Some(token) = explicit_token {
644 if let Some(p) = none_if_blank(inputs.profile.clone()).as_ref() {
645 bail!(
646 "both explicit credentials (--token / BUS_TOKEN) and a profile ('{p}', from \
647 --profile / BUS_PROFILE) were given; drop one so it is clear which identity \
648 this window uses"
649 );
650 }
651 let token_origin = inputs.token_origin;
657 let mut warnings = Vec::new();
658 if token_origin == Some(Origin::Environment) {
659 let shadowed = match project_cfg.profile.as_deref() {
660 Some(p) => Some((p.to_owned(), "the project's .acs.toml names")),
661 None => load_profiles(&inputs.config_dir)
664 .ok()
665 .and_then(|p| p.default)
666 .map(|p| (p, "the user default is")),
667 };
668 if let Some((name, how)) = shadowed {
669 warnings.push(format!(
670 "BUS_TOKEN (environment) is overriding profile '{name}' ({how} it): this \
671 window authenticates with the environment token, not the profile. Unset \
672 BUS_TOKEN and BUS_URL to use the profile, or drop the profile if the \
673 override is intended. `ai-crew-sync context verify` shows who each one is."
674 ));
675 }
676 }
677 let mcp_url = match explicit_url {
678 Some(u) => mcp_url_of(&u)?,
679 None => DEFAULT_MCP_URL.to_owned(),
680 };
681 return Ok(Resolved {
682 mcp_url,
683 token,
684 source: Source::Explicit,
685 profile: None,
686 expected: None,
687 tokens_file: None,
688 token_key: None,
689 project: project_cfg.project,
690 channel: project_cfg.channel,
691 project_root,
692 session,
693 token_origin,
694 url_origin: inputs.url_origin,
695 warnings,
696 });
697 }
698
699 let profiles = load_profiles(&inputs.config_dir)?;
700 let (name, source) = if let Some(name) = profile_flag {
701 (name, Source::ProfileFlag)
702 } else if let Some(name) = project_cfg.profile.clone() {
703 (name, Source::ProjectDefault)
704 } else if let Some(name) = profiles.default.clone() {
705 (name, Source::UserDefault)
706 } else {
707 bail!(
708 "no credentials: pass --token / set BUS_TOKEN, select a profile with --profile / \
709 BUS_PROFILE, add `profile = \"<name>\"` to {PROJECT_FILE} at the project root, or \
710 set a default with `ai-crew-sync context profile default <name>` \
711 (profiles: `ai-crew-sync context profile add`)"
712 );
713 };
714 let name = validate_name("profile name", &name)?;
715 let Some(profile) = profiles.profiles.get(&name) else {
716 let where_from = match source {
717 Source::ProfileFlag => "selected with --profile / BUS_PROFILE".to_owned(),
718 Source::ProjectDefault => format!(
719 "named by {} — a repository may only reference profiles you approved locally",
720 project_root
721 .as_ref()
722 .map(|r| r.join(PROJECT_FILE).display().to_string())
723 .unwrap_or_else(|| PROJECT_FILE.to_owned())
724 ),
725 _ => "set as the user default".to_owned(),
726 };
727 let known: Vec<&String> = profiles.profiles.keys().collect();
728 bail!(
729 "profile '{name}' does not exist ({where_from}). Known profiles: {}. Create it with \
730 `ai-crew-sync context profile add --name {name} --url <bus> --team <team> \
731 --agent <agent> --tokens tokens-<team>`",
732 if known.is_empty() {
733 "none".to_owned()
734 } else {
735 known
736 .iter()
737 .map(|k| k.as_str())
738 .collect::<Vec<_>>()
739 .join(", ")
740 }
741 );
742 };
743
744 let mut warnings = Vec::new();
747 let url_origin = explicit_url.is_some().then_some(()).and(inputs.url_origin);
748 if let (Some(_), Some(Origin::Environment)) = (&explicit_url, inputs.url_origin) {
749 warnings.push(format!(
750 "BUS_URL (environment) is overriding profile '{name}'s endpoint: the profile's \
751 token will be presented to a different bus. Unset BUS_URL to use the profile's \
752 endpoint, or pass --url if the override is intended."
753 ));
754 }
755 let mcp_url = match explicit_url {
756 Some(u) => mcp_url_of(&u)?,
757 None => mcp_url_of(&profile.url)?,
758 };
759 let tokens_file = inputs
760 .config_dir
761 .join(validate_tokens_ref(&profile.tokens)?);
762 let mut candidates: Vec<String> = Vec::new();
766 for c in [
767 project_cfg.key.clone(),
768 project_cfg.project.clone(),
769 profile.key.clone(),
770 ]
771 .into_iter()
772 .flatten()
773 {
774 if !candidates.contains(&c) {
775 candidates.push(c);
776 }
777 }
778 candidates.push(BASE_KEY.to_owned());
779 let mut found = None;
780 for key in &candidates {
781 if let Some(token) = read_token_entry(&tokens_file, key)? {
782 found = Some((key.clone(), token));
783 break;
784 }
785 }
786 let Some((token_key, token)) = found else {
787 bail!(
788 "profile '{name}': no entry {} in {}. Issue one with `ai-crew-sync admin token issue \
789 --team {} --agent {} --save --repo {}`",
790 candidates
791 .iter()
792 .map(|c| format!("'{c}'"))
793 .collect::<Vec<_>>()
794 .join(" or "),
795 tokens_file.display(),
796 profile.team,
797 profile.agent,
798 candidates.first().map(String::as_str).unwrap_or(BASE_KEY)
799 );
800 };
801 if !token.starts_with(TOKEN_PREFIX) {
802 bail!(
803 "profile '{name}': entry '{token_key}' in {} is not an agent token (expected the \
804 {TOKEN_PREFIX} prefix)",
805 tokens_file.display()
806 );
807 }
808
809 Ok(Resolved {
810 mcp_url,
811 token,
812 source,
813 profile: Some(name),
814 expected: Some((profile.team.clone(), profile.agent.clone())),
815 tokens_file: Some(tokens_file),
816 token_key: Some(token_key),
817 project: project_cfg.project,
818 channel: project_cfg.channel,
819 project_root,
820 session,
821 token_origin: None,
822 url_origin,
823 warnings,
824 })
825}
826
827#[derive(Clone, Debug, Serialize)]
829pub struct Verified {
830 pub agent: String,
831 pub team: String,
832}
833
834pub async fn verify(resolved: &Resolved) -> anyhow::Result<Verified> {
838 let (agent, team) = crate::admin_cli::whoami_on_mcp(&resolved.mcp_url, &resolved.token)
842 .await
843 .with_context(|| {
844 let provenance = format!(
845 "endpoint {} came from {}; the credential came from {}",
846 resolved.mcp_url,
847 resolved.url_provenance(),
848 resolved.credential_provenance()
849 );
850 match &resolved.profile {
851 Some(p) => format!(
852 "profile '{p}': the bus at {} did not accept the token. {provenance}. \
853 It may be revoked; issue a new one with `admin token issue --save`",
854 resolved.mcp_url
855 ),
856 None => format!(
857 "the bus at {} did not accept the token. {provenance}",
858 resolved.mcp_url
859 ),
860 }
861 })?;
862 if let Some((exp_team, exp_agent)) = &resolved.expected
863 && (&agent != exp_agent || &team != exp_team)
864 {
865 bail!(
866 "profile '{}' expects {exp_agent}@{exp_team} but the token authenticates as \
867 {agent}@{team}. The entry '{}' of {} belongs to someone else; fix the profile \
868 or replace the entry",
869 resolved.profile.as_deref().unwrap_or("?"),
870 resolved.token_key.as_deref().unwrap_or("?"),
871 resolved
872 .tokens_file
873 .as_ref()
874 .map(|p| p.display().to_string())
875 .unwrap_or_default()
876 );
877 }
878 Ok(Verified { agent, team })
879}
880
881#[cfg(test)]
882mod tests {
883 use super::*;
884
885 fn tmp(name: &str) -> PathBuf {
886 let dir = std::env::temp_dir().join(format!("acs-ctx-{name}-{}", uuid::Uuid::new_v4()));
887 std::fs::create_dir_all(&dir).unwrap();
888 dir
889 }
890
891 fn seed(dir: &Path) {
892 save_profiles(
893 dir,
894 &Profiles {
895 default: Some("acme".into()),
896 profiles: BTreeMap::from([
897 (
898 "acme".into(),
899 Profile {
900 url: "https://acme.example:8443".into(),
901 team: "acme".into(),
902 agent: "joaquin".into(),
903 tokens: "tokens-acme".into(),
904 key: None,
905 },
906 ),
907 (
908 "other".into(),
909 Profile {
910 url: "https://other.example".into(),
911 team: "other".into(),
912 agent: "joaquin".into(),
913 tokens: "tokens-other".into(),
914 key: None,
915 },
916 ),
917 ]),
918 },
919 )
920 .unwrap();
921 std::fs::write(
922 dir.join("tokens-acme"),
923 "_base=acs_base00000000\napi=acs_api000000000\n",
924 )
925 .unwrap();
926 std::fs::write(dir.join("tokens-other"), "_base=acs_other0000000\n").unwrap();
927 }
928
929 fn inputs(dir: &Path, project: &Path) -> Inputs {
930 Inputs {
931 config_dir: dir.to_path_buf(),
932 project_dir: Some(project.to_path_buf()),
933 ..Default::default()
934 }
935 }
936
937 #[test]
938 fn explicit_credentials_win_and_keep_project_metadata() {
939 let dir = tmp("explicit");
940 seed(&dir);
941 let repo = dir.join("repo");
942 std::fs::create_dir_all(&repo).unwrap();
943 std::fs::write(
944 repo.join(PROJECT_FILE),
945 "profile = \"other\"\nproject = \"api\"\nchannel = \"api\"\n",
946 )
947 .unwrap();
948 let mut i = inputs(&dir, &repo);
949 i.explicit_token = Some("acs_explicit".into());
950 i.explicit_url = Some("https://x.example/mcp".into());
951 let r = resolve(&i).unwrap();
952 assert_eq!(r.source, Source::Explicit);
953 assert_eq!(r.token, "acs_explicit");
954 assert_eq!(r.mcp_url, "https://x.example/mcp");
955 assert!(r.expected.is_none(), "explicit credentials promise nothing");
956 assert_eq!(r.project.as_deref(), Some("api"));
957 i.profile = Some("acme".into());
958 let err = resolve(&i).unwrap_err().to_string();
959 assert!(err.contains("drop one"), "two explicit selections: {err}");
960 assert_eq!(r.channel.as_deref(), Some("api"));
961 assert_eq!(
962 r.project_root.as_deref(),
963 Some(repo.canonicalize().unwrap().as_path())
964 );
965 }
966
967 #[test]
968 fn environment_shadowing_a_profile_is_warned_never_reordered() {
969 let dir = tmp("shadow");
970 seed(&dir);
971 let repo = dir.join("repo");
972 std::fs::create_dir_all(&repo).unwrap();
973
974 let mut i = inputs(&dir, &repo);
977 i.explicit_token = Some("acs_leftover".into());
978 i.token_origin = Some(Origin::Environment);
979 let r = resolve(&i).unwrap();
980 assert_eq!(r.source, Source::Explicit, "precedence must not move");
981 assert_eq!(r.token, "acs_leftover");
982 assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
983 assert!(r.warnings[0].contains("BUS_TOKEN (environment)"));
984 assert!(
985 r.warnings[0].contains("profile 'acme'"),
986 "{}",
987 r.warnings[0]
988 );
989 assert!(
990 !r.warnings[0].contains("acs_leftover"),
991 "a warning never carries the secret"
992 );
993
994 let mut i = inputs(&dir, &repo);
997 i.explicit_token = Some("acs_leftover".into());
998 i.token_origin = Some(Origin::Flag);
999 assert!(resolve(&i).unwrap().warnings.is_empty());
1000
1001 std::fs::write(repo.join(PROJECT_FILE), "profile = \"other\"\n").unwrap();
1003 let mut i = inputs(&dir, &repo);
1004 i.explicit_token = Some("acs_leftover".into());
1005 i.token_origin = Some(Origin::Environment);
1006 let r = resolve(&i).unwrap();
1007 assert!(
1008 r.warnings[0].contains("profile 'other'"),
1009 "{}",
1010 r.warnings[0]
1011 );
1012
1013 let bare = tmp("shadow-bare");
1015 let mut i = inputs(&bare, &repo.join("..")); i.explicit_token = Some("acs_leftover".into());
1017 i.token_origin = Some(Origin::Environment);
1018 assert!(resolve(&i).unwrap().warnings.is_empty());
1019 }
1020
1021 #[test]
1022 fn bus_url_from_the_environment_over_a_profile_is_warned() {
1023 let dir = tmp("shadow-url");
1024 seed(&dir);
1025 let repo = dir.join("repo");
1026 std::fs::create_dir_all(&repo).unwrap();
1027
1028 let mut i = inputs(&dir, &repo);
1029 i.explicit_url = Some("https://elsewhere.example".into());
1030 i.url_origin = Some(Origin::Environment);
1031 let r = resolve(&i).unwrap();
1032 assert_eq!(r.mcp_url, "https://elsewhere.example/mcp");
1033 assert_eq!(r.warnings.len(), 1, "{:?}", r.warnings);
1034 assert!(r.warnings[0].contains("BUS_URL (environment)"));
1035 assert!(
1036 r.warnings[0].contains("profile 'acme'"),
1037 "{}",
1038 r.warnings[0]
1039 );
1040 assert!(r.url_provenance().contains("BUS_URL (environment)"));
1041
1042 let mut i = inputs(&dir, &repo);
1044 i.explicit_url = Some("https://elsewhere.example".into());
1045 i.url_origin = Some(Origin::Flag);
1046 assert!(resolve(&i).unwrap().warnings.is_empty());
1047 }
1048
1049 #[test]
1050 fn provenance_names_the_source_without_the_secret() {
1051 let dir = tmp("provenance");
1052 seed(&dir);
1053 let repo = dir.join("repo");
1054 std::fs::create_dir_all(&repo).unwrap();
1055
1056 let r = resolve(&inputs(&dir, &repo)).unwrap();
1058 let p = r.credential_provenance();
1059 assert!(p.contains("entry '_base'"), "{p}");
1060 assert!(p.contains("tokens-acme"), "{p}");
1061 assert!(p.contains("profile 'acme'"), "{p}");
1062 assert!(p.contains("user default"), "{p}");
1063 assert!(!p.contains("acs_base00000000"), "never the secret: {p}");
1064 assert!(r.url_provenance().contains("profile 'acme'"));
1065
1066 let secret = "acs_explicit0secret0secret0secret0secret";
1070 let mut i = inputs(&dir, &repo);
1071 i.explicit_token = Some(secret.into());
1072 i.token_origin = Some(Origin::Environment);
1073 let r = resolve(&i).unwrap();
1074 assert_eq!(r.credential_provenance(), "BUS_TOKEN (environment)");
1075 assert_eq!(r.url_provenance(), "the built-in default");
1076
1077 let view = r.redacted();
1079 assert_eq!(view["token_from"], "BUS_TOKEN (environment)");
1080 assert!(!view.to_string().contains(secret));
1081 }
1082
1083 #[test]
1084 fn origin_of_an_unset_variable_is_the_flag() {
1085 assert_eq!(
1086 Origin::of("ACS_TEST_UNSET_VARIABLE_187", "acs_x"),
1087 Origin::Flag
1088 );
1089 assert_eq!(
1090 Origin::Environment.describe("BUS_TOKEN", "--token"),
1091 "BUS_TOKEN (environment)"
1092 );
1093 assert_eq!(Origin::Flag.describe("BUS_URL", "--url"), "--url (flag)");
1094 }
1095
1096 #[test]
1097 fn profile_flag_beats_project_which_beats_user_default() {
1098 let dir = tmp("precedence");
1099 seed(&dir);
1100 let repo = dir.join("repo");
1101 let nested = repo.join("src").join("deep");
1102 std::fs::create_dir_all(&nested).unwrap();
1103
1104 let r = resolve(&inputs(&dir, &nested)).unwrap();
1106 assert_eq!(r.source, Source::UserDefault);
1107 assert_eq!(r.profile.as_deref(), Some("acme"));
1108 assert_eq!(r.token_key.as_deref(), Some(BASE_KEY));
1109 assert_eq!(r.mcp_url, "https://acme.example:8443/mcp");
1110
1111 std::fs::write(
1114 repo.join(PROJECT_FILE),
1115 "profile = \"acme\"\nproject = \"api\"\n",
1116 )
1117 .unwrap();
1118 let r = resolve(&inputs(&dir, &nested)).unwrap();
1119 assert_eq!(r.source, Source::ProjectDefault);
1120 assert_eq!(r.token_key.as_deref(), Some("api"));
1121 assert_eq!(r.token, "acs_api000000000");
1122 assert_eq!(r.project.as_deref(), Some("api"));
1123
1124 let mut i = inputs(&dir, &nested);
1126 i.profile = Some("other".into());
1127 let r = resolve(&i).unwrap();
1128 assert_eq!(r.source, Source::ProfileFlag);
1129 assert_eq!(r.profile.as_deref(), Some("other"));
1130 assert_eq!(r.token, "acs_other0000000");
1131 assert_eq!(
1132 std::fs::read_to_string(repo.join(PROJECT_FILE)).unwrap(),
1133 "profile = \"acme\"\nproject = \"api\"\n",
1134 "per-invocation selection never rewrites project defaults"
1135 );
1136 }
1137
1138 #[test]
1139 fn a_missing_profile_is_an_error_never_another_team() {
1140 let dir = tmp("missing");
1141 seed(&dir);
1142 let repo = dir.join("repo");
1143 std::fs::create_dir_all(&repo).unwrap();
1144 std::fs::write(repo.join(PROJECT_FILE), "profile = \"stranger\"\n").unwrap();
1145 let err = resolve(&inputs(&dir, &repo)).unwrap_err().to_string();
1146 assert!(err.contains("'stranger' does not exist"), "{err}");
1147 assert!(err.contains("approved locally"), "{err}");
1148 assert!(err.contains("acme, other"), "{err}");
1149
1150 let mut i = inputs(&dir, &repo);
1151 i.profile = Some("nope".into());
1152 let err = resolve(&i).unwrap_err().to_string();
1153 assert!(err.contains("--profile"), "{err}");
1154
1155 std::fs::write(dir.join("tokens-other"), "web=acs_web\n").unwrap();
1157 i.profile = Some("other".into());
1158 let err = resolve(&i).unwrap_err().to_string();
1159 assert!(err.contains("no entry '_base'"), "{err}");
1160 assert!(err.contains("admin token issue"), "{err}");
1161 }
1162
1163 #[test]
1164 fn a_repository_may_not_carry_an_endpoint_or_a_credential() {
1165 let dir = tmp("malicious");
1166 seed(&dir);
1167 let repo = dir.join("repo");
1168 std::fs::create_dir_all(&repo).unwrap();
1169 for evil in [
1170 "profile = \"acme\"\nurl = \"https://evil.example\"\n",
1171 "profile = \"acme\"\ntoken = \"acs_stolen\"\n",
1172 "profile = \"acme\"\ntokens = \"../../etc/passwd\"\n",
1173 "profile = \"acme\"\ntoken_file = \"~/.ssh/id_rsa\"\n",
1177 "profile = \"acme\"\nmcp_url = \"https://evil.example/mcp\"\n",
1178 ] {
1179 std::fs::write(repo.join(PROJECT_FILE), evil).unwrap();
1180 let err = format!("{:#}", resolve(&inputs(&dir, &repo)).unwrap_err());
1181 assert!(
1182 err.contains("may not do") || err.contains("does not accept"),
1183 "{evil}: {err}"
1184 );
1185 }
1186 std::fs::write(
1189 dir.join(PROFILES_FILE),
1190 "[profiles.bad]\nurl = \"https://x\"\nteam = \"t\"\nagent = \"a\"\ntokens = \"../secrets\"\n",
1191 )
1192 .unwrap();
1193 let err = load_profiles(&dir).unwrap_err().to_string();
1194 assert!(err.contains("bare file name"), "{err}");
1195 }
1196
1197 #[test]
1198 fn a_linked_worktree_inherits_the_main_worktrees_project_file() {
1199 let dir = tmp("worktree");
1200 seed(&dir);
1201 let main = dir.join("main");
1202 let wt = dir.join("wt");
1203 std::fs::create_dir_all(main.join(".git").join("worktrees").join("wt")).unwrap();
1204 std::fs::create_dir_all(wt.join("src")).unwrap();
1205 std::fs::write(
1206 main.join(PROJECT_FILE),
1207 "profile = \"acme\"\nproject = \"api\"\n",
1208 )
1209 .unwrap();
1210 std::fs::write(
1211 wt.join(".git"),
1212 format!("gitdir: {}\n", main.join(".git/worktrees/wt").display()),
1213 )
1214 .unwrap();
1215 std::fs::write(main.join(".git/worktrees/wt/commondir"), "../..\n").unwrap();
1216 let (root, cfg) = find_project(&wt.join("src"))
1217 .unwrap()
1218 .expect("found via worktree");
1219 assert_eq!(root, main.canonicalize().unwrap());
1220 assert_eq!(cfg.project.as_deref(), Some("api"));
1221
1222 std::fs::write(
1224 wt.join(PROJECT_FILE),
1225 "profile = \"acme\"\nproject = \"wt\"\n",
1226 )
1227 .unwrap();
1228 let (root, cfg) = find_project(&wt.join("src")).unwrap().unwrap();
1229 assert_eq!(root, wt.canonicalize().unwrap());
1230 assert_eq!(cfg.project.as_deref(), Some("wt"));
1231
1232 let other = dir.join("solo");
1234 std::fs::create_dir_all(other.join(".git")).unwrap();
1235 std::fs::write(dir.join(PROJECT_FILE), "profile = \"acme\"\n").unwrap();
1236 assert!(find_project(&other).unwrap().is_none());
1237 }
1238
1239 #[test]
1240 fn concurrent_profile_updates_never_interleave() {
1241 let dir = tmp("concurrent");
1242 seed(&dir);
1243 let handles: Vec<_> = (0..16)
1244 .map(|i| {
1245 let dir = dir.clone();
1246 std::thread::spawn(move || {
1247 update_profiles(&dir, |p| {
1248 p.profiles.insert(
1249 format!("p{i}"),
1250 Profile {
1251 url: "https://x.example".into(),
1252 team: "t".into(),
1253 agent: "a".into(),
1254 tokens: "tokens-t".into(),
1255 key: None,
1256 },
1257 );
1258 Ok(())
1259 })
1260 .unwrap();
1261 })
1262 })
1263 .collect();
1264 for h in handles {
1265 h.join().unwrap();
1266 }
1267 let p = load_profiles(&dir).unwrap();
1268 assert_eq!(
1269 p.profiles.len(),
1270 2 + 16,
1271 "every update landed, none was lost"
1272 );
1273 #[cfg(unix)]
1274 {
1275 use std::os::unix::fs::PermissionsExt;
1276 let mode = std::fs::metadata(dir.join(PROFILES_FILE))
1277 .unwrap()
1278 .permissions()
1279 .mode()
1280 & 0o777;
1281 assert_eq!(mode, 0o600);
1282 }
1283 }
1284}