Skip to main content

codex/
home.rs

1use std::{
2    ffi::OsString,
3    fs as std_fs,
4    path::{Path, PathBuf},
5};
6
7use thiserror::Error;
8use tokio::process::Command;
9
10use crate::defaults::{default_rust_log_value, CODEX_BINARY_ENV, CODEX_HOME_ENV, RUST_LOG_ENV};
11use crate::CodexError;
12
13#[derive(Clone, Debug)]
14pub(super) struct CommandEnvironment {
15    binary: PathBuf,
16    codex_home: Option<CodexHomeLayout>,
17    create_home_dirs: bool,
18}
19
20impl CommandEnvironment {
21    pub(super) fn new(
22        binary: PathBuf,
23        codex_home: Option<PathBuf>,
24        create_home_dirs: bool,
25    ) -> Self {
26        Self {
27            binary,
28            codex_home: codex_home.map(CodexHomeLayout::new),
29            create_home_dirs,
30        }
31    }
32
33    pub(super) fn binary_path(&self) -> &Path {
34        &self.binary
35    }
36
37    pub(super) fn codex_home_layout(&self) -> Option<CodexHomeLayout> {
38        self.codex_home.clone()
39    }
40
41    pub(super) fn environment_overrides(&self) -> Result<Vec<(OsString, OsString)>, CodexError> {
42        if let Some(home) = &self.codex_home {
43            home.materialize(self.create_home_dirs)?;
44        }
45
46        let mut envs = Vec::new();
47        envs.push((
48            OsString::from(CODEX_BINARY_ENV),
49            self.binary.as_os_str().to_os_string(),
50        ));
51
52        if let Some(home) = &self.codex_home {
53            envs.push((
54                OsString::from(CODEX_HOME_ENV),
55                home.root().as_os_str().to_os_string(),
56            ));
57        }
58
59        if let Some(value) = default_rust_log_value() {
60            envs.push((OsString::from(RUST_LOG_ENV), OsString::from(value)));
61        }
62
63        Ok(envs)
64    }
65
66    pub(super) fn apply(&self, command: &mut Command) -> Result<(), CodexError> {
67        for (key, value) in self.environment_overrides()? {
68            command.env(key, value);
69        }
70        Ok(())
71    }
72}
73
74/// Describes the on-disk layout used by the Codex CLI when `CODEX_HOME` is set.
75///
76/// Files are rooted next to `config.toml`, `auth.json`, `.credentials.json`, and
77/// `history.jsonl`; `conversations/` holds transcript JSONL files and `logs/`
78/// holds `codex-*.log` outputs. Call [`Self::materialize`] to create the
79/// directories when standing up an app-scoped home.
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct CodexHomeLayout {
82    root: PathBuf,
83}
84
85impl CodexHomeLayout {
86    /// Creates a new layout description rooted at `root`.
87    pub fn new(root: impl Into<PathBuf>) -> Self {
88        Self { root: root.into() }
89    }
90
91    /// Returns the `CODEX_HOME` root.
92    pub fn root(&self) -> &Path {
93        self.root.as_path()
94    }
95
96    /// Path to `config.toml` under `CODEX_HOME`.
97    pub fn config_path(&self) -> PathBuf {
98        self.root.join("config.toml")
99    }
100
101    /// Path to `auth.json` under `CODEX_HOME`.
102    pub fn auth_path(&self) -> PathBuf {
103        self.root.join("auth.json")
104    }
105
106    /// Path to `.credentials.json` under `CODEX_HOME`.
107    pub fn credentials_path(&self) -> PathBuf {
108        self.root.join(".credentials.json")
109    }
110
111    /// Path to `history.jsonl` under `CODEX_HOME`.
112    pub fn history_path(&self) -> PathBuf {
113        self.root.join("history.jsonl")
114    }
115
116    /// Directory containing conversation transcripts.
117    pub fn conversations_dir(&self) -> PathBuf {
118        self.root.join("conversations")
119    }
120
121    /// Directory containing Codex log files.
122    pub fn logs_dir(&self) -> PathBuf {
123        self.root.join("logs")
124    }
125
126    /// Creates the `CODEX_HOME` root and its known subdirectories when
127    /// `create_home_dirs` is `true`. No-op when disabled.
128    pub fn materialize(&self, create_home_dirs: bool) -> Result<(), CodexError> {
129        if !create_home_dirs {
130            return Ok(());
131        }
132
133        let conversations = self.conversations_dir();
134        let logs = self.logs_dir();
135        for path in [self.root(), conversations.as_path(), logs.as_path()] {
136            std_fs::create_dir_all(path).map_err(|source| CodexError::PrepareCodexHome {
137                path: path.to_path_buf(),
138                source,
139            })?;
140        }
141        Ok(())
142    }
143
144    /// Copies login artifacts (`auth.json` and `.credentials.json`) from a trusted seed home into
145    /// this layout. History and logs are intentionally excluded.
146    ///
147    /// This is opt-in and leaves defaults untouched. Missing files raise errors only when marked
148    /// as required in `options`; otherwise they are skipped. Target directories are created when
149    /// `create_target_dirs` is `true`.
150    pub fn seed_auth_from(
151        &self,
152        seed_home: impl AsRef<Path>,
153        options: AuthSeedOptions,
154    ) -> Result<AuthSeedOutcome, AuthSeedError> {
155        let seed_home = seed_home.as_ref();
156        let seed_meta =
157            std_fs::metadata(seed_home).map_err(|source| AuthSeedError::SeedHomeUnreadable {
158                seed_home: seed_home.to_path_buf(),
159                source,
160            })?;
161        if !seed_meta.is_dir() {
162            return Err(AuthSeedError::SeedHomeNotDirectory {
163                seed_home: seed_home.to_path_buf(),
164            });
165        }
166
167        let mut outcome = AuthSeedOutcome::default();
168        let targets = [
169            (
170                "auth.json",
171                options.require_auth,
172                &mut outcome.copied_auth,
173                self.auth_path(),
174            ),
175            (
176                ".credentials.json",
177                options.require_credentials,
178                &mut outcome.copied_credentials,
179                self.credentials_path(),
180            ),
181        ];
182
183        for (name, required, copied, destination) in targets {
184            let source = seed_home.join(name);
185            match std_fs::metadata(&source) {
186                Ok(metadata) => {
187                    if !metadata.is_file() {
188                        return Err(AuthSeedError::SeedFileNotFile { path: source });
189                    }
190
191                    if options.create_target_dirs {
192                        if let Some(parent) = destination.parent() {
193                            std_fs::create_dir_all(parent).map_err(|source_err| {
194                                AuthSeedError::CreateTargetDir {
195                                    path: parent.to_path_buf(),
196                                    source: source_err,
197                                }
198                            })?;
199                        }
200                    }
201
202                    std_fs::copy(&source, &destination).map_err(|error| AuthSeedError::Copy {
203                        source: source.clone(),
204                        destination: destination.to_path_buf(),
205                        error,
206                    })?;
207                    *copied = true;
208                }
209                Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
210                    if required {
211                        return Err(AuthSeedError::SeedFileMissing { path: source });
212                    }
213                }
214                Err(err) => {
215                    return Err(AuthSeedError::SeedFileUnreadable {
216                        path: source,
217                        source: err,
218                    })
219                }
220            }
221        }
222
223        Ok(outcome)
224    }
225}
226
227/// Options controlling how auth files are seeded from a trusted home.
228#[derive(Clone, Debug, Eq, PartialEq)]
229pub struct AuthSeedOptions {
230    /// Whether missing `auth.json` is an error (default: false, skip when missing).
231    pub require_auth: bool,
232    /// Whether missing `.credentials.json` is an error (default: false, skip when missing).
233    pub require_credentials: bool,
234    /// Create destination directories when needed (default: true).
235    pub create_target_dirs: bool,
236}
237
238impl Default for AuthSeedOptions {
239    fn default() -> Self {
240        Self {
241            require_auth: false,
242            require_credentials: false,
243            create_target_dirs: true,
244        }
245    }
246}
247
248/// Result of seeding Codex auth files into a target home.
249#[derive(Clone, Debug, Default, Eq, PartialEq)]
250pub struct AuthSeedOutcome {
251    /// `true` when `auth.json` was copied.
252    pub copied_auth: bool,
253    /// `true` when `.credentials.json` was copied.
254    pub copied_credentials: bool,
255}
256
257/// Errors that may occur while seeding Codex auth files into a target home.
258#[derive(Debug, Error)]
259pub enum AuthSeedError {
260    #[error("seed CODEX_HOME `{seed_home}` does not exist or is unreadable")]
261    SeedHomeUnreadable {
262        seed_home: PathBuf,
263        #[source]
264        source: std::io::Error,
265    },
266    #[error("seed CODEX_HOME `{seed_home}` is not a directory")]
267    SeedHomeNotDirectory { seed_home: PathBuf },
268    #[error("seed file `{path}` is missing")]
269    SeedFileMissing { path: PathBuf },
270    #[error("seed file `{path}` is not a file")]
271    SeedFileNotFile { path: PathBuf },
272    #[error("seed file `{path}` is unreadable")]
273    SeedFileUnreadable {
274        path: PathBuf,
275        #[source]
276        source: std::io::Error,
277    },
278    #[error("failed to create target directory `{path}`")]
279    CreateTargetDir {
280        path: PathBuf,
281        #[source]
282        source: std::io::Error,
283    },
284    #[error("failed to copy `{source}` to `{destination}`")]
285    Copy {
286        source: PathBuf,
287        destination: PathBuf,
288        #[source]
289        error: std::io::Error,
290    },
291}