1use std::collections::HashMap;
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use crate::backend::providers::resolve_provider_alias;
24use crate::backend::storage::store::{IdentityAccount, SecretRef, Store};
25use crate::backend::storage::StoreError;
26use crate::backend::mps::Broker;
27
28use super::errors::SpawnGateError;
29use super::oauth_probe::{oauth_status, probe_oauth_status};
30use super::provider::{provider_class, ProviderClass};
31use super::secret::resolve_secret;
32
33pub fn inject_identity_env(
75 mstore: Arc<Store>,
76 id_store: Arc<Store>,
77 identity_store: Arc<Store>,
78 block_id: &str,
79 env_vars: &mut HashMap<String, String>,
80) -> Result<(), SpawnGateError> {
81 inject_identity_env_with_broker(mstore, id_store, identity_store, None, block_id, env_vars)
82}
83
84pub async fn inject_identity_env_async(
105 mstore: Arc<Store>,
106 id_store: Arc<Store>,
107 identity_store: Arc<Store>,
108 broker: Option<Arc<Broker>>,
109 block_id: String,
110 env_vars: HashMap<String, String>,
111) -> Result<HashMap<String, String>, SpawnGateError> {
112 match tokio::task::spawn_blocking(move || {
113 let mut env = env_vars;
114 inject_identity_env_with_broker(mstore, id_store, identity_store, broker, &block_id, &mut env)
115 .map(|()| env)
116 })
117 .await
118 {
119 Ok(result) => result,
120 Err(e) => {
121 tracing::warn!(
128 target: "identity",
129 error = %e,
130 "identity.spawn.blocked: injection task join failed — failing closed"
131 );
132 Err(SpawnGateError::InjectionUnavailable { detail: e.to_string() })
133 }
134 }
135}
136
137struct IdentityBinding {
143 provider: String,
144 account_id: String,
145}
146
147fn template_parent_id_if_seeded(mstore: &Store, parent_id: &str) -> String {
203 if parent_id.is_empty() {
204 return String::new();
205 }
206 match mstore.agent_def_get(parent_id) {
207 Ok(Some(parent)) if parent.is_seeded == 1 => parent_id.to_string(),
208 Ok(Some(_)) => String::new(), Ok(None) => String::new(),
210 Err(e) => {
211 tracing::warn!(
212 target: "identity",
213 "template-parent lookup failed for {}: {} — treating as no eligible template parent",
214 parent_id,
215 e,
216 );
217 String::new()
218 }
219 }
220}
221
222fn resolve_bindings_for_instance(
223 id_store: &Store,
224 instance: &crate::backend::storage::store::AgentInstance,
225 parent_template_id: &str,
226 broker: Option<&Arc<Broker>>,
227) -> Vec<IdentityBinding> {
228 let direct = id_store
229 .agent_identity_list_for_agent(&instance.definition_id)
230 .unwrap_or_else(|e| {
231 tracing::warn!(
232 target: "identity",
233 "direct-link lookup failed for definition {}: {}",
234 instance.definition_id,
235 e,
236 );
237 Vec::new()
238 });
239
240 if direct.is_empty() && !parent_template_id.is_empty() {
241 let from_template = id_store
242 .agent_identity_list_for_agent(parent_template_id)
243 .unwrap_or_else(|e| {
244 tracing::warn!(
245 target: "identity",
246 "template link fallback failed for template {}: {}",
247 parent_template_id,
248 e,
249 );
250 Vec::new()
251 });
252 if !from_template.is_empty() {
253 return from_template
254 .into_iter()
255 .map(|l| IdentityBinding { provider: l.provider, account_id: l.account_id })
256 .collect();
257 }
258 }
259
260 if direct.is_empty() {
261 tracing::warn!(
268 target: "identity",
269 "no direct account links for definition {} (identity {}) — \
270 nothing to inject.",
271 instance.definition_id,
272 instance.identity_id,
273 );
274 if let Some(b) = broker {
275 let event = crate::backend::mps_events::NoDirectLinksEvent {
276 definition_id: instance.definition_id.clone(),
277 identity_id: instance.identity_id.clone(),
278 };
279 b.publish_event::<crate::backend::mps_events::IdentityNoDirectLinks>(&event, vec![], 20);
285 }
286 }
287
288 direct
289 .into_iter()
290 .map(|l| IdentityBinding {
291 provider: l.provider,
292 account_id: l.account_id,
293 })
294 .collect()
295}
296
297pub fn resolve_account(
315 id_store: &Arc<Store>,
316 identity_store: &Arc<Store>,
317 account_id: &str,
318) -> Result<Option<(IdentityAccount, Arc<Store>)>, StoreError> {
319 if let Some(a) = id_store.identity_get(account_id)? {
320 return Ok(Some((a, id_store.clone())));
321 }
322 if let Some(a) = identity_store.identity_get(account_id)? {
323 return Ok(Some((a, identity_store.clone())));
324 }
325 Ok(None)
326}
327
328pub fn resolve_account_for_spawn(
357 id_store: &Arc<Store>,
358 account_id: &str,
359) -> Result<Option<(IdentityAccount, Arc<Store>)>, StoreError> {
360 Ok(id_store
361 .identity_get(account_id)?
362 .map(|a| (a, id_store.clone())))
363}
364
365pub fn resolve_bound_oauth_config_dir(
386 mstore: &Arc<Store>,
387 id_store: &Arc<Store>,
388 identity_store: &Arc<Store>,
389 block_id: &str,
390) -> Option<PathBuf> {
391 let instance = mstore.instance_get_active_for_block(block_id).ok().flatten()?;
392 bound_oauth_config_dir(mstore, id_store, identity_store, &instance)
393}
394
395pub fn resolve_bound_claude_config_dir_for_agent(
402 mstore: &Store,
403 id_store: &Arc<Store>,
404 identity_store: &Arc<Store>,
405 agent_id: &str,
406) -> Option<PathBuf> {
407 let instance = mstore.instance_get(agent_id).ok().flatten()?;
408 let def = mstore.agent_def_get(&instance.definition_id).ok().flatten()?;
409 let provider = id_store.resolve_effective_provider_id(&def);
410 if resolve_provider_alias(&provider) != "claude" {
411 return None;
412 }
413 let dir = bound_oauth_config_dir(mstore, id_store, identity_store, &instance)?;
414 let claude = crate::backend::providers::get_provider("claude")?;
415 if crate::backend::providers::is_provider_ambient_home_dir(claude, &dir.to_string_lossy()) {
416 return None;
417 }
418 Some(dir)
419}
420
421pub fn agent_has_identity_binding(mstore: &Store, identity_store: &Store, agent_id: &str) -> bool {
426 let direct = identity_store
427 .agent_identity_list_for_agent(agent_id)
428 .map(|l| !l.is_empty())
429 .unwrap_or(false);
430 if direct {
431 return true;
432 }
433 let Some(def) = mstore.agent_def_get(agent_id).ok().flatten() else {
434 return false;
435 };
436 let parent = template_parent_id_if_seeded(mstore, &def.parent_id);
437 !parent.is_empty()
438 && identity_store
439 .agent_identity_list_for_agent(&parent)
440 .map(|l| !l.is_empty())
441 .unwrap_or(false)
442}
443
444fn bound_oauth_config_dir(
445 mstore: &Store,
446 id_store: &Arc<Store>,
447 identity_store: &Arc<Store>,
448 instance: &crate::backend::storage::store::AgentInstance,
449) -> Option<PathBuf> {
450 let def = mstore.agent_def_get(&instance.definition_id).ok().flatten()?;
451 let effective_provider = id_store.resolve_effective_provider_id(&def);
452 let canonical_provider = resolve_provider_alias(&effective_provider).to_string();
453 if !matches!(provider_class(&canonical_provider), Some(ProviderClass::OAuth { .. })) {
454 return None;
455 }
456
457 let template_parent_id = template_parent_id_if_seeded(mstore, &def.parent_id);
458 let bindings = resolve_bindings_for_instance(identity_store, instance, &template_parent_id, None);
459 let binding = bindings
460 .iter()
461 .find(|b| resolve_provider_alias(&b.provider) == canonical_provider)?;
462 let (account, _store) = resolve_account_for_spawn(id_store, &binding.account_id).ok().flatten()?;
467 match account.secret_ref {
468 SecretRef::OAuthConfigDir { dir } => Some(PathBuf::from(dir)),
469 _ => None,
470 }
471}
472
473pub(crate) fn block_agent_id(mstore: &Store, block_id: &str) -> Option<String> {
475 let block: crate::backend::obj::Block = mstore.get(block_id).ok().flatten()?;
476 crate::backend::obj::block_meta_agent_id(&block.meta)
477 .map(str::trim)
478 .filter(|s| !s.is_empty())
479 .map(str::to_string)
480}
481
482pub fn inject_identity_env_with_broker(
494 mstore: Arc<Store>,
495 id_store: Arc<Store>,
496 identity_store: Arc<Store>,
497 broker: Option<Arc<Broker>>,
498 block_id: &str,
499 env_vars: &mut HashMap<String, String>,
500) -> Result<(), SpawnGateError> {
501 let instance = match mstore.instance_get_active_for_block(block_id) {
503 Ok(Some(i)) => i,
504 Ok(None) => {
505 if let Some(agent_id) = block_agent_id(&mstore, block_id) {
511 if mstore.agent_was_deleted(&agent_id) {
512 return Err(SpawnGateError::AgentDeleted { agent_id });
513 }
514 }
515 return Ok(());
519 }
520 Err(e) => {
521 tracing::warn!(target: "identity", "instance lookup failed for block {}: {}", block_id, e);
522 return Ok(());
523 }
524 };
525
526 if instance.identity_id.is_empty() || instance.identity_id == "blank" {
544 tracing::warn!(
545 target: "identity",
546 "instance {} has empty/blank identity_id — falling through to the \
547 layer-3 gate instead of ambient creds. Legacy row or UI regression?",
548 block_id
549 );
550 }
551
552 let (def_provider, parent_template_id) = match mstore.agent_def_get(&instance.definition_id) {
576 Ok(Some(d)) => {
577 let effective_provider = id_store.resolve_effective_provider_id(&d);
578 let template_parent_id = template_parent_id_if_seeded(&mstore, &d.parent_id);
579 (
580 Some(resolve_provider_alias(&effective_provider).to_string()),
581 template_parent_id,
582 )
583 }
584 Ok(None) => (None, String::new()),
585 Err(e) => {
586 tracing::warn!(
587 target: "identity",
588 "definition lookup failed for {}: {}",
589 instance.definition_id,
590 e,
591 );
592 (None, String::new())
593 }
594 };
595
596 let bindings = resolve_bindings_for_instance(&identity_store, &instance, &parent_template_id, broker.as_ref());
608
609 let mut injected_oauth: std::collections::HashSet<String> =
622 std::collections::HashSet::new();
623
624 let gate_oauth_failure = |provider: &str, detail: &str| -> Result<(), SpawnGateError> {
636 tracing::warn!(
637 target: "identity",
638 "identity.spawn.blocked: no credentials for provider {} \
639 (definition {}, identity {}) — {}; spawn refused \
640 (single-point enforcement)",
641 provider,
642 instance.definition_id,
643 instance.identity_id,
644 detail,
645 );
646 Err(SpawnGateError::MissingCredentials {
647 provider: provider.to_string(),
648 })
649 };
650
651 for binding in &bindings {
652 let class = match provider_class(&binding.provider) {
653 Some(c) => c,
654 None => {
655 tracing::warn!(
656 target: "identity",
657 "no provider class for {} (binding for identity {}) — skipping",
658 binding.provider,
659 instance.identity_id,
660 );
661 continue;
662 }
663 };
664 let is_oauth_class = matches!(class, ProviderClass::OAuth { .. });
665
666 let (account, account_store) = match resolve_account_for_spawn(&id_store, &binding.account_id) {
669 Ok(Some(pair)) => pair,
670 Ok(None) => {
671 if is_oauth_class {
677 gate_oauth_failure(
678 &binding.provider,
679 &format!("account {} row not found", binding.account_id),
680 )?;
681 continue;
682 }
683 tracing::warn!(
684 target: "identity",
685 "account {} bound to identity {} but row not found — skipping",
686 binding.account_id,
687 instance.identity_id,
688 );
689 continue;
690 }
691 Err(e) => {
692 if is_oauth_class {
693 gate_oauth_failure(
694 &binding.provider,
695 &format!("account lookup failed for {}: {}", binding.account_id, e),
696 )?;
697 continue;
698 }
699 tracing::warn!(
700 target: "identity",
701 "account lookup failed for {}: {}",
702 binding.account_id,
703 e,
704 );
705 continue;
706 }
707 };
708
709 match class {
710 ProviderClass::ApiKey { env_vars: env_keys } => {
711 let secret = match resolve_secret(&account.secret_ref) {
712 Ok(s) => s,
713 Err(e) => {
714 tracing::warn!(
715 target: "identity",
716 "secret resolution failed for account {} (provider {}): {} — skipping",
717 binding.account_id,
718 binding.provider,
719 e,
720 );
721 continue;
722 }
723 };
724 let env_key_count = env_keys.len();
725 for key in env_keys {
726 env_vars.insert(key.to_string(), secret.clone());
727 }
728 tracing::info!(
729 target: "identity",
730 "injected {} env var(s) for api-key provider {} (identity={}, account={})",
731 env_key_count,
732 binding.provider,
733 instance.identity_id,
734 binding.account_id,
735 );
736 }
737 ProviderClass::OAuth { config_dir_env_var } => {
738 let dir = match &account.secret_ref {
744 SecretRef::OAuthConfigDir { dir } => dir.clone(),
745 other => {
746 gate_oauth_failure(
747 &binding.provider,
748 &format!(
749 "non-OAuthConfigDir secret_ref ({:?}) on account {}",
750 other, binding.account_id,
751 ),
752 )?;
753 continue;
754 }
755 };
756 if let Some(provider_cfg) =
764 crate::backend::providers::get_provider(&resolve_provider_alias(&binding.provider))
765 {
766 if crate::backend::providers::is_provider_ambient_home_dir(provider_cfg, &dir) {
767 tracing::warn!(
768 target: "identity",
769 "identity.spawn.blocked: account {} for provider {} \
770 resolves to the ambient home dir ({}) — refusing to \
771 spawn (identity {})",
772 binding.account_id,
773 binding.provider,
774 dir,
775 instance.identity_id,
776 );
777 return Err(SpawnGateError::AmbientHomeDirNotAllowed {
778 provider: binding.provider.clone(),
779 dir: dir.clone(),
780 });
781 }
782 if let Err(e) =
794 crate::backend::providers::seed_claude_md_placeholder_if_missing(provider_cfg, &dir)
795 {
796 tracing::warn!(
797 target: "identity",
798 "identity.spawn.blocked: account {} for provider {} \
799 failed CLAUDE.md isolation seed ({}) — refusing to \
800 spawn (identity {})",
801 binding.account_id,
802 binding.provider,
803 e,
804 instance.identity_id,
805 );
806 return Err(SpawnGateError::ClaudeMdSeedFailed {
807 provider: binding.provider.clone(),
808 dir: dir.clone(),
809 error: e.to_string(),
810 });
811 }
812 if let Err(e) =
815 crate::backend::providers::seed_transcript_retention_if_missing(provider_cfg, &dir)
816 {
817 tracing::warn!(
818 target: "identity",
819 "account {} for provider {}: could not set transcript retention in {} ({}) — \
820 Claude Code's 30-day default will sweep this account's shared history",
821 binding.account_id,
822 binding.provider,
823 dir,
824 e,
825 );
826 }
827 }
828 env_vars.insert(config_dir_env_var.to_string(), dir.clone());
829 injected_oauth.insert(resolve_provider_alias(&binding.provider).to_string());
833 tracing::info!(
834 target: "identity",
835 "injected {} for oauth provider {} (identity={}, account={})",
836 config_dir_env_var,
837 binding.provider,
838 instance.identity_id,
839 binding.account_id,
840 );
841
842 if let Some(paths) = agentmux_common::DataPaths::from_env() {
852 if let Some(provider_cfg) =
853 crate::backend::providers::get_provider(&resolve_provider_alias(&binding.provider))
854 {
855 crate::server::identity_auth_dirs::link_history_if_isolated(
856 &paths,
857 std::path::Path::new(&dir),
858 provider_cfg,
859 &binding.account_id,
860 "inject_identity_env (spawn)",
861 );
862 }
863 }
864
865 let now_ms = agentmux_common::time::now_ms();
874 let canonical_provider = resolve_provider_alias(&binding.provider);
880 let probed = probe_oauth_status(canonical_provider, &dir, now_ms);
881 let new_status: Option<&'static str> = match probed {
898 Some(p) => Some(p.as_str()),
899 None if canonical_provider == "claude"
900 && cfg!(target_os = "macos")
901 && account.status == oauth_status::NEEDS_REAUTH =>
902 {
903 Some("unknown")
904 }
905 None => None,
906 };
907 if let Some(new_status) = new_status {
908 if account.status != new_status {
909 let mut updated = account.clone();
910 updated.status = new_status.to_string();
911 updated.updated_at = now_ms;
912 match account_store.identity_upsert(&updated) {
918 Ok(()) => {
919 tracing::info!(
920 target: "identity",
921 provider = %binding.provider,
922 account_id = %binding.account_id,
923 old_status = %account.status,
924 new_status,
925 "oauth probe: status updated"
926 );
927 if let Some(b) = broker.as_ref() {
933 b.publish_event::<crate::backend::mps_events::IdentityAccountsChanged>(&(), vec![], 0);
934 }
935 }
936 Err(e) => {
937 tracing::warn!(
938 target: "identity",
939 provider = %binding.provider,
940 account_id = %binding.account_id,
941 error = %e,
942 "oauth probe: identity_upsert failed — status not persisted",
943 );
944 }
945 }
946 }
947 }
948 }
949 }
950 }
951
952 if let Some(p) = def_provider {
960 if matches!(provider_class(&p), Some(ProviderClass::OAuth { .. }))
961 && !injected_oauth.contains(&p)
962 && !bindings.iter().any(|b| resolve_provider_alias(&b.provider) == p)
969 {
970 gate_oauth_failure(&p, "no account bound for the agent's provider")?;
971 }
972 }
973
974 if mstore.agent_was_deleted(&instance.id) {
981 return Err(SpawnGateError::AgentDeleted {
982 agent_id: instance.id.clone(),
983 });
984 }
985
986 Ok(())
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992 use super::super::oauth_probe::oauth_status;
993 use crate::backend::storage::store::{
994 AgentInstance, IdentityAccount, InstanceStatus, Bundle, SecretRef,
995 };
996
997 fn make_store() -> Arc<Store> {
998 Arc::new(Store::open_in_memory().unwrap())
999 }
1000
1001 fn store_with_def_registry(tmp: &tempfile::TempDir) -> Arc<Store> {
1004 let store = make_store();
1005 let defs = crate::registry::DefinitionStore::open(tmp.path().join("definitions")).unwrap();
1006 store.set_def_registry(Arc::new(defs));
1007 store
1008 }
1009
1010 #[test]
1014 fn a_block_naming_a_deleted_agent_is_refused() {
1015 let tmp = tempfile::tempdir().unwrap();
1016 let store = store_with_def_registry(&tmp);
1017 let mut def = crate::backend::storage::agents::test_agent_def(
1018 "agent-deleted-3577",
1019 "Gone",
1020 "claude",
1021 "agent",
1022 1,
1023 "",
1024 );
1025 store.agent_def_insert(&mut def).unwrap();
1026 assert!(
1027 store
1028 .shared_def_registry()
1029 .unwrap()
1030 .exists("agent-deleted-3577"),
1031 "precondition: mirrored"
1032 );
1033 assert!(store.agent_def_delete("agent-deleted-3577").unwrap());
1034 insert_block_for_agent(&store, "block-deleted-3577", "agent-deleted-3577");
1035
1036 let mut env = HashMap::new();
1037 let res = inject_identity_env_with_broker(
1038 store.clone(),
1039 store.clone(),
1040 store.clone(),
1041 None,
1042 "block-deleted-3577",
1043 &mut env,
1044 );
1045 assert_eq!(
1046 res,
1047 Err(SpawnGateError::AgentDeleted {
1048 agent_id: "agent-deleted-3577".to_string()
1049 })
1050 );
1051 }
1052
1053 #[test]
1059 fn a_row_less_block_not_naming_a_deleted_agent_is_not_refused() {
1060 let tmp = tempfile::tempdir().unwrap();
1061 let store = store_with_def_registry(&tmp);
1062 let mut tpl = crate::backend::storage::agents::test_agent_def(
1063 "claude", "Claude", "claude", "agent", 1, "",
1064 );
1065 tpl.is_seeded = 1;
1066 store.agent_def_insert(&mut tpl).unwrap();
1067 store
1068 .shared_def_registry()
1069 .unwrap()
1070 .upsert(&crate::registry::DefinitionRecord {
1071 schema_version: crate::registry::DEF_MAX_SUPPORTED_SCHEMA,
1072 data: crate::registry::DefinitionRecordV1 {
1073 id: "agent-elsewhere-3577".to_string(),
1074 name: "Elsewhere".to_string(),
1075 provider: "claude".to_string(),
1076 ..Default::default()
1077 },
1078 })
1079 .unwrap();
1080 insert_block_for_agent(&store, "block-template-3577", "claude");
1081 insert_block_for_agent(&store, "block-cross-3577", "agent-elsewhere-3577");
1082 insert_block_for_agent(&store, "block-unknown-3577", "never-existed");
1083 for block in [
1084 "block-template-3577",
1085 "block-cross-3577",
1086 "block-unknown-3577",
1087 ] {
1088 let mut env = HashMap::new();
1089 let res = inject_identity_env_with_broker(
1090 store.clone(),
1091 store.clone(),
1092 store.clone(),
1093 None,
1094 block,
1095 &mut env,
1096 );
1097 assert_eq!(res, Ok(()), "{block}");
1098 }
1099
1100 let bare = make_store();
1103 insert_block_for_agent(&bare, "block-bare-3577", "agent-deleted-anywhere");
1104 let mut env = HashMap::new();
1105 assert_eq!(
1106 inject_identity_env_with_broker(
1107 bare.clone(),
1108 bare.clone(),
1109 bare.clone(),
1110 None,
1111 "block-bare-3577",
1112 &mut env
1113 ),
1114 Ok(())
1115 );
1116 }
1117
1118 fn make_account(
1119 id: &str,
1120 provider: &str,
1121 secret_ref: SecretRef,
1122 ) -> IdentityAccount {
1123 IdentityAccount {
1124 id: id.to_string(),
1125 name: format!("{}-{}", provider, id),
1126 provider: provider.to_string(),
1127 kind: "pat".to_string(),
1128 display_name: String::new(),
1129 secret_ref,
1130 context: serde_json::json!({}),
1131 status: "unknown".to_string(),
1132 created_at: 0,
1133 updated_at: 0,
1134 }
1135 }
1136
1137 fn insert_block_for_agent(store: &Store, block_id: &str, agent_id: &str) {
1143 use crate::backend::obj::{Block, MetaMapType};
1144 let mut block = Block {
1145 oid: block_id.to_string(),
1146 parentoref: String::new(),
1147 version: 0,
1148 runtimeopts: None,
1149 stickers: None,
1150 meta: {
1151 let mut m = MetaMapType::new();
1152 m.insert("view".to_string(), serde_json::json!("agent"));
1153 m.insert("agentId".to_string(), serde_json::json!(agent_id));
1154 m
1155 },
1156 subblockids: None,
1157 };
1158 store.insert(&mut block).unwrap();
1159 }
1160
1161 fn test_config_dir(name: &str) -> String {
1171 std::env::temp_dir()
1172 .join(format!("agentmux-inject-test-{}-{name}", std::process::id()))
1173 .to_string_lossy()
1174 .into_owned()
1175 }
1176
1177 fn make_instance(block_id: &str, identity_id: &str) -> AgentInstance {
1178 AgentInstance {
1179 id: format!("inst-{block_id}"),
1180 definition_id: "def-1".to_string(),
1181 parent_instance_id: String::new(),
1182 block_id: block_id.to_string(),
1183 session_id: String::new(),
1184 status: InstanceStatus::Running.as_str().to_string(),
1185 github_context: String::new(),
1186 started_at: 0,
1187 ended_at: 0,
1188 created_at: 0,
1189 identity_id: identity_id.to_string(),
1190 memory_id: String::new(),
1191 instance_name: String::new(),
1192 working_directory: String::new(),
1193 display_hidden: false,
1194 }
1195 }
1196
1197 #[cfg(debug_assertions)]
1198 #[test]
1199 fn inject_oauth_class_sets_config_dir_env_var() {
1200 let store = make_store();
1201
1202 let mut def = crate::backend::storage::store::AgentDefinition {
1203 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1204 id: "def-1".to_string(),
1205 slug: String::new(),
1206 name: "T".to_string(),
1207 icon: "✦".to_string(),
1208 provider: "claude".to_string(),
1209 description: String::new(),
1210 working_directory: String::new(),
1211 shell: String::new(),
1212 provider_flags: String::new(),
1213 auto_start: 0,
1214 restart_on_crash: 0,
1215 idle_timeout_minutes: 0,
1216 created_at: 0,
1217 agent_type: String::new(),
1218 environment: String::new(),
1219 agent_bus_id: String::new(),
1220 is_seeded: 0,
1221 accounts: String::new(),
1222 parent_id: String::new(),
1223 branch_label: String::new(),
1224 updated_at: 0,
1225 user_hidden: 0,
1226 container_image: String::new(),
1227 container_volumes: "[]".to_string(),
1228 container_name: String::new(),
1229 use_ambient_login: 0,
1230 auto_continue_enabled: 0,
1231 model_vendor_base_url: String::new(),
1232 memory_id: String::new(),
1233 };
1234 store.agent_def_insert(&mut def).unwrap();
1235
1236 let claude = make_account(
1237 "acct-claude",
1238 "claude",
1239 SecretRef::OAuthConfigDir {
1240 dir: test_config_dir("id-oauth"),
1241 },
1242 );
1243 store.identity_upsert(&claude).unwrap();
1244 store
1245 .agent_identity_link("def-1", "acct-claude", "claude")
1246 .unwrap();
1247
1248 insert_block_for_agent(&store, "block-oauth", "def-1");
1249 let inst = make_instance("block-oauth", "id-oauth");
1250 store.instance_create(&inst).unwrap();
1251
1252 let mut env: HashMap<String, String> = HashMap::new();
1253 inject_identity_env(store.clone(), store.clone(), store, "block-oauth", &mut env).unwrap();
1256
1257 assert_eq!(
1259 env.get("CLAUDE_CONFIG_DIR").cloned(),
1260 Some(test_config_dir("id-oauth")),
1261 );
1262 assert!(env.get("ANTHROPIC_API_KEY").is_none());
1265 }
1266
1267 #[cfg(debug_assertions)]
1278 #[test]
1279 fn inject_oauth_class_blocks_spawn_when_config_dir_is_the_ambient_home() {
1280 let store = make_store();
1281
1282 let mut def = crate::backend::storage::store::AgentDefinition {
1283 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1284 id: "def-1".to_string(),
1285 slug: String::new(),
1286 name: "T".to_string(),
1287 icon: "✦".to_string(),
1288 provider: "claude".to_string(),
1289 description: String::new(),
1290 working_directory: String::new(),
1291 shell: String::new(),
1292 provider_flags: String::new(),
1293 auto_start: 0,
1294 restart_on_crash: 0,
1295 idle_timeout_minutes: 0,
1296 created_at: 0,
1297 agent_type: String::new(),
1298 environment: String::new(),
1299 agent_bus_id: String::new(),
1300 is_seeded: 0,
1301 accounts: String::new(),
1302 parent_id: String::new(),
1303 branch_label: String::new(),
1304 updated_at: 0,
1305 user_hidden: 0,
1306 container_image: String::new(),
1307 container_volumes: "[]".to_string(),
1308 container_name: String::new(),
1309 use_ambient_login: 0,
1310 auto_continue_enabled: 0,
1311 model_vendor_base_url: String::new(),
1312 memory_id: String::new(),
1313 };
1314 store.agent_def_insert(&mut def).unwrap();
1315
1316 let ambient_claude_dir = crate::backend::base::get_home_dir()
1317 .join(".claude")
1318 .to_string_lossy()
1319 .into_owned();
1320 let claude = make_account(
1321 "acct-ambient",
1322 "claude",
1323 SecretRef::OAuthConfigDir { dir: ambient_claude_dir.clone() },
1324 );
1325 store.identity_upsert(&claude).unwrap();
1326 store
1327 .agent_identity_link("def-1", "acct-ambient", "claude")
1328 .unwrap();
1329
1330 insert_block_for_agent(&store, "block-ambient", "def-1");
1331 let inst = make_instance("block-ambient", "id-ambient");
1332 store.instance_create(&inst).unwrap();
1333
1334 let mut env: HashMap<String, String> = HashMap::new();
1335 let err = inject_identity_env(store.clone(), store.clone(), store, "block-ambient", &mut env)
1336 .expect_err("spawn must be refused when the bound account's config dir is the ambient home");
1337
1338 match err {
1339 SpawnGateError::AmbientHomeDirNotAllowed { provider, dir } => {
1340 assert_eq!(provider, "claude");
1341 assert_eq!(dir, ambient_claude_dir);
1342 }
1343 other => panic!("expected AmbientHomeDirNotAllowed, got {other:?}"),
1344 }
1345 assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1348 }
1349
1350 #[cfg(debug_assertions)]
1361 #[test]
1362 fn inject_oauth_class_blocks_spawn_when_claude_md_seed_fails() {
1363 let store = make_store();
1364
1365 let mut def = crate::backend::storage::store::AgentDefinition {
1366 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1367 id: "def-1".to_string(),
1368 slug: String::new(),
1369 name: "T".to_string(),
1370 icon: "✦".to_string(),
1371 provider: "claude".to_string(),
1372 description: String::new(),
1373 working_directory: String::new(),
1374 shell: String::new(),
1375 provider_flags: String::new(),
1376 auto_start: 0,
1377 restart_on_crash: 0,
1378 idle_timeout_minutes: 0,
1379 created_at: 0,
1380 agent_type: String::new(),
1381 environment: String::new(),
1382 agent_bus_id: String::new(),
1383 is_seeded: 0,
1384 accounts: String::new(),
1385 parent_id: String::new(),
1386 branch_label: String::new(),
1387 updated_at: 0,
1388 user_hidden: 0,
1389 container_image: String::new(),
1390 container_volumes: "[]".to_string(),
1391 container_name: String::new(),
1392 use_ambient_login: 0,
1393 auto_continue_enabled: 0,
1394 model_vendor_base_url: String::new(),
1395 memory_id: String::new(),
1396 };
1397 store.agent_def_insert(&mut def).unwrap();
1398
1399 let claude = make_account(
1400 "acct-empty-dir",
1401 "claude",
1402 SecretRef::OAuthConfigDir { dir: String::new() },
1403 );
1404 store.identity_upsert(&claude).unwrap();
1405 store
1406 .agent_identity_link("def-1", "acct-empty-dir", "claude")
1407 .unwrap();
1408
1409 insert_block_for_agent(&store, "block-empty-dir", "def-1");
1410 let inst = make_instance("block-empty-dir", "id-empty-dir");
1411 store.instance_create(&inst).unwrap();
1412
1413 let mut env: HashMap<String, String> = HashMap::new();
1414 let err = inject_identity_env(store.clone(), store.clone(), store, "block-empty-dir", &mut env)
1415 .expect_err("spawn must be refused when the CLAUDE.md isolation seed fails");
1416
1417 match err {
1418 SpawnGateError::ClaudeMdSeedFailed { provider, dir, .. } => {
1419 assert_eq!(provider, "claude");
1420 assert_eq!(dir, "");
1421 }
1422 other => panic!("expected ClaudeMdSeedFailed, got {other:?}"),
1423 }
1424 assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1427 }
1428
1429 #[cfg(debug_assertions)]
1449 #[test]
1450 fn inject_refuses_an_account_that_only_exists_in_the_global_mirror() {
1451 let mstore = make_store();
1452 let id_store = make_store();
1455 let identity_store_tmp = tempfile::NamedTempFile::new().unwrap();
1463 let identity_store = Arc::new(Store::open_identity_store(identity_store_tmp.path()).unwrap());
1464
1465 let mut def = crate::backend::storage::store::AgentDefinition {
1466 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1467 id: "def-1".to_string(),
1468 slug: String::new(),
1469 name: "T".to_string(),
1470 icon: "✦".to_string(),
1471 provider: "claude".to_string(),
1472 description: String::new(),
1473 working_directory: String::new(),
1474 shell: String::new(),
1475 provider_flags: String::new(),
1476 auto_start: 0,
1477 restart_on_crash: 0,
1478 idle_timeout_minutes: 0,
1479 created_at: 0,
1480 agent_type: String::new(),
1481 environment: String::new(),
1482 agent_bus_id: String::new(),
1483 is_seeded: 0,
1484 accounts: String::new(),
1485 parent_id: String::new(),
1486 branch_label: String::new(),
1487 updated_at: 0,
1488 user_hidden: 0,
1489 container_image: String::new(),
1490 container_volumes: "[]".to_string(),
1491 container_name: String::new(),
1492 use_ambient_login: 0,
1493 auto_continue_enabled: 0,
1494 model_vendor_base_url: String::new(),
1495 memory_id: String::new(),
1496 };
1497 mstore.agent_def_insert(&mut def).unwrap();
1498
1499 let claude = make_account(
1500 "acct-migrated",
1501 "claude",
1502 SecretRef::OAuthConfigDir {
1503 dir: test_config_dir("id-migrated"),
1504 },
1505 );
1506 identity_store.identity_upsert(&claude).unwrap();
1507 identity_store
1508 .agent_identity_link("def-1", "acct-migrated", "claude")
1509 .unwrap();
1510
1511 insert_block_for_agent(&mstore, "block-continuing", "def-1");
1512 let inst = make_instance("block-continuing", "id-continuing");
1513 mstore.instance_create(&inst).unwrap();
1514
1515 let mut env: HashMap<String, String> = HashMap::new();
1516 let res = inject_identity_env(mstore, id_store, identity_store, "block-continuing", &mut env);
1517
1518 assert!(
1519 matches!(res, Err(SpawnGateError::MissingCredentials { .. })),
1520 "an account that exists ONLY in the global mirror must NOT satisfy a spawn in \
1521 this channel — that is the per-channel-isolation bypass; expected \
1522 MissingCredentials, got {res:?}"
1523 );
1524 assert!(
1525 env.get("CLAUDE_CONFIG_DIR").is_none(),
1526 "nothing may be injected from a refused cross-channel account"
1527 );
1528 }
1529
1530 #[cfg(debug_assertions)]
1545 #[test]
1546 fn inject_refuses_a_mirror_written_account_after_a_channel_switch() {
1547 let mstore = make_store();
1548 let id_store = make_store();
1549 let identity_store_tmp = tempfile::NamedTempFile::new().unwrap();
1550 let identity_store = Arc::new(Store::open_identity_store(identity_store_tmp.path()).unwrap());
1551
1552 let mut def = crate::backend::storage::store::AgentDefinition {
1555 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1556 id: "def-1".to_string(),
1557 slug: String::new(),
1558 name: "T".to_string(),
1559 icon: "✦".to_string(),
1560 provider: "claude".to_string(),
1561 description: String::new(),
1562 working_directory: String::new(),
1563 shell: String::new(),
1564 provider_flags: String::new(),
1565 auto_start: 0,
1566 restart_on_crash: 0,
1567 idle_timeout_minutes: 0,
1568 created_at: 0,
1569 agent_type: String::new(),
1570 environment: String::new(),
1571 agent_bus_id: String::new(),
1572 is_seeded: 0,
1573 accounts: String::new(),
1574 parent_id: String::new(),
1575 branch_label: String::new(),
1576 updated_at: 0,
1577 user_hidden: 0,
1578 container_image: String::new(),
1579 container_volumes: "[]".to_string(),
1580 container_name: String::new(),
1581 use_ambient_login: 0,
1582 auto_continue_enabled: 0,
1583 model_vendor_base_url: String::new(),
1584 memory_id: String::new(),
1585 };
1586 mstore.agent_def_insert(&mut def).unwrap();
1587
1588 let claude = make_account(
1593 "acct-fresh",
1594 "claude",
1595 SecretRef::OAuthConfigDir {
1596 dir: test_config_dir("id-fresh"),
1597 },
1598 );
1599 id_store.identity_upsert_with_mirror(&identity_store, &claude).unwrap();
1600 identity_store
1601 .agent_identity_link("def-1", "acct-fresh", "claude")
1602 .unwrap();
1603
1604 insert_block_for_agent(&mstore, "block-fresh", "def-1");
1605 let inst = make_instance("block-fresh", "id-fresh");
1606 mstore.instance_create(&inst).unwrap();
1607
1608 let id_store_after_switch = make_store();
1612
1613 let mut env: HashMap<String, String> = HashMap::new();
1614 let res = inject_identity_env(mstore, id_store_after_switch, identity_store, "block-fresh", &mut env);
1615
1616 assert!(
1617 matches!(res, Err(SpawnGateError::MissingCredentials { .. })),
1618 "REVERSED 2026-08-31: a mirror row must no longer carry a spawn across a channel \
1619 switch, however it was written — dual-writing the mirror is not a substitute for \
1620 logging in on this channel; expected MissingCredentials, got {res:?}"
1621 );
1622 assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1623 }
1624
1625 #[cfg(debug_assertions)]
1631 #[test]
1632 fn inject_oauth_class_links_conversation_history_on_the_ordinary_spawn_path() {
1633 let _lock = crate::test_support::ISOLATED_AUTH_ENV_LOCK
1634 .lock()
1635 .unwrap_or_else(|e| e.into_inner());
1636 let tmp = tempfile::TempDir::new().unwrap();
1637 std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
1638 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
1639 let paths = agentmux_common::DataPaths::resolve(
1640 "0.0.0-test",
1641 &agentmux_common::RuntimeMode::Installed,
1642 )
1643 .unwrap();
1644 paths.ensure_dirs().unwrap();
1645 for (k, v) in paths.to_env_vars() {
1646 std::env::set_var(k, v);
1647 }
1648
1649 let isolated_dir = paths.instance_dir.join("identities").join("acct-claude").join("claude");
1654 std::fs::create_dir_all(isolated_dir.join("projects")).unwrap();
1655 std::fs::write(
1656 isolated_dir.join("projects").join("existing-session.jsonl"),
1657 b"pre-existing history",
1658 )
1659 .unwrap();
1660
1661 let store = make_store();
1662 let mut def = crate::backend::storage::store::AgentDefinition {
1663 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1664 id: "def-1".to_string(),
1665 slug: String::new(),
1666 name: "T".to_string(),
1667 icon: "✦".to_string(),
1668 provider: "claude".to_string(),
1669 description: String::new(),
1670 working_directory: String::new(),
1671 shell: String::new(),
1672 provider_flags: String::new(),
1673 auto_start: 0,
1674 restart_on_crash: 0,
1675 idle_timeout_minutes: 0,
1676 created_at: 0,
1677 agent_type: String::new(),
1678 environment: String::new(),
1679 agent_bus_id: String::new(),
1680 is_seeded: 0,
1681 accounts: String::new(),
1682 parent_id: String::new(),
1683 branch_label: String::new(),
1684 updated_at: 0,
1685 user_hidden: 0,
1686 container_image: String::new(),
1687 container_volumes: "[]".to_string(),
1688 container_name: String::new(),
1689 use_ambient_login: 0,
1690 auto_continue_enabled: 0,
1691 model_vendor_base_url: String::new(),
1692 memory_id: String::new(),
1693 };
1694 store.agent_def_insert(&mut def).unwrap();
1695
1696 let claude = make_account(
1697 "acct-claude",
1698 "claude",
1699 SecretRef::OAuthConfigDir { dir: isolated_dir.to_string_lossy().to_string() },
1700 );
1701 store.identity_upsert(&claude).unwrap();
1702 store.agent_identity_link("def-1", "acct-claude", "claude").unwrap();
1703 insert_block_for_agent(&store, "block-oauth-history", "def-1");
1704 let inst = make_instance("block-oauth-history", "id-oauth");
1705 store.instance_create(&inst).unwrap();
1706
1707 let mut env: HashMap<String, String> = HashMap::new();
1708 inject_identity_env(store.clone(), store.clone(), store, "block-oauth-history", &mut env).unwrap();
1709
1710 let global_history = paths.identity_history_dir("acct-claude", "claude", "projects");
1711 let via_link = std::fs::read(global_history.join("existing-session.jsonl"))
1712 .expect("pre-existing history must have been migrated to the global location by the spawn-time link");
1713 assert_eq!(via_link, b"pre-existing history");
1714
1715 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
1716 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
1717 for (k, _) in paths.to_env_vars() {
1718 std::env::remove_var(k);
1719 }
1720 }
1721
1722 #[cfg(debug_assertions)]
1723 #[test]
1724 fn inject_oauth_class_blocks_account_with_non_oauth_secret_ref() {
1725 let store = make_store();
1732
1733 let mut def = crate::backend::storage::store::AgentDefinition {
1734 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1735 id: "def-1".to_string(),
1736 slug: String::new(),
1737 name: "T".to_string(),
1738 icon: "✦".to_string(),
1739 provider: "claude".to_string(),
1740 description: String::new(),
1741 working_directory: String::new(),
1742 shell: String::new(),
1743 provider_flags: String::new(),
1744 auto_start: 0,
1745 restart_on_crash: 0,
1746 idle_timeout_minutes: 0,
1747 created_at: 0,
1748 agent_type: String::new(),
1749 environment: String::new(),
1750 agent_bus_id: String::new(),
1751 is_seeded: 0,
1752 accounts: String::new(),
1753 parent_id: String::new(),
1754 branch_label: String::new(),
1755 updated_at: 0,
1756 user_hidden: 0,
1757 container_image: String::new(),
1758 container_volumes: "[]".to_string(),
1759 container_name: String::new(),
1760 use_ambient_login: 0,
1761 auto_continue_enabled: 0,
1762 model_vendor_base_url: String::new(),
1763 memory_id: String::new(),
1764 };
1765 store.agent_def_insert(&mut def).unwrap();
1766
1767 let bad = make_account(
1768 "acct-bad",
1769 "claude",
1770 SecretRef::Env {
1771 env_var: "CLAUDE_TOKEN_NOT_A_DIR".to_string(),
1772 },
1773 );
1774 store.identity_upsert(&bad).unwrap();
1775 store
1776 .agent_identity_link("def-1", "acct-bad", "claude")
1777 .unwrap();
1778
1779 insert_block_for_agent(&store, "block-bad", "def-1");
1780 let inst = make_instance("block-bad", "id-bad");
1781 store.instance_create(&inst).unwrap();
1782
1783 let mut env: HashMap<String, String> = HashMap::new();
1784 let res = inject_identity_env(store.clone(), store.clone(), store, "block-bad", &mut env);
1785
1786 assert_eq!(
1788 res,
1789 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
1790 );
1791 assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
1792 assert!(env.is_empty());
1793 }
1794
1795 #[cfg(debug_assertions)]
1796 #[test]
1797 fn inject_oauth_class_succeeds_when_the_only_binding_is_under_a_legacy_alias() {
1798 let store = make_store();
1810
1811 let mut def = crate::backend::storage::store::AgentDefinition {
1812 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1813 id: "def-1".to_string(),
1814 slug: String::new(),
1815 name: "T".to_string(),
1816 icon: "✦".to_string(),
1817 provider: "claude".to_string(),
1818 description: String::new(),
1819 working_directory: String::new(),
1820 shell: String::new(),
1821 provider_flags: String::new(),
1822 auto_start: 0,
1823 restart_on_crash: 0,
1824 idle_timeout_minutes: 0,
1825 created_at: 0,
1826 agent_type: String::new(),
1827 environment: String::new(),
1828 agent_bus_id: String::new(),
1829 is_seeded: 0,
1830 accounts: String::new(),
1831 parent_id: String::new(),
1832 branch_label: String::new(),
1833 updated_at: 0,
1834 user_hidden: 0,
1835 container_image: String::new(),
1836 container_volumes: "[]".to_string(),
1837 container_name: String::new(),
1838 use_ambient_login: 0,
1839 auto_continue_enabled: 0,
1840 model_vendor_base_url: String::new(),
1841 memory_id: String::new(),
1842 };
1843 store.agent_def_insert(&mut def).unwrap();
1844
1845 let claude = make_account(
1846 "acct-alias",
1847 "claude-code",
1848 SecretRef::OAuthConfigDir {
1849 dir: test_config_dir("id-alias"),
1850 },
1851 );
1852 store.identity_upsert(&claude).unwrap();
1853 store
1855 .agent_identity_link("def-1", "acct-alias", "claude-code")
1856 .unwrap();
1857
1858 insert_block_for_agent(&store, "block-alias", "def-1");
1859 let inst = make_instance("block-alias", "id-alias");
1860 store.instance_create(&inst).unwrap();
1861
1862 let mut env: HashMap<String, String> = HashMap::new();
1863 let res = inject_identity_env(store.clone(), store.clone(), store, "block-alias", &mut env);
1864
1865 assert!(res.is_ok(), "expected Ok, got {res:?}");
1866 assert_eq!(
1867 env.get("CLAUDE_CONFIG_DIR").cloned(),
1868 Some(test_config_dir("id-alias")),
1869 );
1870 }
1871
1872 fn gate_def(use_ambient_login: i64) -> crate::backend::storage::store::AgentDefinition {
1878 crate::backend::storage::store::AgentDefinition {
1879 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
1880 id: "def-1".to_string(),
1881 slug: String::new(),
1882 name: "T".to_string(),
1883 icon: "✦".to_string(),
1884 provider: "claude".to_string(),
1885 description: String::new(),
1886 working_directory: String::new(),
1887 shell: String::new(),
1888 provider_flags: String::new(),
1889 auto_start: 0,
1890 restart_on_crash: 0,
1891 idle_timeout_minutes: 0,
1892 created_at: 0,
1893 agent_type: String::new(),
1894 environment: String::new(),
1895 agent_bus_id: String::new(),
1896 is_seeded: 0,
1897 accounts: String::new(),
1898 parent_id: String::new(),
1899 branch_label: String::new(),
1900 updated_at: 0,
1901 user_hidden: 0,
1902 container_image: String::new(),
1903 container_volumes: "[]".to_string(),
1904 container_name: String::new(),
1905 use_ambient_login,
1906 auto_continue_enabled: 0,
1907 model_vendor_base_url: String::new(),
1908 memory_id: String::new(),
1909 }
1910 }
1911
1912 fn delete_account_row_keep_link(store: &Store, account_id: &str) {
1918 let conn = store.conn().lock().unwrap();
1919 conn.execute_batch(&format!(
1920 "PRAGMA foreign_keys=OFF;
1921 DELETE FROM db_accounts WHERE id = '{account_id}';
1922 PRAGMA foreign_keys=ON;"
1923 ))
1924 .unwrap();
1925 }
1926
1927 #[test]
1928 fn spawn_blocked_when_bound_oauth_account_missing_and_flag_false() {
1929 let store = make_store();
1932 let mut def = gate_def(0);
1933 store.agent_def_insert(&mut def).unwrap();
1934
1935 let claude = make_account(
1936 "acct-gone",
1937 "claude",
1938 SecretRef::OAuthConfigDir {
1939 dir: test_config_dir("id-x"),
1940 },
1941 );
1942 store.identity_upsert(&claude).unwrap();
1943 store
1944 .agent_identity_link("def-1", "acct-gone", "claude")
1945 .unwrap();
1946 delete_account_row_keep_link(&store, "acct-gone");
1950
1951 insert_block_for_agent(&store, "block-gate-1", "def-1");
1952 let inst = make_instance("block-gate-1", "id-x");
1953 store.instance_create(&inst).unwrap();
1954
1955 let mut env: HashMap<String, String> = HashMap::new();
1956 let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-1", &mut env);
1957
1958 assert_eq!(
1959 res,
1960 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
1961 );
1962 assert!(env.is_empty());
1964 let msg = res.unwrap_err().to_string();
1967 assert!(msg.contains("no credentials for claude"), "got: {msg}");
1968 assert!(msg.contains("Bind an account for this provider in Connectors → Accounts"), "got: {msg}");
1969 }
1970
1971 #[test]
1972 fn spawn_still_blocked_when_bound_oauth_account_missing_and_flag_true() {
1973 let store = make_store();
1980 let mut def = gate_def(1);
1981 store.agent_def_insert(&mut def).unwrap();
1982
1983 let claude = make_account(
1984 "acct-gone-2",
1985 "claude",
1986 SecretRef::OAuthConfigDir {
1987 dir: test_config_dir("id-y"),
1988 },
1989 );
1990 store.identity_upsert(&claude).unwrap();
1991 store
1992 .agent_identity_link("def-1", "acct-gone-2", "claude")
1993 .unwrap();
1994 delete_account_row_keep_link(&store, "acct-gone-2");
1995
1996 insert_block_for_agent(&store, "block-gate-2", "def-1");
1997 let inst = make_instance("block-gate-2", "id-y");
1998 store.instance_create(&inst).unwrap();
1999
2000 let mut env: HashMap<String, String> = HashMap::new();
2001 let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-2", &mut env);
2002
2003 assert_eq!(
2004 res,
2005 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2006 );
2007 assert!(env.is_empty());
2008 }
2009
2010 #[test]
2011 fn spawn_blocked_when_oauth_def_provider_has_no_binding_and_flag_false() {
2012 let store = make_store();
2031 let mut def = gate_def(0);
2032 store.agent_def_insert(&mut def).unwrap();
2033
2034 insert_block_for_agent(&store, "block-gate-3", "def-1");
2035 let inst = make_instance("block-gate-3", "id-z");
2036 store.instance_create(&inst).unwrap();
2037
2038 let mut env: HashMap<String, String> = HashMap::new();
2039 let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-3", &mut env);
2040
2041 assert_eq!(
2042 res,
2043 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2044 );
2045 assert!(env.is_empty());
2046 }
2047
2048 #[test]
2075 fn spawn_is_gated_for_a_template_launch_with_no_binding_where_it_used_to_be_invisible_to_the_gate() {
2076 let store = make_store();
2077 let mut tpl = gate_def(1); tpl.id = "tpl-ambient".to_string();
2079 tpl.is_seeded = 1;
2080 store.agent_def_insert(&mut tpl).unwrap();
2081
2082 let inst = crate::backend::storage::store::AgentInstance {
2086 id: "launch-ambient".to_string(),
2087 definition_id: "tpl-ambient".to_string(),
2088 parent_instance_id: String::new(),
2089 block_id: "block-gate-ambient".to_string(),
2090 session_id: String::new(),
2091 status: InstanceStatus::Running.as_str().to_string(),
2092 github_context: String::new(),
2093 started_at: 0,
2094 ended_at: 0,
2095 created_at: 0,
2096 identity_id: "id-ambient".to_string(),
2097 memory_id: String::new(),
2098 instance_name: String::new(),
2099 working_directory: String::new(),
2100 display_hidden: false,
2101 };
2102 let canonical = store.instance_create(&inst).unwrap();
2103 assert_eq!(canonical.id, "launch-ambient", "sanity: a template launch creates its own row");
2104
2105 insert_block_for_agent(&store, "block-gate-ambient", "launch-ambient");
2106
2107 let mut env: HashMap<String, String> = HashMap::new();
2108 let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-ambient", &mut env);
2109
2110 assert_eq!(
2111 res,
2112 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2113 "a template launch's zero-binding oauth-class provider must now reach Step 5's gate, not skip it",
2114 );
2115 assert!(env.is_empty());
2116 }
2117
2118 #[test]
2127 fn spawn_proceeds_when_a_template_launch_inherits_the_templates_own_identity_link() {
2128 let store = make_store();
2129 let mut tpl = gate_def(0);
2130 tpl.id = "tpl-bound".to_string();
2131 tpl.is_seeded = 1;
2132 store.agent_def_insert(&mut tpl).unwrap();
2133
2134 let claude = make_account(
2135 "acct-tpl",
2136 "claude",
2137 SecretRef::OAuthConfigDir { dir: test_config_dir("id-tpl-bound") },
2138 );
2139 store.identity_upsert(&claude).unwrap();
2140 store.agent_identity_link("tpl-bound", "acct-tpl", "claude").unwrap();
2143
2144 let inst = crate::backend::storage::store::AgentInstance {
2145 id: "launch-bound".to_string(),
2146 definition_id: "tpl-bound".to_string(),
2147 parent_instance_id: String::new(),
2148 block_id: "block-gate-bound".to_string(),
2149 session_id: String::new(),
2150 status: InstanceStatus::Running.as_str().to_string(),
2151 github_context: String::new(),
2152 started_at: 0,
2153 ended_at: 0,
2154 created_at: 0,
2155 identity_id: "id-tpl-bound".to_string(),
2156 memory_id: String::new(),
2157 instance_name: String::new(),
2158 working_directory: String::new(),
2159 display_hidden: false,
2160 };
2161 let canonical = store.instance_create(&inst).unwrap();
2162 assert_eq!(canonical.id, "launch-bound", "sanity: a template launch creates its own row");
2163 assert!(store.agent_identity_list_for_agent("launch-bound").unwrap().is_empty());
2167
2168 insert_block_for_agent(&store, "block-gate-bound", "launch-bound");
2169
2170 let mut env: HashMap<String, String> = HashMap::new();
2171 let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-bound", &mut env);
2172
2173 assert_eq!(res, Ok(()), "a template's own real binding must still let its launches spawn");
2174 assert_eq!(
2175 env.get("CLAUDE_CONFIG_DIR").map(String::as_str),
2176 Some(test_config_dir("id-tpl-bound").as_str()),
2177 "the template's bound config dir must actually be injected, not just pass the gate",
2178 );
2179 }
2180
2181 #[test]
2193 fn a_fork_of_a_regular_agent_does_not_inherit_the_sources_identity_link() {
2194 let store = make_store();
2195 let mut source = gate_def(0);
2196 source.id = "agent-source".to_string();
2197 source.is_seeded = 0; store.agent_def_insert(&mut source).unwrap();
2199
2200 let claude = make_account(
2201 "acct-source",
2202 "claude",
2203 SecretRef::OAuthConfigDir { dir: test_config_dir("id-source-only") },
2204 );
2205 store.identity_upsert(&claude).unwrap();
2206 store.agent_identity_link("agent-source", "acct-source", "claude").unwrap();
2207
2208 let mut fork = gate_def(0);
2213 fork.id = "agent-fork".to_string();
2214 fork.is_seeded = 0;
2215 fork.parent_id = "agent-source".to_string();
2216 fork.accounts = String::new();
2217 store.agent_def_insert(&mut fork).unwrap();
2218
2219 let inst = crate::backend::storage::store::AgentInstance {
2220 id: "agent-fork".to_string(),
2221 definition_id: "agent-fork".to_string(),
2222 parent_instance_id: String::new(),
2223 block_id: "block-gate-fork".to_string(),
2224 session_id: String::new(),
2225 status: InstanceStatus::Running.as_str().to_string(),
2226 github_context: String::new(),
2227 started_at: 0,
2228 ended_at: 0,
2229 created_at: 0,
2230 identity_id: "id-fork".to_string(),
2231 memory_id: String::new(),
2232 instance_name: String::new(),
2233 working_directory: String::new(),
2234 display_hidden: false,
2235 };
2236 let canonical = store.instance_create(&inst).unwrap();
2237 assert_eq!(canonical.id, "agent-fork", "sanity: a fork of a non-template agent folds into its own row");
2238 assert!(store.agent_identity_list_for_agent("agent-fork").unwrap().is_empty(), "sanity: the fork has no link of its own");
2239
2240 insert_block_for_agent(&store, "block-gate-fork", "agent-fork");
2241
2242 let mut env: HashMap<String, String> = HashMap::new();
2243 let res = inject_identity_env(store.clone(), store.clone(), store, "block-gate-fork", &mut env);
2244
2245 assert_eq!(
2246 res,
2247 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2248 "a fork must NOT silently inherit and spawn on its source agent's credentials",
2249 );
2250 assert!(env.is_empty(), "nothing must be injected — especially not the source's config dir");
2251 }
2252
2253 #[test]
2254 fn inject_no_instance_does_nothing() {
2255 let store = make_store();
2256 let mut env: HashMap<String, String> = HashMap::new();
2257 inject_identity_env(store.clone(), store.clone(), store, "block-no-instance", &mut env).unwrap();
2258 assert!(env.is_empty());
2259 }
2260
2261 #[test]
2262 fn inject_blank_identity_is_gated_same_as_any_other_unresolvable_identity() {
2263 let store = make_store();
2275 let mut def = gate_def(0);
2276 store.agent_def_insert(&mut def).unwrap();
2277
2278 insert_block_for_agent(&store, "block-blank", "def-1");
2279 let inst = make_instance("block-blank", "blank");
2280 store.instance_create(&inst).unwrap();
2281
2282 let mut env: HashMap<String, String> = HashMap::new();
2283 let res = inject_identity_env(store.clone(), store.clone(), store, "block-blank", &mut env);
2284
2285 assert_eq!(
2286 res,
2287 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2288 );
2289 assert!(env.is_empty());
2290 }
2291
2292 #[test]
2293 fn inject_empty_identity_is_gated_same_as_blank() {
2294 let store = make_store();
2299 let mut def = gate_def(0);
2300 store.agent_def_insert(&mut def).unwrap();
2301
2302 insert_block_for_agent(&store, "block-empty", "def-1");
2303 let inst = make_instance("block-empty", "");
2304 store.instance_create(&inst).unwrap();
2305
2306 let mut env: HashMap<String, String> = HashMap::new();
2307 let res = inject_identity_env(store.clone(), store.clone(), store, "block-empty", &mut env);
2308
2309 assert_eq!(
2310 res,
2311 Err(SpawnGateError::MissingCredentials { provider: "claude".to_string() }),
2312 );
2313 assert!(env.is_empty());
2314 }
2315
2316 #[cfg(debug_assertions)]
2317 #[test]
2318 fn inject_full_round_trip_plaintext_dev() {
2319 let store = make_store();
2320
2321 let mut def = crate::backend::storage::store::AgentDefinition {
2323 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2324 id: "def-1".to_string(),
2325 slug: String::new(),
2326 name: "T".to_string(),
2327 icon: "✦".to_string(),
2328 provider: "kimi".to_string(),
2336 description: String::new(),
2337 working_directory: String::new(),
2338 shell: String::new(),
2339 provider_flags: String::new(),
2340 auto_start: 0,
2341 restart_on_crash: 0,
2342 idle_timeout_minutes: 0,
2343 created_at: 0,
2344 agent_type: String::new(),
2345 environment: String::new(),
2346 agent_bus_id: String::new(),
2347 is_seeded: 0,
2348 accounts: String::new(),
2349 parent_id: String::new(),
2350 branch_label: String::new(),
2351 updated_at: 0,
2352 user_hidden: 0,
2353 container_image: String::new(),
2354 container_volumes: "[]".to_string(),
2355 container_name: String::new(),
2356 use_ambient_login: 0,
2357 auto_continue_enabled: 0,
2358 model_vendor_base_url: String::new(),
2359 memory_id: String::new(),
2360 };
2361 store.agent_def_insert(&mut def).unwrap();
2362
2363 let github = make_account(
2365 "acct-gh",
2366 "github",
2367 SecretRef::PlaintextDev {
2368 plaintext_dev: "ghp_round_trip".to_string(),
2369 },
2370 );
2371 store.identity_upsert(&github).unwrap();
2372 store
2373 .agent_identity_link("def-1", "acct-gh", "github")
2374 .unwrap();
2375
2376 let anthropic = make_account(
2378 "acct-anth",
2379 "anthropic",
2380 SecretRef::PlaintextDev {
2381 plaintext_dev: "sk-ant-round_trip".to_string(),
2382 },
2383 );
2384 store.identity_upsert(&anthropic).unwrap();
2385 store
2386 .agent_identity_link("def-1", "acct-anth", "anthropic")
2387 .unwrap();
2388
2389 insert_block_for_agent(&store, "block-1", "def-1");
2391 let inst = make_instance("block-1", "id-work");
2392 store.instance_create(&inst).unwrap();
2393
2394 let mut env: HashMap<String, String> = HashMap::new();
2395 inject_identity_env(store.clone(), store.clone(), store, "block-1", &mut env).unwrap();
2396
2397 assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_round_trip"));
2399 assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_round_trip"));
2400 assert_eq!(
2402 env.get("ANTHROPIC_API_KEY").map(String::as_str),
2403 Some("sk-ant-round_trip"),
2404 );
2405 }
2406
2407 #[cfg(debug_assertions)]
2408 #[test]
2409 fn inject_via_direct_link_round_trip() {
2410 let store = make_store();
2413
2414 let mut def = crate::backend::storage::store::AgentDefinition {
2415 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2416 id: "def-1".to_string(),
2417 slug: String::new(),
2418 name: "T".to_string(),
2419 icon: "✦".to_string(),
2420 provider: "kimi".to_string(),
2423 description: String::new(),
2424 working_directory: String::new(),
2425 shell: String::new(),
2426 provider_flags: String::new(),
2427 auto_start: 0,
2428 restart_on_crash: 0,
2429 idle_timeout_minutes: 0,
2430 created_at: 0,
2431 agent_type: String::new(),
2432 environment: String::new(),
2433 agent_bus_id: String::new(),
2434 is_seeded: 0,
2435 accounts: String::new(),
2436 parent_id: String::new(),
2437 branch_label: String::new(),
2438 updated_at: 0,
2439 user_hidden: 0,
2440 container_image: String::new(),
2441 container_volumes: "[]".to_string(),
2442 container_name: String::new(),
2443 use_ambient_login: 0,
2444 auto_continue_enabled: 0,
2445 model_vendor_base_url: String::new(),
2446 memory_id: String::new(),
2447 };
2448 store.agent_def_insert(&mut def).unwrap();
2449
2450 let github = make_account(
2451 "acct-gh",
2452 "github",
2453 SecretRef::PlaintextDev {
2454 plaintext_dev: "ghp_direct".to_string(),
2455 },
2456 );
2457 store.identity_upsert(&github).unwrap();
2458 store
2459 .agent_identity_link("def-1", "acct-gh", "github")
2460 .unwrap();
2461
2462 insert_block_for_agent(&store, "block-direct", "def-1");
2463 let inst = make_instance("block-direct", "id-direct");
2464 store.instance_create(&inst).unwrap();
2465
2466 let mut env: HashMap<String, String> = HashMap::new();
2467 inject_identity_env(store.clone(), store.clone(), store, "block-direct", &mut env).unwrap();
2468
2469 assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_direct"));
2470 assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_direct"));
2471 }
2472
2473 #[cfg(debug_assertions)]
2474 #[test]
2475 fn inject_no_direct_links_injects_nothing() {
2476 let store = make_store();
2480
2481 let mut def = crate::backend::storage::store::AgentDefinition {
2482 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2483 id: "def-1".to_string(),
2484 slug: String::new(),
2485 name: "T".to_string(),
2486 icon: "✦".to_string(),
2487 provider: "kimi".to_string(),
2492 description: String::new(),
2493 working_directory: String::new(),
2494 shell: String::new(),
2495 provider_flags: String::new(),
2496 auto_start: 0,
2497 restart_on_crash: 0,
2498 idle_timeout_minutes: 0,
2499 created_at: 0,
2500 agent_type: String::new(),
2501 environment: String::new(),
2502 agent_bus_id: String::new(),
2503 is_seeded: 0,
2504 accounts: String::new(),
2505 parent_id: String::new(),
2506 branch_label: String::new(),
2507 updated_at: 0,
2508 user_hidden: 0,
2509 container_image: String::new(),
2510 container_volumes: "[]".to_string(),
2511 container_name: String::new(),
2512 use_ambient_login: 0,
2513 auto_continue_enabled: 0,
2514 model_vendor_base_url: String::new(),
2515 memory_id: String::new(),
2516 };
2517 store.agent_def_insert(&mut def).unwrap();
2518
2519 let github = make_account(
2520 "acct-unlinked",
2521 "github",
2522 SecretRef::PlaintextDev {
2523 plaintext_dev: "ghp_unlinked".to_string(),
2524 },
2525 );
2526 store.identity_upsert(&github).unwrap();
2527
2528 insert_block_for_agent(&store, "block-unlinked", "def-1");
2529 let inst = make_instance("block-unlinked", "id-unused");
2530 store.instance_create(&inst).unwrap();
2531
2532 let mut env: HashMap<String, String> = HashMap::new();
2533 inject_identity_env(store.clone(), store.clone(), store, "block-unlinked", &mut env).unwrap();
2534
2535 assert!(env.get("GITHUB_TOKEN").is_none());
2538 assert!(env.is_empty());
2539 }
2540
2541 #[cfg(debug_assertions)]
2542 #[test]
2543 fn inject_no_direct_links_publishes_event() {
2544 let store = make_store();
2549
2550 let mut def = crate::backend::storage::store::AgentDefinition {
2551 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2552 id: "def-1".to_string(),
2553 slug: String::new(),
2554 name: "T".to_string(),
2555 icon: "✦".to_string(),
2556 provider: "kimi".to_string(),
2559 description: String::new(),
2560 working_directory: String::new(),
2561 shell: String::new(),
2562 provider_flags: String::new(),
2563 auto_start: 0,
2564 restart_on_crash: 0,
2565 idle_timeout_minutes: 0,
2566 created_at: 0,
2567 agent_type: String::new(),
2568 environment: String::new(),
2569 agent_bus_id: String::new(),
2570 is_seeded: 0,
2571 accounts: String::new(),
2572 parent_id: String::new(),
2573 branch_label: String::new(),
2574 updated_at: 0,
2575 user_hidden: 0,
2576 container_image: String::new(),
2577 container_volumes: "[]".to_string(),
2578 container_name: String::new(),
2579 use_ambient_login: 0,
2580 auto_continue_enabled: 0,
2581 model_vendor_base_url: String::new(),
2582 memory_id: String::new(),
2583 };
2584 store.agent_def_insert(&mut def).unwrap();
2585
2586 let github = make_account(
2587 "acct-unlinked-2",
2588 "github",
2589 SecretRef::PlaintextDev {
2590 plaintext_dev: "ghp_unlinked_2".to_string(),
2591 },
2592 );
2593 store.identity_upsert(&github).unwrap();
2594
2595 insert_block_for_agent(&store, "block-unlinked-2", "def-1");
2596 let inst = make_instance("block-unlinked-2", "id-unused-2");
2597 store.instance_create(&inst).unwrap();
2598
2599 let broker = Arc::new(Broker::new());
2600 let mut env: HashMap<String, String> = HashMap::new();
2601 inject_identity_env_with_broker(
2602 store.clone(),
2603 store.clone(),
2604 store,
2605 Some(broker.clone()),
2606 "block-unlinked-2",
2607 &mut env,
2608 )
2609 .unwrap();
2610
2611 let history = broker.read_event_history("identity:no-direct-links", "", 10);
2615 let event = history.last().expect("expected identity:no-direct-links event");
2616 assert_eq!(
2617 event.data.as_ref().and_then(|d| d.get("definition_id")).and_then(|v| v.as_str()),
2618 Some("def-1"),
2619 );
2620 }
2621
2622 #[cfg(debug_assertions)]
2623 #[test]
2624 fn inject_partial_success_skips_failed_bindings() {
2625 let store = make_store();
2626
2627 let mut def = crate::backend::storage::store::AgentDefinition {
2628 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2629 id: "def-1".to_string(),
2630 slug: String::new(),
2631 name: "T".to_string(),
2632 icon: "✦".to_string(),
2633 provider: "kimi".to_string(),
2637 description: String::new(),
2638 working_directory: String::new(),
2639 shell: String::new(),
2640 provider_flags: String::new(),
2641 auto_start: 0,
2642 restart_on_crash: 0,
2643 idle_timeout_minutes: 0,
2644 created_at: 0,
2645 agent_type: String::new(),
2646 environment: String::new(),
2647 agent_bus_id: String::new(),
2648 is_seeded: 0,
2649 accounts: String::new(),
2650 parent_id: String::new(),
2651 branch_label: String::new(),
2652 updated_at: 0,
2653 user_hidden: 0,
2654 container_image: String::new(),
2655 container_volumes: "[]".to_string(),
2656 container_name: String::new(),
2657 use_ambient_login: 0,
2658 auto_continue_enabled: 0,
2659 model_vendor_base_url: String::new(),
2660 memory_id: String::new(),
2661 };
2662 store.agent_def_insert(&mut def).unwrap();
2663
2664 let good = make_account(
2666 "acct-good",
2667 "github",
2668 SecretRef::PlaintextDev {
2669 plaintext_dev: "ghp_good".to_string(),
2670 },
2671 );
2672 store.identity_upsert(&good).unwrap();
2673 store
2674 .agent_identity_link("def-1", "acct-good", "github")
2675 .unwrap();
2676
2677 let bad = make_account(
2679 "acct-bad",
2680 "anthropic",
2681 SecretRef::Env {
2682 env_var: "AGENTMUX_TEST_DEFINITELY_NOT_SET_4242".to_string(),
2683 },
2684 );
2685 store.identity_upsert(&bad).unwrap();
2686 store
2687 .agent_identity_link("def-1", "acct-bad", "anthropic")
2688 .unwrap();
2689
2690 insert_block_for_agent(&store, "block-mixed", "def-1");
2691 let inst = make_instance("block-mixed", "id-mixed");
2692 store.instance_create(&inst).unwrap();
2693
2694 let mut env: HashMap<String, String> = HashMap::new();
2695 inject_identity_env(store.clone(), store.clone(), store, "block-mixed", &mut env).unwrap();
2696
2697 assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_good"));
2699 assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_good"));
2700 assert!(env.get("ANTHROPIC_API_KEY").is_none());
2702 }
2703
2704 #[cfg(debug_assertions)]
2705 #[test]
2706 fn inject_unknown_provider_is_skipped() {
2707 let store = make_store();
2708
2709 let mut def = crate::backend::storage::store::AgentDefinition {
2710 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2711 id: "def-1".to_string(),
2712 slug: String::new(),
2713 name: "T".to_string(),
2714 icon: "✦".to_string(),
2715 provider: "kimi".to_string(),
2718 description: String::new(),
2719 working_directory: String::new(),
2720 shell: String::new(),
2721 provider_flags: String::new(),
2722 auto_start: 0,
2723 restart_on_crash: 0,
2724 idle_timeout_minutes: 0,
2725 created_at: 0,
2726 agent_type: String::new(),
2727 environment: String::new(),
2728 agent_bus_id: String::new(),
2729 is_seeded: 0,
2730 accounts: String::new(),
2731 parent_id: String::new(),
2732 branch_label: String::new(),
2733 updated_at: 0,
2734 user_hidden: 0,
2735 container_image: String::new(),
2736 container_volumes: "[]".to_string(),
2737 container_name: String::new(),
2738 use_ambient_login: 0,
2739 auto_continue_enabled: 0,
2740 model_vendor_base_url: String::new(),
2741 memory_id: String::new(),
2742 };
2743 store.agent_def_insert(&mut def).unwrap();
2744
2745 let custom = make_account(
2746 "acct-custom",
2747 "custom",
2748 SecretRef::PlaintextDev {
2749 plaintext_dev: "ignored".to_string(),
2750 },
2751 );
2752 store.identity_upsert(&custom).unwrap();
2753 store
2754 .agent_identity_link("def-1", "acct-custom", "custom")
2755 .unwrap();
2756
2757 insert_block_for_agent(&store, "block-future", "def-1");
2758 let inst = make_instance("block-future", "id-future");
2759 store.instance_create(&inst).unwrap();
2760
2761 let mut env: HashMap<String, String> = HashMap::new();
2762 inject_identity_env(store.clone(), store.clone(), store, "block-future", &mut env).unwrap();
2763 assert!(env.is_empty());
2765 }
2766
2767 fn write_claude_creds(
2776 dir: &std::path::Path,
2777 expires_ms: i64,
2778 with_refresh: bool,
2779 ) {
2780 std::fs::create_dir_all(dir).unwrap();
2781 let body = serde_json::json!({
2782 "claudeAiOauth": {
2783 "accessToken": "test-access",
2784 "refreshToken": if with_refresh { "test-refresh" } else { "" },
2785 "expiresAt": expires_ms,
2786 }
2787 });
2788 std::fs::write(
2789 dir.join(".credentials.json"),
2790 serde_json::to_string(&body).unwrap(),
2791 )
2792 .unwrap();
2793 }
2794
2795 #[cfg(debug_assertions)]
2796 #[test]
2797 fn inject_oauth_class_probes_and_flips_status_to_needs_reauth() {
2798 let store = make_store();
2811
2812 let mut def = crate::backend::storage::store::AgentDefinition {
2813 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2814 id: "def-1".to_string(),
2815 slug: String::new(),
2816 name: "T".to_string(),
2817 icon: "✦".to_string(),
2818 provider: "codex".to_string(),
2819 description: String::new(),
2820 working_directory: String::new(),
2821 shell: String::new(),
2822 provider_flags: String::new(),
2823 auto_start: 0,
2824 restart_on_crash: 0,
2825 idle_timeout_minutes: 0,
2826 created_at: 0,
2827 agent_type: String::new(),
2828 environment: String::new(),
2829 agent_bus_id: String::new(),
2830 is_seeded: 0,
2831 accounts: String::new(),
2832 parent_id: String::new(),
2833 branch_label: String::new(),
2834 updated_at: 0,
2835 user_hidden: 0,
2836 container_image: String::new(),
2837 container_volumes: "[]".to_string(),
2838 container_name: String::new(),
2839 use_ambient_login: 0,
2840 auto_continue_enabled: 0,
2841 model_vendor_base_url: String::new(),
2842 memory_id: String::new(),
2843 };
2844 store.agent_def_insert(&mut def).unwrap();
2845
2846 let tmp = tempfile::tempdir().unwrap();
2849 let bundle_dir = tmp.path().to_str().unwrap().to_string();
2850
2851 let codex_acct = IdentityAccount {
2852 id: "acct-codex".to_string(),
2853 name: "codex-acct-codex".to_string(),
2854 provider: "codex".to_string(),
2855 kind: "oauth".to_string(),
2856 display_name: String::new(),
2857 secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
2858 context: serde_json::json!({}),
2859 status: oauth_status::VALID.to_string(),
2862 created_at: 0,
2863 updated_at: 0,
2864 };
2865 store.identity_upsert(&codex_acct).unwrap();
2866 store
2867 .agent_identity_link("def-1", "acct-codex", "codex")
2868 .unwrap();
2869
2870 insert_block_for_agent(&store, "block-probe", "def-1");
2871 let inst = make_instance("block-probe", "id-probe");
2872 store.instance_create(&inst).unwrap();
2873
2874 let mut env: HashMap<String, String> = HashMap::new();
2875 inject_identity_env(store.clone(), store.clone(), store.clone(), "block-probe", &mut env).unwrap();
2876
2877 assert!(env.get("CODEX_HOME").is_some());
2881
2882 let after = store.identity_get("acct-codex").unwrap().unwrap();
2884 assert_eq!(after.status, oauth_status::NEEDS_REAUTH);
2885 }
2886
2887 #[cfg(target_os = "macos")]
2888 #[cfg(debug_assertions)]
2889 #[test]
2890 fn inject_oauth_class_self_heals_stale_needs_reauth_for_claude_on_macos() {
2891 let store = make_store();
2899
2900 let mut def = gate_def(0);
2902 store.agent_def_insert(&mut def).unwrap();
2903
2904 let tmp = tempfile::tempdir().unwrap();
2908 let bundle_dir = tmp.path().to_str().unwrap().to_string();
2909
2910 let stale = IdentityAccount {
2911 id: "acct-stale".to_string(),
2912 name: "claude-acct-stale".to_string(),
2913 provider: "claude".to_string(),
2914 kind: "oauth".to_string(),
2915 display_name: String::new(),
2916 secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
2917 context: serde_json::json!({}),
2918 status: oauth_status::NEEDS_REAUTH.to_string(),
2919 created_at: 0,
2920 updated_at: 0,
2921 };
2922 store.identity_upsert(&stale).unwrap();
2923 store
2924 .agent_identity_link("def-1", "acct-stale", "claude")
2925 .unwrap();
2926
2927 insert_block_for_agent(&store, "block-self-heal", "def-1");
2928 let inst = make_instance("block-self-heal", "id-self-heal");
2929 store.instance_create(&inst).unwrap();
2930
2931 let mut env: HashMap<String, String> = HashMap::new();
2932 inject_identity_env(store.clone(), store.clone(), store.clone(), "block-self-heal", &mut env).unwrap();
2933
2934 let after = store.identity_get("acct-stale").unwrap().unwrap();
2935 assert_eq!(
2936 after.status, "unknown",
2937 "a stale pre-carve-out needs_reauth must self-heal to unknown, not stay stuck"
2938 );
2939 }
2940
2941 #[cfg(debug_assertions)]
2942 #[test]
2943 fn inject_oauth_class_probe_canonicalizes_a_legacy_alias_binding() {
2944 let store = make_store();
2960
2961 let mut def = crate::backend::storage::store::AgentDefinition {
2962 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
2963 id: "def-1".to_string(),
2964 slug: String::new(),
2965 name: "T".to_string(),
2966 icon: "✦".to_string(),
2967 provider: "codex".to_string(),
2968 description: String::new(),
2969 working_directory: String::new(),
2970 shell: String::new(),
2971 provider_flags: String::new(),
2972 auto_start: 0,
2973 restart_on_crash: 0,
2974 idle_timeout_minutes: 0,
2975 created_at: 0,
2976 agent_type: String::new(),
2977 environment: String::new(),
2978 agent_bus_id: String::new(),
2979 is_seeded: 0,
2980 accounts: String::new(),
2981 parent_id: String::new(),
2982 branch_label: String::new(),
2983 updated_at: 0,
2984 user_hidden: 0,
2985 container_image: String::new(),
2986 container_volumes: "[]".to_string(),
2987 container_name: String::new(),
2988 use_ambient_login: 0,
2989 auto_continue_enabled: 0,
2990 model_vendor_base_url: String::new(),
2991 memory_id: String::new(),
2992 };
2993 store.agent_def_insert(&mut def).unwrap();
2994
2995 let tmp = tempfile::tempdir().unwrap();
2996 let bundle_dir = tmp.path().to_str().unwrap().to_string();
2997
2998 let codex_acct = IdentityAccount {
2999 id: "acct-alias".to_string(),
3000 name: "codex-cli-acct-alias".to_string(),
3001 provider: "codex-cli".to_string(),
3002 kind: "oauth".to_string(),
3003 display_name: String::new(),
3004 secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
3005 context: serde_json::json!({}),
3006 status: oauth_status::VALID.to_string(),
3007 created_at: 0,
3008 updated_at: 0,
3009 };
3010 store.identity_upsert(&codex_acct).unwrap();
3011 store
3013 .agent_identity_link("def-1", "acct-alias", "codex-cli")
3014 .unwrap();
3015
3016 insert_block_for_agent(&store, "block-probe-alias", "def-1");
3017 let inst = make_instance("block-probe-alias", "id-probe-alias");
3018 store.instance_create(&inst).unwrap();
3019
3020 let mut env: HashMap<String, String> = HashMap::new();
3021 inject_identity_env(store.clone(), store.clone(), store.clone(), "block-probe-alias", &mut env).unwrap();
3022
3023 assert!(env.get("CODEX_HOME").is_some());
3024 let after = store.identity_get("acct-alias").unwrap().unwrap();
3028 assert_eq!(after.status, oauth_status::NEEDS_REAUTH);
3029 }
3030
3031 #[cfg(debug_assertions)]
3032 #[test]
3033 fn inject_oauth_class_probe_preserves_status_when_valid() {
3034 let store = make_store();
3039
3040 let mut def = crate::backend::storage::store::AgentDefinition {
3041 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
3042 id: "def-1".to_string(),
3043 slug: String::new(),
3044 name: "T".to_string(),
3045 icon: "✦".to_string(),
3046 provider: "claude".to_string(),
3047 description: String::new(),
3048 working_directory: String::new(),
3049 shell: String::new(),
3050 provider_flags: String::new(),
3051 auto_start: 0,
3052 restart_on_crash: 0,
3053 idle_timeout_minutes: 0,
3054 created_at: 0,
3055 agent_type: String::new(),
3056 environment: String::new(),
3057 agent_bus_id: String::new(),
3058 is_seeded: 0,
3059 accounts: String::new(),
3060 parent_id: String::new(),
3061 branch_label: String::new(),
3062 updated_at: 0,
3063 user_hidden: 0,
3064 container_image: String::new(),
3065 container_volumes: "[]".to_string(),
3066 container_name: String::new(),
3067 use_ambient_login: 0,
3068 auto_continue_enabled: 0,
3069 model_vendor_base_url: String::new(),
3070 memory_id: String::new(),
3071 };
3072 store.agent_def_insert(&mut def).unwrap();
3073
3074 let tmp = tempfile::tempdir().unwrap();
3075 let now_ms = agentmux_common::time::now_ms();
3076 write_claude_creds(tmp.path(), now_ms + 3_600_000, true);
3077
3078 let claude = IdentityAccount {
3079 id: "acct-ok".to_string(),
3080 name: "claude-acct-ok".to_string(),
3081 provider: "claude".to_string(),
3082 kind: "oauth".to_string(),
3083 display_name: String::new(),
3084 secret_ref: SecretRef::OAuthConfigDir {
3085 dir: tmp.path().to_str().unwrap().to_string(),
3086 },
3087 context: serde_json::json!({}),
3088 status: oauth_status::VALID.to_string(),
3089 created_at: 0,
3090 updated_at: 0,
3091 };
3092 store.identity_upsert(&claude).unwrap();
3093 store
3094 .agent_identity_link("def-1", "acct-ok", "claude")
3095 .unwrap();
3096
3097 insert_block_for_agent(&store, "block-ok", "def-1");
3098 let inst = make_instance("block-ok", "id-ok");
3099 store.instance_create(&inst).unwrap();
3100
3101 let mut env: HashMap<String, String> = HashMap::new();
3102 inject_identity_env(store.clone(), store.clone(), store.clone(), "block-ok", &mut env).unwrap();
3103
3104 let after = store.identity_get("acct-ok").unwrap().unwrap();
3105 assert_eq!(after.status, oauth_status::VALID);
3106 assert_eq!(after.updated_at, 0);
3109 }
3110
3111 #[test]
3131 fn spawn_gate_checks_the_agents_own_provider_not_its_bundles() {
3132 let mstore = make_store();
3133 let id_store_tmp = tempfile::NamedTempFile::new().unwrap();
3142 let id_store = Arc::new(Store::open_shared(id_store_tmp.path()).unwrap());
3143 let identity_store_tmp = tempfile::NamedTempFile::new().unwrap();
3148 let identity_store = Arc::new(Store::open_identity_store(identity_store_tmp.path()).unwrap());
3149
3150 let mut def = crate::backend::storage::store::AgentDefinition {
3151 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
3152 id: "def-1".to_string(),
3153 slug: String::new(),
3154 name: "T".to_string(),
3155 icon: "✦".to_string(),
3156 provider: "codex".to_string(),
3158 description: String::new(),
3159 working_directory: String::new(),
3160 shell: String::new(),
3161 provider_flags: String::new(),
3162 auto_start: 0,
3163 restart_on_crash: 0,
3164 idle_timeout_minutes: 0,
3165 created_at: 0,
3166 agent_type: String::new(),
3167 environment: String::new(),
3168 agent_bus_id: String::new(),
3169 is_seeded: 0,
3170 accounts: String::new(),
3171 parent_id: String::new(),
3172 branch_label: String::new(),
3173 updated_at: 0,
3174 user_hidden: 0,
3175 container_image: String::new(),
3176 container_volumes: "[]".to_string(),
3177 container_name: String::new(),
3178 use_ambient_login: 0,
3179 auto_continue_enabled: 0,
3180 model_vendor_base_url: String::new(),
3181 memory_id: "bundle-1".to_string(),
3183 };
3184 mstore.agent_def_insert(&mut def).unwrap();
3185
3186 let bundle = Bundle {
3187 id: "bundle-1".to_string(),
3188 name: "Drift Test Bundle".to_string(),
3189 description: String::new(),
3190 is_blank: false,
3191 is_global: false,
3192 provider: "claude".to_string(),
3193 model: "anthropic".to_string(),
3194 instructions: String::new(),
3195 instructions_by_provider: "{}".to_string(),
3196 context_files: "[]".to_string(),
3197 mcp_servers: "[]".to_string(),
3198 skills: "[]".to_string(),
3199 sort_order: 0,
3200 created_at: 0,
3201 updated_at: 0,
3202 is_system: false,
3203 };
3204 id_store.bundle_upsert(&bundle).unwrap();
3205
3206 let claude = make_account(
3207 "acct-drift",
3208 "claude",
3209 SecretRef::OAuthConfigDir {
3210 dir: test_config_dir("id-drift"),
3211 },
3212 );
3213 id_store.identity_upsert_with_mirror(&identity_store, &claude).unwrap();
3214 identity_store
3215 .agent_identity_link("def-1", "acct-drift", "claude")
3216 .unwrap();
3217
3218 insert_block_for_agent(&mstore, "block-drift", "def-1");
3219 let inst = make_instance("block-drift", "id-drift");
3220 mstore.instance_create(&inst).unwrap();
3221
3222 let mut env: HashMap<String, String> = HashMap::new();
3223 let res = inject_identity_env(mstore, id_store, identity_store, "block-drift", &mut env);
3224
3225 assert!(
3228 matches!(&res, Err(SpawnGateError::MissingCredentials { provider }) if provider == "codex"),
3229 "the agent owns its harness; the bundle's provider is a hint (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1): {res:?}"
3230 );
3231 }
3232
3233 fn make_agent_def_with_provider(id: &str, provider: &str) -> crate::backend::storage::store::AgentDefinition {
3234 crate::backend::storage::store::AgentDefinition {
3235 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
3236 id: id.to_string(),
3237 slug: String::new(),
3238 name: "T".to_string(),
3239 icon: "✦".to_string(),
3240 provider: provider.to_string(),
3241 description: String::new(),
3242 working_directory: String::new(),
3243 shell: String::new(),
3244 provider_flags: String::new(),
3245 auto_start: 0,
3246 restart_on_crash: 0,
3247 idle_timeout_minutes: 0,
3248 created_at: 0,
3249 agent_type: String::new(),
3250 environment: String::new(),
3251 agent_bus_id: String::new(),
3252 is_seeded: 0,
3253 accounts: String::new(),
3254 parent_id: String::new(),
3255 branch_label: String::new(),
3256 updated_at: 0,
3257 user_hidden: 0,
3258 container_image: String::new(),
3259 container_volumes: "[]".to_string(),
3260 container_name: String::new(),
3261 use_ambient_login: 0,
3262 auto_continue_enabled: 0,
3263 model_vendor_base_url: String::new(),
3264 memory_id: String::new(),
3265 }
3266 }
3267
3268 #[cfg(debug_assertions)]
3270 #[test]
3271 fn resolve_bound_oauth_config_dir_resolves_the_identity_bound_dir() {
3272 let store = make_store();
3273 let mut def = make_agent_def_with_provider("def-1", "claude");
3274 store.agent_def_insert(&mut def).unwrap();
3275
3276 let claude = make_account(
3277 "acct-claude",
3278 "claude",
3279 SecretRef::OAuthConfigDir {
3280 dir: test_config_dir("id-bound"),
3281 },
3282 );
3283 store.identity_upsert(&claude).unwrap();
3284 store.agent_identity_link("def-1", "acct-claude", "claude").unwrap();
3285
3286 insert_block_for_agent(&store, "block-bound", "def-1");
3287 let inst = make_instance("block-bound", "id-bound");
3288 store.instance_create(&inst).unwrap();
3289
3290 let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "block-bound");
3291 assert_eq!(
3292 resolved,
3293 Some(std::path::PathBuf::from(test_config_dir("id-bound"))),
3294 );
3295 }
3296
3297 #[cfg(debug_assertions)]
3298 #[test]
3299 fn resolve_bound_oauth_config_dir_none_when_no_instance_for_block() {
3300 let store = make_store();
3301 let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "no-such-block");
3302 assert_eq!(resolved, None);
3303 }
3304
3305 #[cfg(debug_assertions)]
3306 #[test]
3307 fn resolve_bound_oauth_config_dir_none_for_api_key_class_provider() {
3308 let store = make_store();
3309 let mut def = make_agent_def_with_provider("def-1", "github");
3311 store.agent_def_insert(&mut def).unwrap();
3312
3313 let github = make_account(
3314 "acct-gh",
3315 "github",
3316 SecretRef::PlaintextDev { plaintext_dev: "ghp_x".to_string() },
3317 );
3318 store.identity_upsert(&github).unwrap();
3319 store.agent_identity_link("def-1", "acct-gh", "github").unwrap();
3320
3321 insert_block_for_agent(&store, "block-gh", "def-1");
3322 let inst = make_instance("block-gh", "id-gh");
3323 store.instance_create(&inst).unwrap();
3324
3325 let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "block-gh");
3326 assert_eq!(resolved, None);
3327 }
3328
3329 #[cfg(debug_assertions)]
3330 #[test]
3331 fn resolve_bound_oauth_config_dir_none_when_oauth_provider_has_no_binding() {
3332 let store = make_store();
3333 let mut def = make_agent_def_with_provider("def-1", "claude");
3334 store.agent_def_insert(&mut def).unwrap();
3335 insert_block_for_agent(&store, "block-unbound", "def-1");
3338 let inst = make_instance("block-unbound", "");
3339 store.instance_create(&inst).unwrap();
3340
3341 let resolved = resolve_bound_oauth_config_dir(&store, &store, &store, "block-unbound");
3342 assert_eq!(resolved, None, "ambient/unbound agents must fall through, not error");
3343 }
3344
3345 #[cfg(debug_assertions)]
3351 #[test]
3352 fn resolve_bound_oauth_config_dir_does_not_mutate_account_status() {
3353 let store = make_store();
3354 let mut def = make_agent_def_with_provider("def-1", "claude");
3355 store.agent_def_insert(&mut def).unwrap();
3356
3357 let claude = make_account(
3358 "acct-claude",
3359 "claude",
3360 SecretRef::OAuthConfigDir { dir: "/nonexistent/dir/for/probe".to_string() },
3361 );
3362 store.identity_upsert(&claude).unwrap();
3363 store.agent_identity_link("def-1", "acct-claude", "claude").unwrap();
3364
3365 insert_block_for_agent(&store, "block-status", "def-1");
3366 let inst = make_instance("block-status", "id-status");
3367 store.instance_create(&inst).unwrap();
3368
3369 let _ = resolve_bound_oauth_config_dir(&store, &store, &store, "block-status");
3370
3371 let reread = store.identity_get("acct-claude").unwrap().unwrap();
3372 assert_eq!(reread.status, "unknown", "status must be untouched — no probe/upsert side effect");
3373 }
3374}