1use super::*;
2
3#[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#[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#[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#[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#[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#[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#[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#[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#[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 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 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 {
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 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 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 if let Err(e) = identity_store.agent_identity_link(&def_id, &account_id, &req.provider) {
262 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 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 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 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 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 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 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 match crate::identity::resolver::resolve_account(&state.id_store, &state.identity_store, &link.account_id) {
407 Ok(Some((acct, account_store))) => {
408 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 }
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
445pub(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 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 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 #[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}