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}