1use std::collections::HashMap;
13use std::path::Path;
14use std::sync::Arc;
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use crate::backend::storage::error::StoreError;
18use crate::backend::storage::wstore::{SecretRef, WaveStore};
19use crate::backend::wps::{Broker, WaveEvent};
20
21pub mod oauth_status {
31 pub const VALID: &str = "valid";
33 pub const EXPIRED: &str = "expired";
35 pub const NEEDS_REAUTH: &str = "needs_reauth";
37 pub const UNKNOWN: &str = "unknown";
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum OAuthProbeStatus {
49 Valid,
50 Expired,
51 NeedsReauth,
52}
53
54impl OAuthProbeStatus {
55 pub fn as_str(self) -> &'static str {
56 match self {
57 Self::Valid => oauth_status::VALID,
58 Self::Expired => oauth_status::EXPIRED,
59 Self::NeedsReauth => oauth_status::NEEDS_REAUTH,
60 }
61 }
62}
63
64pub fn probe_oauth_status(
86 provider: &str,
87 dir: &str,
88 now_ms: i64,
89) -> Option<OAuthProbeStatus> {
90 let probe_path: std::path::PathBuf = match provider {
91 "claude" | "codex" | "openclaw" => Path::new(dir).join(".credentials.json"),
101 _ => return None,
102 };
103
104 let contents = match std::fs::read_to_string(&probe_path) {
105 Ok(s) => s,
106 Err(e) => {
107 tracing::debug!(
108 target: "identity",
109 provider,
110 path = %probe_path.display(),
111 error = %e,
112 "oauth probe: token file unreadable — status=needs_reauth"
113 );
114 return Some(OAuthProbeStatus::NeedsReauth);
115 }
116 };
117 let json: serde_json::Value = match serde_json::from_str(&contents) {
118 Ok(v) => v,
119 Err(e) => {
120 tracing::debug!(
121 target: "identity",
122 provider,
123 path = %probe_path.display(),
124 error = %e,
125 "oauth probe: token file parse failed — status=needs_reauth"
126 );
127 return Some(OAuthProbeStatus::NeedsReauth);
128 }
129 };
130
131 let expires_at_ms = json
136 .get("claudeAiOauth")
137 .and_then(|o| o.get("expiresAt"))
138 .and_then(|v| v.as_i64())
139 .or_else(|| json.get("expiresAt").and_then(|v| v.as_i64()))
140 .or_else(|| json.get("expires_at").and_then(|v| v.as_i64()));
141
142 let has_refresh = json
143 .get("claudeAiOauth")
144 .and_then(|o| o.get("refreshToken"))
145 .and_then(|v| v.as_str())
146 .map(|s| !s.is_empty())
147 .unwrap_or(false)
148 || json
149 .get("refresh_token")
150 .and_then(|v| v.as_str())
151 .map(|s| !s.is_empty())
152 .unwrap_or(false);
153
154 match expires_at_ms {
155 Some(exp) if exp <= now_ms => {
156 if has_refresh {
161 Some(OAuthProbeStatus::Expired)
162 } else {
163 Some(OAuthProbeStatus::NeedsReauth)
164 }
165 }
166 Some(_) => Some(OAuthProbeStatus::Valid),
167 None => {
168 tracing::debug!(
173 target: "identity",
174 provider,
175 path = %probe_path.display(),
176 "oauth probe: file present but no parseable expiry — status=valid (best-effort)"
177 );
178 Some(OAuthProbeStatus::Valid)
179 }
180 }
181}
182
183#[derive(Debug, thiserror::Error)]
187pub enum ResolverError {
188 #[error("account not found: {0}")]
189 AccountNotFound(String),
190
191 #[error("env var not set in srv environment: {0}")]
192 EnvVarMissing(String),
193
194 #[error("AWS Secrets Manager backend not yet supported (Phase 3)")]
195 SecretsManagerUnsupported,
196
197 #[error("PlaintextDev secrets are disabled in release builds")]
198 PlaintextDevDisabledInRelease,
199
200 #[error("OAuthConfigDir is a config-dir pointer, not a resolvable secret — routed via the oauth-class injection path, not resolve_secret")]
208 OAuthConfigDirNotASecret,
209
210 #[error("storage error: {0}")]
211 Storage(#[from] StoreError),
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum ProviderClass {
219 ApiKey { env_vars: &'static [&'static str] },
225 OAuth { config_dir_env_var: &'static str },
230}
231
232pub fn provider_class(provider: &str) -> Option<ProviderClass> {
235 match provider {
236 "github" => Some(ProviderClass::ApiKey {
240 env_vars: &["GITHUB_TOKEN", "GH_TOKEN"],
241 }),
242 "anthropic" => Some(ProviderClass::ApiKey {
243 env_vars: &["ANTHROPIC_API_KEY"],
244 }),
245 "openai" => Some(ProviderClass::ApiKey {
246 env_vars: &["OPENAI_API_KEY"],
247 }),
248 "kimi" => Some(ProviderClass::ApiKey {
249 env_vars: &["MOONSHOT_API_KEY"],
250 }),
251 "aws" => Some(ProviderClass::ApiKey {
252 env_vars: &["AWS_ACCESS_KEY_ID"],
253 }),
254 "claude" | "codex" | "openclaw" => {
265 crate::backend::providers::get_provider(provider).map(|cfg| {
266 ProviderClass::OAuth {
267 config_dir_env_var: cfg.auth_config_dir_env_var,
268 }
269 })
270 }
271 _ => None,
272 }
273}
274
275pub fn provider_env_vars(provider: &str) -> Vec<&'static str> {
280 match provider_class(provider) {
281 Some(ProviderClass::ApiKey { env_vars }) => env_vars.to_vec(),
282 _ => Vec::new(),
283 }
284}
285
286pub fn resolve_secret(secret_ref: &SecretRef) -> Result<String, ResolverError> {
302 match secret_ref {
303 SecretRef::Env { env_var } => std::env::var(env_var)
304 .map_err(|_| ResolverError::EnvVarMissing(env_var.clone())),
305 SecretRef::PlaintextDev { plaintext_dev } => {
306 #[cfg(debug_assertions)]
307 {
308 Ok(plaintext_dev.clone())
309 }
310 #[cfg(not(debug_assertions))]
311 {
312 let _ = plaintext_dev;
313 Err(ResolverError::PlaintextDevDisabledInRelease)
314 }
315 }
316 SecretRef::SecretsManager { .. } => Err(ResolverError::SecretsManagerUnsupported),
317 SecretRef::OAuthConfigDir { dir } => {
318 let _ = dir;
324 Err(ResolverError::OAuthConfigDirNotASecret)
325 }
326 }
327}
328
329pub fn inject_identity_env(
354 wstore: Arc<WaveStore>,
355 block_id: &str,
356 env_vars: &mut HashMap<String, String>,
357) {
358 inject_identity_env_with_broker(wstore, None, block_id, env_vars);
359}
360
361pub fn inject_identity_env_with_broker(
370 wstore: Arc<WaveStore>,
371 broker: Option<Arc<Broker>>,
372 block_id: &str,
373 env_vars: &mut HashMap<String, String>,
374) {
375 let instance = match wstore.instance_get_active_for_block(block_id) {
377 Ok(Some(i)) => i,
378 Ok(None) => {
379 return;
381 }
382 Err(e) => {
383 tracing::warn!(target: "identity", "instance lookup failed for block {}: {}", block_id, e);
384 return;
385 }
386 };
387
388 if instance.identity_id.is_empty() || instance.identity_id == "blank" {
390 tracing::warn!(
397 target: "identity",
398 "instance {} has empty/blank identity_id — falling back to ambient creds. \
399 Legacy row or UI regression?",
400 block_id
401 );
402 return;
403 }
404
405 let bindings = match wstore.bundle_identity_bindings(&instance.identity_id) {
407 Ok(b) => b,
408 Err(e) => {
409 tracing::warn!(
410 target: "identity",
411 "bindings lookup failed for identity {}: {}",
412 instance.identity_id,
413 e,
414 );
415 return;
416 }
417 };
418
419 if bindings.is_empty() {
420 return;
422 }
423
424 for binding in &bindings {
436 let class = match provider_class(&binding.provider) {
437 Some(c) => c,
438 None => {
439 tracing::warn!(
440 target: "identity",
441 "no provider class for {} (binding for identity {}) — skipping",
442 binding.provider,
443 instance.identity_id,
444 );
445 continue;
446 }
447 };
448
449 let account = match wstore.identity_get(&binding.account_id) {
450 Ok(Some(a)) => a,
451 Ok(None) => {
452 tracing::warn!(
453 target: "identity",
454 "account {} bound to identity {} but row not found — skipping",
455 binding.account_id,
456 instance.identity_id,
457 );
458 continue;
459 }
460 Err(e) => {
461 tracing::warn!(
462 target: "identity",
463 "account lookup failed for {}: {}",
464 binding.account_id,
465 e,
466 );
467 continue;
468 }
469 };
470
471 match class {
472 ProviderClass::ApiKey { env_vars: env_keys } => {
473 let secret = match resolve_secret(&account.secret_ref) {
474 Ok(s) => s,
475 Err(e) => {
476 tracing::warn!(
477 target: "identity",
478 "secret resolution failed for account {} (provider {}): {} — skipping",
479 binding.account_id,
480 binding.provider,
481 e,
482 );
483 continue;
484 }
485 };
486 let env_key_count = env_keys.len();
487 for key in env_keys {
488 env_vars.insert(key.to_string(), secret.clone());
489 }
490 tracing::info!(
491 target: "identity",
492 "injected {} env var(s) for api-key provider {} (identity={}, account={})",
493 env_key_count,
494 binding.provider,
495 instance.identity_id,
496 binding.account_id,
497 );
498 }
499 ProviderClass::OAuth { config_dir_env_var } => {
500 let dir = match &account.secret_ref {
504 SecretRef::OAuthConfigDir { dir } => dir.clone(),
505 other => {
506 tracing::warn!(
507 target: "identity",
508 "oauth-class provider {} has non-OAuthConfigDir secret_ref \
509 ({:?}) on account {} — skipping",
510 binding.provider,
511 other,
512 binding.account_id,
513 );
514 continue;
515 }
516 };
517 env_vars.insert(config_dir_env_var.to_string(), dir.clone());
518 tracing::info!(
519 target: "identity",
520 "injected {} for oauth provider {} (identity={}, account={})",
521 config_dir_env_var,
522 binding.provider,
523 instance.identity_id,
524 binding.account_id,
525 );
526
527 let now_ms = SystemTime::now()
536 .duration_since(UNIX_EPOCH)
537 .map(|d| d.as_millis() as i64)
538 .unwrap_or(0);
539 if let Some(probed) = probe_oauth_status(&binding.provider, &dir, now_ms) {
540 let new_status = probed.as_str();
541 if account.status != new_status {
542 let mut updated = account.clone();
543 updated.status = new_status.to_string();
544 updated.updated_at = now_ms;
545 match wstore.identity_upsert(&updated) {
546 Ok(()) => {
547 tracing::info!(
548 target: "identity",
549 provider = %binding.provider,
550 account_id = %binding.account_id,
551 old_status = %account.status,
552 new_status,
553 "oauth probe: status updated"
554 );
555 if let Some(b) = broker.as_ref() {
564 b.publish(WaveEvent {
565 event: format!(
566 "identitybundlebindings:changed:{}",
567 instance.identity_id,
568 ),
569 scopes: vec![],
570 sender: String::new(),
571 persist: 0,
572 data: None,
573 });
574 }
575 }
576 Err(e) => {
577 tracing::warn!(
578 target: "identity",
579 provider = %binding.provider,
580 account_id = %binding.account_id,
581 error = %e,
582 "oauth probe: identity_upsert failed — status not persisted",
583 );
584 }
585 }
586 }
587 }
588 }
589 }
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::backend::storage::wstore::{
597 AgentInstance, Identity, IdentityAccount, InstanceStatus, SecretRef,
598 };
599
600 fn make_store() -> Arc<WaveStore> {
601 Arc::new(WaveStore::open_in_memory().unwrap())
602 }
603
604 fn make_account(
605 id: &str,
606 provider: &str,
607 secret_ref: SecretRef,
608 ) -> IdentityAccount {
609 IdentityAccount {
610 id: id.to_string(),
611 name: format!("{}-{}", provider, id),
612 provider: provider.to_string(),
613 kind: "pat".to_string(),
614 display_name: String::new(),
615 secret_ref,
616 context: serde_json::json!({}),
617 status: "unknown".to_string(),
618 created_at: 0,
619 updated_at: 0,
620 }
621 }
622
623 fn make_instance(block_id: &str, identity_id: &str) -> AgentInstance {
624 AgentInstance {
625 id: format!("inst-{block_id}"),
626 definition_id: "def-1".to_string(),
627 parent_instance_id: String::new(),
628 block_id: block_id.to_string(),
629 session_id: String::new(),
630 status: InstanceStatus::Running.as_str().to_string(),
631 github_context: String::new(),
632 started_at: 0,
633 ended_at: 0,
634 created_at: 0,
635 identity_id: identity_id.to_string(),
636 memory_id: String::new(),
637 instance_name: String::new(),
638 working_directory: String::new(),
639 display_hidden: false,
640 }
641 }
642
643 #[test]
644 fn provider_env_vars_matrix() {
645 assert_eq!(provider_env_vars("github"), vec!["GITHUB_TOKEN", "GH_TOKEN"]);
646 assert_eq!(provider_env_vars("anthropic"), vec!["ANTHROPIC_API_KEY"]);
647 assert_eq!(provider_env_vars("openai"), vec!["OPENAI_API_KEY"]);
648 assert_eq!(provider_env_vars("kimi"), vec!["MOONSHOT_API_KEY"]);
649 assert_eq!(provider_env_vars("aws"), vec!["AWS_ACCESS_KEY_ID"]);
650 assert!(provider_env_vars("unknown").is_empty());
651 }
652
653 #[cfg(debug_assertions)]
661 #[test]
662 fn resolve_plaintext_dev() {
663 let s = resolve_secret(&SecretRef::PlaintextDev {
664 plaintext_dev: "ghp_test123".to_string(),
665 })
666 .unwrap();
667 assert_eq!(s, "ghp_test123");
668 }
669
670 #[test]
671 fn resolve_env_var_missing() {
672 let res = resolve_secret(&SecretRef::Env {
673 env_var: "AGENTMUX_TEST_NEVER_SET_X9Q".to_string(),
674 });
675 assert!(matches!(res, Err(ResolverError::EnvVarMissing(_))));
676 }
677
678 #[test]
679 fn resolve_secrets_manager_unsupported() {
680 let res = resolve_secret(&SecretRef::SecretsManager {
681 sm_path: "ignored".to_string(),
682 sm_json_path: None,
683 });
684 assert!(matches!(res, Err(ResolverError::SecretsManagerUnsupported)));
685 }
686
687 #[test]
688 fn provider_class_oauth_providers() {
689 assert_eq!(
696 provider_class("claude"),
697 Some(ProviderClass::OAuth { config_dir_env_var: "CLAUDE_CONFIG_DIR" }),
698 );
699 assert_eq!(
700 provider_class("codex"),
701 Some(ProviderClass::OAuth { config_dir_env_var: "CODEX_HOME" }),
702 );
703 assert_eq!(
704 provider_class("openclaw"),
705 Some(ProviderClass::OAuth { config_dir_env_var: "OPENCLAW_HOME" }),
706 );
707 }
708
709 #[cfg(debug_assertions)]
710 #[test]
711 fn inject_oauth_class_sets_config_dir_env_var() {
712 let store = make_store();
713
714 let mut def = crate::backend::storage::wstore::AgentDefinition {
715 id: "def-1".to_string(),
716 slug: String::new(),
717 name: "T".to_string(),
718 icon: "✦".to_string(),
719 provider: "claude".to_string(),
720 description: String::new(),
721 working_directory: String::new(),
722 shell: String::new(),
723 provider_flags: String::new(),
724 auto_start: 0,
725 restart_on_crash: 0,
726 idle_timeout_minutes: 0,
727 created_at: 0,
728 agent_type: String::new(),
729 environment: String::new(),
730 agent_bus_id: String::new(),
731 is_seeded: 0,
732 accounts: String::new(),
733 parent_id: String::new(),
734 branch_label: String::new(),
735 updated_at: 0,
736 user_hidden: 0,
737 };
738 store.agent_def_insert(&mut def).unwrap();
739
740 let identity = Identity {
741 id: "id-oauth".to_string(),
742 name: "OAuth".to_string(),
743 description: String::new(),
744 is_blank: false,
745 created_at: 0,
746 updated_at: 0,
747 };
748 store.bundle_identity_upsert(&identity).unwrap();
749
750 let claude = make_account(
751 "acct-claude",
752 "claude",
753 SecretRef::OAuthConfigDir {
754 dir: "/var/agentmux/identities/id-oauth/claude".to_string(),
755 },
756 );
757 store.identity_upsert(&claude).unwrap();
758 store
759 .bundle_identity_bind("id-oauth", "claude", "acct-claude")
760 .unwrap();
761
762 let inst = make_instance("block-oauth", "id-oauth");
763 store.instance_create(&inst).unwrap();
764
765 let mut env: HashMap<String, String> = HashMap::new();
766 inject_identity_env(store, "block-oauth", &mut env);
767
768 assert_eq!(
770 env.get("CLAUDE_CONFIG_DIR").map(String::as_str),
771 Some("/var/agentmux/identities/id-oauth/claude"),
772 );
773 assert!(env.get("ANTHROPIC_API_KEY").is_none());
776 }
777
778 #[cfg(debug_assertions)]
779 #[test]
780 fn inject_oauth_class_skips_account_with_non_oauth_secret_ref() {
781 let store = make_store();
786
787 let mut def = crate::backend::storage::wstore::AgentDefinition {
788 id: "def-1".to_string(),
789 slug: String::new(),
790 name: "T".to_string(),
791 icon: "✦".to_string(),
792 provider: "claude".to_string(),
793 description: String::new(),
794 working_directory: String::new(),
795 shell: String::new(),
796 provider_flags: String::new(),
797 auto_start: 0,
798 restart_on_crash: 0,
799 idle_timeout_minutes: 0,
800 created_at: 0,
801 agent_type: String::new(),
802 environment: String::new(),
803 agent_bus_id: String::new(),
804 is_seeded: 0,
805 accounts: String::new(),
806 parent_id: String::new(),
807 branch_label: String::new(),
808 updated_at: 0,
809 user_hidden: 0,
810 };
811 store.agent_def_insert(&mut def).unwrap();
812
813 let identity = Identity {
814 id: "id-bad".to_string(),
815 name: "Bad".to_string(),
816 description: String::new(),
817 is_blank: false,
818 created_at: 0,
819 updated_at: 0,
820 };
821 store.bundle_identity_upsert(&identity).unwrap();
822
823 let bad = make_account(
824 "acct-bad",
825 "claude",
826 SecretRef::Env {
827 env_var: "CLAUDE_TOKEN_NOT_A_DIR".to_string(),
828 },
829 );
830 store.identity_upsert(&bad).unwrap();
831 store
832 .bundle_identity_bind("id-bad", "claude", "acct-bad")
833 .unwrap();
834
835 let inst = make_instance("block-bad", "id-bad");
836 store.instance_create(&inst).unwrap();
837
838 let mut env: HashMap<String, String> = HashMap::new();
839 inject_identity_env(store, "block-bad", &mut env);
840
841 assert!(env.get("CLAUDE_CONFIG_DIR").is_none());
843 assert!(env.is_empty());
844 }
845
846 #[test]
847 fn resolve_oauth_config_dir_is_not_a_secret() {
848 let res = resolve_secret(&SecretRef::OAuthConfigDir {
856 dir: "/path/to/bundle/claude".to_string(),
857 });
858 assert!(matches!(res, Err(ResolverError::OAuthConfigDirNotASecret)));
859 }
860
861 #[test]
862 fn inject_no_instance_does_nothing() {
863 let store = make_store();
864 let mut env: HashMap<String, String> = HashMap::new();
865 inject_identity_env(store, "block-no-instance", &mut env);
866 assert!(env.is_empty());
867 }
868
869 #[test]
870 fn inject_blank_identity_does_nothing() {
871 let store = make_store();
872 let mut def = crate::backend::storage::wstore::AgentDefinition {
874 id: "def-1".to_string(),
875 slug: String::new(),
876 name: "T".to_string(),
877 icon: "✦".to_string(),
878 provider: "claude".to_string(),
879 description: String::new(),
880 working_directory: String::new(),
881 shell: String::new(),
882 provider_flags: String::new(),
883 auto_start: 0,
884 restart_on_crash: 0,
885 idle_timeout_minutes: 0,
886 created_at: 0,
887 agent_type: String::new(),
888 environment: String::new(),
889 agent_bus_id: String::new(),
890 is_seeded: 0,
891 accounts: String::new(),
892 parent_id: String::new(),
893 branch_label: String::new(),
894 updated_at: 0,
895 user_hidden: 0,
896 };
897 store.agent_def_insert(&mut def).unwrap();
898
899 let mut inst = make_instance("block-blank", "blank");
900 store.instance_create(&inst).unwrap();
901 let _ = inst; let mut env: HashMap<String, String> = HashMap::new();
904 inject_identity_env(store, "block-blank", &mut env);
905 assert!(env.is_empty());
906 }
907
908 #[cfg(debug_assertions)]
909 #[test]
910 fn inject_full_round_trip_plaintext_dev() {
911 let store = make_store();
912
913 let mut def = crate::backend::storage::wstore::AgentDefinition {
915 id: "def-1".to_string(),
916 slug: String::new(),
917 name: "T".to_string(),
918 icon: "✦".to_string(),
919 provider: "claude".to_string(),
920 description: String::new(),
921 working_directory: String::new(),
922 shell: String::new(),
923 provider_flags: String::new(),
924 auto_start: 0,
925 restart_on_crash: 0,
926 idle_timeout_minutes: 0,
927 created_at: 0,
928 agent_type: String::new(),
929 environment: String::new(),
930 agent_bus_id: String::new(),
931 is_seeded: 0,
932 accounts: String::new(),
933 parent_id: String::new(),
934 branch_label: String::new(),
935 updated_at: 0,
936 user_hidden: 0,
937 };
938 store.agent_def_insert(&mut def).unwrap();
939
940 let identity = Identity {
942 id: "id-work".to_string(),
943 name: "Work".to_string(),
944 description: String::new(),
945 is_blank: false,
946 created_at: 0,
947 updated_at: 0,
948 };
949 store.bundle_identity_upsert(&identity).unwrap();
950
951 let github = make_account(
953 "acct-gh",
954 "github",
955 SecretRef::PlaintextDev {
956 plaintext_dev: "ghp_round_trip".to_string(),
957 },
958 );
959 store.identity_upsert(&github).unwrap();
960 store
961 .bundle_identity_bind("id-work", "github", "acct-gh")
962 .unwrap();
963
964 let anthropic = make_account(
966 "acct-anth",
967 "anthropic",
968 SecretRef::PlaintextDev {
969 plaintext_dev: "sk-ant-round_trip".to_string(),
970 },
971 );
972 store.identity_upsert(&anthropic).unwrap();
973 store
974 .bundle_identity_bind("id-work", "anthropic", "acct-anth")
975 .unwrap();
976
977 let inst = make_instance("block-1", "id-work");
979 store.instance_create(&inst).unwrap();
980
981 let mut env: HashMap<String, String> = HashMap::new();
982 inject_identity_env(store, "block-1", &mut env);
983
984 assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_round_trip"));
986 assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_round_trip"));
987 assert_eq!(
989 env.get("ANTHROPIC_API_KEY").map(String::as_str),
990 Some("sk-ant-round_trip"),
991 );
992 }
993
994 #[cfg(debug_assertions)]
995 #[test]
996 fn inject_partial_success_skips_failed_bindings() {
997 let store = make_store();
998
999 let mut def = crate::backend::storage::wstore::AgentDefinition {
1000 id: "def-1".to_string(),
1001 slug: String::new(),
1002 name: "T".to_string(),
1003 icon: "✦".to_string(),
1004 provider: "claude".to_string(),
1005 description: String::new(),
1006 working_directory: String::new(),
1007 shell: String::new(),
1008 provider_flags: String::new(),
1009 auto_start: 0,
1010 restart_on_crash: 0,
1011 idle_timeout_minutes: 0,
1012 created_at: 0,
1013 agent_type: String::new(),
1014 environment: String::new(),
1015 agent_bus_id: String::new(),
1016 is_seeded: 0,
1017 accounts: String::new(),
1018 parent_id: String::new(),
1019 branch_label: String::new(),
1020 updated_at: 0,
1021 user_hidden: 0,
1022 };
1023 store.agent_def_insert(&mut def).unwrap();
1024
1025 let identity = Identity {
1026 id: "id-mixed".to_string(),
1027 name: "Mixed".to_string(),
1028 description: String::new(),
1029 is_blank: false,
1030 created_at: 0,
1031 updated_at: 0,
1032 };
1033 store.bundle_identity_upsert(&identity).unwrap();
1034
1035 let good = make_account(
1037 "acct-good",
1038 "github",
1039 SecretRef::PlaintextDev {
1040 plaintext_dev: "ghp_good".to_string(),
1041 },
1042 );
1043 store.identity_upsert(&good).unwrap();
1044 store
1045 .bundle_identity_bind("id-mixed", "github", "acct-good")
1046 .unwrap();
1047
1048 let bad = make_account(
1050 "acct-bad",
1051 "anthropic",
1052 SecretRef::Env {
1053 env_var: "AGENTMUX_TEST_DEFINITELY_NOT_SET_4242".to_string(),
1054 },
1055 );
1056 store.identity_upsert(&bad).unwrap();
1057 store
1058 .bundle_identity_bind("id-mixed", "anthropic", "acct-bad")
1059 .unwrap();
1060
1061 let inst = make_instance("block-mixed", "id-mixed");
1062 store.instance_create(&inst).unwrap();
1063
1064 let mut env: HashMap<String, String> = HashMap::new();
1065 inject_identity_env(store, "block-mixed", &mut env);
1066
1067 assert_eq!(env.get("GITHUB_TOKEN").map(String::as_str), Some("ghp_good"));
1069 assert_eq!(env.get("GH_TOKEN").map(String::as_str), Some("ghp_good"));
1070 assert!(env.get("ANTHROPIC_API_KEY").is_none());
1072 }
1073
1074 #[cfg(debug_assertions)]
1075 #[test]
1076 fn inject_unknown_provider_is_skipped() {
1077 let store = make_store();
1078
1079 let mut def = crate::backend::storage::wstore::AgentDefinition {
1080 id: "def-1".to_string(),
1081 slug: String::new(),
1082 name: "T".to_string(),
1083 icon: "✦".to_string(),
1084 provider: "claude".to_string(),
1085 description: String::new(),
1086 working_directory: String::new(),
1087 shell: String::new(),
1088 provider_flags: String::new(),
1089 auto_start: 0,
1090 restart_on_crash: 0,
1091 idle_timeout_minutes: 0,
1092 created_at: 0,
1093 agent_type: String::new(),
1094 environment: String::new(),
1095 agent_bus_id: String::new(),
1096 is_seeded: 0,
1097 accounts: String::new(),
1098 parent_id: String::new(),
1099 branch_label: String::new(),
1100 updated_at: 0,
1101 user_hidden: 0,
1102 };
1103 store.agent_def_insert(&mut def).unwrap();
1104
1105 let identity = Identity {
1106 id: "id-future".to_string(),
1107 name: "Future".to_string(),
1108 description: String::new(),
1109 is_blank: false,
1110 created_at: 0,
1111 updated_at: 0,
1112 };
1113 store.bundle_identity_upsert(&identity).unwrap();
1114
1115 let custom = make_account(
1116 "acct-custom",
1117 "custom",
1118 SecretRef::PlaintextDev {
1119 plaintext_dev: "ignored".to_string(),
1120 },
1121 );
1122 store.identity_upsert(&custom).unwrap();
1123 store
1124 .bundle_identity_bind("id-future", "custom", "acct-custom")
1125 .unwrap();
1126
1127 let inst = make_instance("block-future", "id-future");
1128 store.instance_create(&inst).unwrap();
1129
1130 let mut env: HashMap<String, String> = HashMap::new();
1131 inject_identity_env(store, "block-future", &mut env);
1132 assert!(env.is_empty());
1134 }
1135
1136 fn write_claude_creds(
1143 dir: &std::path::Path,
1144 expires_ms: i64,
1145 with_refresh: bool,
1146 ) {
1147 std::fs::create_dir_all(dir).unwrap();
1148 let body = serde_json::json!({
1149 "claudeAiOauth": {
1150 "accessToken": "test-access",
1151 "refreshToken": if with_refresh { "test-refresh" } else { "" },
1152 "expiresAt": expires_ms,
1153 }
1154 });
1155 std::fs::write(
1156 dir.join(".credentials.json"),
1157 serde_json::to_string(&body).unwrap(),
1158 )
1159 .unwrap();
1160 }
1161
1162 #[test]
1163 fn probe_oauth_status_unknown_provider_returns_none() {
1164 let r = probe_oauth_status("github", "/tmp/whatever", 0);
1170 assert_eq!(r, None);
1171 }
1172
1173 #[test]
1174 fn probe_oauth_status_missing_dir_is_needs_reauth() {
1175 let r = probe_oauth_status("claude", "/definitely/does/not/exist-xyz-9q", 0);
1176 assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
1177 }
1178
1179 #[test]
1180 fn probe_oauth_status_future_expiry_is_valid() {
1181 let tmp = tempfile::tempdir().unwrap();
1182 let now_ms = 1_700_000_000_000;
1183 write_claude_creds(tmp.path(), now_ms + 3_600_000, true);
1184 let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
1185 assert_eq!(r, Some(OAuthProbeStatus::Valid));
1186 }
1187
1188 #[test]
1189 fn probe_oauth_status_past_expiry_with_refresh_is_expired() {
1190 let tmp = tempfile::tempdir().unwrap();
1191 let now_ms = 1_700_000_000_000;
1192 write_claude_creds(tmp.path(), now_ms - 1, true);
1193 let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
1194 assert_eq!(r, Some(OAuthProbeStatus::Expired));
1195 }
1196
1197 #[test]
1198 fn probe_oauth_status_past_expiry_no_refresh_is_needs_reauth() {
1199 let tmp = tempfile::tempdir().unwrap();
1203 let now_ms = 1_700_000_000_000;
1204 write_claude_creds(tmp.path(), now_ms - 1, false);
1205 let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), now_ms);
1206 assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
1207 }
1208
1209 #[test]
1210 fn probe_oauth_status_malformed_json_is_needs_reauth() {
1211 let tmp = tempfile::tempdir().unwrap();
1212 std::fs::write(tmp.path().join(".credentials.json"), "{ not json").unwrap();
1213 let r = probe_oauth_status("claude", tmp.path().to_str().unwrap(), 0);
1214 assert_eq!(r, Some(OAuthProbeStatus::NeedsReauth));
1215 }
1216
1217 #[test]
1218 fn probe_oauth_status_codex_unknown_shape_is_valid_best_effort() {
1219 let tmp = tempfile::tempdir().unwrap();
1225 std::fs::write(
1226 tmp.path().join(".credentials.json"),
1227 r#"{"some":"opaque-codex-blob"}"#,
1228 )
1229 .unwrap();
1230 let r = probe_oauth_status("codex", tmp.path().to_str().unwrap(), 0);
1231 assert_eq!(r, Some(OAuthProbeStatus::Valid));
1232 }
1233
1234 #[cfg(debug_assertions)]
1235 #[test]
1236 fn inject_oauth_class_probes_and_flips_status_to_needs_reauth() {
1237 let store = make_store();
1242
1243 let mut def = crate::backend::storage::wstore::AgentDefinition {
1244 id: "def-1".to_string(),
1245 slug: String::new(),
1246 name: "T".to_string(),
1247 icon: "✦".to_string(),
1248 provider: "claude".to_string(),
1249 description: String::new(),
1250 working_directory: String::new(),
1251 shell: String::new(),
1252 provider_flags: String::new(),
1253 auto_start: 0,
1254 restart_on_crash: 0,
1255 idle_timeout_minutes: 0,
1256 created_at: 0,
1257 agent_type: String::new(),
1258 environment: String::new(),
1259 agent_bus_id: String::new(),
1260 is_seeded: 0,
1261 accounts: String::new(),
1262 parent_id: String::new(),
1263 branch_label: String::new(),
1264 updated_at: 0,
1265 user_hidden: 0,
1266 };
1267 store.agent_def_insert(&mut def).unwrap();
1268
1269 let identity = Identity {
1270 id: "id-probe".to_string(),
1271 name: "Probe".to_string(),
1272 description: String::new(),
1273 is_blank: false,
1274 created_at: 0,
1275 updated_at: 0,
1276 };
1277 store.bundle_identity_upsert(&identity).unwrap();
1278
1279 let tmp = tempfile::tempdir().unwrap();
1282 let bundle_dir = tmp.path().to_str().unwrap().to_string();
1283
1284 let claude = IdentityAccount {
1285 id: "acct-claude".to_string(),
1286 name: "claude-acct-claude".to_string(),
1287 provider: "claude".to_string(),
1288 kind: "oauth".to_string(),
1289 display_name: String::new(),
1290 secret_ref: SecretRef::OAuthConfigDir { dir: bundle_dir },
1291 context: serde_json::json!({}),
1292 status: oauth_status::VALID.to_string(),
1295 created_at: 0,
1296 updated_at: 0,
1297 };
1298 store.identity_upsert(&claude).unwrap();
1299 store
1300 .bundle_identity_bind("id-probe", "claude", "acct-claude")
1301 .unwrap();
1302
1303 let inst = make_instance("block-probe", "id-probe");
1304 store.instance_create(&inst).unwrap();
1305
1306 let mut env: HashMap<String, String> = HashMap::new();
1307 inject_identity_env(store.clone(), "block-probe", &mut env);
1308
1309 assert!(env.get("CLAUDE_CONFIG_DIR").is_some());
1313
1314 let after = store.identity_get("acct-claude").unwrap().unwrap();
1316 assert_eq!(after.status, oauth_status::NEEDS_REAUTH);
1317 }
1318
1319 #[cfg(debug_assertions)]
1320 #[test]
1321 fn inject_oauth_class_probe_preserves_status_when_valid() {
1322 let store = make_store();
1327
1328 let mut def = crate::backend::storage::wstore::AgentDefinition {
1329 id: "def-1".to_string(),
1330 slug: String::new(),
1331 name: "T".to_string(),
1332 icon: "✦".to_string(),
1333 provider: "claude".to_string(),
1334 description: String::new(),
1335 working_directory: String::new(),
1336 shell: String::new(),
1337 provider_flags: String::new(),
1338 auto_start: 0,
1339 restart_on_crash: 0,
1340 idle_timeout_minutes: 0,
1341 created_at: 0,
1342 agent_type: String::new(),
1343 environment: String::new(),
1344 agent_bus_id: String::new(),
1345 is_seeded: 0,
1346 accounts: String::new(),
1347 parent_id: String::new(),
1348 branch_label: String::new(),
1349 updated_at: 0,
1350 user_hidden: 0,
1351 };
1352 store.agent_def_insert(&mut def).unwrap();
1353
1354 let identity = Identity {
1355 id: "id-ok".to_string(),
1356 name: "Ok".to_string(),
1357 description: String::new(),
1358 is_blank: false,
1359 created_at: 0,
1360 updated_at: 0,
1361 };
1362 store.bundle_identity_upsert(&identity).unwrap();
1363
1364 let tmp = tempfile::tempdir().unwrap();
1365 let now_ms = SystemTime::now()
1366 .duration_since(UNIX_EPOCH)
1367 .unwrap()
1368 .as_millis() as i64;
1369 write_claude_creds(tmp.path(), now_ms + 3_600_000, true);
1370
1371 let claude = IdentityAccount {
1372 id: "acct-ok".to_string(),
1373 name: "claude-acct-ok".to_string(),
1374 provider: "claude".to_string(),
1375 kind: "oauth".to_string(),
1376 display_name: String::new(),
1377 secret_ref: SecretRef::OAuthConfigDir {
1378 dir: tmp.path().to_str().unwrap().to_string(),
1379 },
1380 context: serde_json::json!({}),
1381 status: oauth_status::VALID.to_string(),
1382 created_at: 0,
1383 updated_at: 0,
1384 };
1385 store.identity_upsert(&claude).unwrap();
1386 store
1387 .bundle_identity_bind("id-ok", "claude", "acct-ok")
1388 .unwrap();
1389
1390 let inst = make_instance("block-ok", "id-ok");
1391 store.instance_create(&inst).unwrap();
1392
1393 let mut env: HashMap<String, String> = HashMap::new();
1394 inject_identity_env(store.clone(), "block-ok", &mut env);
1395
1396 let after = store.identity_get("acct-ok").unwrap().unwrap();
1397 assert_eq!(after.status, oauth_status::VALID);
1398 assert_eq!(after.updated_at, 0);
1401 }
1402}