1use crate::backend::storage::agents::AgentNameMatch;
34use crate::backend::storage::error::StoreError;
35use crate::backend::storage::store::Store;
36
37use super::caller::Caller;
38use super::AppState;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub(crate) enum Outcome {
42 Mismatch,
43 Ambiguous,
44 Absent,
45 Unchecked,
46}
47
48macro_rules! actor_sites {
49 ($($site:ident => $name:literal,)*) => {
50 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
52 pub(crate) enum ActorSite {
53 $($site,)*
54 }
55
56 impl ActorSite {
57 pub(crate) fn counter(self, outcome: Outcome) -> &'static str {
58 match (self, outcome) {
59 $(
60 (ActorSite::$site, Outcome::Mismatch) => concat!("m4.actor_mismatch.", $name),
61 (ActorSite::$site, Outcome::Ambiguous) => concat!("m4.actor_ambiguous.", $name),
62 (ActorSite::$site, Outcome::Absent) => concat!("m4.actor_absent.", $name),
63 (ActorSite::$site, Outcome::Unchecked) => concat!("m4.actor_unchecked.", $name),
64 )*
65 }
66 }
67 }
68 };
69}
70
71actor_sites! {
72 MemoryList => "memory_list",
73 MemoryRead => "memory_read",
74 MemoryWrite => "memory_write",
75 MemoryHistory => "memory_history",
76 MemoryDiff => "memory_diff",
77 MemoryRevert => "memory_revert",
78 GlobalMemoryWrite => "globalmemory_write",
79 GlobalMemoryRevert => "globalmemory_revert",
80 IdentityAccounts => "identity_accounts",
81 PresetGet => "preset_get",
82 IdentityValidate => "identity_validate",
83 HistorySearch => "history_search",
84 WorkEnqueue => "work_enqueue",
85 WorkClaim => "work_claim",
86 WorkHeartbeat => "work_heartbeat",
87 WorkComplete => "work_complete",
88 WorkRelease => "work_release",
89 CronCreate => "cron_create",
90 Inject => "inject",
91 SupervisorDecision => "supervisor_decision",
92 BusSend => "bus_send",
93 BusInject => "bus_inject",
94 BusBroadcast => "bus_broadcast",
95 UiAuth => "ui_auth",
96}
97
98pub(crate) fn names_the_row(row: &AgentNameMatch, actor: &str) -> bool {
100 let actor = actor.trim();
101 !actor.is_empty()
102 && (actor == row.slug
103 || actor.eq_ignore_ascii_case(&row.name)
104 || actor.eq_ignore_ascii_case(&row.instance_name))
105}
106
107pub(crate) fn classify(
112 row: &AgentNameMatch,
113 actor: &str,
114 rows_named: impl FnOnce(&str) -> Result<Vec<AgentNameMatch>, StoreError>,
115) -> Option<Outcome> {
116 let actor = actor.trim();
117 if actor == row.slug {
118 return None;
119 }
120 if !names_the_row(row, actor) {
121 return Some(Outcome::Mismatch);
122 }
123 match rows_named(actor) {
124 Ok(rows) if rows.iter().any(|r| r.id != row.id) => Some(Outcome::Ambiguous),
125 Ok(_) => None,
126 Err(_) => Some(Outcome::Unchecked),
127 }
128}
129
130pub(crate) fn uid_has_slug(store: &Store, uid: &str, name: &str) -> bool {
134 matches!(store.agent_names_by_id(uid), Ok(Some(row)) if !row.slug.is_empty() && row.slug == name)
135}
136
137pub(crate) fn jekt_sender_by_token(store: &Store, req: &crate::backend::reactive::InjectionRequest, claimed: &str) -> bool {
141 req.delivery_tier.as_deref() == Some("host")
142 && !req.audit_source_uid.is_empty()
143 && uid_has_slug(store, &req.audit_source_uid, claimed)
144}
145
146pub(crate) fn own_pane_by_token(store: &Store, caller: Option<&Caller>, name: &str) -> Option<String> {
150 let uid = caller.and_then(Caller::uid)?;
151 if !uid_has_slug(store, uid, name) {
152 return None;
153 }
154 crate::backend::reactive::handler::get_global_handler().block_for_uid(uid)
155}
156
157pub(crate) fn check_actor(
162 state: &AppState,
163 caller: Option<&Caller>,
164 site: ActorSite,
165 actor: Option<&str>,
166) {
167 let Some(uid) = caller.and_then(Caller::uid) else {
168 return;
169 };
170 let Some(actor) = actor.map(str::trim).filter(|a| !a.is_empty()) else {
171 crate::backend::agent_resolve::record_uid_fallback(site.counter(Outcome::Absent));
172 return;
173 };
174 let (mstore, uid, actor) = (state.mstore.clone(), uid.to_string(), actor.to_string());
175 let run = move || count_against_row(&mstore, &uid, site, &actor);
176 #[cfg(not(test))]
177 drop(tokio::task::spawn_blocking(run));
178 #[cfg(test)]
179 run();
180}
181
182fn count_against_row(store: &Store, uid: &str, site: ActorSite, actor: &str) {
183 let outcome = match store.agent_names_by_id(uid) {
184 Ok(Some(row)) => classify(&row, actor, |name| store.rows_answering_to(name)),
185 Ok(None) | Err(_) => Some(Outcome::Unchecked),
186 };
187 let Some(outcome) = outcome else {
188 return;
189 };
190 let counter = site.counter(outcome);
191 crate::backend::agent_resolve::record_uid_fallback(counter);
192 tracing::debug!(
193 counter,
194 caller_uid = uid,
195 actor = %actor.chars().take(128).collect::<String>(),
196 "identity M4a-2: the request's actor name is not plainly the calling agent's"
197 );
198}
199
200pub(crate) fn check_carried_uid(caller: Option<&Caller>, carried_uid: &str) {
204 let Some(uid) = caller.and_then(Caller::uid) else {
205 return;
206 };
207 let carried = carried_uid.trim();
208 if !carried.is_empty() && carried != uid {
209 crate::backend::agent_resolve::record_uid_fallback("m4.actor_uid_mismatch.work_claim");
210 }
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 fn row(id: &str, name: &str, slug: &str, instance_name: &str) -> AgentNameMatch {
218 AgentNameMatch {
219 id: id.into(),
220 name: name.into(),
221 slug: slug.into(),
222 instance_name: instance_name.into(),
223 }
224 }
225
226 fn only(
227 rows: Vec<AgentNameMatch>,
228 ) -> impl FnOnce(&str) -> Result<Vec<AgentNameMatch>, StoreError> {
229 move |_| Ok(rows)
230 }
231
232 #[test]
233 fn a_name_the_row_answers_to_matches() {
234 let r = row("uid-y", "AgentY", "agenty", "Yardbird");
235 assert!(names_the_row(&r, "agenty"));
236 assert!(names_the_row(&r, " agenty "));
237 assert!(
238 names_the_row(&r, "AGENTY"),
239 "display name is case-insensitive"
240 );
241 assert!(names_the_row(&r, "yardbird"));
242 assert!(!names_the_row(&r, "agentz"));
243 assert!(!names_the_row(&r, ""));
244 let r = row("uid-y", "Why", "agenty", "");
246 assert!(!names_the_row(&r, "AGENTY"));
247 assert!(
248 !names_the_row(&r, ""),
249 "an empty instance name matches nothing"
250 );
251 }
252
253 #[test]
254 fn the_callers_own_slug_is_never_counted_and_asks_nothing() {
255 let r = row("uid-y2", "AGENTY", "agenty-2", "");
256 assert_eq!(classify(&r, "agenty-2", |_| panic!("not asked")), None);
257 }
258
259 #[test]
260 fn a_name_that_is_not_the_callers_is_a_mismatch() {
261 let r = row("uid-y2", "AGENTY", "agenty-2", "");
262 assert_eq!(
263 classify(&r, "agentz", |_| panic!("not asked")),
264 Some(Outcome::Mismatch)
265 );
266 }
267
268 #[test]
273 fn a_colliding_name_that_also_selects_another_row_is_ambiguous() {
274 let first = row("uid-y", "AgentY", "agenty", "");
275 let second = row("uid-y2", "AGENTY", "agenty-2", "");
276 assert_eq!(
277 classify(&second, "agenty", only(vec![first.clone(), second.clone()])),
278 Some(Outcome::Ambiguous)
279 );
280 assert_eq!(
282 classify(&second, "AGENTY", only(vec![first.clone(), second.clone()])),
283 Some(Outcome::Ambiguous)
284 );
285 let zed = row("uid-z", "Zed", "zed-2", "");
287 assert_eq!(classify(&zed, "zed", only(vec![zed.clone()])), None);
288 assert_eq!(
289 classify(&second, "agenty", |_| Err(StoreError::NotFound)),
290 Some(Outcome::Unchecked)
291 );
292 }
293}