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
311/// What [`keep_project_file_local`] did.
312#[derive(Debug, PartialEq, Eq)]
313pub enum LocalOutcome {
314    /// The entry was appended to this exclude file.
315    Added(PathBuf),
316    /// This exclude file already had it.
317    AlreadyExcluded(PathBuf),
318    /// Not inside a git repository: nothing to exclude.
319    NotARepository,
320}
321
322/// The git directory shared by every worktree of the repository holding
323/// `start`: `.git` itself, or the `commondir` a linked worktree points at.
324fn 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                // A submodule's gitdir is its own common directory.
342                Err(_) => Some(gitdir),
343            };
344        }
345        dir = d.parent();
346    }
347    None
348}
349
350/// Keep the project file out of git: list it in the repository's
351/// `info/exclude` (shared by linked worktrees). It names profiles from each
352/// person's own `profiles.toml`, so a committed copy only works for a team
353/// whose members all use the same profile names; local is the default.
354/// Idempotent when callers serialise it (`set-project` holds the
355/// configuration lock across the check and the append). An exclude never
356/// hides a file git already tracks; see [`project_file_is_tracked`].
357pub 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
394/// Whether git already tracks the project file at `root`, which an exclude
395/// cannot undo. `None` when git is not available to ask.
396pub 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    // Atomic like every other write here; readable by the repository's
414    // tooling, since there is nothing secret in it.
415    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// ----------------------------------------------------------------- resolve --
424
425/// Everything a caller can say. Each field is `None` when not given; the
426/// binary fills them from flags and environment, tests fill them directly.
427/// Where an explicit value physically came from. Provenance for humans
428/// debugging an upgrade, never an authorization signal: precedence is
429/// decided by [`resolve`] exactly as before, whatever the origin.
430#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
431#[serde(rename_all = "kebab-case")]
432pub enum Origin {
433    /// Typed on the command line.
434    Flag,
435    /// Inherited from the process environment.
436    Environment,
437}
438
439impl Origin {
440    /// Best-effort attribution of a clap value that may come from a flag or
441    /// an environment variable. clap does not say which one won, but it
442    /// resolves flag-over-environment; so a value equal to the live
443    /// environment variable is attributed to the environment. The one
444    /// ambiguous case — a flag typed with exactly the environment's value —
445    /// is attributed to the environment, which is harmless because both are
446    /// the same value.
447    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    /// How this origin reads next to the thing it qualifies, e.g.
455    /// `BUS_TOKEN (environment)` or `--token (flag)`.
456    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    /// `--url` / `BUS_URL`.
468    pub explicit_url: Option<String>,
469    /// Where `explicit_url` came from, when known.
470    pub url_origin: Option<Origin>,
471    /// `--token` / `BUS_TOKEN`.
472    pub explicit_token: Option<String>,
473    /// Where `explicit_token` came from, when known.
474    pub token_origin: Option<Origin>,
475    /// `--session` / `BUS_SESSION`.
476    pub explicit_session: Option<String>,
477    /// `--profile` / `BUS_PROFILE`.
478    pub profile: Option<String>,
479    /// `--project-dir` / `BUS_PROJECT_DIR`; the current directory when
480    /// absent.
481    pub project_dir: Option<PathBuf>,
482    /// `--host-session` / `BUS_HOST_SESSION`: the id the host gives this
483    /// conversation. Two processes of one conversation — the MCP proxy and a
484    /// lifecycle hook — derive the same bus session from it without sharing
485    /// state, which is what keeps a hook from draining a sibling window's
486    /// messages.
487    pub host_session: Option<String>,
488}
489
490/// The bus session a conversation id maps to. Pure and deterministic, so
491/// every process of that conversation agrees without coordinating: this is
492/// the handshake, not a file.
493pub 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
498/// Key of the binding record a proxy writes for its conversation.
499pub fn binding_key(host_id: &str) -> String {
500    hex::encode(Sha256::digest(host_id.trim().as_bytes()))
501}
502
503/// What the proxy of this conversation recorded: which profile, project and
504/// role it settled on. Advisory — a hook works without it, just with less.
505#[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    /// Endpoint the proxy of this conversation is connected to.
514    pub mcp_url: Option<String>,
515    /// The session credential. Present only while the window is open, and
516    /// only ever read by `context hook`: never printed, logged or passed in
517    /// argv.
518    pub session_token: Option<String>,
519    pub session_id: Option<String>,
520    /// Epoch to send with it. A hook uses the proxy's epoch rather than
521    /// registering, which would bump it and fence the proxy it belongs to.
522    pub epoch: Option<i64>,
523    pub expires_at: Option<String>,
524    pub closed_at: Option<String>,
525    /// Where the proxy got the conversation id this record is keyed by:
526    /// `explicit`, `claude-code`, `antigravity`, `request-meta` (Codex's
527    /// `_meta.threadId` on the host's own tool calls) or `instance`. Absent in records older
528    /// than 0.7.7. The Codex wake adapter only accepts `request-meta`: the
529    /// thread id must come from Codex, never from a guess.
530    #[serde(default)]
531    pub binding: Option<String>,
532}
533
534/// The binding source that proves a record's key is a Codex thread id.
535pub const CODEX_THREAD_BINDING: &str = "request-meta";
536
537/// Directory holding one record per live conversation. Mode 0700: it is the
538/// only place a session credential is written, and `context hook` is the
539/// only thing that reads one.
540pub 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
547/// Write a binding record: 0700 directory, 0600 file, atomic replace.
548pub 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            // Tightened every time: a directory created by an older version
555            // (or by a careless umask) is corrected rather than trusted.
556            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/// Which rule produced the credentials.
568#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
569#[serde(rename_all = "kebab-case")]
570pub enum Source {
571    /// `--token` / `BUS_TOKEN`.
572    Explicit,
573    /// `--profile` / `BUS_PROFILE`.
574    ProfileFlag,
575    /// `.acs.toml` at the project root.
576    ProjectDefault,
577    /// `default = "…"` in the profile store. Only `profile default` or
578    /// `profile add --default` sets it.
579    UserDefault,
580}
581
582/// The answer. `token` is the secret and the only field [`Self::redacted`]
583/// hides.
584#[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    /// `(team, agent)` the credential is expected to be. Only from a
591    /// profile; explicit credentials promise nothing.
592    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    /// Where the explicit token came from, when `source` is
600    /// [`Source::Explicit`] and the caller said.
601    pub token_origin: Option<Origin>,
602    /// Where the URL came from when it was explicit.
603    pub url_origin: Option<Origin>,
604    /// Configuration the winning rule silently outranked — an installed
605    /// default profile shadowed by leftover environment exports, say. Each
606    /// entry is one printable sentence with no secret in it; every entry
607    /// point shows them on stderr (or the log), never on MCP stdout.
608    pub warnings: Vec<String>,
609}
610
611impl Resolved {
612    /// Where the credential came from, in words a person debugging an
613    /// upgrade can act on. Never contains the secret.
614    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    /// Where the endpoint came from. The URL itself is not a secret; what
638    /// matters is whether the profile's endpoint or an override is in use.
639    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    /// What `context show` prints: everything but the secret, which is
648    /// replaced by its display prefix.
649    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
675/// Read one `name=token` entry from a tokens file.
676fn 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
704/// Resolve the connection context. See the module docs for the order.
705pub 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    // A conversation id fixes the session for every process of that
713    // conversation, and the proxy may have recorded which profile it settled
714    // on. The record never selects a profile over an explicit one, and never
715    // carries a credential.
716    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    // Project metadata is welcome whatever selects the credentials; a
737    // broken project file is reported rather than silently ignored, since
738    // it may be the very thing the operator is trying to use.
739    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    // 1. Explicit credentials win, whole. Two explicit selections at once
750    // are a contradiction to report, not a tie to break quietly.
751    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        // The winner is decided; now name what it silently outranked.
760        // Leftover exports from a previous release shadowing a freshly
761        // configured profile is the normal state of an upgraded machine,
762        // and invisible precedence is what made issue #187 cost hours.
763        // Precedence itself does not move: this only reports it.
764        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                // A broken profile store must not fail explicit credentials,
770                // which never needed it; it just cannot be reported on.
771                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    // The endpoint comes from the explicit flag or the profile — never from
853    // the project file, which cannot even express one.
854    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    // Which entry: the project file's key, the project name, the profile's
871    // own default, then the shared `_base` line — the same order the
872    // per-directory shell wrapper used, so the files it reads keep working.
873    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/// What the server says the credential is.
936#[derive(Clone, Debug, Serialize)]
937pub struct Verified {
938    pub agent: String,
939    pub team: String,
940}
941
942/// Present the resolved credential to the bus and require it to be the
943/// agent and team the profile expects. Explicit credentials, which promise
944/// nothing, are simply reported.
945pub async fn verify(resolved: &Resolved) -> anyhow::Result<Verified> {
946    // On failure the reader gets what the symptom hides: which endpoint was
947    // called and where each piece came from. The one thing never printed is
948    // the credential itself.
949    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        // An environment token over an installed user default: the token
1083        // still wins (precedence untouched) and the shadow is named.
1084        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        // The same token typed as a flag shadows nothing worth warning on:
1103        // the operator said it out loud.
1104        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        // A project file naming a profile is reported over the user default.
1110        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        // No profile anywhere: an explicit token shadows nothing.
1122        let bare = tmp("shadow-bare");
1123        let mut i = inputs(&bare, &repo.join("..")); // no seed: empty config
1124        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        // The same override typed as a flag is intentional: no warning.
1151        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        // Profile path: entry, file and what selected the profile.
1165        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        // Explicit path: the origin, or the flag when unsaid. The fake
1175        // token is full-length so the 12-character display prefix does not
1176        // accidentally equal the whole secret.
1177        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        // Serialized view carries the same, still without the secret.
1186        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        // No project file: the user default.
1213        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        // Project file found from a nested directory; its project name is
1220        // the token key.
1221        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        // The flag overrides the project file without touching it.
1233        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        // Profile exists, entry does not, no _base either.
1264        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            // Not on the forbidden list, and still refused: an unknown key
1282            // is a claim this version does not understand, and tolerating
1283            // it would make a credential-shaped one look accepted.
1284            "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        // A profile whose tokens reference escapes the directory is refused
1295        // at load time.
1296        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        // A worktree with its own file uses that one.
1331        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        // The search stops at a repository root: a file above it is not ours.
1341        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}