Skip to main content

agentmux_srv/identity/resolver/
inject.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The credential-injection entry points and the layer-3 spawn gate.
5//!
6//! Split out of the single ~2193-line `resolver.rs` (pure relocation, no
7//! behavior change): `inject_identity_env` / `inject_identity_env_async`
8//! (thin public wrappers), `IdentityBinding` + `resolve_bindings_for_instance`
9//! (direct-link lookup), and `inject_identity_env_with_broker` — the
10//! security-critical credential-injection gate. Every test that exercises
11//! `inject_identity_env_with_broker` moved here WITH it, as one atomic unit,
12//! per the module split's constraint that this function and its tests must
13//! never be separated.
14//!
15//! **Before touching `gate_oauth_failure` / `inject_identity_env_with_broker`:**
16//! see the warning doc comment directly above `inject_identity_env_with_broker`
17//! below.
18
19use std::collections::HashMap;
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use crate::backend::providers::resolve_provider_alias;
24use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
25use crate::backend::storage::StoreError;
26use crate::backend::mps::Broker;
27
28use super::errors::SpawnGateError;
29use super::oauth_probe::{oauth_status, probe_oauth_status};
30use super::provider::{provider_class, ProviderClass};
31use super::secret::resolve_secret;
32
33/// Inject identity-derived env vars into the spawn map for a block.
34///
35/// Synchronous, broker-less entry point — in production only the durable-jekt
36/// sign-in recheck (server/jekt_held.rs) calls it. The CLI-spawn paths use
37/// [`inject_identity_env_async`] instead (via `build_persistent_spawn_env` in
38/// server/agent_handlers/input.rs). Resolution flow:
39///
40/// 1. Look up the active `AgentInstance` for this block. If none
41///    exists, the caller didn't go through the launch modal — return
42///    immediately, no injection (unless the block names a deleted agent:
43///    that is refused with `SpawnGateError::AgentDeleted`, #3577).
44/// 2. Read its `identity_id`. Empty / "blank" no longer short-circuits to
45///    ambient creds (see #2463 — this used to bypass the layer-3 gate
46///    entirely, so whether a spawn required a bound account depended on
47///    whether a stray ambient credential happened to exist on the test
48///    machine). It now falls through to the same steps below, which gate
49///    purely on the agent definition's own provider — identical to how a
50///    non-empty-but-unresolvable `identity_id` (e.g. a legacy sentinel)
51///    already behaved.
52/// 3. Read the `db_agent_identity_links` rows for the instance's definition.
53/// 4. For each binding: fetch the account, resolve its `SecretRef`,
54///    look up the provider's env-var matrix, write each var into
55///    `env_vars`.
56///    - **Api-key-class** per-binding failures are logged and skipped —
57///      other bindings still inject (historical behavior).
58///    - **Oauth-class** failures (account row missing, lookup error,
59///      non-OAuthConfigDir secret_ref) are always BLOCKING: the function
60///      returns [`SpawnGateError`] before the CLI process is created.
61///      `use_ambient_login` no longer exempts an agent and is not read
62///      here (the opt-out was retired, see
63///      m0017_ambient_login_grandfather.rs).
64/// 5. If the agent definition's own provider is oauth-class and no binding
65///    for it exists at all (fresh/never-bound or post-delete-cascade), the
66///    same gate applies — implicit ambient fallback is how the
67///    delete-doesn't-deauthenticate gap happened (spec §2.2 edge case).
68///
69/// Layer 3 of SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md. Before
70/// it, this function was intentionally infallible ("the spawn never aborts
71/// because a secret didn't resolve") — that is still true for api-key-class
72/// bindings, but oauth-class resolution failures now fail the spawn by
73/// default so account deletion is honest at the next spawn.
74pub fn inject_identity_env(
75    mstore: Arc<Store>,
76    id_store: Arc<Store>,
77    identity_store: Arc<Store>,
78    block_id: &str,
79    env_vars: &mut HashMap<String, String>,
80) -> Result<(), SpawnGateError> {
81    inject_identity_env_with_broker(mstore, id_store, identity_store, None, block_id, env_vars)
82}
83
84/// The optional broker lets the OAuth-class branch publish
85/// `identityaccounts:changed` on a status change discovered by the expiry
86/// probe. `None` skips the publish; the `agentinput` turn
87/// (server/agent_handlers/input.rs) and `agent.send` (app_api/agent_io.rs)
88/// pass `Some(broker.clone())` through `build_persistent_spawn_env` so any
89/// live account list flips its status badge without a reload. Per spec §4.4.
90///
91/// Async wrapper around [`inject_identity_env_with_broker`] for use from
92/// async spawn handlers. The underlying path does blocking I/O — synchronous
93/// SQLite reads and, for `SecretRef::Keychain` accounts, a blocking
94/// `keyring` (D-Bus Secret Service on Linux) read — so it runs on a blocking
95/// thread via `spawn_blocking` rather than stalling an async runtime worker.
96/// Takes ownership of `env_vars` and returns it with identity vars merged in.
97///
98/// `Err(SpawnGateError)` is the layer-3 spawn gate (see
99/// [`inject_identity_env`]): the caller must NOT spawn the CLI and must
100/// surface the error on the agent pane like any other spawn failure. A
101/// task-join failure also fails CLOSED (`InjectionUnavailable`, blocking
102/// the spawn) — see that variant's doc for why an open fallback would
103/// systemically bypass the gate after any store panic.
104pub async fn inject_identity_env_async(
105    mstore: Arc<Store>,
106    id_store: Arc<Store>,
107    identity_store: Arc<Store>,
108    broker: Option<Arc<Broker>>,
109    block_id: String,
110    env_vars: HashMap<String, String>,
111) -> Result<HashMap<String, String>, SpawnGateError> {
112    match tokio::task::spawn_blocking(move || {
113        let mut env = env_vars;
114        inject_identity_env_with_broker(mstore, id_store, identity_store, broker, &block_id, &mut env)
115            .map(|()| env)
116    })
117    .await
118    {
119        Ok(result) => result,
120        Err(e) => {
121            // Fail CLOSED. A join failure means the closure panicked (or
122            // was cancelled) — the gate never rendered a verdict, and the
123            // panic has likely poisoned the Store mutex, so failing open
124            // here would bypass the oauth gate for every later
125            // spawn too (reagent P1, PR #2164 round 1). See
126            // SpawnGateError::InjectionUnavailable.
127            tracing::warn!(
128                target: "identity",
129                error = %e,
130                "identity.spawn.blocked: injection task join failed — failing closed"
131            );
132            Err(SpawnGateError::InjectionUnavailable { detail: e.to_string() })
133        }
134    }
135}
136
137/// Resolved (provider, account) pair ready for env injection.
138///
139/// Historically a row of the `db_identity_bindings` table (retired in
140/// Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md); now purely
141/// an internal shape built from `AgentIdentityLink` rows.
142struct IdentityBinding {
143    provider: String,
144    account_id: String,
145}
146
147/// Resolve the identity bindings for an instance from the **direct**
148/// agent↔account links only.
149///
150/// Phase 3 slice 2 PR-B (the flip): the resolver previously dual-read —
151/// preferring `db_agent_identity_links` keyed on the instance's DEFINITION
152/// id, falling back to the bundle bindings (`db_identity_bindings`) when no
153/// direct links existed (PR-A, #1927). That fallback was removed here, and
154/// Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md later dropped
155/// `db_identity_bundles`/`db_identity_bindings` entirely — every write path
156/// (launch flow, per-agent Accounts tab, OAuth Connect/Reconnect) writes a
157/// direct link, and the m0013/m0014 migrations (#1927/#1952) backfilled
158/// direct links for every pre-existing bundle binding before the drop.
159///
160/// `parent_template_id`: the launch's eligible TEMPLATE parent's own id —
161/// `""` unless the caller has already confirmed, via
162/// `template_parent_id_if_seeded`, that `db_agents.parent_template_id`
163/// actually points at a row with `is_seeded = 1`. That column is NOT
164/// exclusively "which template was I launched from": `forkagentdefinition`
165/// (`template.rs`) reuses the same column to record a plain agent fork's
166/// SOURCE agent id, `is_seeded = 0`. reagent P0 on #3092 caught an earlier
167/// version of this fallback trusting the column unconditionally — a fork
168/// of any regular agent that had a bound identity would silently inherit
169/// and spawn on the SOURCE agent's credentials, even though forks are
170/// deliberately created with `accounts: ""` and no link of their own
171/// (fail-closed by design, the same convention `conversation_visibility`
172/// documents right next to `parent_id` in `template.rs`). Callers MUST
173/// resolve this through `template_parent_id_if_seeded`, never pass
174/// `d.parent_id` directly.
175///
176/// Agent-concept consolidation's definition flip
177/// (`SPEC_AGENT_ARCHITECTURE_2026_05_27.md` Phase 3d) made `agent_def_get`
178/// resolve a template launch's own row for the first time, which enabled
179/// Step 5's definition-provider gate for that whole agent class — but
180/// binding an identity to a TEMPLATE (the Agent Setup modal's supported
181/// target) has always written the link under the TEMPLATE's id, never the
182/// launch's own id, since nothing writes a launch-scoped link. Without
183/// this fallback, enabling Step 5 would have converted every template
184/// launch with a genuinely configured account into a hard spawn failure —
185/// the direct link lookup below finds nothing under the launch's own id
186/// regardless of whether an account is actually bound, because it never
187/// was.
188///
189/// `broker` publishes `identity:no-direct-links` when the direct set is
190/// empty — a standing diagnostic for a non-sentinel identity resolving to
191/// zero links (e.g. a stale/migrated-away instance). `None` in tests /
192/// call sites that don't have a broker handle.
193/// `parent_id`'s eligible-template gate for `resolve_bindings_for_instance`
194/// (see that function's doc comment for the P0 this closes): returns
195/// `parent_id` back only when it names a row with `is_seeded = 1` — a
196/// genuine template — never for a plain agent-to-agent fork, which reuses
197/// the same `parent_id`/`parent_template_id` column for unrelated lineage.
198/// `mstore` is the store `parent_id` was read from (the same one
199/// `agent_def_get` resolved the launch's own row through); an empty
200/// `parent_id`, a lookup failure, or a parent that resolves but isn't
201/// seeded all return `""` — "no eligible template parent," not an error.
202fn template_parent_id_if_seeded(mstore: &Store, parent_id: &str) -> String {
203    if parent_id.is_empty() {
204        return String::new();
205    }
206    match mstore.agent_def_get(parent_id) {
207        Ok(Some(parent)) if parent.is_seeded == 1 => parent_id.to_string(),
208        Ok(Some(_)) => String::new(), // a fork's source, not a template — reagent P0 on #3092
209        Ok(None) => String::new(),
210        Err(e) => {
211            tracing::warn!(
212                target: "identity",
213                "template-parent lookup failed for {}: {} — treating as no eligible template parent",
214                parent_id,
215                e,
216            );
217            String::new()
218        }
219    }
220}
221
222fn resolve_bindings_for_instance(
223    id_store: &Store,
224    instance: &crate::backend::storage::store::AgentInstance,
225    parent_template_id: &str,
226    broker: Option<&Arc<Broker>>,
227) -> Vec<IdentityBinding> {
228    let direct = id_store
229        .agent_identity_list_for_agent(&instance.definition_id)
230        .unwrap_or_else(|e| {
231            tracing::warn!(
232                target: "identity",
233                "direct-link lookup failed for definition {}: {}",
234                instance.definition_id,
235                e,
236            );
237            Vec::new()
238        });
239
240    if direct.is_empty() && !parent_template_id.is_empty() {
241        let from_template = id_store
242            .agent_identity_list_for_agent(parent_template_id)
243            .unwrap_or_else(|e| {
244                tracing::warn!(
245                    target: "identity",
246                    "template link fallback failed for template {}: {}",
247                    parent_template_id,
248                    e,
249                );
250                Vec::new()
251            });
252        if !from_template.is_empty() {
253            return from_template
254                .into_iter()
255                .map(|l| IdentityBinding { provider: l.provider, account_id: l.account_id })
256                .collect();
257        }
258    }
259
260    if direct.is_empty() {
261        // Distinct log line + event so a non-sentinel identity resolving
262        // to zero links (e.g. an agent that was never linked to an
263        // account) is visible, not silently indistinguishable from
264        // routine "no accounts configured." Whether the spawn proceeds
265        // is decided by the caller's layer-3 definition-provider gate
266        // (oauth-class CLI provider with no bound account → blocked).
267        tracing::warn!(
268            target: "identity",
269            "no direct account links for definition {} (identity {}) — \
270             nothing to inject.",
271            instance.definition_id,
272            instance.identity_id,
273        );
274        if let Some(b) = broker {
275            let event = crate::backend::mps_events::NoDirectLinksEvent {
276                definition_id: instance.definition_id.clone(),
277                identity_id: instance.identity_id.clone(),
278            };
279            // Persisted (unlike the rest of this file's ephemeral
280            // events) — nothing subscribes live yet, so without
281            // persistence a future frontend surface polling via
282            // read_event_history would see nothing. Small window;
283            // this is a diagnostic, not an audit log.
284            b.publish_event::<crate::backend::mps_events::IdentityNoDirectLinks>(&event, vec![], 20);
285        }
286    }
287
288    direct
289        .into_iter()
290        .map(|l| IdentityBinding {
291            provider: l.provider,
292            account_id: l.account_id,
293        })
294        .collect()
295}
296
297/// Resolve an account by id, checking `id_store` first — the
298/// authoritative, per-account-isolatable primary, which is what preserves
299/// the disposable-test-account isolation guarantee — and falling
300/// back to `identity_store`'s read-through mirror
301/// (`IDENTITY_STORE_SCHEMA_VERSION` v2,
302/// `docs/specs/SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md` §3.1) only when the
303/// primary lookup misses. Returns the account alongside WHICH store it
304/// came from, so a caller that later writes a status update (the OAuth
305/// expiry probe below) writes it back to the SAME store instead of always
306/// assuming `id_store` — writing to the wrong store would silently create
307/// a second, never-read copy.
308///
309/// reagentx P0 review on PR #2632: before this, the link resolved via
310/// `identity_store` (always global) but the account it pointed at could
311/// still only exist in `id_store`, which is empty on a fresh, isolated
312/// channel — the reported continuity bug wasn't actually fixed end to end
313/// without this fallback.
314pub fn resolve_account(
315    id_store: &Arc<Store>,
316    identity_store: &Arc<Store>,
317    account_id: &str,
318) -> Result<Option<(IdentityAccount, Arc<Store>)>, StoreError> {
319    if let Some(a) = id_store.identity_get(account_id)? {
320        return Ok(Some((a, id_store.clone())));
321    }
322    if let Some(a) = identity_store.identity_get(account_id)? {
323        return Ok(Some((a, identity_store.clone())));
324    }
325    Ok(None)
326}
327
328/// SPAWN-PATH account lookup — **current channel's `id_store` only, never the
329/// global mirror.**
330///
331/// Deliberately NOT [`resolve_account`]: that function's global fallback is
332/// correct for read-only/display callers (the Connectors → Accounts list, link
333/// summaries — they should still describe an account that exists elsewhere),
334/// but using it to satisfy a *spawn* lets an agent in a fresh channel launch
335/// against a credential authenticated in a DIFFERENT channel. That is exactly
336/// the per-channel-isolation bypass the operator asked to close:
337///
338/// > "we want per-channel isolation. a user needs a login anytime in the
339/// > channel. the problem is agents are able to login in a channel without a
340/// > claude auth."
341///
342/// **This intentionally reverses part of reagentx's P0 on PR #2632.** That
343/// review added the fallback to preserve binding continuity across a
344/// version/channel switch, which was the right call under the then-current
345/// requirement. The requirement has since changed: per-channel isolation is a
346/// wanted feature, and re-login-per-channel is the accepted cost. The fallback
347/// is retained for display paths precisely so that continuity fix isn't lost
348/// where it was actually about *describing* an account rather than *launching*
349/// with one. See
350/// `docs/analysis/ANALYSIS_PER_CHANNEL_AUTH_BYPASSES_2026_08_31.md` §2 (#1).
351///
352/// No dangling-link deadlock results from the stricter lookup:
353/// `db_agent_identity_links` is `PRIMARY KEY (agent_id, provider)`, so a
354/// re-login in the new channel REPLACES the link rather than leaving a stale
355/// one that would block every future spawn.
356pub fn resolve_account_for_spawn(
357    id_store: &Arc<Store>,
358    account_id: &str,
359) -> Result<Option<(IdentityAccount, Arc<Store>)>, StoreError> {
360    Ok(id_store
361        .identity_get(account_id)?
362        .map(|a| (a, id_store.clone())))
363}
364
365/// Read-only lookup of a block's identity-bound OAuth config dir — the same
366/// directory `inject_identity_env_with_broker`'s OAuth branch would inject,
367/// without any of that function's side effects (no token-expiry probe, no
368/// account-status upsert, no `identityaccounts:changed` publish, no
369/// `SpawnGateError` on failure). For a background caller that only needs to
370/// know WHERE to look (e.g. `subagent_watcher` deciding which directory to
371/// watch at agent registration, not spawn) rather than what to inject.
372///
373/// Deliberately does not call `inject_identity_env`/`inject_identity_env_with_broker`:
374/// those also resolve API-key-class secrets into the caller's env map (a
375/// leak risk for a caller that only wants a path) and perform real writes —
376/// none of which belong on every registration, only on an actual spawn/turn.
377/// See `SPEC_SUBAGENT_WATCHER_IDENTITY_BOUND_CONFIG_DIR_2026_08_22.md`.
378///
379/// Returns `None` (never an error) for: no instance for this block, no
380/// definition, a non-OAuth-class (or unrecognized) provider, no direct
381/// binding for that provider, an account row that doesn't resolve, or a
382/// `secret_ref` that isn't `OAuthConfigDir` — every one of these is exactly
383/// "nothing bound, fall back to the old behavior" for a caller that isn't
384/// gating a spawn.
385pub fn resolve_bound_oauth_config_dir(
386    mstore: &Arc<Store>,
387    id_store: &Arc<Store>,
388    identity_store: &Arc<Store>,
389    block_id: &str,
390) -> Option<PathBuf> {
391    let instance = mstore.instance_get_active_for_block(block_id).ok().flatten()?;
392    bound_oauth_config_dir(mstore, id_store, identity_store, &instance)
393}
394
395/// [`resolve_bound_oauth_config_dir`] for a **Claude** agent row by its id,
396/// with no block — memory resolution's question (#3603): where does this
397/// agent's Claude run, so where are its memories (`projects/<cwd>/memory`,
398/// a Claude layout). `None` for any other provider, and for an account bound
399/// to the ambient `~/.claude` — the spawn refuses that dir, and memory
400/// writes must never land in the user's own Claude projects.
401pub fn resolve_bound_claude_config_dir_for_agent(
402    mstore: &Store,
403    id_store: &Arc<Store>,
404    identity_store: &Arc<Store>,
405    agent_id: &str,
406) -> Option<PathBuf> {
407    let instance = mstore.instance_get(agent_id).ok().flatten()?;
408    let def = mstore.agent_def_get(&instance.definition_id).ok().flatten()?;
409    let provider = id_store.resolve_effective_provider_id(&def);
410    if resolve_provider_alias(&provider) != "claude" {
411        return None;
412    }
413    let dir = bound_oauth_config_dir(mstore, id_store, identity_store, &instance)?;
414    let claude = crate::backend::providers::get_provider("claude")?;
415    if crate::backend::providers::is_provider_ambient_home_dir(claude, &dir.to_string_lossy()) {
416        return None;
417    }
418    Some(dir)
419}
420
421/// Whether an agent has any identity binding — its own links, or its
422/// template's when it has none — checked without the binding resolver's
423/// "no links" warning. For callers that ask on every sweep (memory), so an
424/// unlinked agent costs a lookup, not a log line.
425pub fn agent_has_identity_binding(mstore: &Store, identity_store: &Store, agent_id: &str) -> bool {
426    let direct = identity_store
427        .agent_identity_list_for_agent(agent_id)
428        .map(|l| !l.is_empty())
429        .unwrap_or(false);
430    if direct {
431        return true;
432    }
433    let Some(def) = mstore.agent_def_get(agent_id).ok().flatten() else {
434        return false;
435    };
436    let parent = template_parent_id_if_seeded(mstore, &def.parent_id);
437    !parent.is_empty()
438        && identity_store
439            .agent_identity_list_for_agent(&parent)
440            .map(|l| !l.is_empty())
441            .unwrap_or(false)
442}
443
444fn bound_oauth_config_dir(
445    mstore: &Store,
446    id_store: &Arc<Store>,
447    identity_store: &Arc<Store>,
448    instance: &crate::backend::storage::store::AgentInstance,
449) -> Option<PathBuf> {
450    let def = mstore.agent_def_get(&instance.definition_id).ok().flatten()?;
451    let effective_provider = id_store.resolve_effective_provider_id(&def);
452    let canonical_provider = resolve_provider_alias(&effective_provider).to_string();
453    if !matches!(provider_class(&canonical_provider), Some(ProviderClass::OAuth { .. })) {
454        return None;
455    }
456
457    let template_parent_id = template_parent_id_if_seeded(mstore, &def.parent_id);
458    let bindings = resolve_bindings_for_instance(identity_store, instance, &template_parent_id, None);
459    let binding = bindings
460        .iter()
461        .find(|b| resolve_provider_alias(&b.provider) == canonical_provider)?;
462    // Per-channel only, matching the spawn gate exactly: this function's whole
463    // contract is "where WOULD the spawn inject," so resolving an account the
464    // spawn itself would now refuse would point a watcher at a directory no
465    // agent can actually launch against.
466    let (account, _store) = resolve_account_for_spawn(id_store, &binding.account_id).ok().flatten()?;
467    match account.secret_ref {
468        SecretRef::OAuthConfigDir { dir } => Some(PathBuf::from(dir)),
469        _ => None,
470    }
471}
472
473/// The agent a block names (`agentId`, or legacy `agent:id`), if any.
474pub(crate) fn block_agent_id(mstore: &Store, block_id: &str) -> Option<String> {
475    let block: crate::backend::obj::Block = mstore.get(block_id).ok().flatten()?;
476    crate::backend::obj::block_meta_agent_id(&block.meta)
477        .map(str::trim)
478        .filter(|s| !s.is_empty())
479        .map(str::to_string)
480}
481
482/// **Before touching `gate_oauth_failure` / `inject_identity_env_with_broker`:**
483/// this module is where `SPEC_PROVIDER_ISOLATION_2026_06_20.md`'s INV-A
484/// ("never the user's global `~/.<P>` dir") is enforced — or, once already,
485/// silently stopped being enforced. Read
486/// `docs/retro/retro-auth-isolation-invariant-silently-orphaned-2026-07-14.md`
487/// first. Short version: an unbound oauth-class provider used to
488/// auto-route to an AgentMux-owned isolated dir (no user action, no global
489/// exposure); a 2026-07-08 refactor orphaned that path without meaning to,
490/// and it was never restored. Today's gate always blocks an unbound
491/// oauth-class provider; the per-agent "true ambient" opt-out
492/// (`use_ambient_login`) is no longer read.
493pub fn inject_identity_env_with_broker(
494    mstore: Arc<Store>,
495    id_store: Arc<Store>,
496    identity_store: Arc<Store>,
497    broker: Option<Arc<Broker>>,
498    block_id: &str,
499    env_vars: &mut HashMap<String, String>,
500) -> Result<(), SpawnGateError> {
501    // Step 1: instance lookup — per-channel, always reads from mstore.
502    let instance = match mstore.instance_get_active_for_block(block_id) {
503        Ok(Some(i)) => i,
504        Ok(None) => {
505            // #3577: a block that names an agent which was DELETED — its pane
506            // survived the delete (another window, or mid-spawn) — is refused,
507            // not spawned on the shared login with no identity. Only a
508            // positive "deleted" signal refuses; every other row-less block
509            // keeps today's behaviour.
510            if let Some(agent_id) = block_agent_id(&mstore, block_id) {
511                if mstore.agent_was_deleted(&agent_id) {
512                    return Err(SpawnGateError::AgentDeleted { agent_id });
513                }
514            }
515            // Otherwise the block has no agent instance row — nothing to
516            // inject, and no gating either: template sessions and quick-launch
517            // panes are outside the managed-credentials contract.
518            return Ok(());
519        }
520        Err(e) => {
521            tracing::warn!(target: "identity", "instance lookup failed for block {}: {}", block_id, e);
522            return Ok(());
523        }
524    };
525
526    // Step 2: identity_id check.
527    //
528    // #2463 Finding 2: this used to `return Ok(())` here (silent ambient-
529    // creds fallback), which bypassed the layer-3 "must have a bound
530    // account" gate below entirely — a genuinely brand-new agent launched
531    // with no account selected got identity_id="" and spawned on whatever
532    // ambient credential happened to be sitting on disk, with nothing
533    // bound in Connectors. Observed behavior therefore depended on whether the
534    // test machine happened to have a stray credential file, not on any
535    // actual policy decision. The UI no longer produces empty/"blank"
536    // identity_id for new launches (identity is required at submit-time —
537    // SPEC_LAUNCH_MODAL_STATE_MACHINE_2026_05_19.md), so seeing one here
538    // means a legacy continuation row or a UI regression; either way it
539    // must be gated the same as any other unresolvable identity_id rather
540    // than silently bypassed. `resolve_bindings_for_instance` below keys
541    // strictly off `instance.definition_id`, not `identity_id`, so falling
542    // through here is safe regardless of what identity_id contains.
543    if instance.identity_id.is_empty() || instance.identity_id == "blank" {
544        tracing::warn!(
545            target: "identity",
546            "instance {} has empty/blank identity_id — falling through to the \
547             layer-3 gate instead of ambient creds. Legacy row or UI regression?",
548            block_id
549        );
550    }
551
552    // Layer-3 gate input: the agent definition's own CLI provider (the
553    // oauth-class provider every launch of this agent uses, whether or not
554    // a binding row exists for it). A missing definition row reads as no
555    // expected provider — the per-binding gate still applies to whatever
556    // links exist.
557    //
558    // Provider is resolved via `id_store.resolve_effective_provider_id`
559    // (`backend/storage/agents/definition.rs`), the one resolver `agent_open.rs`'s
560    // spawn path also uses, so this gate checks credentials for exactly the
561    // provider that will run: the agent's own, read-only after creation, or
562    // its bundle's when it has none (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md
563    // §3.1). One resolver for both call sites so they can't disagree (#2587).
564    //
565    // Canonicalized via `resolve_provider_alias` (codex P1 on PR #2377): a
566    // definition's own `provider` field predates this alias table in rare
567    // cases, and this value is compared below against `injected_oauth` /
568    // `bindings`' raw (possibly aliased) provider strings — comparing
569    // uncanonicalized would let a genuinely-injected alias-only binding
570    // still trip the "no account bound" gate. Applied to the bundle-
571    // resolved value, not the raw column, so an aliased definition whose
572    // bundle already carries the canonical id doesn't get re-aliased
573    // incorrectly (resolve_provider_alias is idempotent on an
574    // already-canonical id, so this is safe either way).
575    let (def_provider, parent_template_id) = match mstore.agent_def_get(&instance.definition_id) {
576        Ok(Some(d)) => {
577            let effective_provider = id_store.resolve_effective_provider_id(&d);
578            let template_parent_id = template_parent_id_if_seeded(&mstore, &d.parent_id);
579            (
580                Some(resolve_provider_alias(&effective_provider).to_string()),
581                template_parent_id,
582            )
583        }
584        Ok(None) => (None, String::new()),
585        Err(e) => {
586            tracing::warn!(
587                target: "identity",
588                "definition lookup failed for {}: {}",
589                instance.definition_id,
590                e,
591            );
592            (None, String::new())
593        }
594    };
595
596    // Step 3: bindings — global, reads from id_store. Direct-links-only
597    // as of Phase 3 slice 2 PR-B (the flip) — see resolve_bindings_for_instance's
598    // doc comment for the transitional gap this closes and the one it
599    // doesn't (#1624 PR-C). Falls back to the launch's TEMPLATE's own links
600    // when the launch has none of its own (codex P1 on #3092) — binding an
601    // identity to a template has always written the link under the
602    // template's id, never a launch's.
603    //
604    // NOTE: an empty set no longer short-circuits — it falls through to the
605    // definition-provider gate below (spec §2.2 edge case: an agent whose
606    // oauth-class CLI provider has no binding at all is blocked).
607    let bindings = resolve_bindings_for_instance(&identity_store, &instance, &parent_template_id, broker.as_ref());
608
609    // Step 4: per-binding resolution + env injection.
610    //
611    // Each binding's provider determines HOW its account contributes
612    // to the agent's env (SPEC_OAUTH_IDENTITY_BUNDLES §4.3):
613    //   - ApiKey  — resolve secret_ref to a string, inject as env var(s).
614    //   - OAuth   — expect SecretRef::OAuthConfigDir, inject its dir
615    //               as the provider's config-dir env var.
616    //
617    // Api-key-class per-binding failures (unknown provider, account row
618    // missing, mismatched secret_ref, secret resolution failed) are logged
619    // and skipped — other bindings still inject. Oauth-class failures go
620    // through the layer-3 gate, which always blocks.
621    let mut injected_oauth: std::collections::HashSet<String> =
622        std::collections::HashSet::new();
623
624    // Gate helper for an unresolvable oauth-class binding/provider. Always
625    // blocks the spawn — an oauth-class provider must resolve to a real
626    // bound IdentityAccount, full stop.
627    //
628    // `use_ambient_login` used to let this fall through to the user's
629    // global/ambient CLI login instead of blocking. That escape hatch is
630    // retired (PLAN_LOGIN_SINGLE_PATH_CONSOLIDATION_2026_07_20.md §7,
631    // "single point, not global"): a credential the app can't attribute to
632    // a specific account is exactly the state that left Marks
633    // silently working with an untracked, unrefreshed shared-dir credential
634    // and no visible account anywhere in Connectors. The flag is no longer read.
635    let gate_oauth_failure = |provider: &str, detail: &str| -> Result<(), SpawnGateError> {
636        tracing::warn!(
637            target: "identity",
638            "identity.spawn.blocked: no credentials for provider {} \
639             (definition {}, identity {}) — {}; spawn refused \
640             (single-point enforcement)",
641            provider,
642            instance.definition_id,
643            instance.identity_id,
644            detail,
645        );
646        Err(SpawnGateError::MissingCredentials {
647            provider: provider.to_string(),
648        })
649    };
650
651    for binding in &bindings {
652        let class = match provider_class(&binding.provider) {
653            Some(c) => c,
654            None => {
655                tracing::warn!(
656                    target: "identity",
657                    "no provider class for {} (binding for identity {}) — skipping",
658                    binding.provider,
659                    instance.identity_id,
660                );
661                continue;
662            }
663        };
664        let is_oauth_class = matches!(class, ProviderClass::OAuth { .. });
665
666        // Per-channel ONLY (see `resolve_account_for_spawn`) — a credential
667        // authenticated in another channel must not satisfy this spawn.
668        let (account, account_store) = match resolve_account_for_spawn(&id_store, &binding.account_id) {
669            Ok(Some(pair)) => pair,
670            Ok(None) => {
671                // The post-delete case (analysis §2.3): the link survived
672                // (or was cascaded and re-created stale) but the account
673                // row is gone. For oauth-class providers this is the
674                // load-bearing layer-3 gate — no more silent fallback to
675                // the user's global login.
676                if is_oauth_class {
677                    gate_oauth_failure(
678                        &binding.provider,
679                        &format!("account {} row not found", binding.account_id),
680                    )?;
681                    continue;
682                }
683                tracing::warn!(
684                    target: "identity",
685                    "account {} bound to identity {} but row not found — skipping",
686                    binding.account_id,
687                    instance.identity_id,
688                );
689                continue;
690            }
691            Err(e) => {
692                if is_oauth_class {
693                    gate_oauth_failure(
694                        &binding.provider,
695                        &format!("account lookup failed for {}: {}", binding.account_id, e),
696                    )?;
697                    continue;
698                }
699                tracing::warn!(
700                    target: "identity",
701                    "account lookup failed for {}: {}",
702                    binding.account_id,
703                    e,
704                );
705                continue;
706            }
707        };
708
709        match class {
710            ProviderClass::ApiKey { env_vars: env_keys } => {
711                let secret = match resolve_secret(&account.secret_ref) {
712                    Ok(s) => s,
713                    Err(e) => {
714                        tracing::warn!(
715                            target: "identity",
716                            "secret resolution failed for account {} (provider {}): {} — skipping",
717                            binding.account_id,
718                            binding.provider,
719                            e,
720                        );
721                        continue;
722                    }
723                };
724                let env_key_count = env_keys.len();
725                for key in env_keys {
726                    env_vars.insert(key.to_string(), secret.clone());
727                }
728                tracing::info!(
729                    target: "identity",
730                    "injected {} env var(s) for api-key provider {} (identity={}, account={})",
731                    env_key_count,
732                    binding.provider,
733                    instance.identity_id,
734                    binding.account_id,
735                );
736            }
737            ProviderClass::OAuth { config_dir_env_var } => {
738                // OAuth-class bindings expect SecretRef::OAuthConfigDir.
739                // Any other variant is a misconfiguration — the account is
740                // unresolvable for this provider, so it goes through the
741                // layer-3 gate rather than silently leaving the CLI on the
742                // user's global login.
743                let dir = match &account.secret_ref {
744                    SecretRef::OAuthConfigDir { dir } => dir.clone(),
745                    other => {
746                        gate_oauth_failure(
747                            &binding.provider,
748                            &format!(
749                                "non-OAuthConfigDir secret_ref ({:?}) on account {}",
750                                other, binding.account_id,
751                            ),
752                        )?;
753                        continue;
754                    }
755                };
756                // Block unconditionally if this account's config dir IS the
757                // provider's literal ambient home (e.g. `~/.claude`) rather
758                // than an AgentMux-isolated dir — nothing upstream of this
759                // point validates that, and a real, currently-live account
760                // configured exactly this way was found in this repo's own
761                // data (docs/status/STATUS_IDENTITY_ISOLATION_GATE_NOT_ENFORCING_2026_08_20.md
762                // §8). See docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md.
763                if let Some(provider_cfg) =
764                    crate::backend::providers::get_provider(&resolve_provider_alias(&binding.provider))
765                {
766                    if crate::backend::providers::is_provider_ambient_home_dir(provider_cfg, &dir) {
767                        tracing::warn!(
768                            target: "identity",
769                            "identity.spawn.blocked: account {} for provider {} \
770                             resolves to the ambient home dir ({}) — refusing to \
771                             spawn (identity {})",
772                            binding.account_id,
773                            binding.provider,
774                            dir,
775                            instance.identity_id,
776                        );
777                        return Err(SpawnGateError::AmbientHomeDirNotAllowed {
778                            provider: binding.provider.clone(),
779                            dir: dir.clone(),
780                        });
781                    }
782                    // `dir` is confirmed NOT the ambient home at this point —
783                    // but a genuinely separate isolated dir can still lack a
784                    // CLAUDE.md of its own, which Claude Code CLI treats as
785                    // "fall through to the real $HOME/.claude/CLAUDE.md,"
786                    // not "no user-level memory file." Idempotent — cheap
787                    // no-op after the first call. Fail closed (same as the
788                    // ambient-home-dir check above) rather than warn-and-
789                    // continue: Codex P1 + ReAgent P1 on PR #2854 — a
790                    // warn-only failure here would launch the agent with
791                    // exactly the unprotected condition this fix exists to
792                    // close. See SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md.
793                    if let Err(e) =
794                        crate::backend::providers::seed_claude_md_placeholder_if_missing(provider_cfg, &dir)
795                    {
796                        tracing::warn!(
797                            target: "identity",
798                            "identity.spawn.blocked: account {} for provider {} \
799                             failed CLAUDE.md isolation seed ({}) — refusing to \
800                             spawn (identity {})",
801                            binding.account_id,
802                            binding.provider,
803                            e,
804                            instance.identity_id,
805                        );
806                        return Err(SpawnGateError::ClaudeMdSeedFailed {
807                            provider: binding.provider.clone(),
808                            dir: dir.clone(),
809                            error: e.to_string(),
810                        });
811                    }
812                    // Soft-fail: a missing retention setting costs old
813                    // transcripts, not isolation, so it never blocks a spawn.
814                    if let Err(e) =
815                        crate::backend::providers::seed_transcript_retention_if_missing(provider_cfg, &dir)
816                    {
817                        tracing::warn!(
818                            target: "identity",
819                            "account {} for provider {}: could not set transcript retention in {} ({}) — \
820                             Claude Code's 30-day default will sweep this account's shared history",
821                            binding.account_id,
822                            binding.provider,
823                            dir,
824                            e,
825                        );
826                    }
827                }
828                env_vars.insert(config_dir_env_var.to_string(), dir.clone());
829                // Canonicalized (codex P1 on PR #2377) — see def_provider's
830                // doc comment above; def_provider is now canonical too, so
831                // this must match it on the same terms.
832                injected_oauth.insert(resolve_provider_alias(&binding.provider).to_string());
833                tracing::info!(
834                    target: "identity",
835                    "injected {} for oauth provider {} (identity={}, account={})",
836                    config_dir_env_var,
837                    binding.provider,
838                    instance.identity_id,
839                    binding.account_id,
840                );
841
842                // reagentx P1 on PR #2605: `dir` above is read from this
843                // account's PREVIOUSLY STORED SecretRef::OAuthConfigDir —
844                // this is the ordinary spawn path, not `auth.start`, which
845                // is the only place that used to (re-)create the history
846                // link. An account provisioned before this whole feature
847                // shipped, or since its last `auth.start`, would otherwise
848                // never get linked at all. Re-verify/re-create it on every
849                // spawn instead, same best-effort, non-blocking guarantee
850                // as the rest of this function.
851                if let Some(paths) = agentmux_common::DataPaths::from_env() {
852                    if let Some(provider_cfg) =
853                        crate::backend::providers::get_provider(&resolve_provider_alias(&binding.provider))
854                    {
855                        crate::server::identity_auth_dirs::link_history_if_isolated(
856                            &paths,
857                            std::path::Path::new(&dir),
858                            provider_cfg,
859                            &binding.account_id,
860                            "inject_identity_env (spawn)",
861                        );
862                    }
863                }
864
865                // Per spec §4.4 — cheap on-disk expiry probe. Reads the
866                // CLI's token file inside the bundle dir and refines
867                // the IdentityAccount's `status` so the UI can show
868                // valid/expired/needs_reauth. Best-effort: probe and
869                // upsert failures are logged + ignored (mirrors the
870                // per-binding "log + skip" pattern). The probe runs at
871                // every spawn but is a single `fs::read_to_string` +
872                // JSON parse — negligible overhead.
873                let now_ms = agentmux_common::time::now_ms();
874                // Canonicalized (codex P2 on PR #2377): probe_oauth_status
875                // only recognizes the canonical "claude"/"codex"/"openclaw"
876                // strings — passing a raw alias like "claude-code" always
877                // returned None, silently never refreshing an alias-bound
878                // account's status or publishing identityaccounts:changed.
879                let canonical_provider = resolve_provider_alias(&binding.provider);
880                let probed = probe_oauth_status(canonical_provider, &dir, now_ms);
881                // Self-heal a stale false-positive (Codex P1, PR #2645): the
882                // macOS Keychain carve-out in oauth_probe.rs makes a missing
883                // token file return `None` for `claude` on macOS instead of
884                // `needs_reauth` — but an account probed BEFORE that
885                // carve-out existed may already have `needs_reauth`
886                // persisted from the old, buggy behavior. A `None` probe
887                // never upserts anything, so without this, an already-
888                // mislabeled account would stay stuck showing "reconnect"
889                // forever, even though this fix landed specifically to stop
890                // that. Correcting to "unknown" rather than "valid" — the
891                // probe genuinely can't tell on macOS, and a false "valid"
892                // would be exactly as dishonest as the "needs_reauth" this
893                // is fixing. Scoped tightly to the exact carve-out
894                // condition (not "any None"), so an unsupported provider's
895                // genuinely-earned needs_reauth (set by some other path)
896                // is never touched by this.
897                let new_status: Option<&'static str> = match probed {
898                    Some(p) => Some(p.as_str()),
899                    None if canonical_provider == "claude"
900                        && cfg!(target_os = "macos")
901                        && account.status == oauth_status::NEEDS_REAUTH =>
902                    {
903                        Some("unknown")
904                    }
905                    None => None,
906                };
907                if let Some(new_status) = new_status {
908                    if account.status != new_status {
909                        let mut updated = account.clone();
910                        updated.status = new_status.to_string();
911                        updated.updated_at = now_ms;
912                        // Write back to whichever store the account was
913                        // actually resolved from (resolve_account above) —
914                        // not always id_store, since a fallback-mirror hit
915                        // must update the mirror, not silently create a
916                        // second, never-read copy in id_store.
917                        match account_store.identity_upsert(&updated) {
918                            Ok(()) => {
919                                tracing::info!(
920                                    target: "identity",
921                                    provider = %binding.provider,
922                                    account_id = %binding.account_id,
923                                    old_status = %account.status,
924                                    new_status,
925                                    "oauth probe: status updated"
926                                );
927                                // Publish accounts-changed so any live
928                                // account list (Connectors → Accounts,
929                                // launch modal) refreshes its Status
930                                // column without a reload — the account
931                                // row itself is what changed.
932                                if let Some(b) = broker.as_ref() {
933                                    b.publish_event::<crate::backend::mps_events::IdentityAccountsChanged>(&(), vec![], 0);
934                                }
935                            }
936                            Err(e) => {
937                                tracing::warn!(
938                                    target: "identity",
939                                    provider = %binding.provider,
940                                    account_id = %binding.account_id,
941                                    error = %e,
942                                    "oauth probe: identity_upsert failed — status not persisted",
943                                );
944                            }
945                        }
946                    }
947                }
948            }
949        }
950    }
951
952    // Step 5: definition-provider gate (spec §2.2 edge case). The agent's
953    // own CLI provider is an oauth-class provider it is "supposed to have
954    // credentials for" whether or not a binding row exists — an agent with
955    // NO binding for it (never bound, or the link was cascaded away by an
956    // account delete) must not silently launch on the user's global login.
957    // Bindings that existed but failed were already gated inside the loop,
958    // so this only fires for the genuinely-unbound case (no double logs).
959    if let Some(p) = def_provider {
960        if matches!(provider_class(&p), Some(ProviderClass::OAuth { .. }))
961            && !injected_oauth.contains(&p)
962            // Canonicalized (codex P1 on PR #2377): `b.provider` is the raw
963            // DB value and may still be a legacy alias even though `p` is
964            // canonical — without this, a binding that failed injection for
965            // an unrelated reason (already gated above) would be
966            // misclassified as "no binding at all" here whenever it's
967            // stored under an alias.
968            && !bindings.iter().any(|b| resolve_provider_alias(&b.provider) == p)
969        {
970            gate_oauth_failure(&p, "no account bound for the agent's provider")?;
971        }
972    }
973
974    // #3577, re-checked at the gate's own exit: the instance resolved above,
975    // but the agent can be deleted while this function reads its definition
976    // and bindings — then `def_provider` is `None`, `bindings` is empty, and
977    // the checks above pass vacuously (ReAgent P0 / Codex P1 on #3591). The
978    // only caller, `build_persistent_spawn_env`, re-checks again at its own
979    // last store read; this keeps the gate correct on its own.
980    if mstore.agent_was_deleted(&instance.id) {
981        return Err(SpawnGateError::AgentDeleted {
982            agent_id: instance.id.clone(),
983        });
984    }
985
986    Ok(())
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992    use super::super::oauth_probe::oauth_status;
993    use crate::backend::storage::store::{
994        AgentInstance, IdentityAccount, InstanceStatus, Bundle, SecretRef,
995    };
996
997    fn make_store() -> Arc<Store> {
998        Arc::new(Store::open_in_memory().unwrap())
999    }
1000
1001    /// A store with a shared definition registry (in `tmp`) — the thing that
1002    /// remembers a deleted agent as a retired record.
1003    fn store_with_def_registry(tmp: &tempfile::TempDir) -> Arc<Store> {
1004        let store = make_store();
1005        let defs = crate::registry::DefinitionStore::open(tmp.path().join("definitions")).unwrap();
1006        store.set_def_registry(Arc::new(defs));
1007        store
1008    }
1009
1010    /// #3577: a pane whose agent was deleted (row gone, registry record
1011    /// retired) is refused at the gate instead of spawning on the shared
1012    /// login with no identity.
1013    #[test]
1014    fn a_block_naming_a_deleted_agent_is_refused() {
1015        let tmp = tempfile::tempdir().unwrap();
1016        let store = store_with_def_registry(&tmp);
1017        let mut def = crate::backend::storage::agents::test_agent_def(
1018            "agent-deleted-3577",
1019            "Gone",
1020            "claude",
1021            "agent",
1022            1,
1023            "",
1024        );
1025        store.agent_def_insert(&mut def).unwrap();
1026        assert!(
1027            store
1028                .shared_def_registry()
1029                .unwrap()
1030                .exists("agent-deleted-3577"),
1031            "precondition: mirrored"
1032        );
1033        assert!(store.agent_def_delete("agent-deleted-3577").unwrap());
1034        insert_block_for_agent(&store, "block-deleted-3577", "agent-deleted-3577");
1035
1036        let mut env = HashMap::new();
1037        let res = inject_identity_env_with_broker(
1038            store.clone(),
1039            store.clone(),
1040            store.clone(),
1041            None,
1042            "block-deleted-3577",
1043            &mut env,
1044        );
1045        assert_eq!(
1046            res,
1047            Err(SpawnGateError::AgentDeleted {
1048                agent_id: "agent-deleted-3577".to_string()
1049            })
1050        );
1051    }
1052
1053    /// Only a positive "deleted" signal refuses. A block naming a template
1054    /// id (a template session, a quick-launch provider key), a live
1055    /// cross-channel agent (active registry record, no local row), or
1056    /// anything at all when there is no registry to ask, keeps today's
1057    /// ungated behaviour.
1058    #[test]
1059    fn a_row_less_block_not_naming_a_deleted_agent_is_not_refused() {
1060        let tmp = tempfile::tempdir().unwrap();
1061        let store = store_with_def_registry(&tmp);
1062        let mut tpl = crate::backend::storage::agents::test_agent_def(
1063            "claude", "Claude", "claude", "agent", 1, "",
1064        );
1065        tpl.is_seeded = 1;
1066        store.agent_def_insert(&mut tpl).unwrap();
1067        store
1068            .shared_def_registry()
1069            .unwrap()
1070            .upsert(&crate::registry::DefinitionRecord {
1071                schema_version: crate::registry::DEF_MAX_SUPPORTED_SCHEMA,
1072                data: crate::registry::DefinitionRecordV1 {
1073                    id: "agent-elsewhere-3577".to_string(),
1074                    name: "Elsewhere".to_string(),
1075                    provider: "claude".to_string(),
1076                    ..Default::default()
1077                },
1078            })
1079            .unwrap();
1080        insert_block_for_agent(&store, "block-template-3577", "claude");
1081        insert_block_for_agent(&store, "block-cross-3577", "agent-elsewhere-3577");
1082        insert_block_for_agent(&store, "block-unknown-3577", "never-existed");
1083        for block in [
1084            "block-template-3577",
1085            "block-cross-3577",
1086            "block-unknown-3577",
1087        ] {
1088            let mut env = HashMap::new();
1089            let res = inject_identity_env_with_broker(
1090                store.clone(),
1091                store.clone(),
1092                store.clone(),
1093                None,
1094                block,
1095                &mut env,
1096            );
1097            assert_eq!(res, Ok(()), "{block}");
1098        }
1099
1100        // No registry at all: a deleted agent cannot be told apart, so the
1101        // gate stays open rather than guessing.
1102        let bare = make_store();
1103        insert_block_for_agent(&bare, "block-bare-3577", "agent-deleted-anywhere");
1104        let mut env = HashMap::new();
1105        assert_eq!(
1106            inject_identity_env_with_broker(
1107                bare.clone(),
1108                bare.clone(),
1109                bare.clone(),
1110                None,
1111                "block-bare-3577",
1112                &mut env
1113            ),
1114            Ok(())
1115        );
1116    }
1117
1118    fn make_account(
1119        id: &str,
1120        provider: &str,
1121        secret_ref: SecretRef,
1122    ) -> IdentityAccount {
1123        IdentityAccount {
1124            id: id.to_string(),
1125            name: format!("{}-{}", provider, id),
1126            provider: provider.to_string(),
1127            kind: "pat".to_string(),
1128            display_name: String::new(),
1129            secret_ref,
1130            context: serde_json::json!({}),
1131            status: "unknown".to_string(),
1132            created_at: 0,
1133            updated_at: 0,
1134        }
1135    }
1136
1137    /// Insert a Block row whose `meta.agentId` points at the agent
1138    /// def. Phase 3b.4 made `instance_get_active_for_block` resolve
1139    /// via the block→agent reference (instead of filtering instance
1140    /// rows by status), so every resolver test that exercises the
1141    /// inject path needs a real block in the store.
1142    fn insert_block_for_agent(store: &Store, block_id: &str, agent_id: &str) {
1143        use crate::backend::obj::{Block, MetaMapType};
1144        let mut block = Block {
1145            oid: block_id.to_string(),
1146            parentoref: String::new(),
1147            version: 0,
1148            runtimeopts: None,
1149            stickers: None,
1150            meta: {
1151                let mut m = MetaMapType::new();
1152                m.insert("view".to_string(), serde_json::json!("agent"));
1153                m.insert("agentId".to_string(), serde_json::json!(agent_id));
1154                m
1155            },
1156            subblockids: None,
1157        };
1158        store.insert(&mut block).unwrap();
1159    }
1160
1161    /// A genuinely writable temp path for a test's `SecretRef::OAuthConfigDir`.
1162    ///
1163    /// `inject_identity_env` now really seeds a `CLAUDE.md` into the bound dir
1164    /// and FAILS CLOSED if it can't (SPEC_ISOLATE_HOST_CLAUDE_MD_2026_08_31.md),
1165    /// so these tests need a dir that actually exists/can be created rather
1166    /// than a synthetic absolute path. The previous `/var/agentmux/...`
1167    /// literals were unwritable on Linux CI (`Permission denied`) while
1168    /// silently resolving to the current drive on Windows — green locally,
1169    /// red on the ubuntu leg.
1170    fn test_config_dir(name: &str) -> String {
1171        std::env::temp_dir()
1172            .join(format!("agentmux-inject-test-{}-{name}", std::process::id()))
1173            .to_string_lossy()
1174            .into_owned()
1175    }
1176
1177    fn make_instance(block_id: &str, identity_id: &str) -> AgentInstance {
1178        AgentInstance {
1179            id: format!("inst-{block_id}"),
1180            definition_id: "def-1".to_string(),
1181            parent_instance_id: String::new(),
1182            block_id: block_id.to_string(),
1183            session_id: String::new(),
1184            status: InstanceStatus::Running.as_str().to_string(),
1185            github_context: String::new(),
1186            started_at: 0,
1187            ended_at: 0,
1188            created_at: 0,
1189            identity_id: identity_id.to_string(),
1190            memory_id: String::new(),
1191            instance_name: String::new(),
1192            working_directory: String::new(),
1193            display_hidden: false,
1194        }
1195    }
1196
1197    #[cfg(debug_assertions)]
1198    #[test]
1199    fn inject_oauth_class_sets_config_dir_env_var() {
1200        let store = make_store();
1201
1202        let mut def = crate::backend::storage::store::AgentDefinition {
1203            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1204            id: "def-1".to_string(),
1205            slug: String::new(),
1206            name: "T".to_string(),
1207            icon: "✦".to_string(),
1208            provider: "claude".to_string(),
1209            description: String::new(),
1210            working_directory: String::new(),
1211            shell: String::new(),
1212            provider_flags: String::new(),
1213            auto_start: 0,
1214            restart_on_crash: 0,
1215            idle_timeout_minutes: 0,
1216            created_at: 0,
1217            agent_type: String::new(),
1218            environment: String::new(),
1219            agent_bus_id: String::new(),
1220            is_seeded: 0,
1221            accounts: String::new(),
1222            parent_id: String::new(),
1223            branch_label: String::new(),
1224            updated_at: 0,
1225            user_hidden: 0,
1226            container_image: String::new(),
1227            container_volumes: "[]".to_string(),
1228            container_name: String::new(),
1229            use_ambient_login: 0,
1230            auto_continue_enabled: 0,
1231            model_vendor_base_url: String::new(),
1232            memory_id: String::new(),
1233        };
1234        store.agent_def_insert(&mut def).unwrap();
1235
1236        let claude = make_account(
1237            "acct-claude",
1238            "claude",
1239            SecretRef::OAuthConfigDir {
1240                dir: test_config_dir("id-oauth"),
1241            },
1242        );
1243        store.identity_upsert(&claude).unwrap();
1244        store
1245            .agent_identity_link("def-1", "acct-claude", "claude")
1246            .unwrap();
1247
1248        insert_block_for_agent(&store, "block-oauth", "def-1");
1249        let inst = make_instance("block-oauth", "id-oauth");
1250        store.instance_create(&inst).unwrap();
1251
1252        let mut env: HashMap<String, String> = HashMap::new();
1253        // Spec §2.5 regression: a resolvable oauth account injects
1254        // unchanged — the layer-3 gate never fires.
1255        inject_identity_env(store.clone(), store.clone(), store, "block-oauth", &mut env).unwrap();
1256
1257        // OAuth dispatch sets the provider's config-dir env var.
1258        assert_eq!(
1259            env.get("CLAUDE_CONFIG_DIR").cloned(),
1260            Some(test_config_dir("id-oauth")),
1261        );
1262        // And does NOT set the anthropic api-key env var — dispatch
1263        // is by provider class, not by token shape.
1264        assert!(env.get("ANTHROPIC_API_KEY").is_none());
1265    }
1266
1267    /// docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md —
1268    /// the enforcement that closes the real, currently-live gap found in
1269    /// docs/status/STATUS_IDENTITY_ISOLATION_GATE_NOT_ENFORCING_2026_08_20.md
1270    /// §8 (an account whose `secret_ref.dir` was the literal ambient home).
1271    /// Uses the REAL `get_home_dir()` (not a mock) so this exercises the
1272    /// exact same resolution production code does — deterministic
1273    /// regardless of whether `~/.claude` actually exists on the test
1274    /// machine, since `paths_resolve_to_same_dir`'s lexical fallback
1275    /// handles "doesn't exist" and its canonicalize branch handles
1276    /// "exists" identically correctly (see providers.rs's own tests).
1277    #[cfg(debug_assertions)]
1278    #[test]
1279    fn inject_oauth_class_blocks_spawn_when_config_dir_is_the_ambient_home() {
1280        let store = make_store();
1281
1282        let mut def = crate::backend::storage::store::AgentDefinition {
1283            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1284            id: "def-1".to_string(),
1285            slug: String::new(),
1286            name: "T".to_string(),
1287            icon: "✦".to_string(),
1288            provider: "claude".to_string(),
1289            description: String::new(),
1290            working_directory: String::new(),
1291            shell: String::new(),
1292            provider_flags: String::new(),
1293            auto_start: 0,
1294            restart_on_crash: 0,
1295            idle_timeout_minutes: 0,
1296            created_at: 0,
1297            agent_type: String::new(),
1298            environment: String::new(),
1299            agent_bus_id: String::new(),
1300            is_seeded: 0,
1301            accounts: String::new(),
1302            parent_id: String::new(),
1303            branch_label: String::new(),
1304            updated_at: 0,
1305            user_hidden: 0,
1306            container_image: String::new(),
1307            container_volumes: "[]".to_string(),
1308            container_name: String::new(),
1309            use_ambient_login: 0,
1310            auto_continue_enabled: 0,
1311            model_vendor_base_url: String::new(),
1312            memory_id: String::new(),
1313        };
1314        store.agent_def_insert(&mut def).unwrap();
1315
1316        let ambient_claude_dir = crate::backend::base::get_home_dir()
1317            .join(".claude")
1318            .to_string_lossy()
1319            .into_owned();
1320        let claude = make_account(
1321            "acct-ambient",
1322            "claude",
1323            SecretRef::OAuthConfigDir { dir: ambient_claude_dir.clone() },
1324        );
1325        store.identity_upsert(&claude).unwrap();
1326        store
1327            .agent_identity_link("def-1", "acct-ambient", "claude")
1328            .unwrap();
1329
1330        insert_block_for_agent(&store, "block-ambient", "def-1");
1331        let inst = make_instance("block-ambient", "id-ambient");
1332        store.instance_create(&inst).unwrap();
1333
1334        let mut env: HashMap<String, String> = HashMap::new();
1335        let err = inject_identity_env(store.clone(), store.clone(), store, "block-ambient", &mut env)
1336            .expect_err("spawn must be refused when the bound account's config dir is the ambient home");
1337
1338        match err {
1339            SpawnGateError::AmbientHomeDirNotAllowed { provider, dir } => {
1340                assert_eq!(provider, "claude");
1341                assert_eq!(dir, ambient_claude_dir);
1342            }
1343            other => panic!("expected AmbientHomeDirNotAllowed, got {other:?}"),
1344        }
1345        // The gate must block BEFORE anything is injected — no partial
1346        // env-var leak from the refused binding.
1347        assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1348    }
1349
1350    /// Codex P1 + ReAgent P1 on PR #2854: seeding the CLAUDE.md isolation
1351    /// placeholder must fail closed, not warn-and-continue — otherwise the
1352    /// agent spawns with a CLAUDE_CONFIG_DIR that still has no CLAUDE.md,
1353    /// exactly the condition that falls through to the ambient home. An
1354    /// empty `dir` (rejected by seed_claude_md_placeholder_if_missing's own
1355    /// InvalidInput guard) is a deterministic, cross-platform way to
1356    /// trigger a seed failure without relying on real filesystem
1357    /// permissions. It is also NOT the ambient home, so this exercises the
1358    /// seed check specifically, not the AmbientHomeDirNotAllowed path
1359    /// covered by the test above.
1360    #[cfg(debug_assertions)]
1361    #[test]
1362    fn inject_oauth_class_blocks_spawn_when_claude_md_seed_fails() {
1363        let store = make_store();
1364
1365        let mut def = crate::backend::storage::store::AgentDefinition {
1366            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1367            id: "def-1".to_string(),
1368            slug: String::new(),
1369            name: "T".to_string(),
1370            icon: "✦".to_string(),
1371            provider: "claude".to_string(),
1372            description: String::new(),
1373            working_directory: String::new(),
1374            shell: String::new(),
1375            provider_flags: String::new(),
1376            auto_start: 0,
1377            restart_on_crash: 0,
1378            idle_timeout_minutes: 0,
1379            created_at: 0,
1380            agent_type: String::new(),
1381            environment: String::new(),
1382            agent_bus_id: String::new(),
1383            is_seeded: 0,
1384            accounts: String::new(),
1385            parent_id: String::new(),
1386            branch_label: String::new(),
1387            updated_at: 0,
1388            user_hidden: 0,
1389            container_image: String::new(),
1390            container_volumes: "[]".to_string(),
1391            container_name: String::new(),
1392            use_ambient_login: 0,
1393            auto_continue_enabled: 0,
1394            model_vendor_base_url: String::new(),
1395            memory_id: String::new(),
1396        };
1397        store.agent_def_insert(&mut def).unwrap();
1398
1399        let claude = make_account(
1400            "acct-empty-dir",
1401            "claude",
1402            SecretRef::OAuthConfigDir { dir: String::new() },
1403        );
1404        store.identity_upsert(&claude).unwrap();
1405        store
1406            .agent_identity_link("def-1", "acct-empty-dir", "claude")
1407            .unwrap();
1408
1409        insert_block_for_agent(&store, "block-empty-dir", "def-1");
1410        let inst = make_instance("block-empty-dir", "id-empty-dir");
1411        store.instance_create(&inst).unwrap();
1412
1413        let mut env: HashMap<String, String> = HashMap::new();
1414        let err = inject_identity_env(store.clone(), store.clone(), store, "block-empty-dir", &mut env)
1415            .expect_err("spawn must be refused when the CLAUDE.md isolation seed fails");
1416
1417        match err {
1418            SpawnGateError::ClaudeMdSeedFailed { provider, dir, .. } => {
1419                assert_eq!(provider, "claude");
1420                assert_eq!(dir, "");
1421            }
1422            other => panic!("expected ClaudeMdSeedFailed, got {other:?}"),
1423        }
1424        // The gate must block BEFORE anything is injected — no partial
1425        // env-var leak from the refused binding.
1426        assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1427    }
1428
1429    /// **Behavior deliberately REVERSED 2026-08-31 (operator instruction).**
1430    ///
1431    /// This test previously asserted the opposite: that a spawn MUST succeed
1432    /// by falling back to the global mirror when the per-channel `id_store`
1433    /// lacks the account (reagentx P0 on PR #2632, protecting binding
1434    /// continuity across a version/channel switch).
1435    ///
1436    /// Per-channel isolation is a wanted feature, and that fallback is exactly
1437    /// what let an agent in a fresh channel launch against another channel's
1438    /// credential with no account of its own. The operator's requirement:
1439    ///
1440    /// > "we want per-channel isolation. a user needs a login anytime in the
1441    /// > channel."
1442    ///
1443    /// So the same scenario must now BLOCK, surfacing the ordinary
1444    /// `MissingCredentials` auth card, and the user logs in for this channel.
1445    /// The old continuity guarantee is intentionally given up here; the
1446    /// fallback survives only for read-only/display callers.
1447    /// See `docs/analysis/ANALYSIS_PER_CHANNEL_AUTH_BYPASSES_2026_08_31.md`.
1448    #[cfg(debug_assertions)]
1449    #[test]
1450    fn inject_refuses_an_account_that_only_exists_in_the_global_mirror() {
1451        let mstore = make_store();
1452        // Deliberately EMPTY of the account and link — simulates a fresh,
1453        // isolated per-channel id_store on a new channel/version.
1454        let id_store = make_store();
1455        // Real identity-store schema (no account_id/agent_id FK, matching
1456        // production's Store::open_identity_store — NOT open_in_memory's
1457        // channel schema, which still has both FKs and would reject this
1458        // link since "def-1" only exists in mstore's own definitions
1459        // table, a different physical store). Simulates the post-migration
1460        // state: both the link AND the account mirror row live here,
1461        // exactly what m0022_identity_store_links_backfill produces.
1462        let identity_store_tmp = tempfile::NamedTempFile::new().unwrap();
1463        let identity_store = Arc::new(Store::open_identity_store(identity_store_tmp.path()).unwrap());
1464
1465        let mut def = crate::backend::storage::store::AgentDefinition {
1466            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1467            id: "def-1".to_string(),
1468            slug: String::new(),
1469            name: "T".to_string(),
1470            icon: "✦".to_string(),
1471            provider: "claude".to_string(),
1472            description: String::new(),
1473            working_directory: String::new(),
1474            shell: String::new(),
1475            provider_flags: String::new(),
1476            auto_start: 0,
1477            restart_on_crash: 0,
1478            idle_timeout_minutes: 0,
1479            created_at: 0,
1480            agent_type: String::new(),
1481            environment: String::new(),
1482            agent_bus_id: String::new(),
1483            is_seeded: 0,
1484            accounts: String::new(),
1485            parent_id: String::new(),
1486            branch_label: String::new(),
1487            updated_at: 0,
1488            user_hidden: 0,
1489            container_image: String::new(),
1490            container_volumes: "[]".to_string(),
1491            container_name: String::new(),
1492            use_ambient_login: 0,
1493            auto_continue_enabled: 0,
1494            model_vendor_base_url: String::new(),
1495            memory_id: String::new(),
1496        };
1497        mstore.agent_def_insert(&mut def).unwrap();
1498
1499        let claude = make_account(
1500            "acct-migrated",
1501            "claude",
1502            SecretRef::OAuthConfigDir {
1503                dir: test_config_dir("id-migrated"),
1504            },
1505        );
1506        identity_store.identity_upsert(&claude).unwrap();
1507        identity_store
1508            .agent_identity_link("def-1", "acct-migrated", "claude")
1509            .unwrap();
1510
1511        insert_block_for_agent(&mstore, "block-continuing", "def-1");
1512        let inst = make_instance("block-continuing", "id-continuing");
1513        mstore.instance_create(&inst).unwrap();
1514
1515        let mut env: HashMap<String, String> = HashMap::new();
1516        let res = inject_identity_env(mstore, id_store, identity_store, "block-continuing", &mut env);
1517
1518        assert!(
1519            matches!(res, Err(SpawnGateError::MissingCredentials { .. })),
1520            "an account that exists ONLY in the global mirror must NOT satisfy a spawn in \
1521             this channel — that is the per-channel-isolation bypass; expected \
1522             MissingCredentials, got {res:?}"
1523        );
1524        assert!(
1525            env.get("CLAUDE_CONFIG_DIR").is_none(),
1526            "nothing may be injected from a refused cross-channel account"
1527        );
1528    }
1529
1530    /// **Behavior deliberately REVERSED 2026-08-31 (operator instruction)** —
1531    /// companion to the test above, closing the same bypass from the other
1532    /// direction.
1533    ///
1534    /// Originally reagentx round-3 P0 on PR #2632: it proved that an account
1535    /// written through `identity_upsert_with_mirror` (every live write path —
1536    /// `identity.account.upsert`, OAuth persist) still resolved after a
1537    /// channel switch, not just migration-backfilled ones.
1538    ///
1539    /// That is now precisely the wrong outcome: it made the bypass apply to
1540    /// *newly created* accounts too, so every fresh channel silently inherited
1541    /// the last one's credential. The dual-write itself is retained (display
1542    /// callers still use `resolve_account`'s fallback to describe an account);
1543    /// what changed is that a mirror row can no longer authorize a SPAWN.
1544    #[cfg(debug_assertions)]
1545    #[test]
1546    fn inject_refuses_a_mirror_written_account_after_a_channel_switch() {
1547        let mstore = make_store();
1548        let id_store = make_store();
1549        let identity_store_tmp = tempfile::NamedTempFile::new().unwrap();
1550        let identity_store = Arc::new(Store::open_identity_store(identity_store_tmp.path()).unwrap());
1551
1552        // `make_instance` below hardcodes `definition_id: "def-1"` — match it
1553        // rather than parameterizing a helper shared with other tests.
1554        let mut def = crate::backend::storage::store::AgentDefinition {
1555            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1556            id: "def-1".to_string(),
1557            slug: String::new(),
1558            name: "T".to_string(),
1559            icon: "✦".to_string(),
1560            provider: "claude".to_string(),
1561            description: String::new(),
1562            working_directory: String::new(),
1563            shell: String::new(),
1564            provider_flags: String::new(),
1565            auto_start: 0,
1566            restart_on_crash: 0,
1567            idle_timeout_minutes: 0,
1568            created_at: 0,
1569            agent_type: String::new(),
1570            environment: String::new(),
1571            agent_bus_id: String::new(),
1572            is_seeded: 0,
1573            accounts: String::new(),
1574            parent_id: String::new(),
1575            branch_label: String::new(),
1576            updated_at: 0,
1577            user_hidden: 0,
1578            container_image: String::new(),
1579            container_volumes: "[]".to_string(),
1580            container_name: String::new(),
1581            use_ambient_login: 0,
1582            auto_continue_enabled: 0,
1583            model_vendor_base_url: String::new(),
1584            memory_id: String::new(),
1585        };
1586        mstore.agent_def_insert(&mut def).unwrap();
1587
1588        // "Before the channel switch": the account is created live, via the
1589        // same helper `identity.account.upsert`/OAuth persist use — writing
1590        // to id_store (the then-current, then-primary store) AND dual-
1591        // writing the mirror into identity_store.
1592        let claude = make_account(
1593            "acct-fresh",
1594            "claude",
1595            SecretRef::OAuthConfigDir {
1596                dir: test_config_dir("id-fresh"),
1597            },
1598        );
1599        id_store.identity_upsert_with_mirror(&identity_store, &claude).unwrap();
1600        identity_store
1601            .agent_identity_link("def-1", "acct-fresh", "claude")
1602            .unwrap();
1603
1604        insert_block_for_agent(&mstore, "block-fresh", "def-1");
1605        let inst = make_instance("block-fresh", "id-fresh");
1606        mstore.instance_create(&inst).unwrap();
1607
1608        // "After the channel switch": a brand-new, empty id_store — the
1609        // account row created above must be unreachable here, exactly like
1610        // a fresh per-channel-isolated store on a new channel.
1611        let id_store_after_switch = make_store();
1612
1613        let mut env: HashMap<String, String> = HashMap::new();
1614        let res = inject_identity_env(mstore, id_store_after_switch, identity_store, "block-fresh", &mut env);
1615
1616        assert!(
1617            matches!(res, Err(SpawnGateError::MissingCredentials { .. })),
1618            "REVERSED 2026-08-31: a mirror row must no longer carry a spawn across a channel \
1619             switch, however it was written — dual-writing the mirror is not a substitute for \
1620             logging in on this channel; expected MissingCredentials, got {res:?}"
1621        );
1622        assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1623    }
1624
1625    // reagentx P1 on PR #2605: this is the ordinary spawn path (not
1626    // `auth.start`) — it reads a PREVIOUSLY STORED OAuthConfigDir, which
1627    // is exactly the case an account provisioned before the history-link
1628    // feature existed (or since its last `auth.start`) is in. Proves the
1629    // link now gets (re-)created here too, not only at auth.start.
1630    #[cfg(debug_assertions)]
1631    #[test]
1632    fn inject_oauth_class_links_conversation_history_on_the_ordinary_spawn_path() {
1633        let _lock = crate::test_support::ISOLATED_AUTH_ENV_LOCK
1634            .lock()
1635            .unwrap_or_else(|e| e.into_inner());
1636        let tmp = tempfile::TempDir::new().unwrap();
1637        std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
1638        std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1639        let paths = agentmux_common::DataPaths::resolve(
1640            "0.0.0-test",
1641            &agentmux_common::RuntimeMode::Installed,
1642        )
1643        .unwrap();
1644        paths.ensure_dirs().unwrap();
1645        for (k, v) in paths.to_env_vars() {
1646            std::env::set_var(k, v);
1647        }
1648
1649        // A credential dir that ALREADY exists on disk, under the isolated
1650        // (per-channel) tree, with real pre-existing history in it — the
1651        // account was provisioned (auth.start ran) before this test's
1652        // spawn-time call, exactly like a real already-connected account.
1653        let isolated_dir = paths.instance_dir.join("identities").join("acct-claude").join("claude");
1654        std::fs::create_dir_all(isolated_dir.join("projects")).unwrap();
1655        std::fs::write(
1656            isolated_dir.join("projects").join("existing-session.jsonl"),
1657            b"pre-existing history",
1658        )
1659        .unwrap();
1660
1661        let store = make_store();
1662        let mut def = crate::backend::storage::store::AgentDefinition {
1663            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1664            id: "def-1".to_string(),
1665            slug: String::new(),
1666            name: "T".to_string(),
1667            icon: "✦".to_string(),
1668            provider: "claude".to_string(),
1669            description: String::new(),
1670            working_directory: String::new(),
1671            shell: String::new(),
1672            provider_flags: String::new(),
1673            auto_start: 0,
1674            restart_on_crash: 0,
1675            idle_timeout_minutes: 0,
1676            created_at: 0,
1677            agent_type: String::new(),
1678            environment: String::new(),
1679            agent_bus_id: String::new(),
1680            is_seeded: 0,
1681            accounts: String::new(),
1682            parent_id: String::new(),
1683            branch_label: String::new(),
1684            updated_at: 0,
1685            user_hidden: 0,
1686            container_image: String::new(),
1687            container_volumes: "[]".to_string(),
1688            container_name: String::new(),
1689            use_ambient_login: 0,
1690            auto_continue_enabled: 0,
1691            model_vendor_base_url: String::new(),
1692            memory_id: String::new(),
1693        };
1694        store.agent_def_insert(&mut def).unwrap();
1695
1696        let claude = make_account(
1697            "acct-claude",
1698            "claude",
1699            SecretRef::OAuthConfigDir { dir: isolated_dir.to_string_lossy().to_string() },
1700        );
1701        store.identity_upsert(&claude).unwrap();
1702        store.agent_identity_link("def-1", "acct-claude", "claude").unwrap();
1703        insert_block_for_agent(&store, "block-oauth-history", "def-1");
1704        let inst = make_instance("block-oauth-history", "id-oauth");
1705        store.instance_create(&inst).unwrap();
1706
1707        let mut env: HashMap<String, String> = HashMap::new();
1708        inject_identity_env(store.clone(), store.clone(), store, "block-oauth-history", &mut env).unwrap();
1709
1710        let global_history = paths.identity_history_dir("acct-claude", "claude", "projects");
1711        let via_link = std::fs::read(global_history.join("existing-session.jsonl"))
1712            .expect("pre-existing history must have been migrated to the global location by the spawn-time link");
1713        assert_eq!(via_link, b"pre-existing history");
1714
1715        std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1716        std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1717        for (k, _) in paths.to_env_vars() {
1718            std::env::remove_var(k);
1719        }
1720    }
1721
1722    #[cfg(debug_assertions)]
1723    #[test]
1724    fn inject_oauth_class_blocks_account_with_non_oauth_secret_ref() {
1725        // An oauth-class provider (claude) bound to an account whose
1726        // SecretRef is the API-key shape (Env) is a misconfiguration:
1727        // the account is unresolvable for the provider, so the layer-3
1728        // gate blocks the spawn instead of
1729        // mis-injecting the wrong secret or silently launching on the
1730        // user's global login.
1731        let store = make_store();
1732
1733        let mut def = crate::backend::storage::store::AgentDefinition {
1734            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1735            id: "def-1".to_string(),
1736            slug: String::new(),
1737            name: "T".to_string(),
1738            icon: "✦".to_string(),
1739            provider: "claude".to_string(),
1740            description: String::new(),
1741            working_directory: String::new(),
1742            shell: String::new(),
1743            provider_flags: String::new(),
1744            auto_start: 0,
1745            restart_on_crash: 0,
1746            idle_timeout_minutes: 0,
1747            created_at: 0,
1748            agent_type: String::new(),
1749            environment: String::new(),
1750            agent_bus_id: String::new(),
1751            is_seeded: 0,
1752            accounts: String::new(),
1753            parent_id: String::new(),
1754            branch_label: String::new(),
1755            updated_at: 0,
1756            user_hidden: 0,
1757            container_image: String::new(),
1758            container_volumes: "[]".to_string(),
1759            container_name: String::new(),
1760            use_ambient_login: 0,
1761            auto_continue_enabled: 0,
1762            model_vendor_base_url: String::new(),
1763            memory_id: String::new(),
1764        };
1765        store.agent_def_insert(&mut def).unwrap();
1766
1767        let bad = make_account(
1768            "acct-bad",
1769            "claude",
1770            SecretRef::Env {
1771                env_var: "CLAUDE_TOKEN_NOT_A_DIR".to_string(),
1772            },
1773        );
1774        store.identity_upsert(&bad).unwrap();
1775        store
1776            .agent_identity_link("def-1", "acct-bad", "claude")
1777            .unwrap();
1778
1779        insert_block_for_agent(&store, "block-bad", "def-1");
1780        let inst = make_instance("block-bad", "id-bad");
1781        store.instance_create(&inst).unwrap();
1782
1783        let mut env: HashMap<String, String> = HashMap::new();
1784        let res = inject_identity_env(store.clone(), store.clone(), store, "block-bad", &mut env);
1785
1786        // Blocking spawn-gate error — and nothing injected.
1787        assert_eq!(
1788            res,
1789            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
1790        );
1791        assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1792        assert!(env.is_empty());
1793    }
1794
1795    #[cfg(debug_assertions)]
1796    #[test]
1797    fn inject_oauth_class_succeeds_when_the_only_binding_is_under_a_legacy_alias() {
1798        // codex P1 on PR #2377: a definition's own `provider` is canonical
1799        // ("claude"), but its only db_agent_identity_links row is still
1800        // under a legacy alias ("claude-code") — carried forward from
1801        // before providers.rs's alias table existed, or a definition/link
1802        // pair that otherwise drifted. The binding injects successfully
1803        // (provider_class already resolves aliases), but before this fix
1804        // the def-provider gate compared the raw `injected_oauth`/binding
1805        // provider strings against the canonical def_provider and treated
1806        // the alias-bound account as "no account bound at all" — blocking
1807        // a spawn that had already successfully injected valid credentials
1808        // moments earlier in the same function.
1809        let store = make_store();
1810
1811        let mut def = crate::backend::storage::store::AgentDefinition {
1812            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1813            id: "def-1".to_string(),
1814            slug: String::new(),
1815            name: "T".to_string(),
1816            icon: "✦".to_string(),
1817            provider: "claude".to_string(),
1818            description: String::new(),
1819            working_directory: String::new(),
1820            shell: String::new(),
1821            provider_flags: String::new(),
1822            auto_start: 0,
1823            restart_on_crash: 0,
1824            idle_timeout_minutes: 0,
1825            created_at: 0,
1826            agent_type: String::new(),
1827            environment: String::new(),
1828            agent_bus_id: String::new(),
1829            is_seeded: 0,
1830            accounts: String::new(),
1831            parent_id: String::new(),
1832            branch_label: String::new(),
1833            updated_at: 0,
1834            user_hidden: 0,
1835            container_image: String::new(),
1836            container_volumes: "[]".to_string(),
1837            container_name: String::new(),
1838            use_ambient_login: 0,
1839            auto_continue_enabled: 0,
1840            model_vendor_base_url: String::new(),
1841            memory_id: String::new(),
1842        };
1843        store.agent_def_insert(&mut def).unwrap();
1844
1845        let claude = make_account(
1846            "acct-alias",
1847            "claude-code",
1848            SecretRef::OAuthConfigDir {
1849                dir: test_config_dir("id-alias"),
1850            },
1851        );
1852        store.identity_upsert(&claude).unwrap();
1853        // Bound under the alias, not the canonical "claude".
1854        store
1855            .agent_identity_link("def-1", "acct-alias", "claude-code")
1856            .unwrap();
1857
1858        insert_block_for_agent(&store, "block-alias", "def-1");
1859        let inst = make_instance("block-alias", "id-alias");
1860        store.instance_create(&inst).unwrap();
1861
1862        let mut env: HashMap<String, String> = HashMap::new();
1863        let res = inject_identity_env(store.clone(), store.clone(), store, "block-alias", &mut env);
1864
1865        assert!(res.is_ok(), "expected Ok, got {res:?}");
1866        assert_eq!(
1867            env.get("CLAUDE_CONFIG_DIR").cloned(),
1868            Some(test_config_dir("id-alias")),
1869        );
1870    }
1871
1872    // ── Layer 3 — spawn gating (SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4
1873    //    _2026_07_14.md §2.2/§2.5) ────────────────────────────────────
1874
1875    /// Fixture def for the gating tests — oauth-class CLI provider
1876    /// (claude). `use_ambient_login` is settable only to prove it is inert.
1877    fn gate_def(use_ambient_login: i64) -> crate::backend::storage::store::AgentDefinition {
1878        crate::backend::storage::store::AgentDefinition {
1879            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1880            id: "def-1".to_string(),
1881            slug: String::new(),
1882            name: "T".to_string(),
1883            icon: "✦".to_string(),
1884            provider: "claude".to_string(),
1885            description: String::new(),
1886            working_directory: String::new(),
1887            shell: String::new(),
1888            provider_flags: String::new(),
1889            auto_start: 0,
1890            restart_on_crash: 0,
1891            idle_timeout_minutes: 0,
1892            created_at: 0,
1893            agent_type: String::new(),
1894            environment: String::new(),
1895            agent_bus_id: String::new(),
1896            is_seeded: 0,
1897            accounts: String::new(),
1898            parent_id: String::new(),
1899            branch_label: String::new(),
1900            updated_at: 0,
1901            user_hidden: 0,
1902            container_image: String::new(),
1903            container_volumes: "[]".to_string(),
1904            container_name: String::new(),
1905            use_ambient_login,
1906            auto_continue_enabled: 0,
1907            model_vendor_base_url: String::new(),
1908            memory_id: String::new(),
1909        }
1910    }
1911
1912    /// Delete an account row while KEEPING its link — the post-delete
1913    /// shape on a legacy DB (analysis §2.4: real installs got the links
1914    /// table via ALTER RENAME with no FK cascade clause, so orphan links
1915    /// are real). FKs are toggled off for the surgical delete so the
1916    /// test store's DDL-level cascade doesn't fire.
1917    fn delete_account_row_keep_link(store: &Store, account_id: &str) {
1918        let conn = store.conn().lock().unwrap();
1919        conn.execute_batch(&format!(
1920            "PRAGMA foreign_keys=OFF;
1921             DELETE FROM db_accounts WHERE id = '{account_id}';
1922             PRAGMA foreign_keys=ON;"
1923        ))
1924        .unwrap();
1925    }
1926
1927    #[test]
1928    fn spawn_blocked_when_bound_oauth_account_missing_and_flag_false() {
1929        // Spec §2.5 test 1: binding → missing account → flag false →
1930        // spawn-assembly returns the blocking error (not a skip).
1931        let store = make_store();
1932        let mut def = gate_def(0);
1933        store.agent_def_insert(&mut def).unwrap();
1934
1935        let claude = make_account(
1936            "acct-gone",
1937            "claude",
1938            SecretRef::OAuthConfigDir {
1939                dir: test_config_dir("id-x"),
1940            },
1941        );
1942        store.identity_upsert(&claude).unwrap();
1943        store
1944            .agent_identity_link("def-1", "acct-gone", "claude")
1945            .unwrap();
1946        // Delete the account row out from under the link — the exact
1947        // post-`deleteidentityaccount` shape the resolver used to
1948        // log-and-skip ("row not found — skipping", analysis §2.3).
1949        delete_account_row_keep_link(&store, "acct-gone");
1950
1951        insert_block_for_agent(&store, "block-gate-1", "def-1");
1952        let inst = make_instance("block-gate-1", "id-x");
1953        store.instance_create(&inst).unwrap();
1954
1955        let mut env: HashMap<String, String> = HashMap::new();
1956        let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-1", &mut env);
1957
1958        assert_eq!(
1959            res,
1960            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
1961        );
1962        // Nothing was injected — the CLI process must never be created.
1963        assert!(env.is_empty());
1964        // The error's Display is the user-facing pane message (spec §2.2
1965        // wording) — pin the load-bearing pieces.
1966        let msg = res.unwrap_err().to_string();
1967        assert!(msg.contains("no credentials for claude"), "got: {msg}");
1968        assert!(msg.contains("Bind an account for this provider in Connectors → Accounts"), "got: {msg}");
1969    }
1970
1971    #[test]
1972    fn spawn_still_blocked_when_bound_oauth_account_missing_and_flag_true() {
1973        // Was spawn_proceeds_ambient_when_bound_oauth_account_missing_and_flag_true
1974        // — the ambient opt-in it exercised is retired
1975        // (PLAN_LOGIN_SINGLE_PATH_CONSOLIDATION_2026_07_20.md §7). A missing
1976        // account now blocks the spawn unconditionally; `use_ambient_login`
1977        // no longer changes the outcome. This test pins that the flag is
1978        // truly inert, not just untested.
1979        let store = make_store();
1980        let mut def = gate_def(1);
1981        store.agent_def_insert(&mut def).unwrap();
1982
1983        let claude = make_account(
1984            "acct-gone-2",
1985            "claude",
1986            SecretRef::OAuthConfigDir {
1987                dir: test_config_dir("id-y"),
1988            },
1989        );
1990        store.identity_upsert(&claude).unwrap();
1991        store
1992            .agent_identity_link("def-1", "acct-gone-2", "claude")
1993            .unwrap();
1994        delete_account_row_keep_link(&store, "acct-gone-2");
1995
1996        insert_block_for_agent(&store, "block-gate-2", "def-1");
1997        let inst = make_instance("block-gate-2", "id-y");
1998        store.instance_create(&inst).unwrap();
1999
2000        let mut env: HashMap<String, String> = HashMap::new();
2001        let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-2", &mut env);
2002
2003        assert_eq!(
2004            res,
2005            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2006        );
2007        assert!(env.is_empty());
2008    }
2009
2010    #[test]
2011    fn spawn_blocked_when_oauth_def_provider_has_no_binding_and_flag_false() {
2012        // Spec §2.2 edge case: an agent whose oauth-class CLI provider has
2013        // NO binding at all (never bound, or the link was cascaded away by
2014        // an account delete) is blocked without the opt-in — implicit
2015        // ambient is how the delete-doesn't-deauthenticate gap happened.
2016        // (The m0017 migration grandfathers pre-existing linkless agents
2017        // to flag=1; `inject_no_direct_links_injects_nothing` covers that
2018        // side.)
2019        //
2020        // THIS TEST IS THE INV-A REGRESSION CANARY
2021        // (`docs/retro/retro-auth-isolation-invariant-silently-orphaned-2026-07-14.md`):
2022        // a completely unconfigured agent (zero bindings, default flag)
2023        // is the exact shape that used to fall through to the user's
2024        // global `~/.claude` before commit `e5ab2d09` orphaned the
2025        // isolated-auto-provision path. If a future refactor makes this
2026        // assert start failing (env no longer empty, or `res` becomes
2027        // `Ok`), read that retro before "fixing" the test — the failure
2028        // is very likely the invariant regressing a second time, not the
2029        // test being wrong.
2030        let store = make_store();
2031        let mut def = gate_def(0);
2032        store.agent_def_insert(&mut def).unwrap();
2033
2034        insert_block_for_agent(&store, "block-gate-3", "def-1");
2035        let inst = make_instance("block-gate-3", "id-z");
2036        store.instance_create(&inst).unwrap();
2037
2038        let mut env: HashMap<String, String> = HashMap::new();
2039        let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-3", &mut env);
2040
2041        assert_eq!(
2042            res,
2043            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2044        );
2045        assert!(env.is_empty());
2046    }
2047
2048    /// Regression guard for the definition flip (agent-concept
2049    /// consolidation, `SPEC_AGENT_ARCHITECTURE_2026_05_27.md` Phase 3d).
2050    ///
2051    /// `agent_def_get(&instance.definition_id)` used to be scoped to
2052    /// `db_agent_definitions` and 404 for ANY template launch, since a
2053    /// launch's own row lives only in `db_agents` with no legacy
2054    /// counterpart. `def_provider` (Step 2, above) was therefore always
2055    /// `None` for a template launch — and Step 5's definition-provider gate
2056    /// (`if let Some(p) = def_provider { ... }`) is a no-op when
2057    /// `def_provider` is `None`. So a template-launched agent whose CLI
2058    /// provider is oauth-class, with genuinely ZERO identity binding, was
2059    /// launching WITHOUT ever being checked by the one gate that exists
2060    /// specifically to block exactly that — the definition flip closes
2061    /// this by letting `agent_def_get` resolve the launch's own row, so
2062    /// `def_provider` is `Some("claude")` and Step 5 applies to it like any
2063    /// other agent. (`use_ambient_login` is confirmed inert for this gate
2064    /// either way, per `gate_oauth_failure`'s own doc comment and
2065    /// `spawn_still_blocked_when_bound_oauth_account_missing_and_flag_true`
2066    /// above — this test does not depend on it and sets it to demonstrate
2067    /// that non-effect explicitly.)
2068    ///
2069    /// This template genuinely has NO identity link either — the sibling
2070    /// test `spawn_proceeds_when_a_template_launch_inherits_the_templates_
2071    /// own_identity_link` covers the case that made enabling Step 5 for
2072    /// launches unsafe on its own (codex P1 on #3092): a template WITH a
2073    /// real bound account must still let its launches spawn.
2074    #[test]
2075    fn spawn_is_gated_for_a_template_launch_with_no_binding_where_it_used_to_be_invisible_to_the_gate() {
2076        let store = make_store();
2077        let mut tpl = gate_def(1); // use_ambient_login=1 — must NOT matter, see doc comment.
2078        tpl.id = "tpl-ambient".to_string();
2079        tpl.is_seeded = 1;
2080        store.agent_def_insert(&mut tpl).unwrap();
2081
2082        // A launch of the template: `is_seeded = 1` means `instance_create`
2083        // creates a NEW row keyed by the launch id, not the template's —
2084        // exactly the shape that used to be invisible to `agent_def_get`.
2085        let inst = crate::backend::storage::store::AgentInstance {
2086            id: "launch-ambient".to_string(),
2087            definition_id: "tpl-ambient".to_string(),
2088            parent_instance_id: String::new(),
2089            block_id: "block-gate-ambient".to_string(),
2090            session_id: String::new(),
2091            status: InstanceStatus::Running.as_str().to_string(),
2092            github_context: String::new(),
2093            started_at: 0,
2094            ended_at: 0,
2095            created_at: 0,
2096            identity_id: "id-ambient".to_string(),
2097            memory_id: String::new(),
2098            instance_name: String::new(),
2099            working_directory: String::new(),
2100            display_hidden: false,
2101        };
2102        let canonical = store.instance_create(&inst).unwrap();
2103        assert_eq!(canonical.id, "launch-ambient", "sanity: a template launch creates its own row");
2104
2105        insert_block_for_agent(&store, "block-gate-ambient", "launch-ambient");
2106
2107        let mut env: HashMap<String, String> = HashMap::new();
2108        let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-ambient", &mut env);
2109
2110        assert_eq!(
2111            res,
2112            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2113            "a template launch's zero-binding oauth-class provider must now reach Step 5's gate, not skip it",
2114        );
2115        assert!(env.is_empty());
2116    }
2117
2118    /// Codex P1 on #3092: enabling Step 5 for launch rows (the previous
2119    /// test) would have converted every LEGITIMATELY-configured template
2120    /// launch into a hard spawn failure, without this fallback. Binding an
2121    /// identity to a template — the Agent Setup modal's supported target —
2122    /// has always written the link under the TEMPLATE's id; nothing writes
2123    /// a launch-scoped link. `resolve_bindings_for_instance` now falls back
2124    /// to the template's own links when the launch has none of its own, so
2125    /// a template's real binding still resolves (and injects) once launched.
2126    #[test]
2127    fn spawn_proceeds_when_a_template_launch_inherits_the_templates_own_identity_link() {
2128        let store = make_store();
2129        let mut tpl = gate_def(0);
2130        tpl.id = "tpl-bound".to_string();
2131        tpl.is_seeded = 1;
2132        store.agent_def_insert(&mut tpl).unwrap();
2133
2134        let claude = make_account(
2135            "acct-tpl",
2136            "claude",
2137            SecretRef::OAuthConfigDir { dir: test_config_dir("id-tpl-bound") },
2138        );
2139        store.identity_upsert(&claude).unwrap();
2140        // The link is bound to the TEMPLATE's own id, exactly as the Agent
2141        // Setup modal does today — never the launch's.
2142        store.agent_identity_link("tpl-bound", "acct-tpl", "claude").unwrap();
2143
2144        let inst = crate::backend::storage::store::AgentInstance {
2145            id: "launch-bound".to_string(),
2146            definition_id: "tpl-bound".to_string(),
2147            parent_instance_id: String::new(),
2148            block_id: "block-gate-bound".to_string(),
2149            session_id: String::new(),
2150            status: InstanceStatus::Running.as_str().to_string(),
2151            github_context: String::new(),
2152            started_at: 0,
2153            ended_at: 0,
2154            created_at: 0,
2155            identity_id: "id-tpl-bound".to_string(),
2156            memory_id: String::new(),
2157            instance_name: String::new(),
2158            working_directory: String::new(),
2159            display_hidden: false,
2160        };
2161        let canonical = store.instance_create(&inst).unwrap();
2162        assert_eq!(canonical.id, "launch-bound", "sanity: a template launch creates its own row");
2163        // Sanity: the launch's own row genuinely has no direct link — any
2164        // pass here must come from the template fallback, not a stray
2165        // direct one.
2166        assert!(store.agent_identity_list_for_agent("launch-bound").unwrap().is_empty());
2167
2168        insert_block_for_agent(&store, "block-gate-bound", "launch-bound");
2169
2170        let mut env: HashMap<String, String> = HashMap::new();
2171        let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-bound", &mut env);
2172
2173        assert_eq!(res, Ok(()), "a template's own real binding must still let its launches spawn");
2174        assert_eq!(
2175            env.get("CLAUDE_CONFIG_DIR").map(String::as_str),
2176            Some(test_config_dir("id-tpl-bound").as_str()),
2177            "the template's bound config dir must actually be injected, not just pass the gate",
2178        );
2179    }
2180
2181    /// reagent P0 on #3092: `db_agents.parent_template_id` is NOT
2182    /// exclusively "which template was I launched from" —
2183    /// `forkagentdefinition` (`template.rs`) reuses the same column to
2184    /// record a plain agent fork's SOURCE agent id, `is_seeded = 0`. An
2185    /// earlier version of the template-link fallback above trusted the
2186    /// column unconditionally: a fork of ANY regular agent with a bound
2187    /// identity would silently inherit and spawn on the SOURCE agent's
2188    /// credentials, even though forks are deliberately created with
2189    /// `accounts: ""` and no link of their own — a credential-scope leak
2190    /// across agents. `template_parent_id_if_seeded` closes this by only
2191    /// falling back when the parent actually has `is_seeded = 1`.
2192    #[test]
2193    fn a_fork_of_a_regular_agent_does_not_inherit_the_sources_identity_link() {
2194        let store = make_store();
2195        let mut source = gate_def(0);
2196        source.id = "agent-source".to_string();
2197        source.is_seeded = 0; // a regular agent, NOT a template
2198        store.agent_def_insert(&mut source).unwrap();
2199
2200        let claude = make_account(
2201            "acct-source",
2202            "claude",
2203            SecretRef::OAuthConfigDir { dir: test_config_dir("id-source-only") },
2204        );
2205        store.identity_upsert(&claude).unwrap();
2206        store.agent_identity_link("agent-source", "acct-source", "claude").unwrap();
2207
2208        // Mirrors forkagentdefinition's actual shape (template.rs): a
2209        // plain, non-template fork with parent_id = the source's id,
2210        // accounts reset to empty, and — critically — no identity link of
2211        // its own (fail-closed by design).
2212        let mut fork = gate_def(0);
2213        fork.id = "agent-fork".to_string();
2214        fork.is_seeded = 0;
2215        fork.parent_id = "agent-source".to_string();
2216        fork.accounts = String::new();
2217        store.agent_def_insert(&mut fork).unwrap();
2218
2219        let inst = crate::backend::storage::store::AgentInstance {
2220            id: "agent-fork".to_string(),
2221            definition_id: "agent-fork".to_string(),
2222            parent_instance_id: String::new(),
2223            block_id: "block-gate-fork".to_string(),
2224            session_id: String::new(),
2225            status: InstanceStatus::Running.as_str().to_string(),
2226            github_context: String::new(),
2227            started_at: 0,
2228            ended_at: 0,
2229            created_at: 0,
2230            identity_id: "id-fork".to_string(),
2231            memory_id: String::new(),
2232            instance_name: String::new(),
2233            working_directory: String::new(),
2234            display_hidden: false,
2235        };
2236        let canonical = store.instance_create(&inst).unwrap();
2237        assert_eq!(canonical.id, "agent-fork", "sanity: a fork of a non-template agent folds into its own row");
2238        assert!(store.agent_identity_list_for_agent("agent-fork").unwrap().is_empty(), "sanity: the fork has no link of its own");
2239
2240        insert_block_for_agent(&store, "block-gate-fork", "agent-fork");
2241
2242        let mut env: HashMap<String, String> = HashMap::new();
2243        let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-fork", &mut env);
2244
2245        assert_eq!(
2246            res,
2247            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2248            "a fork must NOT silently inherit and spawn on its source agent's credentials",
2249        );
2250        assert!(env.is_empty(), "nothing must be injected — especially not the source's config dir");
2251    }
2252
2253    #[test]
2254    fn inject_no_instance_does_nothing() {
2255        let store = make_store();
2256        let mut env: HashMap<String, String> = HashMap::new();
2257        inject_identity_env(store.clone(), store.clone(), store, "block-no-instance", &mut env).unwrap();
2258        assert!(env.is_empty());
2259    }
2260
2261    #[test]
2262    fn inject_blank_identity_is_gated_same_as_any_other_unresolvable_identity() {
2263        // #2463 Finding 2: this test used to assert the OPPOSITE — that a
2264        // blank identity_id was NOT gated by layer 3 and silently fell
2265        // back to ambient creds even with use_ambient_login=0. That let a
2266        // brand-new agent launch with no account selected spawn on
2267        // whatever ambient credential happened to be on disk, with
2268        // nothing bound in Connectors — observably different behavior on two
2269        // machines running the identical code, purely as a function of
2270        // which one had a stray credential file. A blank/empty
2271        // identity_id must be gated exactly like any other unresolvable
2272        // one (see `spawn_blocked_when_oauth_def_provider_has_no_binding_
2273        // and_flag_false`, the non-blank equivalent of this test).
2274        let store = make_store();
2275        let mut def = gate_def(0);
2276        store.agent_def_insert(&mut def).unwrap();
2277
2278        insert_block_for_agent(&store, "block-blank", "def-1");
2279        let inst = make_instance("block-blank", "blank");
2280        store.instance_create(&inst).unwrap();
2281
2282        let mut env: HashMap<String, String> = HashMap::new();
2283        let res = inject_identity_env(store.clone(), store.clone(), store, "block-blank", &mut env);
2284
2285        assert_eq!(
2286            res,
2287            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2288        );
2289        assert!(env.is_empty());
2290    }
2291
2292    #[test]
2293    fn inject_empty_identity_is_gated_same_as_blank() {
2294        // Same as the blank case above, but the genuinely-empty string —
2295        // what a real brand-new-agent launch with no account selected
2296        // actually produces (#2463 Finding 2's exact repro), as opposed to
2297        // the legacy "blank" singleton literal.
2298        let store = make_store();
2299        let mut def = gate_def(0);
2300        store.agent_def_insert(&mut def).unwrap();
2301
2302        insert_block_for_agent(&store, "block-empty", "def-1");
2303        let inst = make_instance("block-empty", "");
2304        store.instance_create(&inst).unwrap();
2305
2306        let mut env: HashMap<String, String> = HashMap::new();
2307        let res = inject_identity_env(store.clone(), store.clone(), store, "block-empty", &mut env);
2308
2309        assert_eq!(
2310            res,
2311            Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2312        );
2313        assert!(env.is_empty());
2314    }
2315
2316    #[cfg(debug_assertions)]
2317    #[test]
2318    fn inject_full_round_trip_plaintext_dev() {
2319        let store = make_store();
2320
2321        // Agent definition.
2322        let mut def = crate::backend::storage::store::AgentDefinition {
2323            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2324            id: "def-1".to_string(),
2325            slug: String::new(),
2326            name: "T".to_string(),
2327            icon: "✦".to_string(),
2328            // Not oauth-class — the layer-3 gate only applies to providers
2329            // `provider_class` classifies as OAuth-class (claude / codex /
2330            // openclaw / gemini / copilot as of
2331            // REPORT_AUTH_ARCHITECTURE_STATE_AND_RETHINK_2026_07_21.md
2332            // §2.5/§6), so a non-oauth def here keeps this test focused on
2333            // the api-key round trip below without a claude account fixture
2334            // it doesn't otherwise need.
2335            provider: "kimi".to_string(),
2336            description: String::new(),
2337            working_directory: String::new(),
2338            shell: String::new(),
2339            provider_flags: String::new(),
2340            auto_start: 0,
2341            restart_on_crash: 0,
2342            idle_timeout_minutes: 0,
2343            created_at: 0,
2344            agent_type: String::new(),
2345            environment: String::new(),
2346            agent_bus_id: String::new(),
2347            is_seeded: 0,
2348            accounts: String::new(),
2349            parent_id: String::new(),
2350            branch_label: String::new(),
2351            updated_at: 0,
2352            user_hidden: 0,
2353            container_image: String::new(),
2354            container_volumes: "[]".to_string(),
2355            container_name: String::new(),
2356            use_ambient_login: 0,
2357            auto_continue_enabled: 0,
2358            model_vendor_base_url: String::new(),
2359            memory_id: String::new(),
2360        };
2361        store.agent_def_insert(&mut def).unwrap();
2362
2363        // GitHub account (PlaintextDev for test simplicity).
2364        let github = make_account(
2365            "acct-gh",
2366            "github",
2367            SecretRef::PlaintextDev {
2368                plaintext_dev: "ghp_round_trip".to_string(),
2369            },
2370        );
2371        store.identity_upsert(&github).unwrap();
2372        store
2373            .agent_identity_link("def-1", "acct-gh", "github")
2374            .unwrap();
2375
2376        // Anthropic account.
2377        let anthropic = make_account(
2378            "acct-anth",
2379            "anthropic",
2380            SecretRef::PlaintextDev {
2381                plaintext_dev: "sk-ant-round_trip".to_string(),
2382            },
2383        );
2384        store.identity_upsert(&anthropic).unwrap();
2385        store
2386            .agent_identity_link("def-1", "acct-anth", "anthropic")
2387            .unwrap();
2388
2389        // Instance for the block, pointing at id-work.
2390        insert_block_for_agent(&store, "block-1", "def-1");
2391        let inst = make_instance("block-1", "id-work");
2392        store.instance_create(&inst).unwrap();
2393
2394        let mut env: HashMap<String, String> = HashMap::new();
2395        inject_identity_env(store.clone(), store.clone(), store, "block-1", &mut env).unwrap();
2396
2397        // GitHub writes both standard env-var names from one secret.
2398        assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_round_trip"));
2399        assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_round_trip"));
2400        // Anthropic writes its single env var.
2401        assert_eq!(
2402            env.get("ANTHROPIC_API_KEY").map(String::as_str),
2403            Some("sk-ant-round_trip"),
2404        );
2405    }
2406
2407    #[cfg(debug_assertions)]
2408    #[test]
2409    fn inject_via_direct_link_round_trip() {
2410        // A DIRECT agent<->account link resolves and injects the
2411        // account's env vars. Mirrors `inject_full_round_trip_plaintext_dev`.
2412        let store = make_store();
2413
2414        let mut def = crate::backend::storage::store::AgentDefinition {
2415            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2416            id: "def-1".to_string(),
2417            slug: String::new(),
2418            name: "T".to_string(),
2419            icon: "✦".to_string(),
2420            // Not oauth-class (§4.3) — see the identical note in
2421            // inject_full_round_trip_plaintext_dev above.
2422            provider: "kimi".to_string(),
2423            description: String::new(),
2424            working_directory: String::new(),
2425            shell: String::new(),
2426            provider_flags: String::new(),
2427            auto_start: 0,
2428            restart_on_crash: 0,
2429            idle_timeout_minutes: 0,
2430            created_at: 0,
2431            agent_type: String::new(),
2432            environment: String::new(),
2433            agent_bus_id: String::new(),
2434            is_seeded: 0,
2435            accounts: String::new(),
2436            parent_id: String::new(),
2437            branch_label: String::new(),
2438            updated_at: 0,
2439            user_hidden: 0,
2440            container_image: String::new(),
2441            container_volumes: "[]".to_string(),
2442            container_name: String::new(),
2443            use_ambient_login: 0,
2444            auto_continue_enabled: 0,
2445            model_vendor_base_url: String::new(),
2446            memory_id: String::new(),
2447        };
2448        store.agent_def_insert(&mut def).unwrap();
2449
2450        let github = make_account(
2451            "acct-gh",
2452            "github",
2453            SecretRef::PlaintextDev {
2454                plaintext_dev: "ghp_direct".to_string(),
2455            },
2456        );
2457        store.identity_upsert(&github).unwrap();
2458        store
2459            .agent_identity_link("def-1", "acct-gh", "github")
2460            .unwrap();
2461
2462        insert_block_for_agent(&store, "block-direct", "def-1");
2463        let inst = make_instance("block-direct", "id-direct");
2464        store.instance_create(&inst).unwrap();
2465
2466        let mut env: HashMap<String, String> = HashMap::new();
2467        inject_identity_env(store.clone(), store.clone(), store, "block-direct", &mut env).unwrap();
2468
2469        assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_direct"));
2470        assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_direct"));
2471    }
2472
2473    #[cfg(debug_assertions)]
2474    #[test]
2475    fn inject_no_direct_links_injects_nothing() {
2476        // An instance with a non-sentinel identity_id but no
2477        // db_agent_identity_links row for its definition — e.g. an
2478        // account exists but was never linked — injects nothing.
2479        let store = make_store();
2480
2481        let mut def = crate::backend::storage::store::AgentDefinition {
2482            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2483            id: "def-1".to_string(),
2484            slug: String::new(),
2485            name: "T".to_string(),
2486            icon: "✦".to_string(),
2487            // Not oauth-class (§4.3) — this test targets the zero-direct-
2488            // links path itself, which is a distinct concern from the
2489            // layer-3 oauth gate (covered separately by the spawn_blocked_*
2490            // tests below).
2491            provider: "kimi".to_string(),
2492            description: String::new(),
2493            working_directory: String::new(),
2494            shell: String::new(),
2495            provider_flags: String::new(),
2496            auto_start: 0,
2497            restart_on_crash: 0,
2498            idle_timeout_minutes: 0,
2499            created_at: 0,
2500            agent_type: String::new(),
2501            environment: String::new(),
2502            agent_bus_id: String::new(),
2503            is_seeded: 0,
2504            accounts: String::new(),
2505            parent_id: String::new(),
2506            branch_label: String::new(),
2507            updated_at: 0,
2508            user_hidden: 0,
2509            container_image: String::new(),
2510            container_volumes: "[]".to_string(),
2511            container_name: String::new(),
2512            use_ambient_login: 0,
2513            auto_continue_enabled: 0,
2514            model_vendor_base_url: String::new(),
2515            memory_id: String::new(),
2516        };
2517        store.agent_def_insert(&mut def).unwrap();
2518
2519        let github = make_account(
2520            "acct-unlinked",
2521            "github",
2522            SecretRef::PlaintextDev {
2523                plaintext_dev: "ghp_unlinked".to_string(),
2524            },
2525        );
2526        store.identity_upsert(&github).unwrap();
2527
2528        insert_block_for_agent(&store, "block-unlinked", "def-1");
2529        let inst = make_instance("block-unlinked", "id-unused");
2530        store.instance_create(&inst).unwrap();
2531
2532        let mut env: HashMap<String, String> = HashMap::new();
2533        inject_identity_env(store.clone(), store.clone(), store, "block-unlinked", &mut env).unwrap();
2534
2535        // Nothing injected — an account with no direct link is invisible
2536        // to the resolver.
2537        assert!(env.get("GITHUB_TOKEN").is_none());
2538        assert!(env.is_empty());
2539    }
2540
2541    #[cfg(debug_assertions)]
2542    #[test]
2543    fn inject_no_direct_links_publishes_event() {
2544        // Same setup as inject_no_direct_links_injects_nothing, but
2545        // asserts the diagnostic MuxEvent fires — the standing signal
2546        // for #1624 so a future frontend surface (or just log triage) can
2547        // see when a non-sentinel identity resolves to zero direct links.
2548        let store = make_store();
2549
2550        let mut def = crate::backend::storage::store::AgentDefinition {
2551            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2552            id: "def-1".to_string(),
2553            slug: String::new(),
2554            name: "T".to_string(),
2555            icon: "✦".to_string(),
2556            // Not oauth-class (§4.3) — same rationale as
2557            // inject_no_direct_links_injects_nothing above.
2558            provider: "kimi".to_string(),
2559            description: String::new(),
2560            working_directory: String::new(),
2561            shell: String::new(),
2562            provider_flags: String::new(),
2563            auto_start: 0,
2564            restart_on_crash: 0,
2565            idle_timeout_minutes: 0,
2566            created_at: 0,
2567            agent_type: String::new(),
2568            environment: String::new(),
2569            agent_bus_id: String::new(),
2570            is_seeded: 0,
2571            accounts: String::new(),
2572            parent_id: String::new(),
2573            branch_label: String::new(),
2574            updated_at: 0,
2575            user_hidden: 0,
2576            container_image: String::new(),
2577            container_volumes: "[]".to_string(),
2578            container_name: String::new(),
2579            use_ambient_login: 0,
2580            auto_continue_enabled: 0,
2581            model_vendor_base_url: String::new(),
2582            memory_id: String::new(),
2583        };
2584        store.agent_def_insert(&mut def).unwrap();
2585
2586        let github = make_account(
2587            "acct-unlinked-2",
2588            "github",
2589            SecretRef::PlaintextDev {
2590                plaintext_dev: "ghp_unlinked_2".to_string(),
2591            },
2592        );
2593        store.identity_upsert(&github).unwrap();
2594
2595        insert_block_for_agent(&store, "block-unlinked-2", "def-1");
2596        let inst = make_instance("block-unlinked-2", "id-unused-2");
2597        store.instance_create(&inst).unwrap();
2598
2599        let broker = Arc::new(Broker::new());
2600        let mut env: HashMap<String, String> = HashMap::new();
2601        inject_identity_env_with_broker(
2602            store.clone(),
2603            store.clone(),
2604            store,
2605            Some(broker.clone()),
2606            "block-unlinked-2",
2607            &mut env,
2608        )
2609        .unwrap();
2610
2611        // The event is persisted (see resolve_bindings_for_instance's
2612        // publish) precisely so it's readable here without needing a live
2613        // subscriber wired up at publish time.
2614        let history = broker.read_event_history("identity:no-direct-links", "", 10);
2615        let event = history.last().expect("expected identity:no-direct-links event");
2616        assert_eq!(
2617            event.data.as_ref().and_then(|d| d.get("definition_id")).and_then(|v| v.as_str()),
2618            Some("def-1"),
2619        );
2620    }
2621
2622    #[cfg(debug_assertions)]
2623    #[test]
2624    fn inject_partial_success_skips_failed_bindings() {
2625        let store = make_store();
2626
2627        let mut def = crate::backend::storage::store::AgentDefinition {
2628            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2629            id: "def-1".to_string(),
2630            slug: String::new(),
2631            name: "T".to_string(),
2632            icon: "✦".to_string(),
2633            // Not oauth-class (§4.3) — this test targets partial-success
2634            // skip behavior across api-key bindings, unrelated to the
2635            // layer-3 oauth gate.
2636            provider: "kimi".to_string(),
2637            description: String::new(),
2638            working_directory: String::new(),
2639            shell: String::new(),
2640            provider_flags: String::new(),
2641            auto_start: 0,
2642            restart_on_crash: 0,
2643            idle_timeout_minutes: 0,
2644            created_at: 0,
2645            agent_type: String::new(),
2646            environment: String::new(),
2647            agent_bus_id: String::new(),
2648            is_seeded: 0,
2649            accounts: String::new(),
2650            parent_id: String::new(),
2651            branch_label: String::new(),
2652            updated_at: 0,
2653            user_hidden: 0,
2654            container_image: String::new(),
2655            container_volumes: "[]".to_string(),
2656            container_name: String::new(),
2657            use_ambient_login: 0,
2658            auto_continue_enabled: 0,
2659            model_vendor_base_url: String::new(),
2660            memory_id: String::new(),
2661        };
2662        store.agent_def_insert(&mut def).unwrap();
2663
2664        // Working account.
2665        let good = make_account(
2666            "acct-good",
2667            "github",
2668            SecretRef::PlaintextDev {
2669                plaintext_dev: "ghp_good".to_string(),
2670            },
2671        );
2672        store.identity_upsert(&good).unwrap();
2673        store
2674            .agent_identity_link("def-1", "acct-good", "github")
2675            .unwrap();
2676
2677        // Account whose Env-backed secret references a missing var.
2678        let bad = make_account(
2679            "acct-bad",
2680            "anthropic",
2681            SecretRef::Env {
2682                env_var: "AGENTMUX_TEST_DEFINITELY_NOT_SET_4242".to_string(),
2683            },
2684        );
2685        store.identity_upsert(&bad).unwrap();
2686        store
2687            .agent_identity_link("def-1", "acct-bad", "anthropic")
2688            .unwrap();
2689
2690        insert_block_for_agent(&store, "block-mixed", "def-1");
2691        let inst = make_instance("block-mixed", "id-mixed");
2692        store.instance_create(&inst).unwrap();
2693
2694        let mut env: HashMap<String, String> = HashMap::new();
2695        inject_identity_env(store.clone(), store.clone(), store, "block-mixed", &mut env).unwrap();
2696
2697        // GitHub injection succeeded.
2698        assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_good"));
2699        assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_good"));
2700        // Anthropic was skipped (env var missing) but didn't abort.
2701        assert!(env.get("ANTHROPIC_API_KEY").is_none());
2702    }
2703
2704    #[cfg(debug_assertions)]
2705    #[test]
2706    fn inject_unknown_provider_is_skipped() {
2707        let store = make_store();
2708
2709        let mut def = crate::backend::storage::store::AgentDefinition {
2710            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2711            id: "def-1".to_string(),
2712            slug: String::new(),
2713            name: "T".to_string(),
2714            icon: "✦".to_string(),
2715            // Not oauth-class (§4.3) — this test targets the unknown-
2716            // provider skip path, unrelated to the layer-3 oauth gate.
2717            provider: "kimi".to_string(),
2718            description: String::new(),
2719            working_directory: String::new(),
2720            shell: String::new(),
2721            provider_flags: String::new(),
2722            auto_start: 0,
2723            restart_on_crash: 0,
2724            idle_timeout_minutes: 0,
2725            created_at: 0,
2726            agent_type: String::new(),
2727            environment: String::new(),
2728            agent_bus_id: String::new(),
2729            is_seeded: 0,
2730            accounts: String::new(),
2731            parent_id: String::new(),
2732            branch_label: String::new(),
2733            updated_at: 0,
2734            user_hidden: 0,
2735            container_image: String::new(),
2736            container_volumes: "[]".to_string(),
2737            container_name: String::new(),
2738            use_ambient_login: 0,
2739            auto_continue_enabled: 0,
2740            model_vendor_base_url: String::new(),
2741            memory_id: String::new(),
2742        };
2743        store.agent_def_insert(&mut def).unwrap();
2744
2745        let custom = make_account(
2746            "acct-custom",
2747            "custom",
2748            SecretRef::PlaintextDev {
2749                plaintext_dev: "ignored".to_string(),
2750            },
2751        );
2752        store.identity_upsert(&custom).unwrap();
2753        store
2754            .agent_identity_link("def-1", "acct-custom", "custom")
2755            .unwrap();
2756
2757        insert_block_for_agent(&store, "block-future", "def-1");
2758        let inst = make_instance("block-future", "id-future");
2759        store.instance_create(&inst).unwrap();
2760
2761        let mut env: HashMap<String, String> = HashMap::new();
2762        inject_identity_env(store.clone(), store.clone(), store, "block-future", &mut env).unwrap();
2763        // No env-var matrix for "custom" — nothing injected, no panic.
2764        assert!(env.is_empty());
2765    }
2766
2767    /// Helper: write a Claude-shape `.credentials.json` into a temp dir
2768    /// and return the dir path. `expires_ms` controls validity; `with_refresh`
2769    /// toggles the refreshToken field so the resolver can distinguish
2770    /// `Expired` (refresh present) from `NeedsReauth` (no refresh).
2771    ///
2772    /// Duplicated from `oauth_probe::tests` (same helper, different file)
2773    /// rather than shared cross-module — kept the modularization split a
2774    /// pure relocation with no new cross-file test-only visibility surface.
2775    fn write_claude_creds(
2776        dir: &std::path::Path,
2777        expires_ms: i64,
2778        with_refresh: bool,
2779    ) {
2780        std::fs::create_dir_all(dir).unwrap();
2781        let body = serde_json::json!({
2782            "claudeAiOauth": {
2783                "accessToken": "test-access",
2784                "refreshToken": if with_refresh { "test-refresh" } else { "" },
2785                "expiresAt": expires_ms,
2786            }
2787        });
2788        std::fs::write(
2789            dir.join(".credentials.json"),
2790            serde_json::to_string(&body).unwrap(),
2791        )
2792        .unwrap();
2793    }
2794
2795    #[cfg(debug_assertions)]
2796    #[test]
2797    fn inject_oauth_class_probes_and_flips_status_to_needs_reauth() {
2798        // Full integration: an oauth-class binding pointing at a
2799        // bundle dir with NO token file → the probe surfaces
2800        // `needs_reauth` and the resolver upserts the account row
2801        // with the new status. Spec §4.4.
2802        //
2803        // Uses "codex", not "claude": the macOS Keychain carve-out added
2804        // in oauth_probe.rs (retro-macos-keychain-credential-isolation-gap-
2805        // 2026-08-17.md) makes a missing token file for "claude" return
2806        // `None` (no status change) on macOS specifically, since Claude
2807        // Code never writes `.credentials.json` there regardless of the
2808        // config dir. "codex" isn't covered by that carve-out, so it keeps
2809        // this test's original, platform-independent needs_reauth signal.
2810        let store = make_store();
2811
2812        let mut def = crate::backend::storage::store::AgentDefinition {
2813            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2814            id: "def-1".to_string(),
2815            slug: String::new(),
2816            name: "T".to_string(),
2817            icon: "✦".to_string(),
2818            provider: "codex".to_string(),
2819            description: String::new(),
2820            working_directory: String::new(),
2821            shell: String::new(),
2822            provider_flags: String::new(),
2823            auto_start: 0,
2824            restart_on_crash: 0,
2825            idle_timeout_minutes: 0,
2826            created_at: 0,
2827            agent_type: String::new(),
2828            environment: String::new(),
2829            agent_bus_id: String::new(),
2830            is_seeded: 0,
2831            accounts: String::new(),
2832            parent_id: String::new(),
2833            branch_label: String::new(),
2834            updated_at: 0,
2835            user_hidden: 0,
2836            container_image: String::new(),
2837            container_volumes: "[]".to_string(),
2838            container_name: String::new(),
2839            use_ambient_login: 0,
2840            auto_continue_enabled: 0,
2841            model_vendor_base_url: String::new(),
2842            memory_id: String::new(),
2843        };
2844        store.agent_def_insert(&mut def).unwrap();
2845
2846        // Bundle dir intentionally empty — probe should report
2847        // needs_reauth (no token file).
2848        let tmp = tempfile::tempdir().unwrap();
2849        let bundle_dir = tmp.path().to_str().unwrap().to_string();
2850
2851        let codex_acct = IdentityAccount {
2852            id: "acct-codex".to_string(),
2853            name: "codex-acct-codex".to_string(),
2854            provider: "codex".to_string(),
2855            kind: "oauth".to_string(),
2856            display_name: String::new(),
2857            secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
2858            context: serde_json::json!({}),
2859            // Start as "valid" — the probe should flip it to
2860            // "needs_reauth".
2861            status: oauth_status::VALID.to_string(),
2862            created_at: 0,
2863            updated_at: 0,
2864        };
2865        store.identity_upsert(&codex_acct).unwrap();
2866        store
2867            .agent_identity_link("def-1", "acct-codex", "codex")
2868            .unwrap();
2869
2870        insert_block_for_agent(&store, "block-probe", "def-1");
2871        let inst = make_instance("block-probe", "id-probe");
2872        store.instance_create(&inst).unwrap();
2873
2874        let mut env: HashMap<String, String> = HashMap::new();
2875        inject_identity_env(store.clone(), store.clone(), store.clone(), "block-probe", &mut env).unwrap();
2876
2877        // Env injection still happened (resolver doesn't block on
2878        // probe outcome — the CLI launches with the dir env var set
2879        // and will trigger OAuth itself when it sees no tokens).
2880        assert!(env.get("CODEX_HOME").is_some());
2881
2882        // Status row was UPDATED to needs_reauth.
2883        let after = store.identity_get("acct-codex").unwrap().unwrap();
2884        assert_eq!(after.status, oauth_status::NEEDS_REAUTH);
2885    }
2886
2887    #[cfg(target_os = "macos")]
2888    #[cfg(debug_assertions)]
2889    #[test]
2890    fn inject_oauth_class_self_heals_stale_needs_reauth_for_claude_on_macos() {
2891        // Codex P1 on PR #2645: an account probed BEFORE the macOS
2892        // Keychain carve-out existed may already have `needs_reauth`
2893        // persisted from the old, buggy behavior. Without a self-heal,
2894        // that account stays stuck showing "reconnect" forever, since a
2895        // `None` probe never upserts anything — this proves the resolver
2896        // corrects it back to "unknown" (not a fabricated "valid") the
2897        // next time the agent spawns.
2898        let store = make_store();
2899
2900        // gate_def's own provider is already "claude" — see its doc comment.
2901        let mut def = gate_def(0);
2902        store.agent_def_insert(&mut def).unwrap();
2903
2904        // Bundle dir intentionally empty, same as the pre-fix broken case —
2905        // but the account already carries the stale needs_reauth status
2906        // the old probe would have written.
2907        let tmp = tempfile::tempdir().unwrap();
2908        let bundle_dir = tmp.path().to_str().unwrap().to_string();
2909
2910        let stale = IdentityAccount {
2911            id: "acct-stale".to_string(),
2912            name: "claude-acct-stale".to_string(),
2913            provider: "claude".to_string(),
2914            kind: "oauth".to_string(),
2915            display_name: String::new(),
2916            secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
2917            context: serde_json::json!({}),
2918            status: oauth_status::NEEDS_REAUTH.to_string(),
2919            created_at: 0,
2920            updated_at: 0,
2921        };
2922        store.identity_upsert(&stale).unwrap();
2923        store
2924            .agent_identity_link("def-1", "acct-stale", "claude")
2925            .unwrap();
2926
2927        insert_block_for_agent(&store, "block-self-heal", "def-1");
2928        let inst = make_instance("block-self-heal", "id-self-heal");
2929        store.instance_create(&inst).unwrap();
2930
2931        let mut env: HashMap<String, String> = HashMap::new();
2932        inject_identity_env(store.clone(), store.clone(), store.clone(), "block-self-heal", &mut env).unwrap();
2933
2934        let after = store.identity_get("acct-stale").unwrap().unwrap();
2935        assert_eq!(
2936            after.status, "unknown",
2937            "a stale pre-carve-out needs_reauth must self-heal to unknown, not stay stuck"
2938        );
2939    }
2940
2941    #[cfg(debug_assertions)]
2942    #[test]
2943    fn inject_oauth_class_probe_canonicalizes_a_legacy_alias_binding() {
2944        // codex P2 on PR #2377 (third round): probe_oauth_status only
2945        // recognizes canonical provider strings ("claude"/"codex"/
2946        // "openclaw") — passing the raw alias ("claude-code") a
2947        // migrated/legacy binding may still carry always returned None,
2948        // silently never refreshing that account's status. Same fixture
2949        // as inject_oauth_class_probes_and_flips_status_to_needs_reauth
2950        // above, but bound under the alias instead of the canonical id.
2951        //
2952        // Uses "codex-cli" → "codex", not "claude-code" → "claude": the
2953        // macOS Keychain carve-out (oauth_probe.rs) means a missing token
2954        // file for "claude" returns `None` on macOS regardless of whether
2955        // canonicalization ran, which would make this test unable to
2956        // distinguish "canonicalization is broken" from "hit the carve-out"
2957        // on that platform. "codex" isn't covered by the carve-out, so it
2958        // keeps the original, platform-independent needs_reauth signal.
2959        let store = make_store();
2960
2961        let mut def = crate::backend::storage::store::AgentDefinition {
2962            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2963            id: "def-1".to_string(),
2964            slug: String::new(),
2965            name: "T".to_string(),
2966            icon: "✦".to_string(),
2967            provider: "codex".to_string(),
2968            description: String::new(),
2969            working_directory: String::new(),
2970            shell: String::new(),
2971            provider_flags: String::new(),
2972            auto_start: 0,
2973            restart_on_crash: 0,
2974            idle_timeout_minutes: 0,
2975            created_at: 0,
2976            agent_type: String::new(),
2977            environment: String::new(),
2978            agent_bus_id: String::new(),
2979            is_seeded: 0,
2980            accounts: String::new(),
2981            parent_id: String::new(),
2982            branch_label: String::new(),
2983            updated_at: 0,
2984            user_hidden: 0,
2985            container_image: String::new(),
2986            container_volumes: "[]".to_string(),
2987            container_name: String::new(),
2988            use_ambient_login: 0,
2989            auto_continue_enabled: 0,
2990            model_vendor_base_url: String::new(),
2991            memory_id: String::new(),
2992        };
2993        store.agent_def_insert(&mut def).unwrap();
2994
2995        let tmp = tempfile::tempdir().unwrap();
2996        let bundle_dir = tmp.path().to_str().unwrap().to_string();
2997
2998        let codex_acct = IdentityAccount {
2999            id: "acct-alias".to_string(),
3000            name: "codex-cli-acct-alias".to_string(),
3001            provider: "codex-cli".to_string(),
3002            kind: "oauth".to_string(),
3003            display_name: String::new(),
3004            secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
3005            context: serde_json::json!({}),
3006            status: oauth_status::VALID.to_string(),
3007            created_at: 0,
3008            updated_at: 0,
3009        };
3010        store.identity_upsert(&codex_acct).unwrap();
3011        // Bound under the alias, not the canonical "codex".
3012        store
3013            .agent_identity_link("def-1", "acct-alias", "codex-cli")
3014            .unwrap();
3015
3016        insert_block_for_agent(&store, "block-probe-alias", "def-1");
3017        let inst = make_instance("block-probe-alias", "id-probe-alias");
3018        store.instance_create(&inst).unwrap();
3019
3020        let mut env: HashMap<String, String> = HashMap::new();
3021        inject_identity_env(store.clone(), store.clone(), store.clone(), "block-probe-alias", &mut env).unwrap();
3022
3023        assert!(env.get("CODEX_HOME").is_some());
3024        // Status row was UPDATED to needs_reauth — proves the probe ran
3025        // with the canonicalized provider id, not the raw alias (which
3026        // would have returned None and left status untouched at "valid").
3027        let after = store.identity_get("acct-alias").unwrap().unwrap();
3028        assert_eq!(after.status, oauth_status::NEEDS_REAUTH);
3029    }
3030
3031    #[cfg(debug_assertions)]
3032    #[test]
3033    fn inject_oauth_class_probe_preserves_status_when_valid() {
3034        // Future-dated token + status already "valid" → no-op
3035        // upsert (no spurious updated_at churn). The assertion is
3036        // that the status remains "valid" — proving the probe
3037        // didn't misclassify a working session.
3038        let store = make_store();
3039
3040        let mut def = crate::backend::storage::store::AgentDefinition {
3041            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
3042            id: "def-1".to_string(),
3043            slug: String::new(),
3044            name: "T".to_string(),
3045            icon: "✦".to_string(),
3046            provider: "claude".to_string(),
3047            description: String::new(),
3048            working_directory: String::new(),
3049            shell: String::new(),
3050            provider_flags: String::new(),
3051            auto_start: 0,
3052            restart_on_crash: 0,
3053            idle_timeout_minutes: 0,
3054            created_at: 0,
3055            agent_type: String::new(),
3056            environment: String::new(),
3057            agent_bus_id: String::new(),
3058            is_seeded: 0,
3059            accounts: String::new(),
3060            parent_id: String::new(),
3061            branch_label: String::new(),
3062            updated_at: 0,
3063            user_hidden: 0,
3064            container_image: String::new(),
3065            container_volumes: "[]".to_string(),
3066            container_name: String::new(),
3067            use_ambient_login: 0,
3068            auto_continue_enabled: 0,
3069            model_vendor_base_url: String::new(),
3070            memory_id: String::new(),
3071        };
3072        store.agent_def_insert(&mut def).unwrap();
3073
3074        let tmp = tempfile::tempdir().unwrap();
3075        let now_ms = agentmux_common::time::now_ms();
3076        write_claude_creds(tmp.path(), now_ms + 3_600_000, true);
3077
3078        let claude = IdentityAccount {
3079            id: "acct-ok".to_string(),
3080            name: "claude-acct-ok".to_string(),
3081            provider: "claude".to_string(),
3082            kind: "oauth".to_string(),
3083            display_name: String::new(),
3084            secret_ref: SecretRef::OAuthConfigDir {
3085                dir: tmp.path().to_str().unwrap().to_string(),
3086            },
3087            context: serde_json::json!({}),
3088            status: oauth_status::VALID.to_string(),
3089            created_at: 0,
3090            updated_at: 0,
3091        };
3092        store.identity_upsert(&claude).unwrap();
3093        store
3094            .agent_identity_link("def-1", "acct-ok", "claude")
3095            .unwrap();
3096
3097        insert_block_for_agent(&store, "block-ok", "def-1");
3098        let inst = make_instance("block-ok", "id-ok");
3099        store.instance_create(&inst).unwrap();
3100
3101        let mut env: HashMap<String, String> = HashMap::new();
3102        inject_identity_env(store.clone(), store.clone(), store.clone(), "block-ok", &mut env).unwrap();
3103
3104        let after = store.identity_get("acct-ok").unwrap().unwrap();
3105        assert_eq!(after.status, oauth_status::VALID);
3106        // updated_at unchanged — the resolver only upserts when the
3107        // probed status differs from the stored value.
3108        assert_eq!(after.updated_at, 0);
3109    }
3110
3111    // P0 regression test (found in a follow-up scoping pass after
3112    // agent_open.rs's identical bug was fixed in PR #2587 round 3):
3113    // the layer-3 gate must resolve the definition's provider through
3114    // its bound ABF bundle (`id_store.resolve_effective_provider_id`),
3115    // not `d.provider` directly. Uses two genuinely separate stores —
3116    // mstore (definition, block, instance) and id_store (bundle,
3117    // account, link) — so the test can't pass by accident the way it
3118    // would if both roles were backed by the same `Arc<Store>`, which
3119    // every OTHER test in this module does (matching production's
3120    // AppState.mstore/id_store split, but meaning none of them could
3121    // have caught this class of bug).
3122    //
3123    // Scenario: the agent says "codex"; its bound bundle, from before
3124    // bundles stopped carrying a harness, says "claude", and the agent has
3125    // a Claude account bound. The agent owns its harness
3126    // (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1), so the gate checks
3127    // codex, finds no codex account, and refuses; the bundle's value never
3128    // decides. (The §3.3 migration makes the two agree before this rule
3129    // takes effect, so a real agent doesn't land here.)
3130    #[test]
3131    fn spawn_gate_checks_the_agents_own_provider_not_its_bundles() {
3132        let mstore = make_store();
3133        // Real shared-store schema, not open_in_memory's channel schema —
3134        // db_agent_identity_links only has a `db_agents` FK in the
3135        // channel schema (`run_object_schema` in migrations.rs); the
3136        // shared-store schema (`run_shared_store_schema`) deliberately
3137        // omits it, since agent
3138        // definitions never live in the shared store. Using two
3139        // open_in_memory() stores here would incorrectly enforce an FK
3140        // that doesn't exist in production's real id_store.
3141        let id_store_tmp = tempfile::NamedTempFile::new().unwrap();
3142        let id_store = Arc::new(Store::open_shared(id_store_tmp.path()).unwrap());
3143        // Same reasoning as id_store above, for the new always-global
3144        // identity store (SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md):
3145        // open_identity_store's real schema (no db_accounts FK at all — see
3146        // its own doc comment) rather than open_in_memory's channel schema.
3147        let identity_store_tmp = tempfile::NamedTempFile::new().unwrap();
3148        let identity_store = Arc::new(Store::open_identity_store(identity_store_tmp.path()).unwrap());
3149
3150        let mut def = crate::backend::storage::store::AgentDefinition {
3151            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
3152            id: "def-1".to_string(),
3153            slug: String::new(),
3154            name: "T".to_string(),
3155            icon: "✦".to_string(),
3156            // Drifted: the definition's own column says codex...
3157            provider: "codex".to_string(),
3158            description: String::new(),
3159            working_directory: String::new(),
3160            shell: String::new(),
3161            provider_flags: String::new(),
3162            auto_start: 0,
3163            restart_on_crash: 0,
3164            idle_timeout_minutes: 0,
3165            created_at: 0,
3166            agent_type: String::new(),
3167            environment: String::new(),
3168            agent_bus_id: String::new(),
3169            is_seeded: 0,
3170            accounts: String::new(),
3171            parent_id: String::new(),
3172            branch_label: String::new(),
3173            updated_at: 0,
3174            user_hidden: 0,
3175            container_image: String::new(),
3176            container_volumes: "[]".to_string(),
3177            container_name: String::new(),
3178            use_ambient_login: 0,
3179            auto_continue_enabled: 0,
3180            model_vendor_base_url: String::new(),
3181            // ...but its bound bundle (below, in id_store) says claude.
3182            memory_id: "bundle-1".to_string(),
3183        };
3184        mstore.agent_def_insert(&mut def).unwrap();
3185
3186        let bundle = Bundle {
3187            id: "bundle-1".to_string(),
3188            name: "Drift Test Bundle".to_string(),
3189            description: String::new(),
3190            is_blank: false,
3191            is_global: false,
3192            provider: "claude".to_string(),
3193            model: "anthropic".to_string(),
3194            instructions: String::new(),
3195            instructions_by_provider: "{}".to_string(),
3196            context_files: "[]".to_string(),
3197            mcp_servers: "[]".to_string(),
3198            skills: "[]".to_string(),
3199            sort_order: 0,
3200            created_at: 0,
3201            updated_at: 0,
3202            is_system: false,
3203        };
3204        id_store.bundle_upsert(&bundle).unwrap();
3205
3206        let claude = make_account(
3207            "acct-drift",
3208            "claude",
3209            SecretRef::OAuthConfigDir {
3210                dir: test_config_dir("id-drift"),
3211            },
3212        );
3213        id_store.identity_upsert_with_mirror(&identity_store, &claude).unwrap();
3214        identity_store
3215            .agent_identity_link("def-1", "acct-drift", "claude")
3216            .unwrap();
3217
3218        insert_block_for_agent(&mstore, "block-drift", "def-1");
3219        let inst = make_instance("block-drift", "id-drift");
3220        mstore.instance_create(&inst).unwrap();
3221
3222        let mut env: HashMap<String, String> = HashMap::new();
3223        let res = inject_identity_env(mstore, id_store, identity_store, "block-drift", &mut env);
3224
3225        // The gate checks the agent's own provider (codex), which has no
3226        // account bound; the bundle's claude is only a hint.
3227        assert!(
3228            matches!(&res, Err(SpawnGateError::MissingCredentials { provider }) if provider == "codex"),
3229            "the agent owns its harness; the bundle's provider is a hint (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1): {res:?}"
3230        );
3231    }
3232
3233    fn make_agent_def_with_provider(id: &str, provider: &str) -> crate::backend::storage::store::AgentDefinition {
3234        crate::backend::storage::store::AgentDefinition {
3235            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
3236            id: id.to_string(),
3237            slug: String::new(),
3238            name: "T".to_string(),
3239            icon: "✦".to_string(),
3240            provider: provider.to_string(),
3241            description: String::new(),
3242            working_directory: String::new(),
3243            shell: String::new(),
3244            provider_flags: String::new(),
3245            auto_start: 0,
3246            restart_on_crash: 0,
3247            idle_timeout_minutes: 0,
3248            created_at: 0,
3249            agent_type: String::new(),
3250            environment: String::new(),
3251            agent_bus_id: String::new(),
3252            is_seeded: 0,
3253            accounts: String::new(),
3254            parent_id: String::new(),
3255            branch_label: String::new(),
3256            updated_at: 0,
3257            user_hidden: 0,
3258            container_image: String::new(),
3259            container_volumes: "[]".to_string(),
3260            container_name: String::new(),
3261            use_ambient_login: 0,
3262            auto_continue_enabled: 0,
3263            model_vendor_base_url: String::new(),
3264            memory_id: String::new(),
3265        }
3266    }
3267
3268    // SPEC_SUBAGENT_WATCHER_IDENTITY_BOUND_CONFIG_DIR_2026_08_22.md
3269    #[cfg(debug_assertions)]
3270    #[test]
3271    fn resolve_bound_oauth_config_dir_resolves_the_identity_bound_dir() {
3272        let store = make_store();
3273        let mut def = make_agent_def_with_provider("def-1", "claude");
3274        store.agent_def_insert(&mut def).unwrap();
3275
3276        let claude = make_account(
3277            "acct-claude",
3278            "claude",
3279            SecretRef::OAuthConfigDir {
3280                dir: test_config_dir("id-bound"),
3281            },
3282        );
3283        store.identity_upsert(&claude).unwrap();
3284        store.agent_identity_link("def-1", "acct-claude", "claude").unwrap();
3285
3286        insert_block_for_agent(&store, "block-bound", "def-1");
3287        let inst = make_instance("block-bound", "id-bound");
3288        store.instance_create(&inst).unwrap();
3289
3290        let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "block-bound");
3291        assert_eq!(
3292            resolved,
3293            Some(std::path::PathBuf::from(test_config_dir("id-bound"))),
3294        );
3295    }
3296
3297    #[cfg(debug_assertions)]
3298    #[test]
3299    fn resolve_bound_oauth_config_dir_none_when_no_instance_for_block() {
3300        let store = make_store();
3301        let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "no-such-block");
3302        assert_eq!(resolved, None);
3303    }
3304
3305    #[cfg(debug_assertions)]
3306    #[test]
3307    fn resolve_bound_oauth_config_dir_none_for_api_key_class_provider() {
3308        let store = make_store();
3309        // github is API-key class, not OAuth — never resolves to a config dir.
3310        let mut def = make_agent_def_with_provider("def-1", "github");
3311        store.agent_def_insert(&mut def).unwrap();
3312
3313        let github = make_account(
3314            "acct-gh",
3315            "github",
3316            SecretRef::PlaintextDev { plaintext_dev: "ghp_x".to_string() },
3317        );
3318        store.identity_upsert(&github).unwrap();
3319        store.agent_identity_link("def-1", "acct-gh", "github").unwrap();
3320
3321        insert_block_for_agent(&store, "block-gh", "def-1");
3322        let inst = make_instance("block-gh", "id-gh");
3323        store.instance_create(&inst).unwrap();
3324
3325        let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "block-gh");
3326        assert_eq!(resolved, None);
3327    }
3328
3329    #[cfg(debug_assertions)]
3330    #[test]
3331    fn resolve_bound_oauth_config_dir_none_when_oauth_provider_has_no_binding() {
3332        let store = make_store();
3333        let mut def = make_agent_def_with_provider("def-1", "claude");
3334        store.agent_def_insert(&mut def).unwrap();
3335        // No account, no link — an ambient/unbound agent.
3336
3337        insert_block_for_agent(&store, "block-unbound", "def-1");
3338        let inst = make_instance("block-unbound", "");
3339        store.instance_create(&inst).unwrap();
3340
3341        let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "block-unbound");
3342        assert_eq!(resolved, None, "ambient/unbound agents must fall through, not error");
3343    }
3344
3345    // Proves the "no side effects" claim in the doc comment, not just states
3346    // it: an oauth probe on the real path would flip this account's status
3347    // from "unknown" (probe_oauth_status sees no token file at this
3348    // nonexistent dir and returns needs_reauth) — resolve_bound_oauth_config_dir
3349    // must leave it untouched since it never calls the probe/upsert at all.
3350    #[cfg(debug_assertions)]
3351    #[test]
3352    fn resolve_bound_oauth_config_dir_does_not_mutate_account_status() {
3353        let store = make_store();
3354        let mut def = make_agent_def_with_provider("def-1", "claude");
3355        store.agent_def_insert(&mut def).unwrap();
3356
3357        let claude = make_account(
3358            "acct-claude",
3359            "claude",
3360            SecretRef::OAuthConfigDir { dir: "/nonexistent/dir/for/probe".to_string() },
3361        );
3362        store.identity_upsert(&claude).unwrap();
3363        store.agent_identity_link("def-1", "acct-claude", "claude").unwrap();
3364
3365        insert_block_for_agent(&store, "block-status", "def-1");
3366        let inst = make_instance("block-status", "id-status");
3367        store.instance_create(&inst).unwrap();
3368
3369        let _ = resolve_bound_oauth_config_dir(&store, &store, &store, "block-status");
3370
3371        let reread = store.identity_get("acct-claude").unwrap().unwrap();
3372        assert_eq!(reread.status, "unknown", "status must be untouched — no probe/upsert side effect");
3373    }
3374}