Skip to main content

codex_auth_manager/
manager.rs

1use std::{
2    env::var,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use super::{
8    AuthStatus, Error, Identity, IdentityName, PKG_NAME, UnknownAuthReason,
9    fs::{
10        AUTH_FILE, STORAGE_SUFFIX, absolutize, create_symlink, is_broken_identity_path,
11        normalize_path, replace_file,
12    },
13};
14
15/// A manager rooted at one Codex home directory.
16#[derive(Debug, Clone)]
17pub struct CodexAuthManager {
18    codex_home: PathBuf,
19}
20
21impl CodexAuthManager {
22    /// Create a manager from `$CODEX_HOME`, or `$HOME/.codex` when `$CODEX_HOME` is unset.
23    ///
24    /// The stored path is absolute. The directory is not created by this method.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error when neither environment variable can be read or the current directory
29    /// cannot be determined while absolutizing a relative path.
30    pub fn from_env() -> Result<Self, Error> {
31        let path = var("CODEX_HOME")
32            .map(PathBuf::from)
33            .or_else(|_| var("HOME").map(|home| PathBuf::from(home).join(".codex")))
34            .map_err(|source| Error::Env { source })?;
35        Self::new(path)
36    }
37
38    /// Create a manager for `codex_home`.
39    ///
40    /// The stored path is absolute. The directory is not created by this method.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error when the current directory cannot be determined while absolutizing a
45    /// relative path.
46    pub fn new(codex_home: impl AsRef<Path>) -> Result<Self, Error> {
47        Ok(Self {
48            codex_home: absolutize(codex_home.as_ref())?,
49        })
50    }
51
52    /// The Codex home directory this manager uses.
53    #[must_use]
54    pub fn codex_home(&self) -> &Path {
55        &self.codex_home
56    }
57
58    /// Return the current auth status.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error when CAM cannot inspect `auth.json` or read its symlink target.
63    pub fn status(&self) -> Result<AuthStatus, Error> {
64        if !self.codex_home.exists() {
65            return Ok(AuthStatus::CodexHomeMissing {
66                path: self.codex_home.clone(),
67            });
68        }
69
70        let auth_path = self.auth_path();
71        let metadata = match fs::symlink_metadata(&auth_path) {
72            Ok(metadata) => metadata,
73            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
74                return Ok(AuthStatus::None);
75            }
76            Err(source) => {
77                return Err(Error::Io {
78                    action: "inspect auth file",
79                    path: auth_path,
80                    source,
81                });
82            }
83        };
84
85        let file_type = metadata.file_type();
86        if file_type.is_file() {
87            return Ok(AuthStatus::Native);
88        }
89        if file_type.is_symlink() {
90            return self.status_from_symlink(&auth_path);
91        }
92
93        Ok(AuthStatus::Unknown {
94            reason: UnknownAuthReason::AuthPathIsNotFileOrSymlink,
95        })
96    }
97
98    /// List identities from the manager directory.
99    ///
100    /// Broken identity entries are included. Missing manager directories produce an empty list.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error when the manager directory or one of its entries cannot be inspected.
105    pub fn list(&self) -> Result<Vec<Identity>, Error> {
106        let status = self.status()?;
107        let active = match &status {
108            AuthStatus::Managed { identity } | AuthStatus::BrokenManaged { identity } => Some((
109                identity.clone(),
110                matches!(status, AuthStatus::BrokenManaged { .. }),
111            )),
112            AuthStatus::None
113            | AuthStatus::Native
114            | AuthStatus::CodexHomeMissing { .. }
115            | AuthStatus::Unknown { .. } => None,
116        };
117
118        let mut identities = Vec::new();
119        let manager_dir = self.manager_dir();
120        let entries = match fs::read_dir(&manager_dir) {
121            Ok(entries) => entries,
122            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
123                if let Some((identity, broken)) = active {
124                    identities.push(Identity {
125                        path: self.identity_path(&identity),
126                        name: identity,
127                        active: true,
128                        broken,
129                    });
130                }
131                return Ok(identities);
132            }
133            Err(source) => {
134                return Err(Error::Io {
135                    action: "read identity directory",
136                    path: manager_dir,
137                    source,
138                });
139            }
140        };
141
142        for entry in entries {
143            let entry = entry.map_err(|source| Error::Io {
144                action: "read identity directory entry",
145                path: manager_dir.clone(),
146                source,
147            })?;
148            let file_name = entry.file_name();
149            let Some(file_name) = file_name.to_str() else {
150                continue;
151            };
152            let Some(name) = file_name.strip_suffix(STORAGE_SUFFIX) else {
153                continue;
154            };
155            let Ok(name) = IdentityName::try_from(name) else {
156                continue;
157            };
158            let path = entry.path();
159            let broken = is_broken_identity_path(&path)?;
160            let active_here = active
161                .as_ref()
162                .is_some_and(|(active_name, _)| active_name == &name);
163            identities.push(Identity {
164                path,
165                name,
166                active: active_here,
167                broken,
168            });
169        }
170
171        if let Some((active_name, active_broken)) = active
172            && !identities
173                .iter()
174                .any(|identity| identity.name == active_name)
175        {
176            identities.push(Identity {
177                path: self.identity_path(&active_name),
178                name: active_name,
179                active: true,
180                broken: active_broken,
181            });
182        }
183
184        identities.sort_by(|left, right| left.name.cmp(&right.name));
185        Ok(identities)
186    }
187
188    /// Capture the native auth file into an identity and make it active.
189    ///
190    /// # Errors
191    ///
192    /// Returns an error when there is no native auth file, the destination identity already
193    /// exists without `force`, the destination is broken, or a filesystem operation fails.
194    pub fn capture(&self, identity: &IdentityName, options: CaptureOptions) -> Result<(), Error> {
195        self.require_codex_home()?;
196        match self.status()? {
197            AuthStatus::Native => {}
198            AuthStatus::None
199            | AuthStatus::CodexHomeMissing { .. }
200            | AuthStatus::Managed { .. }
201            | AuthStatus::BrokenManaged { .. } => {
202                return Err(Error::NoNativeAuthFile);
203            }
204            AuthStatus::Unknown { reason } => return Err(Error::UnknownAuthState { reason }),
205        }
206
207        let manager_dir = self.manager_dir();
208        fs::create_dir_all(&manager_dir).map_err(|source| Error::Io {
209            action: "create identity directory",
210            path: manager_dir,
211            source,
212        })?;
213
214        let identity_path = self.identity_path(identity);
215        match fs::symlink_metadata(&identity_path) {
216            Ok(metadata) if metadata.file_type().is_file() && options.force => {}
217            Ok(metadata) if metadata.file_type().is_file() => {
218                return Err(Error::IdentityAlreadyExists {
219                    name: identity.clone(),
220                });
221            }
222            Ok(_) => {
223                return Err(Error::IdentityBroken {
224                    name: identity.clone(),
225                });
226            }
227            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
228            Err(source) => {
229                return Err(Error::Io {
230                    action: "inspect identity",
231                    path: identity_path,
232                    source,
233                });
234            }
235        }
236
237        let auth_path = self.auth_path();
238        let tmp_path = self.create_temporary_auth_symlink(identity)?;
239        replace_file(&auth_path, &identity_path).map_err(|source| Error::Io {
240            action: "capture native auth file",
241            path: auth_path.clone(),
242            source,
243        })?;
244        replace_file(&tmp_path, &auth_path)
245            .map_err(|source| Error::Io {
246                action: "activate identity",
247                path: auth_path.clone(),
248                source,
249            })
250            .inspect_err(|_| {
251                let _ = fs::rename(&identity_path, &auth_path);
252                let _ = fs::remove_file(&tmp_path);
253            })
254    }
255
256    /// Make an existing identity active.
257    ///
258    /// # Errors
259    ///
260    /// Returns an error when the identity is missing or broken, a native auth file would be
261    /// discarded without `force`, the auth state is unknown, or a filesystem operation fails.
262    pub fn use_identity(&self, identity: &IdentityName, options: UseOptions) -> Result<(), Error> {
263        self.require_codex_home()?;
264        self.require_usable_identity(identity)?;
265
266        match self.status()? {
267            AuthStatus::None | AuthStatus::Managed { .. } | AuthStatus::BrokenManaged { .. } => {}
268            AuthStatus::Native if options.force => {
269                fs::remove_file(self.auth_path()).map_err(|source| Error::Io {
270                    action: "discard native auth file",
271                    path: self.auth_path(),
272                    source,
273                })?;
274            }
275            AuthStatus::Native => return Err(Error::NativeAuthExists),
276            AuthStatus::CodexHomeMissing { path } => return Err(Error::CodexHomeMissing { path }),
277            AuthStatus::Unknown { reason } => return Err(Error::UnknownAuthState { reason }),
278        }
279
280        self.replace_auth_symlink(identity)
281    }
282
283    /// Detach Codex from the active managed identity.
284    ///
285    /// # Errors
286    ///
287    /// Returns an error when detaching would discard a native auth file or broken managed link
288    /// without `force`, the auth state is unknown, or a filesystem operation fails.
289    pub fn detach(&self, options: DetachOptions) -> Result<(), Error> {
290        match self.status()? {
291            AuthStatus::None | AuthStatus::CodexHomeMissing { .. } => Ok(()),
292            AuthStatus::Managed { .. } => remove_auth_file(self),
293            AuthStatus::BrokenManaged { .. } | AuthStatus::Native if options.force => {
294                remove_auth_file(self)
295            }
296            AuthStatus::BrokenManaged { identity } => Err(Error::IdentityBroken { name: identity }),
297            AuthStatus::Native => Err(Error::NativeAuthExists),
298            AuthStatus::Unknown { reason } => Err(Error::UnknownAuthState { reason }),
299        }
300    }
301
302    fn auth_path(&self) -> PathBuf {
303        self.codex_home.join(AUTH_FILE)
304    }
305
306    fn manager_dir(&self) -> PathBuf {
307        self.codex_home.join(PKG_NAME)
308    }
309
310    fn identity_path(&self, identity: &IdentityName) -> PathBuf {
311        self.manager_dir()
312            .join(format!("{identity}{STORAGE_SUFFIX}"))
313    }
314
315    fn relative_identity_path(identity: &IdentityName) -> PathBuf {
316        PathBuf::from(PKG_NAME).join(format!("{identity}{STORAGE_SUFFIX}"))
317    }
318
319    fn require_codex_home(&self) -> Result<(), Error> {
320        if self.codex_home.exists() {
321            Ok(())
322        } else {
323            Err(Error::CodexHomeMissing {
324                path: self.codex_home.clone(),
325            })
326        }
327    }
328
329    fn require_usable_identity(&self, identity: &IdentityName) -> Result<(), Error> {
330        let path = self.identity_path(identity);
331        match fs::symlink_metadata(&path) {
332            Ok(metadata) if metadata.file_type().is_file() => Ok(()),
333            Ok(_) => Err(Error::IdentityBroken {
334                name: identity.clone(),
335            }),
336            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
337                Err(Error::IdentityNotFound {
338                    name: identity.clone(),
339                })
340            }
341            Err(source) => Err(Error::Io {
342                action: "inspect identity",
343                path,
344                source,
345            }),
346        }
347    }
348
349    fn status_from_symlink(&self, auth_path: &Path) -> Result<AuthStatus, Error> {
350        let target = fs::read_link(auth_path).map_err(|source| Error::Io {
351            action: "read auth symlink",
352            path: auth_path.to_path_buf(),
353            source,
354        })?;
355        let target = if target.is_absolute() {
356            target
357        } else {
358            self.codex_home.join(target)
359        };
360        let target = normalize_path(&target);
361        let manager_dir = normalize_path(&self.manager_dir());
362        let Ok(relative) = target.strip_prefix(&manager_dir) else {
363            return Ok(AuthStatus::Unknown {
364                reason: UnknownAuthReason::SymlinkTargetOutsideManagerDir,
365            });
366        };
367        if relative.components().count() != 1 {
368            return Ok(AuthStatus::Unknown {
369                reason: UnknownAuthReason::SymlinkTargetOutsideManagerDir,
370            });
371        }
372        let Some(file_name) = relative.file_name().and_then(|name| name.to_str()) else {
373            return Ok(AuthStatus::Unknown {
374                reason: UnknownAuthReason::SymlinkTargetHasInvalidIdentityName,
375            });
376        };
377        let Some(name) = file_name.strip_suffix(STORAGE_SUFFIX) else {
378            return Ok(AuthStatus::Unknown {
379                reason: UnknownAuthReason::SymlinkTargetHasInvalidIdentityName,
380            });
381        };
382        let identity = IdentityName::try_from(name).map_err(|_| Error::UnknownAuthState {
383            reason: UnknownAuthReason::SymlinkTargetHasInvalidIdentityName,
384        })?;
385        match fs::symlink_metadata(&target) {
386            Ok(metadata) if metadata.file_type().is_file() => Ok(AuthStatus::Managed { identity }),
387            Ok(_) | Err(_) => Ok(AuthStatus::BrokenManaged { identity }),
388        }
389    }
390
391    fn replace_auth_symlink(&self, identity: &IdentityName) -> Result<(), Error> {
392        let tmp_path = self.create_temporary_auth_symlink(identity)?;
393        replace_file(&tmp_path, &self.auth_path()).map_err(|source| Error::Io {
394            action: "activate identity",
395            path: self.auth_path(),
396            source,
397        })
398    }
399
400    fn create_temporary_auth_symlink(&self, identity: &IdentityName) -> Result<PathBuf, Error> {
401        let tmp_path = self.codex_home.join(format!(".{AUTH_FILE}.tmp"));
402        if tmp_path.exists() || tmp_path.is_symlink() {
403            fs::remove_file(&tmp_path).map_err(|source| Error::Io {
404                action: "remove stale temporary auth symlink",
405                path: tmp_path.clone(),
406                source,
407            })?;
408        }
409        create_symlink(Self::relative_identity_path(identity), &tmp_path).map_err(|source| {
410            Error::Io {
411                action: "create temporary auth symlink",
412                path: tmp_path.clone(),
413                source,
414            }
415        })?;
416        Ok(tmp_path)
417    }
418}
419
420/// Options for capturing a native auth file.
421#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
422pub struct CaptureOptions {
423    /// Overwrite an existing regular identity file.
424    pub force: bool,
425}
426
427/// Options for using an identity.
428#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
429pub struct UseOptions {
430    /// Discard a blocking native auth file.
431    pub force: bool,
432}
433
434/// Options for detaching from the active identity.
435#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
436pub struct DetachOptions {
437    /// Remove a blocking native auth file or broken managed link.
438    pub force: bool,
439}
440
441fn remove_auth_file(manager: &CodexAuthManager) -> Result<(), Error> {
442    fs::remove_file(manager.auth_path()).map_err(|source| Error::Io {
443        action: "remove auth file",
444        path: manager.auth_path(),
445        source,
446    })
447}
448
449#[cfg(test)]
450mod tests {
451    use std::{
452        fs,
453        path::{Path, PathBuf},
454        time::{SystemTime, UNIX_EPOCH},
455    };
456
457    use crate::{
458        AuthStatus, CaptureOptions, CodexAuthManager, DetachOptions, Error, IdentityName,
459        UseOptions, fs::create_symlink,
460    };
461
462    #[test]
463    fn capture_moves_native_auth_and_marks_identity_active() {
464        let temp = TempHome::new();
465        fs::create_dir_all(temp.path()).unwrap();
466        fs::write(temp.path().join("auth.json"), "{}").unwrap();
467        let manager = CodexAuthManager::new(temp.path()).unwrap();
468        let identity = IdentityName::try_from("work").unwrap();
469
470        manager
471            .capture(&identity, CaptureOptions::default())
472            .unwrap();
473
474        assert_eq!(
475            manager.status().unwrap(),
476            AuthStatus::Managed {
477                identity: identity.clone()
478            }
479        );
480        assert_eq!(
481            fs::read_to_string(temp.path().join("codex-auth-manager/work.json")).unwrap(),
482            "{}"
483        );
484        assert_eq!(
485            fs::read_link(temp.path().join("auth.json")).unwrap(),
486            PathBuf::from("codex-auth-manager/work.json")
487        );
488        let identities = manager.list().unwrap();
489        assert_eq!(identities.len(), 1);
490        assert_eq!(identities[0].name, identity);
491        assert!(identities[0].active);
492        assert!(!identities[0].broken);
493    }
494
495    #[test]
496    fn capture_force_overwrites_existing_regular_identity() {
497        let temp = TempHome::new();
498        fs::create_dir_all(temp.path().join("codex-auth-manager")).unwrap();
499        fs::write(temp.path().join("auth.json"), "new").unwrap();
500        fs::write(temp.path().join("codex-auth-manager/work.json"), "old").unwrap();
501        let manager = CodexAuthManager::new(temp.path()).unwrap();
502        let identity = IdentityName::try_from("work").unwrap();
503
504        assert!(matches!(
505            manager.capture(&identity, CaptureOptions::default()),
506            Err(Error::IdentityAlreadyExists { .. })
507        ));
508        manager
509            .capture(&identity, CaptureOptions { force: true })
510            .unwrap();
511
512        assert_eq!(
513            fs::read_to_string(temp.path().join("codex-auth-manager/work.json")).unwrap(),
514            "new"
515        );
516    }
517
518    #[test]
519    fn use_identity_refuses_native_auth_without_force() {
520        let temp = TempHome::new();
521        fs::create_dir_all(temp.path().join("codex-auth-manager")).unwrap();
522        fs::write(temp.path().join("auth.json"), "native").unwrap();
523        fs::write(temp.path().join("codex-auth-manager/work.json"), "work").unwrap();
524        let manager = CodexAuthManager::new(temp.path()).unwrap();
525        let identity = IdentityName::try_from("work").unwrap();
526
527        assert!(matches!(
528            manager.use_identity(&identity, UseOptions::default()),
529            Err(Error::NativeAuthExists)
530        ));
531        manager
532            .use_identity(&identity, UseOptions { force: true })
533            .unwrap();
534
535        assert_eq!(manager.status().unwrap(), AuthStatus::Managed { identity });
536    }
537
538    #[test]
539    fn list_includes_broken_active_identity() {
540        let temp = TempHome::new();
541        fs::create_dir_all(temp.path()).unwrap();
542        create_symlink(
543            PathBuf::from("codex-auth-manager/work.json"),
544            temp.path().join("auth.json"),
545        )
546        .unwrap();
547        let manager = CodexAuthManager::new(temp.path()).unwrap();
548
549        assert_eq!(
550            manager.status().unwrap(),
551            AuthStatus::BrokenManaged {
552                identity: IdentityName::try_from("work").unwrap()
553            }
554        );
555        let identities = manager.list().unwrap();
556        assert_eq!(identities.len(), 1);
557        assert_eq!(identities[0].name.as_str(), "work");
558        assert!(identities[0].active);
559        assert!(identities[0].broken);
560    }
561
562    #[test]
563    fn detach_force_removes_broken_managed_link() {
564        let temp = TempHome::new();
565        fs::create_dir_all(temp.path()).unwrap();
566        create_symlink(
567            PathBuf::from("codex-auth-manager/work.json"),
568            temp.path().join("auth.json"),
569        )
570        .unwrap();
571        let manager = CodexAuthManager::new(temp.path()).unwrap();
572
573        assert!(matches!(
574            manager.detach(DetachOptions::default()),
575            Err(Error::IdentityBroken { .. })
576        ));
577        manager.detach(DetachOptions { force: true }).unwrap();
578        assert_eq!(manager.status().unwrap(), AuthStatus::None);
579    }
580
581    struct TempHome {
582        path: PathBuf,
583    }
584
585    impl TempHome {
586        fn new() -> Self {
587            let unique = SystemTime::now()
588                .duration_since(UNIX_EPOCH)
589                .unwrap()
590                .as_nanos();
591            Self {
592                path: std::env::temp_dir().join(format!(
593                    "codex-auth-manager-test-{}-{unique}",
594                    std::process::id()
595                )),
596            }
597        }
598
599        fn path(&self) -> &Path {
600            &self.path
601        }
602    }
603
604    impl Drop for TempHome {
605        fn drop(&mut self) {
606            let _ = fs::remove_dir_all(&self.path);
607        }
608    }
609}