Skip to main content

agentmux_srv/server/app_api/
identity.rs

1use super::*;
2
3/// `identity.self.accounts` and `identity.self.unlink`: the agent asking.
4#[derive(Debug, serde::Deserialize, ts_rs::TS)]
5#[ts(export, export_to = "../../../frontend/types/rpc/")]
6pub struct IdentitySelfAccountsReq {
7    pub agent_id: String,
8}
9
10/// `identity.self.accounts`: the provider accounts linked to the agent, with no secrets.
11#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
12#[ts(export, export_to = "../../../frontend/types/rpc/")]
13pub struct IdentitySelfAccountsResult {
14    pub accounts: Vec<IdentitySelfAccount>,
15}
16
17/// One linked account in `IdentitySelfAccountsResult`.
18#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
19#[ts(export, export_to = "../../../frontend/types/rpc/")]
20pub struct IdentitySelfAccount {
21    pub account_id: String,
22    pub provider: String,
23    pub name: String,
24    pub kind: String,
25    pub status: String,
26    pub masked_tail: String,
27    #[ts(type = "number")]
28    pub updated_at: i64,
29}
30
31/// `identity.account.upsert`: an account for the agent's own use, created or updated, and linked.
32#[derive(Debug, serde::Deserialize, ts_rs::TS)]
33#[ts(export, export_to = "../../../frontend/types/rpc/")]
34#[serde(rename_all = "snake_case")]
35pub struct IdentityAccountUpsertReq {
36    pub agent_id: String,
37    pub provider: String,
38    pub name: String,
39    #[serde(default)]
40    #[ts(as = "Option<String>", optional)]
41    pub kind: String,
42    pub secret: String,
43    #[serde(default)]
44    #[ts(as = "Option<bool>", optional)]
45    pub validate: bool,
46    #[serde(default)]
47    #[ts(as = "Option<String>", optional)]
48    pub account_id: String,
49}
50
51/// `identity.account.upsert`: the account as stored, and the validation result when one was asked for.
52#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
53#[ts(export, export_to = "../../../frontend/types/rpc/")]
54pub struct IdentityAccountUpsertResult {
55    pub account_id: String,
56    pub provider: String,
57    pub name: String,
58    #[ts(type = r#""valid" | "invalid" | "unknown""#)]
59    pub status: String,
60    pub masked_tail: String,
61    pub valid: bool,
62    pub error: Option<String>,
63}
64
65/// `identity.account.validate`: a stored account to probe, or a provider and secret to probe ad hoc.
66#[derive(Debug, Default, serde::Deserialize, ts_rs::TS)]
67#[ts(export, export_to = "../../../frontend/types/rpc/")]
68#[serde(rename_all = "snake_case")]
69pub struct IdentityAccountValidateReq {
70    #[serde(default)]
71    #[ts(as = "Option<String>", optional)]
72    pub agent_id: String,
73    #[serde(default)]
74    #[ts(as = "Option<String>", optional)]
75    pub account_id: String,
76    #[serde(default)]
77    #[ts(as = "Option<String>", optional)]
78    pub provider: String,
79    #[serde(default)]
80    #[ts(as = "Option<String>", optional)]
81    pub secret: String,
82}
83
84/// `identity.account.validate`: whether the provider accepted the key.
85#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
86#[ts(export, export_to = "../../../frontend/types/rpc/")]
87pub struct IdentityAccountValidateResult {
88    pub valid: bool,
89    #[ts(type = r#""valid" | "invalid""#)]
90    pub status: String,
91    pub masked_tail: String,
92    pub error: Option<String>,
93}
94
95impl IdentityAccountValidateResult {
96    fn of(outcome: crate::identity::key_validator::ValidationOutcome, masked_tail: String) -> Self {
97        IdentityAccountValidateResult {
98            valid: outcome.valid,
99            status: if outcome.valid { "valid" } else { "invalid" }.to_string(),
100            masked_tail,
101            error: outcome.error,
102        }
103    }
104}
105
106/// `identity.self.unlink`: the provider whose account to unlink from the agent.
107#[derive(Debug, serde::Deserialize, ts_rs::TS)]
108#[ts(export, export_to = "../../../frontend/types/rpc/")]
109pub struct IdentitySelfUnlinkReq {
110    pub agent_id: String,
111    pub provider: String,
112}
113
114/// `identity.self.unlink`: whether a link was removed.
115#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
116#[ts(export, export_to = "../../../frontend/types/rpc/")]
117pub struct IdentitySelfUnlinkResult {
118    pub unlinked: bool,
119}
120
121pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
122    register_identity_self_accounts(engine, state);
123    register_identity_account_upsert(engine, state);
124    register_identity_account_validate(engine, state);
125    register_identity_self_unlink(engine, state);
126}
127
128fn register_identity_self_accounts(engine: &Arc<WshRpcEngine>, state: &AppState) {
129    let state = state.clone();
130    engine.register_typed(COMMAND_IDENTITY_SELF_ACCOUNTS, move |req: IdentitySelfAccountsReq, ctx| {
131        let state = state.clone();
132        async move {
133            check_s1(&state.mstore, &ctx, &req.agent_id)?;
134            identity_self_accounts_impl(&state, &req.agent_id).await
135        }
136    });
137}
138
139fn register_identity_account_upsert(engine: &Arc<WshRpcEngine>, state: &AppState) {
140    let state = state.clone();
141    engine.register_typed(COMMAND_IDENTITY_ACCOUNT_UPSERT, move |req: IdentityAccountUpsertReq, ctx| {
142            let state = state.clone();
143            async move {
144                let id_store = &state.id_store;
145                let identity_store = &state.identity_store;
146                let broker = &state.broker;
147                check_s1(&state.mstore, &ctx, &req.agent_id)?;
148                if req.secret.is_empty() {
149                    return Err("identity.account.upsert: secret must not be empty".to_string());
150                }
151
152                // The link table is keyed by definition id; req.agent_id is
153                // the S1 slug. Resolve once, use for every link-table call
154                // below. See resolve_agent_definition_id (mod.rs) for the
155                // two failure modes writing the slug causes.
156                let def_id = resolve_agent_definition_id(&state, &req.agent_id)
157                    .map_err(|e| format!("identity.account.upsert: {e}"))?;
158
159                let is_new = req.account_id.is_empty();
160                let account_id = if is_new {
161                    uuid::Uuid::new_v4().to_string()
162                } else {
163                    // Ownership check: the supplied account_id must already be
164                    // linked to the calling agent, so callers can't overwrite
165                    // another agent's credentials by guessing a UUID.
166                    let links = identity_store
167                        .agent_identity_list_for_agent(&def_id)
168                        .map_err(|e| format!("identity.account.upsert: {e}"))?;
169                    let owned = links.iter().any(|l| l.account_id == req.account_id);
170                    if !owned {
171                        return Err("FORBIDDEN: account not linked to this agent".to_string());
172                    }
173                    req.account_id.clone()
174                };
175
176                // Step 1: store in keychain unconditionally.
177                {
178                    let aid = account_id.clone();
179                    let key = req.secret.clone();
180                    tokio::task::spawn_blocking(move || {
181                        crate::identity::secret_store::put(&aid, &key)
182                    })
183                    .await
184                    .map_err(|e| format!("identity.account.upsert: keychain task: {e}"))?
185                    .map_err(|e| format!("identity.account.upsert: keychain: {e}"))?;
186                }
187
188                // Step 1b: optional provider probe (validate controls this only).
189                let masked_tail = crate::identity::key_validator::masked_tail(&req.secret);
190                let (status, valid, error_msg) = if req.validate {
191                    let outcome = crate::identity::key_validator::validate(&req.provider, &req.secret).await;
192                    if outcome.valid {
193                        ("valid".to_string(), true, None)
194                    } else {
195                        ("invalid".to_string(), false, outcome.error)
196                    }
197                } else {
198                    ("unknown".to_string(), false, None)
199                };
200
201                let now = agentmux_common::time::now_ms();
202                let existing = id_store.identity_get(&account_id).ok().flatten();
203                let created_at = existing.as_ref()
204                    .map(|a| a.created_at)
205                    .filter(|&c| c != 0)
206                    .unwrap_or(now);
207
208                let mut context = json!({ "masked_tail": masked_tail });
209                if let serde_json::Value::Object(ref mut m) = context {
210                    if let Some(existing_ctx) = existing.as_ref().map(|a| &a.context) {
211                        if let Some(obj) = existing_ctx.as_object() {
212                            for (k, v) in obj {
213                                m.entry(k).or_insert_with(|| v.clone());
214                            }
215                        }
216                    }
217                }
218
219                let account = IdentityAccount {
220                    id: account_id.clone(),
221                    name: req.name.clone(),
222                    provider: req.provider.clone(),
223                    kind: if req.kind.is_empty() { "api_key".to_string() } else { req.kind.clone() },
224                    display_name: String::new(),
225                    secret_ref: crate::backend::storage::identities::SecretRef::Keychain {
226                        service: crate::identity::secret_store::SERVICE.to_string(),
227                        account: crate::identity::secret_store::account_key(&account_id),
228                    },
229                    context,
230                    status: status.clone(),
231                    created_at,
232                    updated_at: now,
233                };
234
235                // Step 3 (upsert DB). Compensate on failure for new accounts.
236                // identity_upsert_with_mirror, not plain identity_upsert —
237                // reagentx P0 review on PR #2632: without the mirror write,
238                // an account created/updated after the fix shipped still had
239                // no fallback entry and reproduced the reported bug on its
240                // own next channel switch.
241                if let Err(e) = id_store.identity_upsert_with_mirror(&identity_store, &account) {
242                    if is_new {
243                        let aid = account_id.clone();
244                        let _ = tokio::task::spawn_blocking(move || {
245                            crate::identity::secret_store::delete(&aid)
246                        }).await;
247                    }
248                    return Err(format!("identity.account.upsert: db: {e}"));
249                }
250
251                // Step 2/4: (re)point the agent's provider link at this account.
252                // agent_identity_link's own `ON CONFLICT(agent_id, provider) DO
253                // UPDATE` already overwrites whatever account_id was linked
254                // before, so no separate unlink is needed for the success path.
255                // A preceding unlink-then-link was here previously (reagent P1 on
256                // PR #2056): now that def_id resolves correctly, an unlink that
257                // succeeds followed by a link that fails would permanently drop
258                // the agent's existing provider link with no compensation (the
259                // failure branch below only cleans up for is_new accounts) —
260                // removed rather than adding yet another compensating delete.
261                if let Err(e) = identity_store.agent_identity_link(&def_id, &account_id, &req.provider) {
262                    // Only clean up for new accounts — on the update path the
263                    // account still exists in the DB and may be linked to other providers,
264                    // so deleting the keychain secret would destroy a valid credential.
265                    // Delete the just-upserted db_accounts row too, not only the
266                    // keychain secret: without it a failed link left an orphaned,
267                    // unlinked account row behind (agent3's report on #1624 PR-C).
268                    if is_new {
269                        let _ = id_store.identity_delete(&account_id);
270                        let aid = account_id.clone();
271                        let _ = tokio::task::spawn_blocking(move || {
272                            crate::identity::secret_store::delete(&aid)
273                        }).await;
274                    }
275                    return Err(format!("identity.account.upsert: link: {e}"));
276                }
277
278                broker.publish_event::<crate::backend::mps_events::IdentityAccountsChanged>(&(), vec![], 0);
279                broker.publish_event_for::<crate::backend::mps_events::AgentIdentitiesChanged>(&req.agent_id, &(), vec![], 0);
280
281                // The subagent watcher's config-dir resolution for this
282                // agent's pane(s) can have raced ahead of this exact
283                // binding (fired at reactive-register time, before the
284                // launch flow's own account-bind write necessarily lands)
285                // and be permanently stuck watching a stale/ambient
286                // directory with no other correction mechanism — see
287                // `recheck_config_dir`'s own doc comment. Cheap, safe
288                // no-op when nothing changed or nothing needs re-pointing.
289                if let Some(watcher) = crate::backend::subagent_watcher::global() {
290                    watcher.recheck_all_watched_agents();
291                }
292
293                Ok(IdentityAccountUpsertResult {
294                    account_id,
295                    provider: req.provider,
296                    name: req.name,
297                    status,
298                    masked_tail,
299                    valid,
300                    error: error_msg,
301                })
302            }
303        });
304}
305
306fn register_identity_account_validate(engine: &Arc<WshRpcEngine>, state: &AppState) {
307    let state = state.clone();
308    engine.register_typed(COMMAND_IDENTITY_ACCOUNT_VALIDATE, move |req: IdentityAccountValidateReq, ctx| {
309            let state = state.clone();
310            async move {
311
312                if !req.account_id.is_empty() {
313                    // Stored-account path: S1 + ownership verification, then probe
314                    // using the stored keychain secret (shared with the REST path).
315                    check_s1(&state.mstore, &ctx, &req.agent_id)?;
316                    return identity_account_validate_stored_impl(&state, &req.agent_id, &req.account_id).await;
317                }
318                if !req.provider.is_empty() && !req.secret.is_empty() {
319                    // Ad-hoc probe — caller supplies their own secret, nothing
320                    // stored. WS-only; not exposed over REST/MCP (no inline secret).
321                    let masked_tail = crate::identity::key_validator::masked_tail(&req.secret);
322                    let outcome = crate::identity::key_validator::validate(&req.provider, &req.secret).await;
323                    return Ok(IdentityAccountValidateResult::of(outcome, masked_tail));
324                }
325                Err("identity.account.validate: provide account_id or (provider + secret)".to_string())
326            }
327        });
328}
329
330fn register_identity_self_unlink(engine: &Arc<WshRpcEngine>, state: &AppState) {
331    let state = state.clone();
332    engine.register_typed(COMMAND_IDENTITY_SELF_UNLINK, move |req: IdentitySelfUnlinkReq, ctx| {
333            let state = state.clone();
334            async move {
335                let identity_store = &state.identity_store;
336                let broker = &state.broker;
337                check_s1(&state.mstore, &ctx, &req.agent_id)?;
338
339                // Link rows are keyed by definition id, not the S1 slug —
340                // unlinking by slug always matched zero rows (silent no-op).
341                let def_id = resolve_agent_definition_id(&state, &req.agent_id)
342                    .map_err(|e| format!("identity.self.unlink: {e}"))?;
343                let unlinked = identity_store
344                    .agent_identity_unlink(&def_id, &req.provider)
345                    .map_err(|e| format!("identity.self.unlink: {e}"))?;
346                // info!, not debug!: the production filter is
347                // "agentmuxsrv=info,info" — debug lines never reach the log
348                // (reagent P1 on PR #2143). Message prefix "identity.unlink:"
349                // is part of the `muxlog auth` vocabulary — keep it stable.
350                // `unlinked == false` (no link row matched) is logged too:
351                // a silent no-op unlink is exactly what an auth stress run
352                // needs to see. Both ids logged: link rows are keyed on
353                // def_id, not the S1 slug (the historical silent-no-op bug
354                // noted above) — a bad slug→def_id resolution is only
355                // visible if the log carries both.
356                tracing::info!(
357                    agent_id = %req.agent_id,
358                    def_id = %def_id,
359                    provider = %req.provider,
360                    unlinked,
361                    "identity.unlink: self-service provider unlink (identity.self.unlink)"
362                );
363                if unlinked {
364                    broker.publish_event_for::<crate::backend::mps_events::AgentIdentitiesChanged>(&req.agent_id, &(), vec![], 0);
365                }
366                Ok(IdentitySelfUnlinkResult { unlinked })
367            }
368        });
369}
370
371pub(crate) async fn identity_self_accounts_impl<'o>(
372    state: &AppState,
373    owner: impl Into<SelfOwner<'o>>,
374) -> Result<IdentitySelfAccountsResult, String> {
375    // Link rows are keyed by definition id, not the S1 slug callers
376    // authenticate with — see resolve_agent_definition_id. Identity M4c-2c:
377    // an attributed caller's is its own row's (`SelfOwner`).
378    let def_id = owner.into().owner_id(&state.mstore)
379        .map_err(|e| format!("identity.self.accounts: {e}"))?;
380    let links = state.identity_store.agent_identity_list_for_agent(&def_id)
381        .map_err(|e| format!("identity.self.accounts: {e}"))?;
382    let mut accounts = Vec::new();
383    for link in &links {
384        // A malformed `secret_ref` on ONE linked account must not hide this
385        // agent's other, perfectly readable accounts — the same "one bad row
386        // hides everything" bug class `identity_list` was fixed for (#2419),
387        // just reachable through this separate per-agent lookup too. Skip and
388        // log rather than `?`-propagate.
389        // resolve_account (WITH the global-mirror fallback), not
390        // resolve_account_for_spawn: without the fallback a migrated/continuing
391        // account showed as "missing" here (#2632).
392        //
393        // CAVEAT (#2878): this is NO LONGER
394        // consistent with the spawn path. `inject.rs` now uses
395        // `resolve_account_for_spawn`, which deliberately has no fallback, so an
396        // oauth-class account resolvable ONLY via the mirror is listed here yet
397        // refused at spawn with `MissingCredentials` (an api-key-class one is
398        // silently skipped instead). That divergence is deliberate — a listing
399        // should describe what exists rather than silently hide it, and hiding
400        // it would reproduce exactly the "my account vanished" confusion #2632
401        // fixed — but it is a real UX gap: nothing in this payload tells the
402        // caller the account can't satisfy a spawn in THIS channel. Logged
403        // below so it is at least observable; surfacing it in the payload (and
404        // in Connectors) is tracked as follow-up, not silently assumed fine. See
405        // docs/analysis/ANALYSIS_PER_CHANNEL_AUTH_BYPASSES_2026_08_31.md §6.
406        match crate::identity::resolver::resolve_account(&state.id_store, &state.identity_store, &link.account_id) {
407            Ok(Some((acct, account_store))) => {
408                // Observability for the divergence documented above: this
409                // account exists only in the always-global mirror, so a spawn in
410                // this channel will not accept it.
411                if !std::sync::Arc::ptr_eq(&account_store, &state.id_store) {
412                    tracing::warn!(
413                        target: "identity",
414                        account_id = %acct.id,
415                        agent_id = %def_id,
416                        provider = %acct.provider,
417                        "identity.self.accounts: account resolved only via the global mirror; it will NOT satisfy a spawn in this channel (per-channel auth enforcement)"
418                    );
419                }
420                let masked_tail = acct.context.get("masked_tail")
421                    .and_then(|v| v.as_str()).unwrap_or("").to_string();
422                accounts.push(IdentitySelfAccount {
423                    account_id: acct.id, provider: acct.provider, name: acct.name,
424                    kind: acct.kind, status: acct.status, masked_tail,
425                    updated_at: acct.updated_at,
426                });
427            }
428            Ok(None) => {
429                // Link points at a since-deleted account — skip silently.
430            }
431            Err(e) => {
432                tracing::warn!(
433                    target: "identity",
434                    account_id = %link.account_id,
435                    agent_id = %def_id,
436                    error = %e,
437                    "identity.self.accounts: skipping unreadable linked account",
438                );
439            }
440        }
441    }
442    Ok(IdentitySelfAccountsResult { accounts })
443}
444
445/// Validate one of the agent's own linked accounts by probing the provider with
446/// the stored keychain secret. Ownership is verified (the account must be linked
447/// to `agent_id`) before the secret is read.
448pub(crate) async fn identity_account_validate_stored_impl<'o>(
449    state: &AppState,
450    owner: impl Into<SelfOwner<'o>>,
451    account_id: &str,
452) -> Result<IdentityAccountValidateResult, String> {
453    // Link rows are keyed by definition id, not the S1 slug — without the
454    // resolution this ownership check always saw zero links and rejected.
455    // Identity M4c-2c: an attributed caller's is its own row's, so it can
456    // no longer live-probe a same-named agent's stored secret.
457    let def_id = owner.into().owner_id(&state.mstore)
458        .map_err(|e| format!("identity.account.validate: {e}"))?;
459    let links = state.identity_store.agent_identity_list_for_agent(&def_id)
460        .map_err(|e| format!("identity.account.validate: {e}"))?;
461    if !links.iter().any(|l| l.account_id == account_id) {
462        return Err("FORBIDDEN: account not linked to this agent".to_string());
463    }
464    // resolve_account (WITH the global-mirror fallback) — same reagentx P1
465    // review as identity_self_accounts_impl above.
466    //
467    // CAVEAT since 2026-08-31 (reagent P2 on PR #2878): "consistently with the
468    // spawn path" is no longer true — the spawn path dropped this fallback. What
469    // this RPC answers is narrower than it looks: it validates the CREDENTIAL
470    // (is the key live at the provider), not whether a spawn in this channel
471    // would accept the account. A cross-channel-only account can therefore come
472    // back `valid: true` and still be refused at spawn. Kept as-is because the
473    // credential-validity answer is genuinely correct and callers use it to
474    // check a key, not to predict a spawn — but do not read `valid` as
475    // "this agent can launch". See the note in identity_self_accounts_impl.
476    let (acct, _account_store) = crate::identity::resolver::resolve_account(&state.id_store, &state.identity_store, account_id)
477        .map_err(|e| format!("identity.account.validate: {e}"))?
478        .ok_or_else(|| format!("identity.account.validate: account {account_id} not found"))?;
479    let aid = account_id.to_string();
480    let plaintext = tokio::task::spawn_blocking(move || crate::identity::secret_store::get(&aid))
481        .await.map_err(|e| format!("identity.account.validate: keychain task: {e}"))?
482        .map_err(|e| format!("identity.account.validate: keychain: {e}"))?;
483    let tail = crate::identity::key_validator::masked_tail(&plaintext);
484    let outcome = crate::identity::key_validator::validate(&acct.provider, &plaintext).await;
485    Ok(IdentityAccountValidateResult::of(outcome, tail))
486}
487
488#[cfg(test)]
489mod wire_tests {
490    use super::*;
491
492    /// Each response serializes as the `json!` it replaced did: same keys, `error` null when there is none.
493    #[test]
494    fn identity_responses_serialize_as_before() {
495        let account = IdentitySelfAccount {
496            account_id: "a1".into(),
497            provider: "claude".into(),
498            name: "Work".into(),
499            kind: "api_key".into(),
500            status: "ok".into(),
501            masked_tail: "…wxyz".into(),
502            updated_at: 7,
503        };
504        assert_eq!(
505            serde_json::to_value(IdentitySelfAccountsResult { accounts: vec![account] }).unwrap(),
506            json!({ "accounts": [{ "account_id": "a1", "provider": "claude", "name": "Work", "kind": "api_key", "status": "ok", "masked_tail": "…wxyz", "updated_at": 7 }] })
507        );
508        let upsert = IdentityAccountUpsertResult {
509            account_id: "a1".into(),
510            provider: "claude".into(),
511            name: "Work".into(),
512            status: "unknown".into(),
513            masked_tail: "…wxyz".into(),
514            valid: false,
515            error: None,
516        };
517        assert_eq!(
518            serde_json::to_value(upsert).unwrap(),
519            json!({ "account_id": "a1", "provider": "claude", "name": "Work", "status": "unknown", "masked_tail": "…wxyz", "valid": false, "error": null })
520        );
521        let outcome = crate::identity::key_validator::ValidationOutcome {
522            valid: false,
523            metadata: json!({}),
524            masked_tail: String::new(),
525            error: Some("401".into()),
526        };
527        assert_eq!(
528            serde_json::to_value(IdentityAccountValidateResult::of(outcome, "…wxyz".into())).unwrap(),
529            json!({ "valid": false, "status": "invalid", "masked_tail": "…wxyz", "error": "401" })
530        );
531        assert_eq!(serde_json::to_value(IdentitySelfUnlinkResult { unlinked: true }).unwrap(), json!({ "unlinked": true }));
532    }
533}