Skip to main content

agentmux_srv/server/agent_handlers/
identity.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::sync::Arc;
6
7use serde_json::json;
8
9use crate::backend::rpc::engine::WshRpcEngine;
10use crate::backend::rpc_types::{
11    COMMAND_LIST_IDENTITY_ACCOUNTS, COMMAND_GET_IDENTITY_ACCOUNT,
12    COMMAND_UPSERT_IDENTITY_ACCOUNT, COMMAND_DELETE_IDENTITY_ACCOUNT,
13    COMMAND_ACCOUNT_KEY_VERIFY,
14    COMMAND_ACCOUNT_OAUTH_START, COMMAND_ACCOUNT_OAUTH_POLL, COMMAND_ACCOUNT_OAUTH_CANCEL,
15    COMMAND_LINK_AGENT_IDENTITY, COMMAND_UNLINK_AGENT_IDENTITY,
16    COMMAND_LIST_AGENT_IDENTITIES, COMMAND_LIST_ALL_AGENT_IDENTITIES,
17    COMMAND_LIST_NAMED_AGENTS, COMMAND_HIDE_NAMED_AGENT,
18    CommandListNamedAgentsData, CommandHideNamedAgentData, HideNamedAgentResult,
19    NamedAgentRow,
20    CommandListIdentityAccountsData, CommandGetIdentityAccountData,
21    CommandDeleteIdentityAccountData,
22    CommandLinkAgentIdentityData, CommandUnlinkAgentIdentityData,
23    CommandListAgentIdentitiesData, CommandListAllAgentIdentitiesData,
24    UnlinkAgentIdentityResult, AccountOAuthCancelResult,
25};
26use crate::backend::storage::store::{
27    AgentIdentityLink, AgentInstance, IdentityAccount, SecretRef,
28};
29
30use super::super::AppState;
31
32/// Refuses to persist an OAuth account bound directly to its provider's
33/// ambient home dir (e.g. `~/.claude`) — nothing downstream validates
34/// `secret_ref.dir`, and a real, currently-live account configured exactly
35/// this way was found in this repo's own data
36/// (`docs/status/STATUS_IDENTITY_ISOLATION_GATE_NOT_ENFORCING_2026_08_20.md`
37/// §8). Split out of the `upsertidentityaccount` handler closure so it's
38/// directly unit-testable without spinning up the RPC engine. See
39/// `docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md`.
40fn reject_ambient_home_dir_binding(account: &IdentityAccount) -> Result<(), String> {
41    let SecretRef::OAuthConfigDir { dir } = &account.secret_ref else {
42        return Ok(());
43    };
44    let Some(provider_cfg) = crate::backend::providers::get_provider(
45        &crate::backend::providers::resolve_provider_alias(&account.provider),
46    ) else {
47        return Ok(());
48    };
49    if crate::backend::providers::is_provider_ambient_home_dir(provider_cfg, dir) {
50        return Err(format!(
51            "upsertidentityaccount: refusing to bind {} identity to its \
52             ambient home directory ({dir}) — this would let a spawned \
53             agent silently share your personal CLI login/session state \
54             instead of using an isolated AgentMux account. Use an \
55             isolated config dir for this account instead.",
56            account.provider,
57        ));
58    }
59    Ok(())
60}
61
62/// Request for `account.key.verify` (account key flow). The `api_key`
63/// field is a secret — never log this struct.
64#[derive(serde::Deserialize)]
65#[serde(rename_all = "camelCase")]
66struct VerifyKeyReq {
67    /// Service id: "github" | "openai" | "anthropic" | "slack" | … .
68    provider: String,
69    /// Account display name (user-chosen label).
70    name: String,
71    #[serde(default)]
72    display_name: String,
73    /// Account kind; defaults to "api_key" when empty.
74    #[serde(default)]
75    kind: String,
76    /// The pasted secret. Used once to (optionally) validate + store in the
77    /// OS keychain, then dropped. Never persisted in the DB, never logged.
78    api_key: String,
79    /// When true, run a live validation probe before storing (user clicked
80    /// "Validate"). When false, store with status "unknown" (the "Save
81    /// without validating" air-gapped path).
82    #[serde(default)]
83    validate: bool,
84    /// Set to replace the key on an existing account; empty mints a new one.
85    #[serde(default)]
86    account_id: String,
87    /// User-entered, non-secret context (github_username, scopes, notes, …).
88    /// Merged over any existing context so editing a key never wipes fields
89    /// the user set previously.
90    #[serde(default)]
91    context: serde_json::Value,
92}
93
94/// Request for `account.oauth.start`. `clientId`/`clientSecret` are BYO OAuth
95/// app credentials (optional; required for secret-mandatory providers).
96#[derive(serde::Deserialize)]
97#[serde(rename_all = "camelCase")]
98struct OAuthStartReq {
99    provider: String,
100    name: String,
101    #[serde(default)]
102    client_id: Option<String>,
103    #[serde(default)]
104    client_secret: Option<String>,
105}
106
107#[derive(serde::Deserialize, serde::Serialize, ts_rs::TS)]
108#[ts(export, export_to = "../../../frontend/types/rpc/")]
109#[serde(rename_all = "camelCase")]
110pub struct OAuthSessionReq {
111    pub session_id: String,
112}
113
114/// Serialize an OAuthStatus to the frontend wire shape.
115fn oauth_status_wire(s: &crate::identity::oauth_client::OAuthStatus) -> serde_json::Value {
116    use crate::identity::oauth_client::OAuthStatus;
117    match s {
118        OAuthStatus::Pending => serde_json::json!({ "status": "pending" }),
119        OAuthStatus::UrlAvailable { auth_url } => {
120            serde_json::json!({ "status": "url-available", "authUrl": auth_url })
121        }
122        OAuthStatus::CodeEmitted { user_code, verification_uri } => serde_json::json!({
123            "status": "code-emitted",
124            "userCode": user_code,
125            "verificationUri": verification_uri,
126        }),
127        OAuthStatus::Success { account_id } => {
128            serde_json::json!({ "status": "success", "accountId": account_id })
129        }
130        OAuthStatus::Failed { error } => serde_json::json!({ "status": "failed", "error": error }),
131    }
132}
133
134/// Shallow-merge the keys of `overlay` (if both are JSON objects) into `base`.
135fn merge_json_object(base: &mut serde_json::Value, overlay: &serde_json::Value) {
136    if let (Some(b), Some(o)) = (base.as_object_mut(), overlay.as_object()) {
137        for (k, v) in o {
138            b.insert(k.clone(), v.clone());
139        }
140    }
141}
142
143/// What `deleteidentityaccount` answers.
144#[derive(serde::Serialize, ts_rs::TS)]
145#[ts(export, export_to = "../../../frontend/types/rpc/")]
146#[serde(rename_all = "camelCase")]
147pub struct DeleteIdentityAccountResult {
148    deleted: bool,
149    /// Agents whose link to the account was removed (delete-time disclosure).
150    affected_agents: Vec<String>,
151    /// What removing the saved login did; null when there was no account row.
152    #[ts(type = "{ outcome: \"removed\" | \"absent\" | \"skipped\" | \"failed\" | \"none\"; path?: string; detail?: string; historyKept?: boolean; historyPath?: string } | null")]
153    cleanup: serde_json::Value,
154}
155
156/// What `account.key.verify` answers: `{ valid: false, error }` when validation
157/// failed, otherwise every field but `error` (the same keys the handler sent).
158#[derive(serde::Serialize, ts_rs::TS)]
159#[ts(export, export_to = "../../../frontend/types/rpc/")]
160#[serde(rename_all = "camelCase")]
161pub struct AccountKeyVerifyResult {
162    valid: bool,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    #[ts(optional)]
165    error: Option<String>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    #[ts(optional)]
168    account_id: Option<String>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    #[ts(optional, type = "string")]
171    masked_tail: Option<serde_json::Value>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    #[ts(optional)]
174    status: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    #[ts(optional, type = "Record<string, unknown>")]
177    metadata: Option<serde_json::Value>,
178}
179
180/// What `account.oauth.start` answers: `{ sessionId, status }`, or `{ error }`.
181#[derive(serde::Serialize, ts_rs::TS)]
182#[ts(export, export_to = "../../../frontend/types/rpc/")]
183#[serde(rename_all = "camelCase")]
184pub struct AccountOAuthStartResult {
185    #[serde(skip_serializing_if = "Option::is_none")]
186    #[ts(optional)]
187    session_id: Option<String>,
188    /// `oauth_status_wire`'s shape, which the frontend models as OAuthFlowStatus.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    #[ts(optional, type = "import(\"@/app/store/rpc-api/types\").OAuthFlowStatus")]
191    status: Option<serde_json::Value>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    #[ts(optional)]
194    error: Option<String>,
195}
196
197pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
198    // ---- Identity account CRUD ----
199    // id_store: routes to shared/store.db when available so accounts survive
200    // version upgrades. Falls back to mstore transparently.
201
202    let mstore = state.id_store.clone();
203    let identity_store = state.identity_store.clone();
204    engine.register_typed(
205        COMMAND_LIST_IDENTITY_ACCOUNTS,
206        move |cmd: CommandListIdentityAccountsData, _ctx| {
207            let mstore = mstore.clone();
208            let identity_store = identity_store.clone();
209            async move {
210                // On the blocking pool: the backfill below reads each OAuth
211                // account's `.claude.json` and canonicalizes its dir (muxreview
212                // P1 on #3617), like this file's other fs/keyring calls.
213                tokio::task::spawn_blocking(move || {
214                let mut accounts = mstore
215                    .identity_list(cmd.provider.as_deref())
216                    .map_err(|e| format!("listidentityaccounts: {e}"))?;
217                // SPEC_ACCOUNT_EMAIL_IN_ARMORY_2026_09_23.md §4: backfill each
218                // OAuth account's login email from its own config dir, so
219                // accounts signed in before #3541 — and every Claude login,
220                // whose transcript prints no email — show it. Written only
221                // when it changed, and only that one field, in this store and
222                // the global mirror (a full upsert here could re-create an
223                // account deleted since the list, undo a status written
224                // meanwhile, or repoint the mirror's `secret_ref`). A failed
225                // write still returns the email.
226                for account in &mut accounts {
227                    if let Some(email) = crate::identity::account_email::refresh_account_email(account) {
228                        for store in [&mstore, &identity_store] {
229                            if let Err(e) = store.identity_set_context_email(&account.id, &email) {
230                                tracing::warn!(target: "identity", account_id = %account.id, error = %e, "listidentityaccounts: email backfill write failed");
231                            }
232                        }
233                    }
234                }
235                Ok(accounts)
236                })
237                .await
238                .map_err(|e| format!("listidentityaccounts: task: {e}"))?
239            }
240        },
241    );
242
243    let mstore = state.id_store.clone();
244    engine.register_typed(
245        COMMAND_GET_IDENTITY_ACCOUNT,
246        move |cmd: CommandGetIdentityAccountData, _ctx| {
247            let mstore = mstore.clone();
248            async move {
249                match mstore
250                    .identity_get(&cmd.id)
251                    .map_err(|e| format!("getidentityaccount: {e}"))?
252                {
253                    Some(a) => Ok(a),
254                    None => Err(format!("getidentityaccount: not found id={}", cmd.id)),
255                }
256            }
257        },
258    );
259
260    let mstore = state.id_store.clone();
261    let identity_store = state.identity_store.clone();
262    let broker = state.broker.clone();
263    // `Value` request: callers send a partial account (generated-manifest.json
264    // keeps `Partial<IdentityAccount>`), which this parses into IdentityAccount.
265    engine.register_typed(COMMAND_UPSERT_IDENTITY_ACCOUNT, move |data: serde_json::Value, _ctx| {
266        let mstore = mstore.clone();
267        let identity_store = identity_store.clone();
268        let broker = broker.clone();
269        async move {
270                // Accept the full IdentityAccount payload. Missing `id` → mint
271                // a fresh UUID; `created_at` and `updated_at` are server-set
272                // so callers don't have to know the current time.
273                let mut account: IdentityAccount = serde_json::from_value(data)
274                    .map_err(|e| format!("upsertidentityaccount: {e}"))?;
275                reject_ambient_home_dir_binding(&account)?;
276                if account.id.is_empty() {
277                    account.id = uuid::Uuid::new_v4().to_string();
278                }
279                let now = agentmux_common::time::now_ms();
280                if account.created_at == 0 {
281                    account.created_at = now;
282                }
283                account.updated_at = now;
284                // identity_upsert_with_mirror — reagentx P0 review on PR
285                // #2632.
286                mstore
287                    .identity_upsert_with_mirror(&identity_store, &account)
288                    .map_err(|e| format!("upsertidentityaccount: {e}"))?;
289                broker.publish_event::<crate::backend::mps_events::IdentityAccountsChanged>(&(), vec![], 0);
290                Ok(account)
291        }
292    });
293
294    // Connectors → Accounts: validate (optional) + securely store an API key.
295    // The plaintext goes to the OS keychain; the DB row keeps only the
296    // SecretRef::Keychain pointer + masked tail + non-secret metadata.
297    // See docs/specs/archive/SPEC_TRUST_CENTER_2026_06_15.md §5/§6.
298    let mstore = state.id_store.clone();
299    let identity_store = state.identity_store.clone();
300    let broker = state.broker.clone();
301    // `Value` request, parsed into VerifyKeyReq below; the stub keeps its type.
302    engine.register_typed(COMMAND_ACCOUNT_KEY_VERIFY, move |data: serde_json::Value, _ctx| {
303        let mstore = mstore.clone();
304        let identity_store = identity_store.clone();
305        let broker = broker.clone();
306        async move {
307                // NB: `req.api_key` is a secret — never log `req`.
308                let req: VerifyKeyReq = serde_json::from_value(data)
309                    .map_err(|e| format!("account.key.verify: {e}"))?;
310
311                // New account (mint id) vs. key replacement on an existing
312                // account. Rollback semantics differ: see the upsert below.
313                let is_new = req.account_id.is_empty();
314                let account_id = if is_new {
315                    uuid::Uuid::new_v4().to_string()
316                } else {
317                    req.account_id.clone()
318                };
319
320                // Optional live validation — the single outbound probe, fired
321                // only when the user clicked "Validate" (validate=true).
322                let (status, metadata, masked_tail, valid) = if req.validate {
323                    let outcome =
324                        crate::identity::key_validator::validate(&req.provider, &req.api_key).await;
325                    if !outcome.valid {
326                        // Nothing stored — surface a structured error so the UI
327                        // stays in the entry state.
328                        return Ok(AccountKeyVerifyResult {
329                            valid: false,
330                            error: Some(outcome.error.unwrap_or_else(|| "validation failed".to_string())),
331                            account_id: None,
332                            masked_tail: None,
333                            status: None,
334                            metadata: None,
335                        });
336                    }
337                    ("valid".to_string(), outcome.metadata, outcome.masked_tail, true)
338                } else {
339                    (
340                        "unknown".to_string(),
341                        serde_json::json!({}),
342                        crate::identity::key_validator::masked_tail(&req.api_key),
343                        false,
344                    )
345                };
346
347                // Store the plaintext in the OS keychain; the DB never sees it.
348                // `keyring` is blocking (sync D-Bus on Linux), so run it off the
349                // async runtime worker via spawn_blocking.
350                {
351                    let aid = account_id.clone();
352                    let key = req.api_key.clone();
353                    tokio::task::spawn_blocking(move || {
354                        crate::identity::secret_store::put(&aid, &key)
355                    })
356                    .await
357                    .map_err(|e| format!("account.key.verify: keychain task: {e}"))?
358                    .map_err(|e| format!("account.key.verify: {e}"))?;
359                }
360
361                // Fetch the existing row once (replacement path) — preserves
362                // created_at and any previously-stored context.
363                let existing = mstore.identity_get(&account_id).ok().flatten();
364
365                // Non-secret context, merged so nothing the user set is lost:
366                //   existing context  →  user-entered context  →  validation
367                //   metadata  →  masked tail.
368                let mut context = existing
369                    .as_ref()
370                    .map(|a| a.context.clone())
371                    .unwrap_or_else(|| serde_json::json!({}));
372                if !context.is_object() {
373                    context = serde_json::json!({});
374                }
375                merge_json_object(&mut context, &req.context);
376                merge_json_object(&mut context, &metadata);
377                if let serde_json::Value::Object(ref mut m) = context {
378                    m.insert("masked_tail".to_string(), serde_json::json!(masked_tail));
379                }
380
381                let now = agentmux_common::time::now_ms();
382                let created_at = existing
383                    .as_ref()
384                    .map(|a| a.created_at)
385                    .filter(|&c| c != 0)
386                    .unwrap_or(now);
387
388                let account = IdentityAccount {
389                    id: account_id.clone(),
390                    name: req.name.clone(),
391                    provider: req.provider.clone(),
392                    kind: if req.kind.is_empty() { "api_key".to_string() } else { req.kind.clone() },
393                    display_name: req.display_name.clone(),
394                    secret_ref: SecretRef::Keychain {
395                        service: crate::identity::secret_store::SERVICE.to_string(),
396                        account: crate::identity::secret_store::account_key(&account_id),
397                    },
398                    context,
399                    status,
400                    created_at,
401                    updated_at: now,
402                };
403                // identity_upsert_with_mirror — reagentx P0 review on PR #2632.
404                if let Err(e) = mstore.identity_upsert_with_mirror(&identity_store, &account) {
405                    // DB write failed after the keychain write.
406                    //  - New account: nothing references the secret yet, so
407                    //    roll it back to avoid an orphan with no DB row.
408                    //  - Replacement: the existing DB row still points at this
409                    //    keychain entry. Deleting it would destroy the
410                    //    previously-working credential; leave the (now
411                    //    overwritten) secret in place — resolution still works
412                    //    with the new key, and the user can retry to fix
413                    //    metadata. So only roll back for new accounts.
414                    if is_new {
415                        let aid = account_id.clone();
416                        if let Err(de) = tokio::task::spawn_blocking(move || {
417                            crate::identity::secret_store::delete(&aid)
418                        })
419                        .await
420                        .unwrap_or_else(|je| Err(format!("join: {je}")))
421                        {
422                            tracing::warn!(
423                                target: "identity",
424                                "rollback of orphaned keychain secret for {} failed: {de}",
425                                account_id,
426                            );
427                        }
428                    }
429                    return Err(format!("account.key.verify: {e}"));
430                }
431                broker.publish_event::<crate::backend::mps_events::IdentityAccountsChanged>(&(), vec![], 0);
432                Ok(AccountKeyVerifyResult {
433                    valid,
434                    error: None,
435                    account_id: Some(account_id),
436                    masked_tail: Some(account.context.get("masked_tail").cloned().unwrap_or_default()),
437                    status: Some(account.status),
438                    metadata: Some(account.context),
439                })
440        }
441    });
442
443    // ── Service OAuth (scaffold) ──
444    // start: resolve config + client (gates on "not configured"), spawn the
445    // flow, return session id + initial status. poll/cancel drive the rest.
446    let oauth_mstore = state.id_store.clone();
447    let oauth_identity_store = state.identity_store.clone();
448    // `Value` request, parsed into OAuthStartReq below; the stub keeps its type.
449    engine.register_typed(COMMAND_ACCOUNT_OAUTH_START, move |data: serde_json::Value, _ctx| {
450        let mstore = oauth_mstore.clone();
451        let identity_store = oauth_identity_store.clone();
452        async move {
453                let req: OAuthStartReq = serde_json::from_value(data)
454                    .map_err(|e| format!("account.oauth.start: {e}"))?;
455                let byo = req.client_id.map(|cid| {
456                    crate::identity::oauth_client::ByoCredentials {
457                        client_id: cid,
458                        client_secret: req.client_secret,
459                    }
460                });
461                match crate::identity::oauth_client::start(&req.provider, req.name, byo, mstore, identity_store) {
462                    Ok((session_id, status)) => Ok(AccountOAuthStartResult {
463                        session_id: Some(session_id),
464                        status: Some(oauth_status_wire(&status)),
465                        error: None,
466                    }),
467                    // "not configured" / unknown provider surface as a clean
468                    // error field, not an RPC failure, so the UI can show it.
469                    Err(e) => Ok(AccountOAuthStartResult { session_id: None, status: None, error: Some(e) }),
470                }
471        }
472    });
473
474    // The response stays `Value`: oauth_status_wire builds it, and the frontend
475    // models it as OAuthFlowStatus (generated-manifest.json `returns`).
476    engine.register_typed(COMMAND_ACCOUNT_OAUTH_POLL, move |req: OAuthSessionReq, _ctx| {
477        async move {
478                match crate::identity::oauth_client::manager().poll(&req.session_id) {
479                    Some(s) => Ok(oauth_status_wire(&s)),
480                    None => Err(format!("account.oauth.poll: unknown session {}", req.session_id)),
481                }
482        }
483    });
484
485    engine.register_typed(
486        COMMAND_ACCOUNT_OAUTH_CANCEL,
487        move |req: OAuthSessionReq, _ctx| {
488            async move {
489                let cancelled = crate::identity::oauth_client::manager().cancel(&req.session_id);
490                Ok(AccountOAuthCancelResult { cancelled })
491            }
492        },
493    );
494
495    let mstore = state.id_store.clone();
496    let identity_store = state.identity_store.clone();
497    let broker = state.broker.clone();
498    engine.register_typed(COMMAND_DELETE_IDENTITY_ACCOUNT, move |cmd: CommandDeleteIdentityAccountData, _ctx| {
499        let mstore = mstore.clone();
500        let identity_store = identity_store.clone();
501        let broker = broker.clone();
502        async move {
503                // Drop the credential material behind the row BEFORE the row
504                // itself: keychain entry for `SecretRef::Keychain`, on-disk
505                // OAuth token dir for `SecretRef::OAuthConfigDir` (layer 1 of
506                // ANALYSIS_ACCOUNT_DELETE_AUTH_LIFECYCLE_GAP_2026_07_14.md §4).
507                // Best-effort — cleanup trouble never blocks the delete; the
508                // cleanup fn logs every outcome under "identity.delete:".
509                // keyring + fs are blocking, so run via spawn_blocking.
510                // Capture provider before the row is deleted so the logout-
511                // side log lines can carry it.
512                let acct = mstore.identity_get(&cmd.id).ok().flatten();
513                let provider = acct.as_ref().map(|a| a.provider.clone()).unwrap_or_default();
514                // What the cleanup did, returned to Connectors so a login that
515                // couldn't be removed is reported, not hidden
516                // (SPEC_ARMORY_ACCOUNTS_DELETE_AND_INLINE_DETAIL_2026_10_04.md H3).
517                let mut cleanup_report = serde_json::Value::Null;
518                if let Some(acct) = acct {
519                    // Containment root for OAuth dirs: only paths inside
520                    // ~/.agentmux/shared/identities/ are ever removed (the
521                    // legacy ~/.claude migration dir is the user's global
522                    // CLI login — never ours to delete).
523                    let identities_root = agentmux_common::DataPaths::from_env()
524                        .map(|p| p.identities_dir());
525                    cleanup_report = match tokio::task::spawn_blocking(move || {
526                        crate::identity::cleanup::cleanup_account_secrets(
527                            &acct,
528                            identities_root.as_deref(),
529                        )
530                    })
531                    .await
532                    {
533                        Ok(outcome) => outcome.report(),
534                        Err(e) => json!({ "outcome": "failed", "detail": e.to_string() }),
535                    };
536                }
537                let outcome = mstore
538                    .identity_delete(&cmd.id)
539                    .map_err(|e| format!("deleteidentityaccount: {e}"))?;
540                let deleted = outcome.deleted;
541                // Links now live in identity_store, a different physical
542                // store than the account row (id_store) —
543                // identity_delete's own cascade above only ever reaches
544                // rows in the SAME store it's called on, so it no longer
545                // finds anything to cascade (SPEC_IDENTITY_STORE_SPLIT_
546                // 2026_08_17.md). Clean up the real link rows here
547                // explicitly and merge the two outcomes for the existing
548                // notification logic below, so this doesn't regress into
549                // silently orphaning links on every account delete.
550                let (links_cascaded_from_identity_store, mut affected_agents) = identity_store
551                    .agent_identity_unlink_by_account(&cmd.id)
552                    .map_err(|e| format!("deleteidentityaccount: identity_store cleanup: {e}"))?;
553                let links_cascaded = outcome.links_cascaded + links_cascaded_from_identity_store;
554                affected_agents.extend(outcome.affected_agents);
555                affected_agents.sort();
556                affected_agents.dedup();
557                // Also remove the account's MIRROR row (identity_upsert_with_mirror's
558                // counterpart on delete) — without this, a stale copy of a
559                // deleted account survives in identity_store, and
560                // resolve_account's fallback would incorrectly resolve it
561                // again on the next spawn once id_store's own row is gone.
562                // Best-effort: a mirror cleanup failure must not block the
563                // real delete, which already succeeded above.
564                if let Err(e) = identity_store.identity_delete(&cmd.id) {
565                    tracing::warn!(
566                        account_id = %cmd.id,
567                        error = %e,
568                        "deleteidentityaccount: identity_store mirror row cleanup failed (non-fatal)"
569                    );
570                }
571                if links_cascaded > 0 {
572                    // info!, not debug!: the production filter is
573                    // "agentmuxsrv=info,info" (reagent P1, PR #2143).
574                    // "identity.delete:" is `muxlog auth` vocabulary.
575                    tracing::info!(
576                        account_id = %cmd.id,
577                        provider = %provider,
578                        links = links_cascaded,
579                        "identity.delete: links cascaded"
580                    );
581                }
582                tracing::info!(
583                    account_id = %cmd.id,
584                    provider = %provider,
585                    deleted,
586                    "identity.delete: account removed (deleteidentityaccount)"
587                );
588                if deleted {
589                    broker.publish_event::<crate::backend::mps_events::IdentityAccountsChanged>(&(), vec![], 0);
590                    // Layer 2 — running-agent reconciliation (spec
591                    // SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md §3):
592                    // the cascaded links name agents that may have a LIVE
593                    // process still holding this account's tokens. We
594                    // surface, not hard-kill — a per-agent revocation event
595                    // drives the pane chip; enforcement lands at the next
596                    // spawn (layer 3). Also poke the per-agent link-changed
597                    // event so link tables refresh.
598                    if !affected_agents.is_empty() {
599                        // info!, not debug!: the production filter is
600                        // "agentmuxsrv=info,info". "identity.delete:" is
601                        // `muxlog auth` vocabulary.
602                        tracing::info!(
603                            account_id = %cmd.id,
604                            provider = %provider,
605                            count = affected_agents.len(),
606                            agent_ids = %affected_agents.join(","),
607                            "identity.delete: running agent(s) affected"
608                        );
609                        for agent_id in &affected_agents {
610                            broker.publish_event_for::<crate::backend::mps_events::AgentIdentitiesChanged>(&agent_id, &(), vec![], 0);
611                            let revoked = crate::backend::mps_events::CredentialsRevokedEvent {
612                                credentials_revoked: true,
613                                provider: provider.to_string(),
614                                account_id: Some(cmd.id.clone()),
615                            };
616                            broker.publish_event_for::<crate::backend::mps_events::AgentCredentialsRevoked>(&agent_id, &revoked, vec![], 0);
617                        }
618                    }
619                }
620                Ok(DeleteIdentityAccountResult {
621                    deleted,
622                    // Layer 4: delete-time disclosure (spec §4).
623                    affected_agents,
624                    // Null when there was no account row to clean up after.
625                    cleanup: cleanup_report,
626                })
627        }
628    });
629
630    // ---- Agent ↔ Identity junction ----
631
632    let mstore = state.identity_store.clone();
633    let broker = state.broker.clone();
634    engine.register_typed(
635        COMMAND_LINK_AGENT_IDENTITY,
636        move |cmd: CommandLinkAgentIdentityData, _ctx| {
637            let mstore = mstore.clone();
638            let broker = broker.clone();
639            async move {
640                mstore
641                    .agent_identity_link(&cmd.agent_id, &cmd.account_id, &cmd.provider)
642                    .map_err(|e| format!("linkagentidentity: {e}"))?;
643                broker.publish_event_for::<crate::backend::mps_events::AgentIdentitiesChanged>(&cmd.agent_id, &(), vec![], 0);
644                // See app_api/identity.rs's identical call for why this is
645                // needed: watch_agent's one-shot config-dir resolution can
646                // race ahead of this exact binding.
647                if let Some(watcher) = crate::backend::subagent_watcher::global() {
648                    watcher.recheck_all_watched_agents();
649                }
650                Ok(())
651            }
652        },
653    );
654
655    let mstore = state.identity_store.clone();
656    let broker = state.broker.clone();
657    engine.register_typed(
658        COMMAND_UNLINK_AGENT_IDENTITY,
659        move |cmd: CommandUnlinkAgentIdentityData, _ctx| {
660            let mstore = mstore.clone();
661            let broker = broker.clone();
662            async move {
663                let removed = mstore
664                    .agent_identity_unlink(&cmd.agent_id, &cmd.provider)
665                    .map_err(|e| format!("unlinkagentidentity: {e}"))?;
666                // info!, not debug!: the production filter is
667                // "agentmuxsrv=info,info" (reagent P1, PR #2143).
668                // "identity.unlink:" is `muxlog auth` vocabulary.
669                tracing::info!(
670                    agent_id = %cmd.agent_id,
671                    provider = %cmd.provider,
672                    unlinked = removed,
673                    "identity.unlink: agent-identity link removed (unlinkagentidentity)"
674                );
675                if removed {
676                    broker.publish_event_for::<crate::backend::mps_events::AgentIdentitiesChanged>(&cmd.agent_id, &(), vec![], 0);
677                    // Spec §3 names unlink alongside delete: a live process
678                    // for this agent still holds the unlinked account's
679                    // tokens until restarted — same disclosure chip. Skipped
680                    // for `silent` unlinks (alias migration, not a real
681                    // unbind — see CommandUnlinkAgentIdentityData's doc
682                    // comment; reagent P2 on PR #2414).
683                    if !cmd.silent {
684                        let revoked = crate::backend::mps_events::CredentialsRevokedEvent {
685                            credentials_revoked: true,
686                            provider: cmd.provider.clone(),
687                            account_id: None,
688                        };
689                        broker.publish_event_for::<crate::backend::mps_events::AgentCredentialsRevoked>(&cmd.agent_id, &revoked, vec![], 0);
690                    }
691                }
692                Ok(UnlinkAgentIdentityResult { unlinked: removed })
693            }
694        },
695    );
696
697    let mstore = state.identity_store.clone();
698    engine.register_typed(
699        COMMAND_LIST_AGENT_IDENTITIES,
700        move |cmd: CommandListAgentIdentitiesData, _ctx| {
701            let mstore = mstore.clone();
702            async move {
703                let rows = mstore
704                    .agent_identity_list_for_agent(&cmd.agent_id)
705                    .map_err(|e| format!("listagentidentities: {e}"))?;
706                Ok(rows)
707            }
708        },
709    );
710
711    // Every direct link across every agent — see the constant's doc comment.
712    let mstore = state.identity_store.clone();
713    engine.register_typed(
714        COMMAND_LIST_ALL_AGENT_IDENTITIES,
715        move |_req: CommandListAllAgentIdentitiesData, _ctx| {
716            let mstore = mstore.clone();
717            async move {
718                let rows = mstore
719                    .agent_identity_list_all()
720                    .map_err(|e| format!("listallagentidentities: {e}"))?;
721                Ok(rows)
722            }
723        },
724    );
725
726    // ---- v8: named agent continuation ----
727
728    // listnamedagents — powers the launch modal's "Continue agent"
729    // dropdown. Joins instance rows with the definition, account and
730    // bundle names so the frontend renders without follow-ups.
731    let mstore = state.mstore.clone();
732    let id_store_lna = state.id_store.clone();
733    let identity_store_lna = state.identity_store.clone();
734    // `Value` request: a malformed one means the defaults (unwrap_or_default).
735    engine.register_typed(COMMAND_LIST_NAMED_AGENTS, move |data: serde_json::Value, _ctx| {
736        let mstore = mstore.clone();
737        let id_store = id_store_lna.clone();
738        let identity_store = identity_store_lna.clone();
739        async move {
740                let cmd: CommandListNamedAgentsData =
741                    serde_json::from_value(data).unwrap_or_default();
742                let limit = if cmd.limit == 0 {
743                    200
744                } else {
745                    cmd.limit.min(1000)
746                };
747                // Resolve names once per response. With ≤200 rows and
748                // typical account/bundle counts in the low dozens, a
749                // linear lookup on cached lists beats per-row
750                // round-trips through the store.
751                let defs = mstore
752                    .agent_def_list()
753                    .map_err(|e| format!("listnamedagents: agent_def_list: {e}"))?;
754                let memories = id_store
755                    .bundle_list()
756                    .map_err(|e| format!("listnamedagents: bundle_list: {e}"))?;
757
758                // Identity display names resolve off the direct
759                // agent<->account links (db_agent_identity_links /
760                // db_accounts) now, not the retired bundle tables — see
761                // SPEC_ARMORY_PHASE4_STORAGE_RENAME_COMPLETION_2026_07_12.md
762                // §4 item 2. Bulk-fetched once and grouped by
763                // definition_id rather than queried per-row.
764                let agent_identity_links = identity_store
765                    .agent_identity_list_all()
766                    .map_err(|e| format!("listnamedagents: agent_identity_links: {e}"))?;
767                let accounts = id_store
768                    .identity_list(None)
769                    .map_err(|e| format!("listnamedagents: accounts: {e}"))?;
770                let own_ids: std::collections::HashSet<&str> = accounts.iter().map(|a| a.id.as_str()).collect();
771                let mut mirror_failed = false;
772                let mirror_accounts: Vec<IdentityAccount> =
773                    if agent_identity_links.iter().any(|l| !own_ids.contains(l.account_id.as_str())) {
774                        identity_store.identity_list(None).unwrap_or_else(|e| {
775                            tracing::warn!(error = %e, "listnamedagents: the global account mirror could not be read");
776                            mirror_failed = true;
777                            Vec::new()
778                        })
779                    } else {
780                        Vec::new()
781                    };
782                let account_names = super::account_label::names_by_id(&accounts, &mirror_accounts);
783                let mut links_by_agent: std::collections::HashMap<&str, Vec<&AgentIdentityLink>> =
784                    std::collections::HashMap::new();
785                for link in &agent_identity_links {
786                    links_by_agent
787                        .entry(link.agent_id.as_str())
788                        .or_default()
789                        .push(link);
790                }
791                let resolve_identity_name = |definition_id: &str| -> String {
792                    super::account_label::account_line(
793                        links_by_agent.get(definition_id).map(|v| v.as_slice()),
794                        &account_names,
795                        super::account_label::Degraded { links: false, accounts: mirror_failed },
796                    )
797                    .text
798                };
799
800                // PR B — read from the cross-version registry when
801                // it's available. Falls back to SQLite when the
802                // registry couldn't be resolved at startup (CI / odd
803                // environments). SQLite remains authoritative for
804                // PR B (parallel-write is still active); the choice
805                // here just affects which surface gets surfaced.
806                let rows: Vec<NamedAgentRow> = match mstore.shared_agent_registry() {
807                    Some(reg) => {
808                        // Re-join relative working_dir against the CURRENT
809                        // channel's agents dir (symmetric with the write
810                        // mirror), not the registry's own parent — P0.3
811                        // re-roots the registry out of channels/<ch>/agents/.
812                        let agents_root = mstore.registry_agents_base();
813                        let mut records = reg
814                            .list_active()
815                            .map_err(|e| format!("listnamedagents: registry: {e}"))?;
816                        if let Some(def_filter) = cmd.definition_id.as_deref() {
817                            records.retain(|r| r.data.definition_id == def_filter);
818                        }
819                        records.sort_by(|a, b| {
820                            b.data
821                                .last_launched_at_ms
822                                .cmp(&a.data.last_launched_at_ms)
823                        });
824                        records.truncate(limit);
825                        // Pre-fetch all candidate same-version rows
826                        // ONCE so enrichment doesn't issue N+1 queries.
827                        // Indexed by instance_id; rows that aren't in
828                        // current SQLite fall through to sentinels.
829                        // Registry enrichment: keep head-of-chain
830                        // only. The registry mirror itself excludes
831                        // continuations (see
832                        // `registry_upsert_if_named`), so the SQLite
833                        // side must match — else under the `limit`
834                        // truncation continuation rows displace
835                        // registry-head rows and the merge-by-id
836                        // enrichment misses, silently downgrading
837                        // running-state badges and block_id_hints to
838                        // "available" / empty.
839                        let sqlite_rows: Vec<AgentInstance> = mstore
840                            .instance_list_named(
841                                records.len().max(1),
842                                cmd.definition_id.as_deref(),
843                                /* identity_id */ None,
844                                /* include_continuations */ false,
845                            )
846                            .unwrap_or_default();
847                        let sqlite_by_id: std::collections::HashMap<&str, &AgentInstance> =
848                            sqlite_rows.iter().map(|i| (i.id.as_str(), i)).collect();
849                        records
850                            .into_iter()
851                            .map(|rec| {
852                                let d = rec.data;
853                                let def = defs.iter().find(|x| x.id == d.definition_id);
854                                let identity_id_str =
855                                    d.identity_id.clone().unwrap_or_default();
856                                let memory_id_str = d.memory_id.clone().unwrap_or_default();
857                                let identity_name = resolve_identity_name(&d.definition_id);
858                                let memory_name = if memory_id_str.is_empty() {
859                                    "(vanilla CLI)".to_string()
860                                } else {
861                                    memories
862                                        .iter()
863                                        .find(|m| m.id == memory_id_str)
864                                        .map(|m| m.name.clone())
865                                        .unwrap_or_else(|| "(missing memory)".to_string())
866                                };
867                                // Reconstruct the absolute working_directory.
868                                // v3 records carry their SOURCE channel agents
869                                // dir, so a row from another channel resolves
870                                // to its real workspace; legacy (v1/v2) records
871                                // fall back to the current channel base — the
872                                // pre-P0.4 behavior (correct for same-channel
873                                // rows, which is all v1/v2 could represent).
874                                let working_directory = if let Some(src) =
875                                    d.source_agents_base.as_deref()
876                                {
877                                    std::path::Path::new(src)
878                                        .join(&d.working_dir)
879                                        .to_string_lossy()
880                                        .to_string()
881                                } else {
882                                    match agents_root.as_ref() {
883                                        Some(root) => root
884                                            .join(&d.working_dir)
885                                            .to_string_lossy()
886                                            .to_string(),
887                                        None => d.working_dir.clone(),
888                                    }
889                                };
890                                // Same-version enrichment: if this id
891                                // also exists in current SQLite, the
892                                // row carries runtime state (block_id
893                                // for focus-existing-pane, status,
894                                // ended_at) that the registry
895                                // intentionally doesn't track.
896                                // Cross-version rows fall through with
897                                // sentinel "available" status and
898                                // empty block_id_hint.
899                                let (block_id_hint, status, ended_at) =
900                                    match sqlite_by_id.get(d.instance_id.as_str()) {
901                                        Some(inst) => (
902                                            inst.block_id.clone(),
903                                            inst.status.clone(),
904                                            inst.ended_at,
905                                        ),
906                                        None => (String::new(), "available".to_string(), 0),
907                                    };
908                                NamedAgentRow {
909                                    instance_id: d.instance_id,
910                                    instance_name: d.instance_name,
911                                    definition_id: d.definition_id.clone(),
912                                    definition_name: def
913                                        .map(|x| x.name.clone())
914                                        .unwrap_or_else(|| "(missing definition)".to_string()),
915                                    provider: def
916                                        .map(|x| x.provider.clone())
917                                        .unwrap_or_default(),
918                                    working_directory,
919                                    identity_id: identity_id_str,
920                                    identity_name,
921                                    memory_id: memory_id_str,
922                                    memory_name,
923                                    started_at: d.last_launched_at_ms,
924                                    ended_at,
925                                    status,
926                                    block_id_hint,
927                                }
928                            })
929                            .collect()
930                    }
931                    None => {
932                        // No-registry fallback: drives the launch
933                        // modal's "Continue agent" dropdown directly.
934                        // One entry per chain root, mirroring the
935                        // registry path's semantics.
936                        let instances = mstore
937                            .instance_list_named(
938                                limit,
939                                cmd.definition_id.as_deref(),
940                                /* identity_id */ None,
941                                /* include_continuations */ false,
942                            )
943                            .map_err(|e| format!("listnamedagents: {e}"))?;
944                        instances
945                            .into_iter()
946                            .map(|inst| {
947                                let def = defs.iter().find(|d| d.id == inst.definition_id);
948                                let identity_name = resolve_identity_name(&inst.definition_id);
949                                let memory_name = if inst.memory_id.is_empty() {
950                                    "(vanilla CLI)".to_string()
951                                } else {
952                                    memories
953                                        .iter()
954                                        .find(|m| m.id == inst.memory_id)
955                                        .map(|m| m.name.clone())
956                                        .unwrap_or_else(|| "(missing memory)".to_string())
957                                };
958                                NamedAgentRow {
959                                    instance_id: inst.id,
960                                    instance_name: inst.instance_name,
961                                    definition_id: inst.definition_id.clone(),
962                                    definition_name: def
963                                        .map(|d| d.name.clone())
964                                        .unwrap_or_else(|| "(missing definition)".to_string()),
965                                    provider: def
966                                        .map(|d| d.provider.clone())
967                                        .unwrap_or_default(),
968                                    working_directory: inst.working_directory,
969                                    identity_id: inst.identity_id,
970                                    identity_name,
971                                    memory_id: inst.memory_id,
972                                    memory_name,
973                                    started_at: inst.started_at,
974                                    ended_at: inst.ended_at,
975                                    status: inst.status,
976                                    block_id_hint: inst.block_id,
977                                }
978                            })
979                            .collect()
980                    }
981                };
982
983                Ok(rows)
984        }
985    });
986
987    // hidenamedagent — soft-delete (sets display_hidden = 1) so the
988    // row disappears from the dropdown. Working dir stays on disk.
989    let mstore = state.mstore.clone();
990    let broker = state.broker.clone();
991    engine.register_typed(
992        COMMAND_HIDE_NAMED_AGENT,
993        move |cmd: CommandHideNamedAgentData, _ctx| {
994            let mstore = mstore.clone();
995            let broker = broker.clone();
996            async move {
997                let hidden = mstore
998                    .instance_set_hidden(&cmd.id, true)
999                    .map_err(|e| format!("hidenamedagent: {e}"))?;
1000                if hidden {
1001                    broker.publish_event::<crate::backend::mps_events::NamedAgentsChanged>(&(), vec![], 0);
1002                }
1003                Ok(HideNamedAgentResult { hidden })
1004            }
1005        },
1006    );
1007}
1008
1009#[cfg(test)]
1010mod ambient_home_dir_binding_tests {
1011    use super::*;
1012    use crate::server::tests::test_state;
1013
1014    fn make_account(provider: &str, secret_ref: SecretRef) -> IdentityAccount {
1015        IdentityAccount {
1016            id: "acct-1".to_string(),
1017            name: "test".to_string(),
1018            provider: provider.to_string(),
1019            kind: "pat".to_string(),
1020            display_name: String::new(),
1021            secret_ref,
1022            context: serde_json::json!({}),
1023            status: "unknown".to_string(),
1024            created_at: 0,
1025            updated_at: 0,
1026        }
1027    }
1028
1029    // docs/specs/SPEC_BLOCK_AMBIENT_HOME_DIR_IDENTITY_BINDING_2026_08_25.md.
1030    // Uses the REAL get_home_dir() (not a mock) — same reasoning as the
1031    // inject.rs spawn-time test, deterministic regardless of whether
1032    // ~/.claude exists on the test machine.
1033    #[test]
1034    fn refuses_oauth_config_dir_pointed_at_the_ambient_home() {
1035        let ambient = crate::backend::base::get_home_dir()
1036            .join(".claude")
1037            .to_string_lossy()
1038            .into_owned();
1039        let account = make_account("claude", SecretRef::OAuthConfigDir { dir: ambient.clone() });
1040
1041        let err = reject_ambient_home_dir_binding(&account)
1042            .expect_err("must refuse an account bound to the ambient home dir");
1043        assert!(err.contains(&ambient), "error should name the offending dir: {err}");
1044        assert!(err.contains("claude"), "error should name the provider: {err}");
1045    }
1046
1047    #[test]
1048    fn allows_oauth_config_dir_pointed_at_an_isolated_dir() {
1049        let isolated = crate::backend::base::get_home_dir()
1050            .join(".agentmux")
1051            .join("shared")
1052            .join("identities")
1053            .join("some-id")
1054            .join("claude")
1055            .to_string_lossy()
1056            .into_owned();
1057        let account = make_account("claude", SecretRef::OAuthConfigDir { dir: isolated });
1058
1059        assert!(reject_ambient_home_dir_binding(&account).is_ok());
1060    }
1061
1062    #[test]
1063    fn allows_non_oauth_secret_refs_unconditionally() {
1064        let account = make_account("github", SecretRef::Env { env_var: "GITHUB_TOKEN".to_string() });
1065        assert!(reject_ambient_home_dir_binding(&account).is_ok());
1066    }
1067
1068    #[test]
1069    fn allows_an_unknown_provider_id_unconditionally() {
1070        // get_provider() returns None for an unrecognized id — the guard
1071        // has nothing to check against, so it must not block (matches the
1072        // existing "unknown provider" skip behavior elsewhere in this
1073        // codebase, not a new failure mode).
1074        let account = make_account(
1075            "not-a-real-provider",
1076            SecretRef::OAuthConfigDir { dir: "/tmp/whatever".to_string() },
1077        );
1078        assert!(reject_ambient_home_dir_binding(&account).is_ok());
1079    }
1080
1081    /// `hidenamedagent` is recorded in the engine's schema with its exact
1082    /// type names. `HideNamedAgentResult` didn't exist before this
1083    /// migration; the handler used to answer with an anonymous
1084    /// `json!({"hidden": ..})`.
1085    #[tokio::test]
1086    async fn register_typed_records_hidenamedagent() {
1087        let state = test_state();
1088        let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1089        register(&engine, &state);
1090        let schema = engine.schema_json();
1091        let rows = schema.as_array().unwrap();
1092        let row = rows
1093            .iter()
1094            .find(|r| r["command"] == COMMAND_HIDE_NAMED_AGENT)
1095            .expect("hidenamedagent missing from the schema");
1096        assert_eq!(row["requestName"], "CommandHideNamedAgentData");
1097        assert_eq!(row["responseName"], "HideNamedAgentResult");
1098        // listnamedagents keeps a loose `Value` request (a malformed body means the
1099        // defaults), but its response is recorded, so the generated stub returns NamedAgentRow.
1100        let list = rows
1101            .iter()
1102            .find(|r| r["command"] == COMMAND_LIST_NAMED_AGENTS)
1103            .expect("listnamedagents missing from the schema");
1104        assert_eq!(list["requestName"], "Value");
1105        assert_eq!(list["responseTs"]["name"], "Array<NamedAgentRow>");
1106    }
1107}