Skip to main content

agentmux_srv/server/agent_handlers/
account_label.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The account line of an agent in a list: the My Agents tile and the "Continue
5//! an existing agent" menu (`SPEC_MY_AGENTS_TILES_AUTH_AND_HISTORY_2026_10_03.md` ยง5.2).
6//!
7//! An agent is either **bound** to an account that resolves, and the line is that
8//! account's name, or it is not, and the line is [`NO_AUTH`]. The old
9//! "(missing account)", "(ambient creds)" and "(unknown account)" are gone: a link
10//! whose account is no longer there cannot launch the agent, so it is "No auth"
11//! too, and a failed lookup claims nothing at all (an empty line).
12
13use std::collections::HashMap;
14
15use crate::backend::storage::store::{AgentIdentityLink, IdentityAccount};
16
17pub(super) const NO_AUTH: &str = "No auth";
18
19/// Account names by id. This channel's own accounts win; the global mirror fills
20/// the rest, the order `identity::resolver::resolve_account` uses for display, so
21/// an isolated channel whose links point at accounts only the mirror holds does
22/// not read as unbound.
23pub(super) fn names_by_id<'a>(
24    own: &'a [IdentityAccount],
25    mirror: &'a [IdentityAccount],
26) -> HashMap<&'a str, &'a str> {
27    let mut names: HashMap<&str, &str> = mirror.iter().map(|a| (a.id.as_str(), a.name.as_str())).collect();
28    names.extend(own.iter().map(|a| (a.id.as_str(), a.name.as_str())));
29    names
30}
31
32/// The text of the line, and how many of the agent's links pointed at an account
33/// that is not there.
34#[derive(Debug, PartialEq, Eq)]
35pub(super) struct AccountLine {
36    pub text: String,
37    pub unresolved: usize,
38}
39
40/// What could not be read, so a link that resolves to nothing may mean "the account
41/// is gone" or just "the lookup failed".
42#[derive(Debug, Default, Clone, Copy)]
43pub(super) struct Degraded {
44    /// The lookup of every agent's links failed: "no links" says nothing about this
45    /// agent, so the line is left empty rather than "No auth".
46    pub links: bool,
47    /// The account lookup failed in this channel's store and the global mirror could
48    /// not fill in: a link that resolves to nothing may be a perfectly good account,
49    /// so it is left empty rather than reported as "No auth".
50    pub accounts: bool,
51}
52
53pub(super) fn account_line(
54    links: Option<&[&AgentIdentityLink]>,
55    names: &HashMap<&str, &str>,
56    degraded: Degraded,
57) -> AccountLine {
58    let links = links.unwrap_or(&[]);
59    if links.is_empty() {
60        let text = if degraded.links { String::new() } else { NO_AUTH.to_string() };
61        return AccountLine { text, unresolved: 0 };
62    }
63    let mut resolved: Vec<String> = links.iter().filter_map(|l| names.get(l.account_id.as_str()).map(|n| n.to_string())).collect();
64    let unresolved = links.len() - resolved.len();
65    resolved.sort();
66    resolved.dedup();
67    let text = match (resolved.is_empty(), degraded.accounts) {
68        (false, _) => resolved.join(", "),
69        // Nothing resolved and the accounts could not be read: unknown, not unbound.
70        (true, true) => String::new(),
71        (true, false) => NO_AUTH.to_string(),
72    };
73    // Only a lookup that worked can say an account is gone.
74    AccountLine { text, unresolved: if degraded.accounts { 0 } else { unresolved } }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    fn acct(id: &str, name: &str) -> IdentityAccount {
82        IdentityAccount {
83            id: id.into(),
84            name: name.into(),
85            provider: "claude".into(),
86            kind: "oauth".into(),
87            display_name: String::new(),
88            secret_ref: crate::backend::storage::store::SecretRef::OAuthConfigDir { dir: "/x".into() },
89            context: serde_json::json!({}),
90            status: "valid".into(),
91            created_at: 0,
92            updated_at: 0,
93        }
94    }
95
96    const OK: Degraded = Degraded { links: false, accounts: false };
97
98    fn link(agent: &str, account: &str) -> AgentIdentityLink {
99        AgentIdentityLink { agent_id: agent.into(), account_id: account.into(), provider: "claude".into() }
100    }
101
102    #[test]
103    fn a_bound_agent_shows_its_accounts_sorted_and_deduplicated() {
104        let own = [acct("a1", "work"), acct("a2", "home")];
105        let names = names_by_id(&own, &[]);
106        let ls = [link("ag", "a1"), link("ag", "a2"), link("ag", "a1")];
107        let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
108        assert_eq!(account_line(Some(&refs), &names, OK), AccountLine { text: "home, work".into(), unresolved: 0 });
109    }
110
111    #[test]
112    fn an_unbound_agent_says_no_auth() {
113        let names = names_by_id(&[], &[]);
114        assert_eq!(account_line(None, &names, OK).text, NO_AUTH);
115        assert_eq!(account_line(Some(&[]), &names, OK).text, NO_AUTH);
116    }
117
118    #[test]
119    fn a_link_to_an_account_that_is_gone_says_no_auth_and_is_counted() {
120        let names = names_by_id(&[], &[]);
121        let ls = [link("ag", "deleted")];
122        let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
123        assert_eq!(account_line(Some(&refs), &names, OK), AccountLine { text: NO_AUTH.into(), unresolved: 1 });
124    }
125
126    #[test]
127    fn a_partly_resolvable_agent_shows_the_accounts_it_can_launch_with() {
128        let own = [acct("a1", "work")];
129        let names = names_by_id(&own, &[]);
130        let ls = [link("ag", "a1"), link("ag", "deleted")];
131        let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
132        assert_eq!(account_line(Some(&refs), &names, OK), AccountLine { text: "work".into(), unresolved: 1 });
133    }
134
135    #[test]
136    fn the_global_mirror_resolves_what_this_channel_does_not_and_this_channel_wins() {
137        let own = [acct("a1", "own name")];
138        let mirror = [acct("a1", "mirror name"), acct("a2", "only in mirror")];
139        let names = names_by_id(&own, &mirror);
140        let ls = [link("ag", "a1"), link("ag", "a2")];
141        let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
142        assert_eq!(account_line(Some(&refs), &names, OK).text, "only in mirror, own name");
143    }
144
145    #[test]
146    fn a_failed_links_lookup_claims_nothing() {
147        let names = names_by_id(&[], &[]);
148        let links_down = Degraded { links: true, accounts: false };
149        assert_eq!(account_line(None, &names, links_down).text, "", "unknown is not 'No auth'");
150        // ...but an agent that does have links still shows them
151        let own = [acct("a1", "work")];
152        let names = names_by_id(&own, &[]);
153        let ls = [link("ag", "a1")];
154        let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
155        assert_eq!(account_line(Some(&refs), &names, links_down).text, "work");
156    }
157
158    #[test]
159    fn an_unreadable_accounts_store_makes_a_link_unknown_not_no_auth() {
160        let names = names_by_id(&[], &[]);
161        let ls = [link("ag", "perfectly-good")];
162        let refs: Vec<&AgentIdentityLink> = ls.iter().collect();
163        let accounts_down = Degraded { links: false, accounts: true };
164        // the account may well exist: the lookup failed, so say nothing...
165        assert_eq!(account_line(Some(&refs), &names, accounts_down), AccountLine { text: String::new(), unresolved: 0 });
166        // ...and an agent with no links at all is still plainly unbound
167        assert_eq!(account_line(None, &names, accounts_down).text, NO_AUTH);
168        // ...and an account that DID resolve is still shown
169        let own = [acct("perfectly-good", "work")];
170        let names = names_by_id(&own, &[]);
171        assert_eq!(account_line(Some(&refs), &names, accounts_down).text, "work");
172    }
173
174    #[test]
175    fn the_old_labels_are_gone() {
176        for old in ["(missing account)", "(ambient creds)", "(unknown account)"] {
177            assert!(!NO_AUTH.contains(old));
178        }
179    }
180}