summaryrefslogtreecommitdiff
path: root/net-misc/airouter/files/airouter-codex-runtime.patch
blob: ec40304b513bb101199c30e84d3ea1a7f3d83151 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
--- 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<u32, PathBuf>,
+}
+
+impl Config {
+    pub fn read(path: &Path) -> Result<Self, String> {
+        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<BTreeMap<String, PathBuf>, 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<Command, String> {
+        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": {}
+}