--- a/crates/airouter-host/src/lib.rs +++ b/crates/airouter-host/src/lib.rs @@ -1,4 +1,5 @@ mod error; +pub mod codex_runtime; pub mod executor; pub mod policy; pub mod observation; --- /dev/null +++ b/crates/airouter-host/src/codex_runtime.rs @@ -0,0 +1,106 @@ +//! Host-selected Codex state. Never inherit another Unix identity's home. +use serde::Deserialize; +use std::{ + collections::BTreeMap, + ffi::OsString, + fs::{self, OpenOptions}, + io::Read, + os::unix::fs::{MetadataExt, OpenOptionsExt}, + path::{Path, PathBuf}, + process::Command, +}; + +pub const CONFIG: &str = "/etc/airouter/codex-runtime.json"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub version: u32, + /// Numeric UIDs are resolved by provisioning, never by provider arguments. + pub homes: BTreeMap, +} + +impl Config { + pub fn read(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .map_err(|e| format!("Codex runtime configuration unavailable: {e}"))?; + let meta = file.metadata().map_err(|e| e.to_string())?; + if !meta.is_file() || meta.uid() != 0 || meta.mode() & 0o022 != 0 { + return Err( + "Codex runtime configuration must be a root-owned, non-writable regular file" + .into(), + ); + } + let mut bytes = Vec::new(); + file.take(65537) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() > 65536 { + return Err("Codex runtime configuration exceeds 64 KiB".into()); + } + serde_json::from_slice(&bytes) + .map_err(|e| format!("Invalid Codex runtime configuration: {e}")) + } + + pub fn environment(&self) -> Result, String> { + // No setuid or identity transition: this runs inside the admitted host's + // existing containment and keeps its Unix permissions. + let uid = unsafe { libc::getuid() }; + if unsafe { libc::geteuid() } != uid || self.version != 1 { + return Err("Unsupported Codex runtime identity or configuration version".into()); + } + let home = self + .homes + .get(&uid) + .ok_or_else(|| format!("No separate Codex home registered for UID {uid}"))?; + private_directory(home, uid)?; + let codex = home.join(".codex"); + private_directory(&codex, uid)?; + for name in ["config.toml", "auth.json"] { + let path = codex.join(name); + match fs::symlink_metadata(&path) { + Ok(meta) if meta.is_file() && meta.uid() == uid && meta.mode() & 0o077 == 0 => (), + Ok(_) => { + return Err(format!( + "{} must be a private regular file owned by UID {uid}", + path.display() + )) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => (), + Err(e) => return Err(format!("{}: {e}", path.display())), + } + } + Ok(BTreeMap::from([ + ("HOME".into(), home.clone()), + ("CODEX_HOME".into(), codex), + ("XDG_CONFIG_HOME".into(), home.join(".config")), + ("XDG_CACHE_HOME".into(), home.join(".cache")), + ("XDG_STATE_HOME".into(), home.join(".local/state")), + ])) + } + + pub fn command(&self, executable: &Path, args: &[OsString]) -> Result { + let mut command = Command::new(executable); + command.args(args).envs(self.environment()?); + Ok(command) + } +} + +fn private_directory(path: &Path, uid: u32) -> Result<(), String> { + let meta = fs::symlink_metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !path.is_absolute() + || !meta.is_dir() + || meta.uid() != uid + || meta.mode() & 0o077 != 0 + || fs::canonicalize(path).map_err(|e| e.to_string())? != path + { + return Err(format!( + "{} must be a private directory owned by UID {uid}, without symlink aliases", + path.display() + )); + } + Ok(()) +} --- /dev/null +++ b/crates/airouter-host/src/bin/airouter-codex.rs @@ -0,0 +1,38 @@ +//! AIRouter's service Codex entrypoint; /usr/bin/codex remains the user's CLI. +use airouter_host::codex_runtime::{Config, CONFIG}; +use std::{os::unix::process::CommandExt, path::Path}; + +fn run() -> Result<(), String> { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + // Package/qualification probes need CLI identity before provisioning login. + // Codex's version parser exits without loading configuration or credentials. + if args == ["--version"] { + return Err(format!( + "Codex version probe failed: {}", + std::process::Command::new("/usr/bin/codex") + .arg("--version") + .exec() + )); + } + let config = Config::read(Path::new(CONFIG))?; + if args == ["--airouter-state"] { + println!( + "{}", + serde_json::to_string(&config.environment()?).map_err(|e| e.to_string())? + ); + return Ok(()); + } + let mut command = config.command(Path::new("/usr/bin/codex"), &args)?; + unsafe { + libc::umask(0o077); + } + // exec keeps PID, stdio, signals and the existing native-executor cgroup. + Err(format!("Codex launch failed: {}", command.exec())) +} + +fn main() { + if let Err(error) = run() { + eprintln!("AIRouter Codex runtime: {error}"); + std::process::exit(1); + } +} --- /dev/null +++ b/crates/airouter-host/tests/codex_runtime.rs @@ -0,0 +1,83 @@ +use airouter_host::codex_runtime::Config; +use std::{ + collections::BTreeMap, + fs, + os::unix::fs::{symlink, PermissionsExt}, + path::Path, +}; + +fn setup() -> (tempfile::TempDir, Config) { + let root = tempfile::tempdir().unwrap(); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); + fs::create_dir(root.path().join(".codex")).unwrap(); + fs::set_permissions( + root.path().join(".codex"), + fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let config = Config { + version: 1, + homes: BTreeMap::from([(unsafe { libc::getuid() }, root.path().to_owned())]), + }; + (root, config) +} + +#[test] +fn provider_reads_its_own_config_after_other_home_is_replaced() { + let (root, config) = setup(); + let other = tempfile::tempdir().unwrap(); + fs::write(other.path().join("config.toml"), "interactive").unwrap(); + fs::write(root.path().join(".codex/config.toml"), "service").unwrap(); + fs::set_permissions( + root.path().join(".codex/config.toml"), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + let args = ["-I", "-c", "import os,pathlib,sys; p=pathlib.Path(os.environ['CODEX_HOME']); assert p==pathlib.Path(os.environ['HOME'])/'.codex'; print((p/'config.toml').read_text()); print(sys.argv[1])", "literal argument"].map(Into::into); + let mut launch = config + .command(Path::new("/usr/bin/python3"), &args) + .unwrap(); + fs::write(other.path().join("replacement"), "changed").unwrap(); + fs::set_permissions( + other.path().join("replacement"), + fs::Permissions::from_mode(0o000), + ) + .unwrap(); + fs::rename( + other.path().join("replacement"), + other.path().join("config.toml"), + ) + .unwrap(); + let output = launch.output().unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "service\nliteral argument\n" + ); +} + +#[test] +fn missing_registration_and_shared_or_symlinked_state_fail_without_fallback() { + let (root, mut config) = setup(); + let homes = config.homes.clone(); + config.homes.clear(); + assert!(config + .environment() + .unwrap_err() + .contains("No separate Codex home")); + config.homes = homes; + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(config.environment().is_err()); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let other = tempfile::tempdir().unwrap(); + symlink( + other.path().join("auth.json"), + root.path().join(".codex/auth.json"), + ) + .unwrap(); + assert!(config.environment().unwrap_err().contains("auth.json")); + fs::remove_file(root.path().join(".codex/auth.json")).unwrap(); + fs::remove_dir(root.path().join(".codex")).unwrap(); + symlink(other.path(), root.path().join(".codex")).unwrap(); + assert!(config.environment().is_err()); +} --- a/crates/airouter-host/README.md +++ b/crates/airouter-host/README.md @@ -56,3 +56,44 @@ runbook, UID, exact config-edit proof/backup and replay rejection; CLI explicit binding/forged-proof rejection and signal interruption. Root service recovery must be tested only against an owned disposable OpenRC fixture. + +## Separate Codex service state + +`airouter-codex` is a service entrypoint for the existing `/usr/bin/codex`. +It reads root-owned `/etc/airouter/codex-runtime.json`: + +```json +{"version":1,"homes":{"981":"/var/lib/airouter-native/codex/workbench"}} +``` + +Provision the numeric UID for the actual execution account; 981 is an example. +Each registered home and its `.codex` child must be real directories owned by +that UID, mode 0700, without symlink aliases. Existing `config.toml` and +`auth.json` must be private regular files owned by that UID. An empty registry, +wrong ownership or unavailable state fails explicitly without falling back to +the interactive user's home. The registry is bounded to 64 KiB and must not be +group/other writable. + +The launcher selects HOME, CODEX_HOME and XDG config/cache/state directories. +It preserves CLI arguments, cwd, stdio and process identity through Unix exec, +so the existing native executor retains signal and cgroup ownership. It does +not select models, grant permissions, retry work, copy credentials or migrate +conversation databases. Applications can call it from their existing adapters; +the system Codex command and interactive CLI state remain separate. + +Provision settings and log in as the registered account before switching an +application to `/usr/libexec/airouter/airouter-codex`: + +```sh +runuser -u workbench -- /usr/libexec/airouter/airouter-codex --airouter-state +runuser -u workbench -- /usr/libexec/airouter/airouter-codex features list +runuser -u workbench -- /usr/libexec/airouter/airouter-codex login --device-auth +runuser -u workbench -- /usr/libexec/airouter/airouter-codex login status +``` + +The state query prints paths only. Login status is a local credential check, +not a provider inference test. `--version` forwards Codex's configuration-free +version query and needs no registered home, including during package qualification. +`cargo test --locked --offline -p airouter-host +--test codex_runtime` checks independent config reads after another home changes, +literal argument forwarding, and rejection of missing/shared/symlinked state. --- /dev/null +++ b/config/codex-runtime.example.json @@ -0,0 +1,4 @@ +{ + "version": 1, + "homes": {} +}