Skip to main content

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}