ai_memory/identity/mod.rs
1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Non-Human Identity (NHI) resolution for `agent_id`.
5//!
6//! Every stored memory carries `metadata.agent_id` — a best-effort identifier
7//! for the agent (AI, human, or system) that wrote it. This module encapsulates
8//! the precedence chain and default-id synthesis for all three entry points
9//! (CLI, MCP, HTTP) so that the identity format is uniform.
10//!
11//! # Precedence (CLI / MCP)
12//!
13//! 1. Explicit id passed by the caller (`--agent-id`, MCP tool param)
14//! 2. `AI_MEMORY_AGENT_ID` environment variable
15//! 3. (MCP only) `initialize.clientInfo.name` captured at handshake time
16//! → `ai:<client>@<hostname>:pid-<pid>`
17//! 4. `host:<hostname>:pid-<pid>-<uuid8>` — stable per-process
18//! 5. `anonymous:pid-<pid>-<uuid8>` — fallback if hostname is unavailable
19//!
20//! # Precedence (HTTP)
21//!
22//! HTTP `serve` is multi-tenant; no process-level default is ever cached.
23//!
24//! 1. Request body `agent_id` field
25//! 2. `X-Agent-Id` request header
26//! 3. Per-request `anonymous:req-<uuid8>` (emits a `WARN` log line)
27//!
28//! # Trust
29//!
30//! `agent_id` is a *claimed* identity, not an *attested* one. Do not use it
31//! for security decisions without pairing it with agent registration (Task
32//! 1.3) and, eventually, signed attestations.
33
34use std::sync::OnceLock;
35
36use anyhow::Result;
37
38use crate::validate;
39
40// v0.7 Track H — Ed25519 attested identity. The keypair lifecycle
41// (generate / save / load / list / export-pub) lives in its own
42// submodule so this file stays focused on `agent_id` resolution. H2+
43// will plumb the loaded `AgentKeypair` through `AppState` for outbound
44// link signing.
45pub mod keypair;
46// H2 — outbound link signing. Canonical CBOR + Ed25519 sign over the
47// six signable link fields. Consumed by `db::create_link_signed` to
48// fill the previously-dead `signature` BLOB column on `memory_links`.
49pub mod sign;
50// H3 — inbound link verification. Mirror of `sign`: re-derives the
51// canonical CBOR bytes from a wire `SignableLink` and verifies the
52// 64-byte signature against the public key associated with the link's
53// `observed_by` claim. Consumed by federation `sync_push` link replay
54// so tampered or forged links never land in `memory_links`.
55pub mod verify;
56// H5 (v0.7.0 round-2) — Ed25519 verify-link replay protection.
57// Bounded in-memory LRU keyed on `(link_id, signature, nonce)`. Sits
58// in front of `verify_link_handler` and rejects exact-repeat requests
59// with 409 Conflict so an attacker cannot replay a captured verify
60// indefinitely. See module docs for the threat model + memory bound.
61pub mod replay;
62// #626 Layer-3 (Task 1.3 / C4) — store-path agent attestation glue.
63// Ties SignableWrite (C1) + bound-key lookup (C3) + the attest_write gate
64// (C4) into stamp_attestation_{sync,async}, which the write surfaces call
65// to resolve metadata.attest_level (claimed / agent_attested) before
66// persisting. Permissive-default; fail-closed on a presented-but-bad sig.
67pub mod attest;
68// #1558 — reserved caller-identity sentinel SSOT. Every internal /
69// system principal string (privileged carve-outs, resolve-failure
70// sentinels, daemon agent ids) lives here as one named const;
71// `crate::validate::RESERVED_AGENT_IDS` is built from these.
72pub mod sentinels;
73
74/// Environment variable override for `agent_id` (used by CLI via clap's
75/// `env = "AI_MEMORY_AGENT_ID"`; read directly for MCP fallback).
76const ENV_AGENT_ID: &str = "AI_MEMORY_AGENT_ID";
77
78/// Environment variable opt-out for the hostname-revealing default (#198).
79/// When truthy (`1`, `true`, `yes`, `on`), the `host:<hostname>:pid-...`
80/// fallback is skipped and `anonymous:pid-...` is used instead.
81/// `pub` since #1558 so the daemon bootstrap (which maps the config
82/// flag onto this env var) shares the spelling.
83/// `AppConfig::effective_anonymize_default()` mirrors the same semantics
84/// from the config file, and CLI startup maps config → this env var so
85/// the downstream resolution stays env-only.
86pub const ENV_ANONYMIZE: &str = "AI_MEMORY_ANONYMIZE";
87
88/// Returns true when the hostname-revealing default should be suppressed.
89fn anonymize_default_enabled() -> bool {
90 let Ok(v) = std::env::var(ENV_ANONYMIZE) else {
91 return false;
92 };
93 matches!(
94 v.trim().to_ascii_lowercase().as_str(),
95 "1" | "true" | "yes" | "on"
96 )
97}
98
99/// Returns a stable-for-this-process discriminator of the form
100/// `<pid>-<uuid8>`. Used to make process-level defaults collision-free
101/// when many agents share a host (e.g., 25 MCP clients on one machine).
102pub fn process_discriminator() -> &'static str {
103 static DISCRIMINATOR: OnceLock<String> = OnceLock::new();
104 DISCRIMINATOR.get_or_init(|| {
105 let pid = std::process::id();
106 let uuid_short = short_uuid();
107 format!("pid-{pid}-{uuid_short}")
108 })
109}
110
111/// Returns the machine hostname (OS-reported) or `None` when unavailable.
112/// Errors or empty hostnames collapse to `None`.
113fn hostname_opt() -> Option<String> {
114 let os = gethostname::gethostname();
115 let s = os.to_string_lossy().to_string();
116 let s = s.trim().to_string();
117 if s.is_empty() { None } else { Some(s) }
118}
119
120/// 8 lowercase hex characters derived from a fresh `UUIDv4`.
121fn short_uuid() -> String {
122 let id = uuid::Uuid::new_v4();
123 let simple = id.simple().to_string(); // 32 hex chars, no hyphens
124 simple[..8].to_string()
125}
126
127/// Sanitize a string for embedding into an `agent_id`.
128///
129/// Replaces any character not in the allowlist with `-` and collapses runs.
130/// This lets us fold arbitrary client names or hostnames (which may contain
131/// dots, spaces, etc.) into valid `agent_id` components without rejecting them.
132fn sanitize_component(input: &str) -> String {
133 let mut out = String::with_capacity(input.len());
134 let mut last_dash = false;
135 for c in input.chars() {
136 if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
137 out.push(c);
138 last_dash = false;
139 } else if !last_dash {
140 out.push('-');
141 last_dash = true;
142 }
143 }
144 // Trim leading/trailing dashes
145 out.trim_matches('-').to_string()
146}
147
148/// Resolve `agent_id` for CLI and MCP paths.
149///
150/// See module docs for precedence. Returned id is always valid per
151/// [`validate::validate_agent_id`].
152pub fn resolve_agent_id(explicit: Option<&str>, mcp_client: Option<&str>) -> Result<String> {
153 // 1. Explicit caller value (already env-merged by clap for CLI)
154 if let Some(id) = explicit
155 && !id.is_empty()
156 {
157 validate::validate_agent_id(id)?;
158 return Ok(id.to_string());
159 }
160
161 // 2. AI_MEMORY_AGENT_ID env var (for MCP path; CLI clap merges this already,
162 // but MCP callers that don't pass it explicitly need this fallback).
163 //
164 // Uses [`validate::validate_agent_id_shape`] (shape-only) rather than
165 // [`validate::validate_agent_id`] (wire-strict, also rejects
166 // [`validate::RESERVED_AGENT_IDS`]) because the env-var path is an
167 // internal-bootstrap surface: the daemon's own self-signing keypair
168 // label (`DAEMON_KEYPAIR_LABEL` = "daemon" at
169 // `src/daemon_runtime.rs`) legitimately resolves through this path
170 // when the operator (or `entrypoint.plan-c.sh` pre-#1231) injects
171 // `AI_MEMORY_AGENT_ID=daemon` for daemon-process startup. Wire-side
172 // callers (HTTP body `agent_id`, MCP `agent_id` tool param) still
173 // flow through the strict `validate_agent_id` at their own ingress
174 // boundary — the env-var carve-out does not loosen the wire posture.
175 // Closes #1234 (RCA: this site was missed when #977 introduced
176 // RESERVED_AGENT_IDS + the shape/wire split).
177 if let Ok(v) = std::env::var(ENV_AGENT_ID)
178 && !v.is_empty()
179 {
180 validate::validate_agent_id_shape(&v)?;
181 return Ok(v);
182 }
183
184 // 3. MCP clientInfo-synthesized id (only when the MCP server captured it)
185 if let Some(client) = mcp_client
186 && !client.is_empty()
187 {
188 let client_s = sanitize_component(client);
189 let host_s =
190 hostname_opt().map_or_else(|| "unknown".to_string(), |h| sanitize_component(&h));
191 let pid = std::process::id();
192 let id = format!("ai:{client_s}@{host_s}:pid-{pid}");
193 if validate::validate_agent_id(&id).is_ok() {
194 return Ok(id);
195 }
196 // Fall through to host: default if the synthesized id is somehow invalid
197 }
198
199 // 4. host:<hostname>:<discriminator> — unless operator opted out (#198).
200 if !anonymize_default_enabled()
201 && let Some(host) = hostname_opt()
202 {
203 let host_s = sanitize_component(&host);
204 if !host_s.is_empty() {
205 let discriminator = process_discriminator();
206 let id = format!("host:{host_s}:{discriminator}");
207 if validate::validate_agent_id(&id).is_ok() {
208 return Ok(id);
209 }
210 }
211 }
212
213 // 5. anonymous:<discriminator>
214 let discriminator = process_discriminator();
215 let id = format!("anonymous:{discriminator}");
216 validate::validate_agent_id(&id)?;
217 Ok(id)
218}
219
220/// v0.7.0 #1468/#1469 — resolve the visibility *caller* for MCP read
221/// paths (`memory_session_start` / `memory_list` / `memory_search` /
222/// `memory_recall`).
223///
224/// Returns ONLY the stable `AI_MEMORY_AGENT_ID` env override — the exact
225/// same step-2 value the write ladder in [`resolve_agent_id`] stamps into
226/// `metadata.agent_id` — or `None`.
227///
228/// This deliberately does NOT fall through to the clientInfo/host
229/// synthesized ids (steps 3-5). Those embed the live `pid`, so a caller
230/// id minted this process can NEVER equal the owner stamped by a *prior*
231/// process. Threading such an id as the read-path caller both (a) hides an
232/// env-pinned agent's own `scope=private` rows on a fresh-process resume
233/// (#1469) and (b) fails to scope a multi-agent deployment that relies on
234/// the env override for stable identity (#1468). Returning `None` when the
235/// env is unset preserves the single-tenant "trust the local caller"
236/// read posture: the handler skips the ownership post-filter entirely.
237#[must_use]
238pub fn resolve_read_visibility_caller() -> Option<String> {
239 let v = std::env::var(ENV_AGENT_ID).ok()?;
240 if v.is_empty() {
241 return None;
242 }
243 // Match the write path's shape gate so the caller string is identical
244 // to the owner the store stamped via the same env var. A
245 // shape-invalid env value never became an owner, so it can never be a
246 // legitimate caller — drop to None (trust-all) rather than filter
247 // against a value nothing is owned by.
248 validate::validate_agent_id_shape(&v).ok()?;
249 Some(v)
250}
251
252/// Resolve `agent_id` for a single HTTP request.
253///
254/// `body` is the (optional) `agent_id` field from `CreateMemory`;
255/// `header` is the value of the `X-Agent-Id` request header. If neither
256/// is present a per-request `anonymous:req-<uuid8>` id is synthesized
257/// and a `WARN` is logged so operators notice unauthenticated writes.
258///
259/// # SECURITY (v0.7.0 — header-first; body must match)
260///
261/// This primitive is **safe by default**: the request header
262/// `X-Agent-Id` is the AUTHORITATIVE identity slot, and any body-side
263/// `agent_id` is a REFINEMENT that MUST agree with the header. The
264/// body slot is caller-controlled — historically it had PRECEDENCE
265/// over the header, which was the cross-tenant spoof vector closed by
266/// the v0.7.0 #874/#901/#905-#910 issue series (#874 unsubscribe +
267/// list_subscriptions, #901 notify + subscribe + get_inbox, #905
268/// power_consolidation, #907 create_memory, #909 quota_status, #910
269/// list_memories + kg_query visibility filter). Those per-handler
270/// patches each had to pass `body: None` as a workaround because the
271/// primitive itself trusted body-first. This fn now closes the
272/// underlying primitive so ANY future caller is structurally safe
273/// regardless of what they pass for `body`.
274///
275/// Resolution rules:
276///
277/// 1. The header is resolved first (or the per-request anonymous
278/// fallback is synthesized when no header is present).
279/// 2. If `body` is `Some(non-empty)` it is validated and compared
280/// against the header-resolved id. A MISMATCH returns an error
281/// tagged `agent_id_body_header_mismatch` so handlers can map it
282/// to `403 Forbidden`. An empty `body` is treated as "no claim"
283/// (same as `None`).
284/// 3. Validation errors on either side surface unchanged.
285///
286/// New callers SHOULD pass `body: None` and rely on header-only
287/// authentication; the body-refinement slot is preserved only for
288/// the existing federation receiver path (where the body carries an
289/// envelope-attributed identity, gated by
290/// `AI_MEMORY_FED_TRUST_BODY_AGENT_ID`) and for backwards-compatible
291/// callers that want defense-in-depth checks at this layer.
292/// Synthesize the per-request anonymous HTTP principal —
293/// `anonymous:req-<uuid8>`. The ONE synthesis path for every HTTP
294/// fallback site (#1560: before this helper, eight handler sites
295/// drifted to a full 36-char uuid suffix while the documented contract
296/// and this module's resolver used uuid8).
297pub fn anonymous_request_id() -> String {
298 format!("{}{}", sentinels::ANONYMOUS_REQ_PREFIX, short_uuid())
299}
300
301pub fn resolve_http_agent_id(body: Option<&str>, header: Option<&str>) -> Result<String> {
302 // 1. Header is authoritative — resolve it first (validate if
303 // present; synthesize anonymous fallback otherwise).
304 let resolved = if let Some(id) = header
305 && !id.is_empty()
306 {
307 validate::validate_agent_id(id)?;
308 id.to_string()
309 } else {
310 let anon = anonymous_request_id();
311 tracing::warn!(
312 "HTTP memory write without agent_id body field or X-Agent-Id header; assigned {anon}"
313 );
314 validate::validate_agent_id(&anon)?;
315 anon
316 };
317
318 // 2. Body, when non-empty, is a refinement that MUST match the
319 // authoritative header-resolved id. Validate the body shape
320 // first so a malformed claim surfaces as a 400 rather than a
321 // 403 mismatch (the validation error is the more informative
322 // diagnostic).
323 if let Some(claim) = body
324 && !claim.is_empty()
325 {
326 validate::validate_agent_id(claim)?;
327 if claim != resolved {
328 anyhow::bail!(
329 "agent_id_body_header_mismatch: body-supplied agent_id {claim:?} disagrees \
330 with authenticated header-resolved id {resolved:?}"
331 );
332 }
333 }
334
335 Ok(resolved)
336}
337
338/// Preserve `existing.agent_id` through update/dedup.
339///
340/// Returns a `serde_json::Value` equal to `incoming` with one override:
341/// if `existing` carries `metadata.agent_id`, that value is copied into the
342/// result (`agent_id` is provenance — immutable after first write).
343pub fn preserve_agent_id(
344 existing: &serde_json::Value,
345 incoming: &serde_json::Value,
346) -> serde_json::Value {
347 let mut merged = if incoming.is_object() {
348 incoming.clone()
349 } else {
350 serde_json::Value::Object(serde_json::Map::new())
351 };
352 if let (Some(existing_id), Some(obj)) =
353 (existing.get("agent_id").cloned(), merged.as_object_mut())
354 {
355 obj.insert("agent_id".to_string(), existing_id);
356 }
357 merged
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 /// M9 — process-wide guard for every test below that mutates
365 /// `ENV_AGENT_ID`. `cargo test --jobs N` runs the test functions in
366 /// parallel by default, so an unguarded `remove_var` race can
367 /// surface as a flake when a sibling test reads the same var
368 /// mid-mutation. Acquire this mutex before every env-mutating step.
369 fn env_var_lock() -> std::sync::MutexGuard<'static, ()> {
370 use std::sync::{Mutex, OnceLock};
371 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
372 LOCK.get_or_init(|| Mutex::new(()))
373 .lock()
374 .unwrap_or_else(std::sync::PoisonError::into_inner)
375 }
376
377 #[test]
378 fn process_discriminator_is_stable() {
379 let a = process_discriminator();
380 let b = process_discriminator();
381 assert_eq!(
382 a, b,
383 "discriminator must be stable for the process lifetime"
384 );
385 assert!(a.starts_with("pid-"));
386 assert!(a.len() >= "pid-1-0000000a".len());
387 }
388
389 #[test]
390 fn short_uuid_is_8_hex_chars() {
391 let s = short_uuid();
392 assert_eq!(s.len(), 8);
393 assert!(
394 s.chars()
395 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
396 );
397 }
398
399 #[test]
400 fn sanitize_component_preserves_safe_chars() {
401 assert_eq!(sanitize_component("claude-code"), "claude-code");
402 assert_eq!(sanitize_component("host.example.com"), "host.example.com");
403 assert_eq!(sanitize_component("devbox_1"), "devbox_1");
404 }
405
406 #[test]
407 fn sanitize_component_replaces_unsafe_chars() {
408 assert_eq!(sanitize_component("my host"), "my-host");
409 assert_eq!(sanitize_component("a/b"), "a-b");
410 assert_eq!(sanitize_component("a b"), "a-b"); // collapses runs
411 assert_eq!(sanitize_component("a;b|c"), "a-b-c");
412 assert_eq!(sanitize_component("---foo---"), "foo");
413 }
414
415 #[test]
416 fn resolve_explicit_caller_wins() {
417 let id = resolve_agent_id(Some("alice"), Some("claude-code")).unwrap();
418 assert_eq!(id, "alice");
419 }
420
421 #[test]
422 fn resolve_validates_explicit_caller() {
423 assert!(resolve_agent_id(Some("alice bob"), None).is_err());
424 assert!(resolve_agent_id(Some("a\0null"), None).is_err());
425 }
426
427 #[test]
428 fn resolve_empty_explicit_falls_through() {
429 // Empty explicit should be treated as "not provided" and fall through
430 // to the MCP client / host / anonymous branches.
431 // M9 — process-wide serialization via env_var_lock.
432 let _g = env_var_lock();
433 // SAFETY: env mutation serialised by `_g`. Scrub env so step 2
434 // doesn't short-circuit.
435 unsafe {
436 std::env::remove_var(ENV_AGENT_ID);
437 }
438 let id = resolve_agent_id(Some(""), None).unwrap();
439 assert!(id.starts_with("host:") || id.starts_with("anonymous:"));
440 }
441
442 #[test]
443 fn resolve_mcp_client_synthesizes_ai_prefix() {
444 // M9 — process-wide serialization via env_var_lock.
445 let _g = env_var_lock();
446 // SAFETY: env mutation serialised by `_g`.
447 unsafe {
448 std::env::remove_var(ENV_AGENT_ID);
449 }
450 let id = resolve_agent_id(None, Some("claude-code")).unwrap();
451 assert!(id.starts_with("ai:claude-code@"));
452 assert!(id.contains(":pid-"));
453 }
454
455 #[test]
456 fn resolve_mcp_client_sanitizes_name() {
457 // M9 — process-wide serialization via env_var_lock.
458 let _g = env_var_lock();
459 // SAFETY: env mutation serialised by `_g`.
460 unsafe {
461 std::env::remove_var(ENV_AGENT_ID);
462 }
463 let id = resolve_agent_id(None, Some("weird client!")).unwrap();
464 assert!(id.starts_with("ai:weird-client@"));
465 }
466
467 #[test]
468 fn resolve_default_is_host_or_anonymous() {
469 // M9 — process-wide serialization via env_var_lock.
470 let _g = env_var_lock();
471 // SAFETY: env mutation serialised by `_g`.
472 unsafe {
473 std::env::remove_var(ENV_AGENT_ID);
474 }
475 let id = resolve_agent_id(None, None).unwrap();
476 assert!(
477 id.starts_with("host:") || id.starts_with("anonymous:"),
478 "got: {id}"
479 );
480 }
481
482 // --- v0.7.0 #1468/#1469 — read-path visibility caller resolution ------
483
484 #[test]
485 fn read_visibility_caller_returns_env_when_set() {
486 let _g = env_var_lock();
487 // SAFETY: env mutation serialised by `_g`.
488 unsafe {
489 std::env::set_var(ENV_AGENT_ID, "ai:alice");
490 }
491 let got = resolve_read_visibility_caller();
492 unsafe {
493 std::env::remove_var(ENV_AGENT_ID);
494 }
495 assert_eq!(got.as_deref(), Some("ai:alice"));
496 }
497
498 #[test]
499 fn read_visibility_caller_none_when_unset() {
500 let _g = env_var_lock();
501 // SAFETY: env mutation serialised by `_g`.
502 unsafe {
503 std::env::remove_var(ENV_AGENT_ID);
504 }
505 assert_eq!(resolve_read_visibility_caller(), None);
506 }
507
508 #[test]
509 fn read_visibility_caller_none_when_empty_or_shape_invalid() {
510 let _g = env_var_lock();
511 // Empty → None (treated as unset).
512 // SAFETY: env mutation serialised by `_g`.
513 unsafe {
514 std::env::set_var(ENV_AGENT_ID, "");
515 }
516 assert_eq!(resolve_read_visibility_caller(), None);
517 // Shape-invalid (whitespace) → None: a value the write path would
518 // have rejected can never be a legitimate owner, so do not filter
519 // against it (drop to trust-all rather than hide everything).
520 // SAFETY: env mutation serialised by `_g`.
521 unsafe {
522 std::env::set_var(ENV_AGENT_ID, "has space");
523 }
524 assert_eq!(resolve_read_visibility_caller(), None);
525 unsafe {
526 std::env::remove_var(ENV_AGENT_ID);
527 }
528 }
529
530 /// v0.7.0 SECURITY regression — primitive-level closure of the
531 /// #874-class agent_id spoof. Previously `body` had PRECEDENCE
532 /// over `header`, so a caller authenticated as `bob` (via
533 /// `X-Agent-Id`) could pass `body=Some("alice")` and the resolver
534 /// would return `"alice"`. Post-fix the header is authoritative
535 /// and a body-vs-header mismatch is a typed error so handlers
536 /// can map to `403 Forbidden`.
537 #[test]
538 fn resolve_http_body_mismatch_is_err() {
539 let r = resolve_http_agent_id(Some("alice"), Some("bob"));
540 assert!(r.is_err(), "mismatch must be Err, got Ok({r:?})");
541 let msg = r.unwrap_err().to_string();
542 assert!(
543 msg.contains("agent_id_body_header_mismatch"),
544 "error must carry tag agent_id_body_header_mismatch, got: {msg}"
545 );
546 // Header value MUST NOT leak into the resolver's return on
547 // mismatch — the contract is "error, not silent override".
548 assert!(!msg.is_empty());
549 }
550
551 #[test]
552 fn resolve_http_body_matching_header_is_ok() {
553 // Body is a defense-in-depth refinement — when it matches the
554 // header the resolver returns the agreed id.
555 let id = resolve_http_agent_id(Some("alice"), Some("alice")).unwrap();
556 assert_eq!(id, "alice");
557 }
558
559 #[test]
560 fn resolve_http_empty_body_is_no_claim() {
561 // Empty body MUST be treated as "no body-side claim" — same
562 // contract as None. Header wins, no mismatch error.
563 let id = resolve_http_agent_id(Some(""), Some("bob")).unwrap();
564 assert_eq!(id, "bob");
565 }
566
567 #[test]
568 fn resolve_http_body_without_header_uses_anonymous_and_mismatches() {
569 // No header → anonymous fallback id is synthesized. A body
570 // claim then mismatches the anonymous id → typed error.
571 // This is the strict posture: a caller cannot launder a body
572 // claim through an absent-header request.
573 let r = resolve_http_agent_id(Some("alice"), None);
574 assert!(r.is_err(), "body without header must be Err, got Ok({r:?})");
575 let msg = r.unwrap_err().to_string();
576 assert!(
577 msg.contains("agent_id_body_header_mismatch"),
578 "error must carry tag agent_id_body_header_mismatch, got: {msg}"
579 );
580 }
581
582 #[test]
583 fn resolve_http_header_used_when_body_missing() {
584 let id = resolve_http_agent_id(None, Some("bob")).unwrap();
585 assert_eq!(id, "bob");
586 }
587
588 #[test]
589 fn resolve_http_fallback_is_anonymous_req() {
590 let id = resolve_http_agent_id(None, None).unwrap();
591 assert!(id.starts_with("anonymous:req-"), "got: {id}");
592 // Two calls produce distinct request-scoped ids
593 let id2 = resolve_http_agent_id(None, None).unwrap();
594 assert_ne!(id, id2);
595 }
596
597 #[test]
598 fn resolve_http_validates_caller_input() {
599 assert!(resolve_http_agent_id(Some("has space"), None).is_err());
600 assert!(resolve_http_agent_id(None, Some("has\0null")).is_err());
601 }
602
603 #[test]
604 fn preserve_agent_id_copies_existing() {
605 let existing = serde_json::json!({"agent_id": "alice", "foo": "old"});
606 let incoming = serde_json::json!({"agent_id": "bob", "foo": "new", "bar": 1});
607 let merged = preserve_agent_id(&existing, &incoming);
608 assert_eq!(merged["agent_id"], "alice");
609 assert_eq!(merged["foo"], "new");
610 assert_eq!(merged["bar"], 1);
611 }
612
613 #[test]
614 fn preserve_agent_id_no_op_when_existing_has_none() {
615 let existing = serde_json::json!({"foo": "x"});
616 let incoming = serde_json::json!({"agent_id": "bob"});
617 let merged = preserve_agent_id(&existing, &incoming);
618 assert_eq!(merged["agent_id"], "bob");
619 }
620
621 #[test]
622 fn preserve_agent_id_handles_non_object_incoming() {
623 let existing = serde_json::json!({"agent_id": "alice"});
624 let incoming = serde_json::json!("not-an-object");
625 let merged = preserve_agent_id(&existing, &incoming);
626 assert!(merged.is_object());
627 assert_eq!(merged["agent_id"], "alice");
628 }
629
630 // -----------------------------------------------------------------
631 // L0.7-2 Tier A — ENV_ANONYMIZE truthy/falsy + env-var fallback
632 // + anonymize-forced default
633 // -----------------------------------------------------------------
634
635 #[test]
636 fn anonymize_default_enabled_truthy_variants() {
637 let _g = env_var_lock();
638 for v in ["1", "true", "yes", "on", "TRUE", " yes ", "On", "YES"] {
639 // SAFETY: env mutation serialised via env_var_lock guard.
640 unsafe {
641 std::env::set_var(ENV_ANONYMIZE, v);
642 }
643 assert!(anonymize_default_enabled(), "value {v:?} must be truthy");
644 }
645 // SAFETY: env mutation serialised.
646 unsafe {
647 std::env::remove_var(ENV_ANONYMIZE);
648 }
649 }
650
651 #[test]
652 fn anonymize_default_enabled_falsy_variants() {
653 let _g = env_var_lock();
654 for v in ["0", "false", "no", "off", "", "garbage"] {
655 // SAFETY: env mutation serialised via env_var_lock guard.
656 unsafe {
657 std::env::set_var(ENV_ANONYMIZE, v);
658 }
659 assert!(!anonymize_default_enabled(), "value {v:?} must be falsy");
660 }
661 // SAFETY: env mutation serialised.
662 unsafe {
663 std::env::remove_var(ENV_ANONYMIZE);
664 }
665 }
666
667 #[test]
668 fn anonymize_default_enabled_unset_is_falsy() {
669 let _g = env_var_lock();
670 // SAFETY: env mutation serialised.
671 unsafe {
672 std::env::remove_var(ENV_ANONYMIZE);
673 }
674 assert!(!anonymize_default_enabled());
675 }
676
677 #[test]
678 fn resolve_uses_env_agent_id_when_no_explicit_no_mcp() {
679 let _g = env_var_lock();
680 // SAFETY: env mutation serialised.
681 unsafe {
682 std::env::set_var(ENV_AGENT_ID, "env-alice");
683 }
684 let id = resolve_agent_id(None, None).unwrap();
685 assert_eq!(id, "env-alice");
686 // SAFETY: env mutation serialised.
687 unsafe {
688 std::env::remove_var(ENV_AGENT_ID);
689 }
690 }
691
692 #[test]
693 fn resolve_anonymize_forces_anonymous_prefix() {
694 let _g = env_var_lock();
695 // SAFETY: env mutation serialised.
696 unsafe {
697 std::env::remove_var(ENV_AGENT_ID);
698 std::env::set_var(ENV_ANONYMIZE, "1");
699 }
700 let id = resolve_agent_id(None, None).unwrap();
701 assert!(
702 id.starts_with("anonymous:"),
703 "AI_MEMORY_ANONYMIZE=1 must skip host: default, got: {id}"
704 );
705 // SAFETY: env mutation serialised.
706 unsafe {
707 std::env::remove_var(ENV_ANONYMIZE);
708 }
709 }
710
711 #[test]
712 fn resolve_empty_env_falls_through() {
713 // Empty env var should be treated as "not set" and continue
714 // down the precedence chain.
715 let _g = env_var_lock();
716 // SAFETY: env mutation serialised.
717 unsafe {
718 std::env::set_var(ENV_AGENT_ID, "");
719 }
720 let id = resolve_agent_id(None, None).unwrap();
721 assert!(
722 id.starts_with("host:") || id.starts_with("anonymous:") || id.starts_with("ai:"),
723 "empty env must fall through to host/anonymous default, got: {id}"
724 );
725 // SAFETY: env mutation serialised.
726 unsafe {
727 std::env::remove_var(ENV_AGENT_ID);
728 }
729 }
730}