1use crate::backend::providers::{get_provider, ProviderConfig};
15use crate::backend::storage::store::Store;
16
17pub(crate) fn link_history_if_isolated(
38 paths: &agentmux_common::DataPaths,
39 dir: &std::path::Path,
40 provider: &ProviderConfig,
41 entity_id: &str,
42 log_ctx: &str,
43) {
44 let Some(subdir) = provider.history_native_subdir else {
45 return;
46 };
47 if !agentmux_common::isolated_auth_enabled() {
48 return;
49 }
50 if paths.identity_dir(entity_id).is_none() {
62 tracing::warn!(
63 target: "identity",
64 provider_id = provider.id,
65 entity_id,
66 "{log_ctx}: entity_id is not a safe path segment — skipping history link"
67 );
68 return;
69 }
70 if let Err(e) = agentmux_common::ensure_history_link(
71 &dir.join(subdir),
72 &paths.identity_history_dir(entity_id, provider.auth_dir_name, subdir),
73 ) {
74 tracing::warn!(
75 target: "identity",
76 provider_id = provider.id,
77 entity_id,
78 error = %e,
79 "{log_ctx}: failed to link conversation history to the global location — \
80 this history may not survive a channel/build change"
81 );
82 }
83}
84
85pub(crate) fn compute_and_ensure_bundle_dir(
109 into_bundle_id: Option<&str>,
110 provider_id: &str,
111 auth_env: &mut std::collections::HashMap<String, String>,
112) -> Option<String> {
113 let bundle_id = into_bundle_id.filter(|s| !s.is_empty())?;
114 match crate::identity::resolver::provider_class(provider_id) {
122 Some(crate::identity::resolver::ProviderClass::OAuth { .. }) => {}
123 _ => return None,
124 }
125 let provider = match get_provider(provider_id) {
126 Some(p) => p,
127 None => {
128 tracing::warn!(
132 target: "identity",
133 provider_id,
134 "auth.start: oauth-class provider not in registry — skipping per-bundle dir override"
135 );
136 return None;
137 }
138 };
139 if provider.auth_config_dir_env_var.is_empty() {
142 return None;
143 }
144 let paths = match agentmux_common::DataPaths::from_env() {
145 Some(p) => p,
146 None => {
147 tracing::warn!(
148 target: "identity",
149 provider_id,
150 bundle_id,
151 "auth.start: DataPaths::from_env() returned None — skipping per-bundle dir override"
152 );
153 return None;
154 }
155 };
156 let bundle_root = match paths.identity_dir(bundle_id) {
161 Some(p) => p,
162 None => {
163 tracing::warn!(
164 target: "identity",
165 provider_id,
166 bundle_id,
167 "auth.start: bundle_id is not a safe path segment — skipping per-bundle dir override"
168 );
169 return None;
170 }
171 };
172 let dir = bundle_root.join(provider.auth_dir_name);
173 if let Err(e) = std::fs::create_dir_all(&dir) {
174 tracing::warn!(
175 target: "identity",
176 provider_id,
177 bundle_id,
178 error = %e,
179 path = %dir.display(),
180 "auth.start: failed to create per-bundle config dir — skipping override"
181 );
182 return None;
183 }
184 link_history_if_isolated(&paths, &dir, provider, bundle_id, "auth.start");
185 let dir_str = dir.to_string_lossy().to_string();
186 auth_env.insert(
193 provider.auth_config_dir_env_var.to_string(),
194 dir_str.clone(),
195 );
196 tracing::info!(
197 target: "identity",
198 provider_id,
199 bundle_id,
200 env_var = provider.auth_config_dir_env_var,
201 dir = %dir.display(),
202 "auth.start: per-bundle OAuth config dir wired"
203 );
204 Some(dir_str)
205}
206
207const ORPHAN_SWEEP_MIN_AGE_SECS: u64 = 30 * 60;
222
223pub(crate) fn compute_and_ensure_account_dir(
229 store: &Store,
230 existing_account_id: &str,
231 provider_id: &str,
232 auth_env: &mut std::collections::HashMap<String, String>,
233) -> (String, Option<String>) {
234 let account_id = if existing_account_id.is_empty() {
235 uuid::Uuid::new_v4().to_string()
236 } else {
237 existing_account_id.to_string()
238 };
239
240 match crate::identity::resolver::provider_class(provider_id) {
246 Some(crate::identity::resolver::ProviderClass::OAuth { .. }) => {}
247 _ => return (account_id, None),
248 }
249 let provider = match get_provider(provider_id) {
250 Some(p) => p,
251 None => {
252 tracing::warn!(
253 target: "identity",
254 provider_id,
255 account_id,
256 "auth.start (direct-account): oauth-class provider not in registry — skipping config dir"
257 );
258 return (account_id, None);
259 }
260 };
261 if provider.auth_config_dir_env_var.is_empty() {
262 return (account_id, None);
263 }
264 let paths = match agentmux_common::DataPaths::from_env() {
265 Some(p) => p,
266 None => {
267 tracing::warn!(
268 target: "identity",
269 provider_id,
270 account_id,
271 "auth.start (direct-account): DataPaths::from_env() returned None — skipping config dir"
272 );
273 return (account_id, None);
274 }
275 };
276 if existing_account_id.is_empty() {
277 let removed = crate::identity::cleanup::sweep_orphaned_account_dirs(
278 store,
279 &paths.identities_dir(),
280 ORPHAN_SWEEP_MIN_AGE_SECS,
281 );
282 if !removed.is_empty() {
283 tracing::info!(
284 target: "identity",
285 provider_id,
286 account_id,
287 swept = removed.len(),
288 "auth.start (direct-account): swept orphaned account dirs before minting a fresh one"
289 );
290 }
291 }
292 let account_root = match paths.identity_dir(&account_id) {
295 Some(p) => p,
296 None => {
297 tracing::warn!(
298 target: "identity",
299 provider_id,
300 account_id,
301 "auth.start (direct-account): account_id is not a safe path segment — skipping config dir"
302 );
303 return (account_id, None);
304 }
305 };
306 let dir = account_root.join(provider.auth_dir_name);
307 if let Err(e) = std::fs::create_dir_all(&dir) {
308 tracing::warn!(
309 target: "identity",
310 provider_id,
311 account_id,
312 error = %e,
313 path = %dir.display(),
314 "auth.start (direct-account): failed to create config dir — skipping"
315 );
316 return (account_id, None);
317 }
318 link_history_if_isolated(&paths, &dir, provider, &account_id, "auth.start (direct-account)");
319 let dir_str = dir.to_string_lossy().to_string();
320 auth_env.insert(provider.auth_config_dir_env_var.to_string(), dir_str.clone());
321 tracing::info!(
322 target: "identity",
323 provider_id,
324 account_id,
325 env_var = provider.auth_config_dir_env_var,
326 dir = %dir.display(),
327 "auth.start (direct-account): OAuth config dir wired"
328 );
329 (account_id, Some(dir_str))
330}
331
332pub(crate) fn caller_auth_dir(
337 provider_id: &str,
338 auth_env: &std::collections::HashMap<String, String>,
339) -> Option<String> {
340 let var = get_provider(provider_id)?.auth_config_dir_env_var;
341 if var.is_empty() {
342 return None;
343 }
344 auth_env.get(var).filter(|d| !d.is_empty()).cloned()
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn caller_auth_dir_reads_the_providers_own_variable() {
353 let mut env = std::collections::HashMap::new();
354 assert_eq!(caller_auth_dir("claude", &env), None);
355 env.insert("CLAUDE_CONFIG_DIR".to_string(), "/accounts/a".to_string());
356 env.insert("CODEX_HOME".to_string(), "/accounts/b".to_string());
357 assert_eq!(caller_auth_dir("claude", &env).as_deref(), Some("/accounts/a"));
358 assert_eq!(caller_auth_dir("codex", &env).as_deref(), Some("/accounts/b"));
359 env.insert("CLAUDE_CONFIG_DIR".to_string(), String::new());
360 assert_eq!(caller_auth_dir("claude", &env), None);
361 assert_eq!(caller_auth_dir("no-such-provider", &env), None);
362 }
363
364 use crate::test_support::ISOLATED_AUTH_ENV_LOCK as ENV_LOCK;
377
378 #[test]
379 fn compute_account_dir_mints_fresh_id_when_existing_is_empty() {
380 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
381 let tmp = tempfile::TempDir::new().unwrap();
382 std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
383 let paths = agentmux_common::DataPaths::resolve(
384 "0.0.0-test",
385 &agentmux_common::RuntimeMode::Installed,
386 )
387 .unwrap();
388 paths.ensure_dirs().unwrap();
389 for (k, v) in paths.to_env_vars() {
390 std::env::set_var(k, v);
391 }
392
393 let mstore = Store::open_in_memory().unwrap();
394 let mut env = std::collections::HashMap::new();
395 let (account_id, dir) = compute_and_ensure_account_dir(&mstore, "", "claude", &mut env);
396 assert!(!account_id.is_empty(), "must mint a fresh id when none supplied");
397 let dir = dir.expect("oauth-class provider must yield a dir");
398 let expected = paths.identity_dir(&account_id).unwrap().join("claude");
399 assert_eq!(std::path::Path::new(&dir), expected);
400 assert!(expected.is_dir());
401 assert_eq!(env.get("CLAUDE_CONFIG_DIR").map(String::as_str), Some(dir.as_str()));
402
403 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
404 for (k, _) in paths.to_env_vars() {
405 std::env::remove_var(k);
406 }
407 }
408
409 #[test]
410 fn compute_account_dir_reuses_existing_id() {
411 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
412 let tmp = tempfile::TempDir::new().unwrap();
413 std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
414 let paths = agentmux_common::DataPaths::resolve(
415 "0.0.0-test",
416 &agentmux_common::RuntimeMode::Installed,
417 )
418 .unwrap();
419 paths.ensure_dirs().unwrap();
420 for (k, v) in paths.to_env_vars() {
421 std::env::set_var(k, v);
422 }
423
424 let mstore = Store::open_in_memory().unwrap();
425 let mut env = std::collections::HashMap::new();
426 let (account_id, _) = compute_and_ensure_account_dir(&mstore, "acc-reconnect", "claude", &mut env);
427 assert_eq!(account_id, "acc-reconnect", "reconnect must reuse the supplied id, not mint a new one");
428
429 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
430 for (k, _) in paths.to_env_vars() {
431 std::env::remove_var(k);
432 }
433 }
434
435 #[test]
444 fn compute_account_dir_links_projects_to_the_global_history_location_when_isolated() {
445 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
446 let tmp = tempfile::TempDir::new().unwrap();
447 std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
448 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
449 let paths = agentmux_common::DataPaths::resolve(
450 "0.0.0-test",
451 &agentmux_common::RuntimeMode::Installed,
452 )
453 .unwrap();
454 paths.ensure_dirs().unwrap();
455 for (k, v) in paths.to_env_vars() {
456 std::env::set_var(k, v);
457 }
458 assert!(agentmux_common::isolated_auth_enabled(), "precondition: this test needs isolation actually on");
459
460 let mstore = Store::open_in_memory().unwrap();
461 let mut env = std::collections::HashMap::new();
462 let (account_id, dir) = compute_and_ensure_account_dir(&mstore, "", "claude", &mut env);
463 let dir = std::path::PathBuf::from(dir.expect("oauth-class provider must yield a dir"));
464 assert!(
465 dir.starts_with(&paths.instance_dir),
466 "precondition: the credential dir must actually be the per-channel isolated one, not the global one, or this test isn't exercising the code path it claims to"
467 );
468
469 let global_history = paths.identity_history_dir(&account_id, "claude", "projects");
470 std::fs::write(global_history.join("real-session.jsonl"), b"a real session").unwrap();
471 let via_isolated_path = std::fs::read(dir.join("projects").join("real-session.jsonl"))
472 .expect("a file written at the global history location must be readable through the isolated credential dir's projects/ subpath");
473 assert_eq!(via_isolated_path, b"a real session");
474
475 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
476 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
477 for (k, _) in paths.to_env_vars() {
478 std::env::remove_var(k);
479 }
480 }
481
482 #[test]
488 fn link_history_if_isolated_rejects_an_unsafe_entity_id() {
489 let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
490 let tmp = tempfile::TempDir::new().unwrap();
491 std::env::set_var("AGENTMUX_HOME_OVERRIDE", tmp.path());
492 std::env::set_var("AGENTMUX_ISOLATED_AUTH", "1");
493 let paths = agentmux_common::DataPaths::resolve(
494 "0.0.0-test",
495 &agentmux_common::RuntimeMode::Installed,
496 )
497 .unwrap();
498 paths.ensure_dirs().unwrap();
499 for (k, v) in paths.to_env_vars() {
500 std::env::set_var(k, v);
501 }
502
503 let provider = get_provider("claude").unwrap();
504 let dir = tmp.path().join("some-credential-dir");
505 std::fs::create_dir_all(&dir).unwrap();
506 link_history_if_isolated(&paths, &dir, provider, "../escape", "test");
509 assert!(
510 !paths.shared_dir.join("identities").parent().unwrap().join("escape").exists(),
511 "an unsafe entity_id must never be used to construct a real filesystem path"
512 );
513
514 std::env::remove_var("AGENTMUX_HOME_OVERRIDE");
515 std::env::remove_var("AGENTMUX_ISOLATED_AUTH");
516 for (k, _) in paths.to_env_vars() {
517 std::env::remove_var(k);
518 }
519 }
520
521 #[test]
522 fn compute_account_dir_skipped_for_api_key_provider() {
523 let mstore = Store::open_in_memory().unwrap();
528 let mut env = std::collections::HashMap::new();
529 let (account_id, dir) = compute_and_ensure_account_dir(&mstore, "", "kimi", &mut env);
530 assert!(!account_id.is_empty());
531 assert!(dir.is_none(), "api-key provider class must skip the OAuth dir path");
532 assert!(env.get("KIMI_SHARE_DIR").is_none());
533 }
534}