Skip to main content

ai_crew_sync/
context.rs

1//! Local connection context: which bus, as whom, for which project.
2//!
3//! Every client-side entry point — the console client, the stdio proxy, the
4//! lifecycle hooks — answers the same question before it can talk to the
5//! bus: *which endpoint, with which token, expected to be which agent of
6//! which team, working on which project?* This module answers it once, from
7//! three local sources and one explicit override, in a fixed order:
8//!
9//! 1. **Explicit credentials** — `--token` / `BUS_TOKEN` (with `--url` /
10//!    `BUS_URL`). The operator said exactly what to use; nothing below may
11//!    override it. A project file still contributes *metadata* (project
12//!    name, channel), never credentials.
13//! 2. **Explicit profile** — `--profile` / `BUS_PROFILE`. Must exist in the
14//!    local profile store; a missing profile is an error, never a fallback.
15//! 3. **Project defaults** — `.acs.toml` at the project root names an
16//!    approved profile and a logical project. The repository is untrusted:
17//!    it may only *name* a profile that the operator defined locally, and it
18//!    may never carry an endpoint or a credential. A name that does not
19//!    resolve locally is an error, not a different team.
20//! 4. **User default** — `default = "…"` in the profile store.
21//!
22//! Profiles live in `<config dir>/profiles.toml` and carry the endpoint, the
23//! expected team and agent, and a *reference* to a credential: the name of a
24//! `tokens-<team>` file (the same `name=token` files `admin token issue
25//! --save` writes) and, optionally, which entry. The secret itself is read
26//! at resolve time and never stored twice.
27//!
28//! Nothing here logs; [`Resolved::redacted`] is what `context show` prints.
29
30use 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";
44/// Entry used when a project has no entry of its own in a tokens file.
45pub const BASE_KEY: &str = "_base";
46/// Default MCP endpoint, kept from the console client's original default.
47pub const DEFAULT_MCP_URL: &str = "http://localhost:8787/mcp";
48
49/// How far up a directory tree the project file is searched for. A source
50/// tree is never this deep; a bound keeps a stray symlink loop finite.
51const MAX_ASCENT: usize = 64;
52
53// ---------------------------------------------------------------- profiles --
54
55/// An operator-approved way to reach a bus: endpoint, expected identity and
56/// where the credential lives. Never contains the secret.
57#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
58pub struct Profile {
59    /// Base URL of the bus (`https://bus.example.com:8443`); `/mcp` is
60    /// appended. A URL pasted with `/mcp` already on it is accepted.
61    pub url: String,
62    /// Team the credential is expected to belong to. Verified against the
63    /// server's `whoami`, never assumed.
64    pub team: String,
65    /// Agent the credential is expected to be.
66    pub agent: String,
67    /// Name of the token file inside the configuration directory, e.g.
68    /// `tokens-acme`. A bare file name: it may not point outside that
69    /// directory.
70    pub tokens: String,
71    /// Entry of the token file to use when the project names none. Defaults
72    /// to the project name, then `_base`.
73    #[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    /// Profile used when nothing else selects one.
80    #[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
90/// A profile name is one safe word: it is a file key people type and a
91/// value a repository may reference.
92pub 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
109/// A tokens file reference stays inside the configuration directory: a bare
110/// name, no separators, no traversal.
111pub 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
143/// Serialise every write to the configuration directory through one lock,
144/// so two `context profile add` or two `--save` running at once cannot
145/// interleave a read-modify-write. The lock file itself is empty.
146pub 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
171/// Read-modify-write a profile store under the lock.
172pub 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// ------------------------------------------------------------ project file --
184
185/// What a repository may say about itself. Names only: it references an
186/// approved profile, it never defines one.
187/// `deny_unknown_fields` on purpose: this file comes from a repository, so
188/// an unrecognised key is a claim we do not understand, not a comment. A
189/// tolerated `token_file =` that silently did nothing would be indistinguishable
190/// from one that worked.
191#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
192#[serde(deny_unknown_fields)]
193pub struct ProjectConfig {
194    /// Locally approved profile to connect with.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub profile: Option<String>,
197    /// Logical project name; also the default token-file entry.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub project: Option<String>,
200    /// Channel this project's sessions post to by default.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub channel: Option<String>,
203    /// Token-file entry to use instead of the project name.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub key: Option<String>,
206}
207
208/// Keys a repository must never carry. Their presence is refused outright
209/// rather than ignored: an operator who sees `url =` in a project file must
210/// not believe it does something.
211const 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
246/// The project a directory belongs to: the nearest ancestor holding
247/// `.acs.toml`. A linked git worktree that has no file of its own inherits
248/// the main worktree's, so one checked-in file covers every worktree of the
249/// repository.
250pub 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        // A `.git` *file* marks a linked worktree; remember where the main
268        // worktree is, and stop climbing past the repository root.
269        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
292/// Resolve `gitdir: …/.git/worktrees/<name>` to the main worktree directory
293/// through the `commondir` file git keeps next to it.
294fn 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    // commondir is the main worktree's .git directory.
308    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    // Atomic like every other write here; readable by the repository's
317    // tooling, since there is nothing secret in it.
318    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// ----------------------------------------------------------------- resolve --
327
328/// Everything a caller can say. Each field is `None` when not given; the
329/// binary fills them from flags and environment, tests fill them directly.
330/// Where an explicit value physically came from. Provenance for humans
331/// debugging an upgrade, never an authorization signal: precedence is
332/// decided by [`resolve`] exactly as before, whatever the origin.
333#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
334#[serde(rename_all = "kebab-case")]
335pub enum Origin {
336    /// Typed on the command line.
337    Flag,
338    /// Inherited from the process environment.
339    Environment,
340}
341
342impl Origin {
343    /// Best-effort attribution of a clap value that may come from a flag or
344    /// an environment variable. clap does not say which one won, but it
345    /// resolves flag-over-environment; so a value equal to the live
346    /// environment variable is attributed to the environment. The one
347    /// ambiguous case — a flag typed with exactly the environment's value —
348    /// is attributed to the environment, which is harmless because both are
349    /// the same value.
350    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    /// How this origin reads next to the thing it qualifies, e.g.
358    /// `BUS_TOKEN (environment)` or `--token (flag)`.
359    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    /// `--url` / `BUS_URL`.
371    pub explicit_url: Option<String>,
372    /// Where `explicit_url` came from, when known.
373    pub url_origin: Option<Origin>,
374    /// `--token` / `BUS_TOKEN`.
375    pub explicit_token: Option<String>,
376    /// Where `explicit_token` came from, when known.
377    pub token_origin: Option<Origin>,
378    /// `--session` / `BUS_SESSION`.
379    pub explicit_session: Option<String>,
380    /// `--profile` / `BUS_PROFILE`.
381    pub profile: Option<String>,
382    /// `--project-dir` / `BUS_PROJECT_DIR`; the current directory when
383    /// absent.
384    pub project_dir: Option<PathBuf>,
385    /// `--host-session` / `BUS_HOST_SESSION`: the id the host gives this
386    /// conversation. Two processes of one conversation — the MCP proxy and a
387    /// lifecycle hook — derive the same bus session from it without sharing
388    /// state, which is what keeps a hook from draining a sibling window's
389    /// messages.
390    pub host_session: Option<String>,
391}
392
393/// The bus session a conversation id maps to. Pure and deterministic, so
394/// every process of that conversation agrees without coordinating: this is
395/// the handshake, not a file.
396pub 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
401/// Key of the binding record a proxy writes for its conversation.
402pub fn binding_key(host_id: &str) -> String {
403    hex::encode(Sha256::digest(host_id.trim().as_bytes()))
404}
405
406/// What the proxy of this conversation recorded: which profile, project and
407/// role it settled on. Advisory — a hook works without it, just with less.
408#[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    /// Endpoint the proxy of this conversation is connected to.
417    pub mcp_url: Option<String>,
418    /// The session credential. Present only while the window is open, and
419    /// only ever read by `context hook`: never printed, logged or passed in
420    /// argv.
421    pub session_token: Option<String>,
422    pub session_id: Option<String>,
423    /// Epoch to send with it. A hook uses the proxy's epoch rather than
424    /// registering, which would bump it and fence the proxy it belongs to.
425    pub epoch: Option<i64>,
426    pub expires_at: Option<String>,
427    pub closed_at: Option<String>,
428}
429
430/// Directory holding one record per live conversation. Mode 0700: it is the
431/// only place a session credential is written, and `context hook` is the
432/// only thing that reads one.
433pub 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
440/// Write a binding record: 0700 directory, 0600 file, atomic replace.
441pub 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            // Tightened every time: a directory created by an older version
448            // (or by a careless umask) is corrected rather than trusted.
449            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/// Which rule produced the credentials.
461#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
462#[serde(rename_all = "kebab-case")]
463pub enum Source {
464    /// `--token` / `BUS_TOKEN`.
465    Explicit,
466    /// `--profile` / `BUS_PROFILE`.
467    ProfileFlag,
468    /// `.acs.toml` at the project root.
469    ProjectDefault,
470    /// `default = "…"` in the profile store.
471    UserDefault,
472}
473
474/// The answer. `token` is the secret and the only field [`Self::redacted`]
475/// hides.
476#[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    /// `(team, agent)` the credential is expected to be. Only from a
483    /// profile; explicit credentials promise nothing.
484    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    /// Where the explicit token came from, when `source` is
492    /// [`Source::Explicit`] and the caller said.
493    pub token_origin: Option<Origin>,
494    /// Where the URL came from when it was explicit.
495    pub url_origin: Option<Origin>,
496    /// Configuration the winning rule silently outranked — an installed
497    /// default profile shadowed by leftover environment exports, say. Each
498    /// entry is one printable sentence with no secret in it; every entry
499    /// point shows them on stderr (or the log), never on MCP stdout.
500    pub warnings: Vec<String>,
501}
502
503impl Resolved {
504    /// Where the credential came from, in words a person debugging an
505    /// upgrade can act on. Never contains the secret.
506    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    /// Where the endpoint came from. The URL itself is not a secret; what
530    /// matters is whether the profile's endpoint or an override is in use.
531    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    /// What `context show` prints: everything but the secret, which is
540    /// replaced by its display prefix.
541    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
567/// Read one `name=token` entry from a tokens file.
568fn 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
596/// Resolve the connection context. See the module docs for the order.
597pub 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    // A conversation id fixes the session for every process of that
605    // conversation, and the proxy may have recorded which profile it settled
606    // on. The record never selects a profile over an explicit one, and never
607    // carries a credential.
608    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    // Project metadata is welcome whatever selects the credentials; a
629    // broken project file is reported rather than silently ignored, since
630    // it may be the very thing the operator is trying to use.
631    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    // 1. Explicit credentials win, whole. Two explicit selections at once
642    // are a contradiction to report, not a tie to break quietly.
643    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        // The winner is decided; now name what it silently outranked.
652        // Leftover exports from a previous release shadowing a freshly
653        // configured profile is the normal state of an upgraded machine,
654        // and invisible precedence is what made issue #187 cost hours.
655        // Precedence itself does not move: this only reports it.
656        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                // A broken profile store must not fail explicit credentials,
662                // which never needed it; it just cannot be reported on.
663                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    // The endpoint comes from the explicit flag or the profile — never from
745    // the project file, which cannot even express one.
746    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    // Which entry: the project file's key, the project name, the profile's
763    // own default, then the shared `_base` line — the same order the
764    // per-directory shell wrapper used, so the files it reads keep working.
765    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/// What the server says the credential is.
828#[derive(Clone, Debug, Serialize)]
829pub struct Verified {
830    pub agent: String,
831    pub team: String,
832}
833
834/// Present the resolved credential to the bus and require it to be the
835/// agent and team the profile expects. Explicit credentials, which promise
836/// nothing, are simply reported.
837pub async fn verify(resolved: &Resolved) -> anyhow::Result<Verified> {
838    // On failure the reader gets what the symptom hides: which endpoint was
839    // called and where each piece came from. The one thing never printed is
840    // the credential itself.
841    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        // An environment token over an installed user default: the token
975        // still wins (precedence untouched) and the shadow is named.
976        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        // The same token typed as a flag shadows nothing worth warning on:
995        // the operator said it out loud.
996        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        // A project file naming a profile is reported over the user default.
1002        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        // No profile anywhere: an explicit token shadows nothing.
1014        let bare = tmp("shadow-bare");
1015        let mut i = inputs(&bare, &repo.join("..")); // no seed: empty config
1016        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        // The same override typed as a flag is intentional: no warning.
1043        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        // Profile path: entry, file and what selected the profile.
1057        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        // Explicit path: the origin, or the flag when unsaid. The fake
1067        // token is full-length so the 12-character display prefix does not
1068        // accidentally equal the whole secret.
1069        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        // Serialized view carries the same, still without the secret.
1078        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        // No project file: the user default.
1105        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        // Project file found from a nested directory; its project name is
1112        // the token key.
1113        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        // The flag overrides the project file without touching it.
1125        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        // Profile exists, entry does not, no _base either.
1156        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            // Not on the forbidden list, and still refused: an unknown key
1174            // is a claim this version does not understand, and tolerating
1175            // it would make a credential-shaped one look accepted.
1176            "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        // A profile whose tokens reference escapes the directory is refused
1187        // at load time.
1188        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        // A worktree with its own file uses that one.
1223        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        // The search stops at a repository root: a file above it is not ours.
1233        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}