From df71b6dd4a41fb6e89e16705d7617a02a45e95e1 Mon Sep 17 00:00:00 2001 From: wuyitao Date: Sat, 11 Jul 2026 15:53:09 +0800 Subject: [PATCH 1/6] Harden session ownership and release checks --- .github/workflows/release.yml | 20 ++ README.md | 11 +- connect | 0 old | 1 - src/autopilot.rs | 5 +- src/distill.rs | 6 +- src/main.rs | 2 +- src/state.rs | 494 ++++++++++++++++++++++++++++++++-- src/ui.rs | 422 +++++++++++++++++++++++------ src/worker.rs | 147 ++++++++-- tests/cockpit.py | 149 +++++++++- tests/fakecodex_sleep | 6 + tests/requirements.txt | 1 + 13 files changed, 1116 insertions(+), 148 deletions(-) delete mode 100644 connect delete mode 100644 old create mode 100755 tests/fakecodex_sleep create mode 100644 tests/requirements.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88a8907..c6763d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,8 +16,28 @@ concurrency: cancel-in-progress: true jobs: + quality: + name: quality gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install cockpit dependencies + run: python -m pip install --requirement tests/requirements.txt + - name: Check + run: cargo check --locked + - name: Test + run: cargo test --locked + - name: Build cockpit binary + run: cargo build --release --locked + - name: Run cockpit audit + run: python tests/cockpit.py ./target/release/rail + build: name: build ${{ matrix.asset }} + needs: quality runs-on: ${{ matrix.os }} strategy: fail-fast: false diff --git a/README.md b/README.md index 9760845..bc1cb94 100644 --- a/README.md +++ b/README.md @@ -221,17 +221,24 @@ on `PATH`. - `state.json` — worker-owned runtime (status, codex id, rollout path, pids) - `label.json` — manager-owned title + pin flag (authoritative over the title) - `output.log` — per-session terminal output + - `.locks/.init.lock` — serializes manager resume/remove for one session + - `.locks/.worker.lock` — held for the lifetime of the session worker + - `.stop-.request` — socket-independent, generation-scoped stop fallback - Sockets: `$XDG_RUNTIME_DIR/codex-rail` (or `/tmp/codex-rail-$UID`) - Distillation: `$XDG_CONFIG_HOME/codex-rail/distill` (or `~/.config/codex-rail/distill`) - `style-vNNN.md` — the versioned distilled style summaries - `corpus/` — the aggregated, codex-readable message chunks (regenerated each run) -State and socket files are locked to the owner (`0600`/`0700`). +State, socket, and distillation data are locked to the owner (`0600` files, +`0700` directories). On startup rail also tightens permissions left by older +versions without following symlinks outside its data directories. ## Limits - Unix-like systems only. -- Only sessions launched by `rail` are managed. +- Rail can adopt Codex histories created in the current working directory as + resumable rows. Live process control is limited to sessions launched or + resumed through rail. - One active attachment per session; a second attach is refused until the first detaches. - Status and title sync rely on codex's on-disk transcript format, which is diff --git a/connect b/connect deleted file mode 100644 index e69de29..0000000 diff --git a/old b/old deleted file mode 100644 index 3d8737b..0000000 --- a/old +++ /dev/null @@ -1 +0,0 @@ -NO - fails - code stuck, new code kills pid 8819 diff --git a/src/autopilot.rs b/src/autopilot.rs index 7819bfd..c6fe8e9 100644 --- a/src/autopilot.rs +++ b/src/autopilot.rs @@ -100,11 +100,8 @@ pub fn load(main_id: &str) -> Option { pub fn save(main_id: &str, st: &AutopilotState) { let p = path(main_id); - if let Some(dir) = p.parent() { - let _ = fs::create_dir_all(dir); - } if let Ok(bytes) = serde_json::to_vec_pretty(st) { - let _ = fs::write(&p, bytes); + let _ = state::write_private_file(&p, bytes); } } diff --git a/src/distill.rs b/src/distill.rs index d728f6a..b562ac3 100644 --- a/src/distill.rs +++ b/src/distill.rs @@ -137,9 +137,13 @@ pub struct DistillPrep { /// Aggregate the archives into `distill_dir()/corpus/` and return a plan the /// launcher turns into a codex session. Regenerates the corpus every call. pub fn prepare() -> Result { + // This tree contains verbatim excerpts from private Codex/Claude history. + // Migrate artifacts created by older builds before reading or rewriting it. + state::ensure_private_distill_storage()?; let workdir = state::distill_dir(); let corpus_dir = workdir.join("corpus"); fs::create_dir_all(&corpus_dir).context("create distill corpus dir")?; + state::restrict_to_owner(&corpus_dir)?; // Clear any corpus from a previous run so stale chunks can't linger. if let Ok(entries) = fs::read_dir(&corpus_dir) { for e in entries.flatten() { @@ -241,7 +245,7 @@ pub fn prepare() -> Result { let content = format!( "# corpus chunk {k}/{n} — the user's own conversations, in context (read this file fully)\n{body}\n<>\n" ); - fs::write(corpus_dir.join(&file), content) + state::write_private_file(&corpus_dir.join(&file), content) .with_context(|| format!("write corpus chunk {file}"))?; chunks.push(Chunk { file, marker }); } diff --git a/src/main.rs b/src/main.rs index 3ffb923..13aa9a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,7 +57,7 @@ fn main() -> Result<()> { // Also drop the exact prompt the UI would launch codex with, so a // headless/real-codex test can drive the same thing. let prompt_path = prep.workdir.join(".last-distill-prompt.txt"); - let _ = std::fs::write(&prompt_path, distill::distill_prompt(&prep)); + state::write_private_file(&prompt_path, distill::distill_prompt(&prep))?; println!( "distill: {} sessions ({} codex + {} claude) · {} user turns · {} chunk(s) · scanned {} files, {} rich sessions available -> {}/corpus", prep.sessions, diff --git a/src/state.rs b/src/state.rs index 7238a2e..096e57e 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,9 +1,11 @@ -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::env; use std::fs; -use std::io::BufRead; -use std::os::unix::fs::PermissionsExt; +use std::io::{self, BufRead, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process; use std::sync::atomic::{AtomicU64, Ordering}; @@ -17,6 +19,8 @@ pub const STATUS_EXITED: &str = "exited"; pub const STATUS_FAILED: &str = "failed"; static ID_COUNTER: AtomicU64 = AtomicU64::new(0); +static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); +const STARTING_GRACE_SECS: u64 = 15; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionState { @@ -27,6 +31,15 @@ pub struct SessionState { pub status: String, pub worker_pid: Option, pub child_pid: Option, + // New workers hold jobs/.locks/.worker.lock for their whole lifetime. + // False means a legacy state from before that protocol existed. + #[serde(default)] + pub worker_lock_protocol: bool, + // A per-worker generation token. It scopes out-of-band control files to the + // exact lock owner, so a stale stop request cannot hit a later worker after + // pid reuse or a session resume. + #[serde(default)] + pub worker_token: Option, pub socket: String, pub created_at: u64, pub updated_at: u64, @@ -88,6 +101,18 @@ pub fn new_session_id() -> String { format!("{:x}-{:x}-{:x}", now_millis(), process::id(), n) } +pub fn validate_session_id(id: &str) -> Result<()> { + if id.is_empty() + || id.len() > 64 + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + bail!("invalid session id"); + } + Ok(()) +} + pub fn data_dir() -> PathBuf { if let Some(path) = env::var_os("XDG_DATA_HOME") { return PathBuf::from(path).join(APP_DIR); @@ -144,6 +169,12 @@ pub fn socket_path(id: &str) -> PathBuf { runtime_dir().join(format!("{id}.sock")) } +fn worker_stop_request_path(id: &str, token: &str) -> Result { + validate_session_id(id)?; + validate_session_id(token)?; + Ok(job_dir(id).join(format!(".stop-{token}.request"))) +} + pub fn ensure_base_dirs() -> Result<()> { fs::create_dir_all(jobs_dir()).context("create jobs directory")?; restrict_to_owner(&jobs_dir())?; @@ -152,6 +183,183 @@ pub fn ensure_base_dirs() -> Result<()> { Ok(()) } +/// Create the private distillation storage and tighten any files left behind by +/// older rail builds. The corpus and style profiles contain excerpts from the +/// user's Codex/Claude history, so relying on the process umask (commonly 022) +/// would leave them readable by other users on a shared host. +pub fn ensure_private_distill_storage() -> Result<()> { + let config = config_dir(); + secure_private_dir(&config)?; + secure_private_dir(&distill_dir()) +} + +fn secure_private_dir(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + bail!("refuse symlinked private directory {}", path.display()) + } + Ok(meta) if !meta.is_dir() => bail!("private path is not a directory: {}", path.display()), + Ok(_) => {} + Err(err) if err.kind() == io::ErrorKind::NotFound => { + fs::create_dir_all(path).with_context(|| format!("create {}", path.display()))?; + let meta = fs::symlink_metadata(path) + .with_context(|| format!("inspect {}", path.display()))?; + if meta.file_type().is_symlink() || !meta.is_dir() { + bail!("private path is not a real directory: {}", path.display()); + } + } + Err(err) => return Err(err).with_context(|| format!("inspect {}", path.display())), + } + restrict_to_owner(path)?; + for entry in fs::read_dir(path).with_context(|| format!("read {}", path.display()))? { + let entry = entry?; + let kind = entry.file_type()?; + let child = entry.path(); + if kind.is_dir() { + secure_private_dir(&child)?; + } else if kind.is_file() { + restrict_file_to_owner(&child)?; + } + // Never follow symlinks while migrating an existing tree: a link may + // intentionally point outside rail's private storage. + } + Ok(()) +} + +/// Write a sensitive file with 0600 from the instant it is created. Existing +/// files are chmodded before truncation by `ensure_private_distill_storage`, and +/// again here for callers writing an individual artifact. +pub fn write_private_file(path: &Path, bytes: impl AsRef<[u8]>) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + restrict_to_owner(parent)?; + } + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("open {}", path.display()))?; + if !file.metadata()?.is_file() { + bail!("private output is not a regular file: {}", path.display()); + } + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(bytes.as_ref()) + .with_context(|| format!("write {}", path.display())) +} + +pub fn restrict_private_file_to_owner(path: &Path) -> Result<()> { + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("open private file {}", path.display()))?; + if !file.metadata()?.is_file() { + bail!("private path is not a regular file: {}", path.display()); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .with_context(|| format!("restrict permissions on {}", path.display())) +} + +fn open_session_lock(id: &str, name: &str, nonblocking: bool) -> Result> { + validate_session_id(id)?; + // Locks live outside the removable job directory. `flock` protects an inode, + // not a pathname: unlinking job_dir/worker.lock while it was held would let a + // second process create a new inode with the same name and split the lock. + let dir = jobs_dir().join(".locks"); + fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + restrict_to_owner(&dir)?; + let path = dir.join(format!("{id}.{name}")); + open_lock_path(&path, nonblocking) +} + +fn open_lock_path(path: &Path, nonblocking: bool) -> Result> { + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .open(&path) + .with_context(|| format!("open {}", path.display()))?; + restrict_file_to_owner(&path)?; + let flags = libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 }; + loop { + if unsafe { libc::flock(file.as_raw_fd(), flags) } == 0 { + return Ok(Some(file)); + } + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted && !nonblocking { + continue; + } + let raw = err.raw_os_error(); + if nonblocking && (raw == Some(libc::EWOULDBLOCK) || raw == Some(libc::EAGAIN)) { + return Ok(None); + } + return Err(err).with_context(|| format!("lock {}", path.display())); + } +} + +/// Serialize manager-side bootstrap/removal for one session. The returned file +/// must stay alive for the whole critical section. +pub fn acquire_session_init_lock(id: &str) -> Result { + open_session_lock(id, "init.lock", false)?.context("blocking lock returned no guard") +} + +pub fn try_acquire_session_init_lock(id: &str) -> Result> { + open_session_lock(id, "init.lock", true) +} + +/// Try to become the sole worker for a session. `Ok(None)` means another worker +/// owns the lifetime lock and must be left completely untouched. +pub fn try_acquire_worker_lock(id: &str) -> Result> { + open_session_lock(id, "worker.lock", true) +} + +/// Ask a lock-aware worker to stop without relying on its Unix socket or pid. +/// This is the portable fallback when a runtime directory has been cleared: +/// the lifetime lock proves that this session still has a worker, while the +/// generation-scoped marker prevents a late request from reaching a successor. +pub fn request_worker_stop(id: &str) -> Result<()> { + validate_session_id(id)?; + let _init_lock = acquire_session_init_lock(id)?; + let current = read_state(id)?; + if !current.worker_lock_protocol { + bail!("worker does not support out-of-band stop requests"); + } + let token = current + .worker_token + .as_deref() + .context("worker state has no generation token")?; + if try_acquire_worker_lock(id)?.is_some() { + bail!("session no longer has a live worker"); + } + write_private_file(&worker_stop_request_path(id, token)?, b"stop\n") +} + +/// Consume only the marker for this exact worker generation. A marker left by +/// a crashed predecessor has a different filename and is therefore inert. +pub fn take_worker_stop_request(state: &SessionState) -> Result { + if !state.worker_lock_protocol { + return Ok(false); + } + let Some(token) = state.worker_token.as_deref() else { + return Ok(false); + }; + let path = worker_stop_request_path(&state.id, token)?; + let meta = match fs::symlink_metadata(&path) { + Ok(meta) => meta, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err).with_context(|| format!("inspect {}", path.display())), + }; + if !meta.is_file() { + bail!("worker stop request is not a regular file: {}", path.display()); + } + fs::remove_file(&path).with_context(|| format!("consume {}", path.display()))?; + Ok(true) +} + // Session state and worker sockets can contain full terminal transcripts // (source, secrets shown on screen). Directories default to umask-masked // 0755 and files to 0644, which is world-readable on shared hosts, so lock @@ -167,17 +375,32 @@ pub fn restrict_file_to_owner(path: &Path) -> Result<()> { } pub fn read_state(id: &str) -> Result { + validate_session_id(id)?; let path = state_path(id); let bytes = fs::read(&path).with_context(|| format!("read {}", path.display()))?; serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display())) } +pub fn read_state_optional(id: &str) -> Result> { + validate_session_id(id)?; + let path = state_path(id); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), + }; + serde_json::from_slice(&bytes) + .with_context(|| format!("parse {}", path.display())) + .map(Some) +} + pub fn write_state(state: &SessionState) -> Result<()> { + validate_session_id(&state.id)?; let dir = job_dir(&state.id); fs::create_dir_all(&dir).context("create job directory")?; restrict_to_owner(&dir)?; let path = state_path(&state.id); - let tmp = path.with_extension("json.tmp"); + let tmp = unique_tmp_path(&dir, "state.json"); let bytes = serde_json::to_vec_pretty(state).context("serialize state")?; fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?; restrict_file_to_owner(&tmp)?; @@ -208,11 +431,12 @@ pub fn read_label(id: &str) -> Option<(String, bool)> { } pub fn write_label(id: &str, title: &str, title_pinned: bool) -> Result<()> { + validate_session_id(id)?; let dir = job_dir(id); fs::create_dir_all(&dir).context("create job directory")?; restrict_to_owner(&dir)?; let path = label_path(id); - let tmp = path.with_extension("json.tmp"); + let tmp = unique_tmp_path(&dir, "label.json"); let label = Label { title: title.to_string(), title_pinned, @@ -224,6 +448,11 @@ pub fn write_label(id: &str, title: &str, title_pinned: bool) -> Result<()> { Ok(()) } +fn unique_tmp_path(dir: &Path, stem: &str) -> PathBuf { + let n = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + dir.join(format!(".{stem}.tmp-{}-{n}", process::id())) +} + pub fn load_sessions() -> Result> { let mut sessions = Vec::new(); let dir = jobs_dir(); @@ -233,6 +462,7 @@ pub fn load_sessions() -> Result> { for entry in fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { let entry = entry?; + let dir_id = entry.file_name().to_string_lossy().to_string(); let path = entry.path().join("state.json"); if !path.exists() { continue; @@ -244,6 +474,10 @@ pub fn load_sessions() -> Result> { .with_context(|| format!("parse {}", path.display())) }) { Ok(mut state) => { + if validate_session_id(&state.id).is_err() || state.id != dir_id { + eprintln!("skip session state whose id does not match its directory: {dir_id}"); + continue; + } reconcile_liveness(&mut state); // label.json (manager-owned) wins over state.json's title, so a // worker's periodic state writes can never revert a rename. @@ -278,30 +512,100 @@ fn reconcile_liveness(state: &mut SessionState) { if !watched { return; } - let Some(pid) = state.worker_pid else { + if state.worker_pid.is_none() { + if state.status == STATUS_STARTING + && now_secs().saturating_sub(state.updated_at) > STARTING_GRACE_SECS + { + state.status = STATUS_EXITED.to_string(); + state.last_error = Some("worker never claimed this session".to_string()); + } return; - }; - if worker_alive(pid) { + } + if session_worker_is_running(state) { return; } state.status = STATUS_EXITED.to_string(); - state.last_error = Some("worker process not found".to_string()); - state.updated_at = now_secs(); - write_state(state).ok(); + state.last_error = Some("worker process not found or no longer owns this session".to_string()); + // This is derived manager state only. Runtime state.json is worker-owned; + // persisting this stale snapshot could clobber a newly-started worker's pid. } -fn worker_alive(pid: u32) -> bool { +pub fn checked_pid(pid: u32) -> Option { + let pid = libc::pid_t::try_from(pid).ok()?; + (pid > 1).then_some(pid) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WorkerIdentity { + Match, + Gone, + Mismatch, + Unknown, +} + +fn worker_identity(pid: u32, id: &str) -> WorkerIdentity { + let Some(raw_pid) = checked_pid(pid) else { + return WorkerIdentity::Mismatch; + }; // kill(pid, 0) also succeeds for a zombie — a process that has already // exited but hasn't been reaped by its parent. On systems whose init // doesn't reap orphans (e.g. a bare container PID 1), a crashed worker // lingers as indefinitely; treating it as alive would pin its // session to "running" forever and make it unstoppable (its socket is // gone, so STOP can't connect). So reject processes in the zombie state. - if unsafe { libc::kill(pid as libc::pid_t, 0) } != 0 { - return false; + if unsafe { libc::kill(raw_pid, 0) } != 0 { + return match io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => WorkerIdentity::Gone, + _ => WorkerIdentity::Unknown, + }; + } + if proc_is_zombie(pid) { + return WorkerIdentity::Gone; + } + if !Path::new("/proc").is_dir() { + return WorkerIdentity::Unknown; + } + let cmdline = match fs::read(format!("/proc/{pid}/cmdline")) { + Ok(cmdline) => cmdline, + Err(err) if err.kind() == io::ErrorKind::NotFound => return WorkerIdentity::Gone, + // macOS and restricted /proc: keep the row live, but never use this + // unverified identity for the PID-kill fallback. + Err(_) => return WorkerIdentity::Unknown, + }; + let parts: Vec<&[u8]> = cmdline + .split(|b| *b == 0) + .filter(|p| !p.is_empty()) + .collect(); + for pair in parts.windows(2) { + if pair[0] == b"--worker" && pair[1] == id.as_bytes() { + return match proc_data_dir(pid) { + Some(dir) if dir == data_dir() => WorkerIdentity::Match, + Some(_) => WorkerIdentity::Mismatch, + None => WorkerIdentity::Unknown, + }; + } + } + WorkerIdentity::Mismatch +} + +fn proc_data_dir(pid: u32) -> Option { + let environ = fs::read(format!("/proc/{pid}/environ")).ok()?; + let mut xdg: Option = None; + let mut home: Option = None; + for kv in environ.split(|b| *b == 0) { + if let Some(rest) = kv.strip_prefix(b"XDG_DATA_HOME=") { + xdg = Some(String::from_utf8_lossy(rest).into_owned()); + } else if let Some(rest) = kv.strip_prefix(b"HOME=") { + home = Some(String::from_utf8_lossy(rest).into_owned()); + } + } + match xdg.filter(|x| !x.is_empty()) { + Some(x) => Some(PathBuf::from(x).join(APP_DIR)), + None => home + .filter(|h| !h.is_empty()) + .map(|h| PathBuf::from(h).join(".local/share").join(APP_DIR)), } - !proc_is_zombie(pid) } fn proc_is_zombie(pid: u32) -> bool { @@ -317,16 +621,75 @@ fn proc_is_zombie(pid: u32) -> bool { } } -/// Whether a worker with this pid is a genuinely live process (not absent, not -/// a zombie). Used by the manager to decide stop-vs-remove for a session. -pub fn worker_is_running(pid: Option) -> bool { - pid.map(worker_alive).unwrap_or(false) +/// Cross-platform liveness: current workers prove life by holding worker.lock. +/// During migration, an older worker can instead prove life with a matching +/// Linux cmdline/data-dir identity or a connectable canonical socket. +pub fn session_worker_is_running(state: &SessionState) -> bool { + if state.status == STATUS_STARTING + && state.worker_pid.is_none() + && now_secs().saturating_sub(state.updated_at) <= STARTING_GRACE_SECS + { + return true; + } + let _init_lock = match try_acquire_session_init_lock(&state.id) { + Ok(Some(lock)) => lock, + Ok(None) | Err(_) => return true, + }; + session_worker_is_running_under_init_lock(state) +} + +/// Same liveness check when the caller already owns init.lock. Acquiring the +/// same flock through a second open fd would conflict with our own guard. +pub fn session_worker_is_running_under_init_lock(state: &SessionState) -> bool { + let _worker_lock = match try_acquire_worker_lock(&state.id) { + Ok(Some(lock)) => lock, + Ok(None) | Err(_) => return true, + }; + legacy_worker_is_running(state) +} + +fn legacy_worker_is_running(state: &SessionState) -> bool { + // The caller already acquired worker.lock. For a state written by the new + // protocol that alone proves its worker is gone, even on macOS/no-/proc where + // a recycled pid would otherwise look "unknown but alive" forever. + if state.worker_lock_protocol { + // A connectable socket is still stronger live evidence if the underlying + // filesystem's flock service is broken or an old/new binary overlaps. + return UnixStream::connect(&state.socket).is_ok(); + } + let identity = state + .worker_pid + .map(|pid| worker_identity(pid, &state.id)); + identity == Some(WorkerIdentity::Match) + || (identity == Some(WorkerIdentity::Unknown) + && matches!( + state.status.as_str(), + STATUS_STARTING | STATUS_RUNNING | STATUS_STOPPING + )) + || UnixStream::connect(&state.socket).is_ok() +} + +/// Strong identity check used before any PID-based signal. Unknown is false: +/// being unable to prove ownership is a reason not to kill. +pub fn worker_matches_session(pid: Option, id: &str) -> bool { + pid.map(|pid| worker_identity(pid, id) == WorkerIdentity::Match) + .unwrap_or(false) } /// Delete a stopped session's on-disk footprint so it leaves the manager list. /// The caller must ensure the worker is not running — removing a live /// session's dir would pull the ground out from under its worker. pub fn remove_session(id: &str) -> Result<()> { + validate_session_id(id)?; + let _init_lock = acquire_session_init_lock(id)?; + let Some(_worker_lock) = try_acquire_worker_lock(id)? else { + bail!("session still has a live worker"); + }; + if let Some(current) = read_state_optional(id)? { + if legacy_worker_is_running(¤t) { + bail!("session state still belongs to a live worker"); + } + } // Best-effort socket cleanup; a cleanly-exited worker already removed it. fs::remove_file(socket_path(id)).ok(); let dir = job_dir(id); @@ -564,7 +927,13 @@ fn walk_rollouts(dir: &Path, depth: u32, out: &mut Vec) { #[cfg(test)] mod tests { - use super::is_synthetic_marker; + use super::{ + checked_pid, is_synthetic_marker, open_lock_path, secure_private_dir, + validate_session_id, write_private_file, + }; + use std::fs; + use std::os::unix::fs::symlink; + use std::os::unix::fs::PermissionsExt; #[test] fn synthetic_markers_are_skipped() { @@ -589,4 +958,89 @@ mod tests { assert!(!is_synthetic_marker("use the [brackets] like this in a sentence")); assert!(!is_synthetic_marker("")); } + + #[test] + fn private_tree_migration_locks_down_existing_and_new_artifacts() { + let root = std::env::temp_dir().join(format!( + "rail-private-tree-{}-{}", + std::process::id(), + super::now_millis() + )); + let nested = root.join("corpus"); + fs::create_dir_all(&nested).unwrap(); + let old = nested.join("corpus-01.md"); + fs::write(&old, "private history").unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&nested, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&old, fs::Permissions::from_mode(0o644)).unwrap(); + + secure_private_dir(&root).unwrap(); + assert_eq!(fs::metadata(&root).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!( + fs::metadata(&nested).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert_eq!(fs::metadata(&old).unwrap().permissions().mode() & 0o777, 0o600); + + let new = nested.join("style-v001.md"); + write_private_file(&new, "profile").unwrap(); + assert_eq!(fs::metadata(&new).unwrap().permissions().mode() & 0o777, 0o600); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn worker_lock_is_exclusive_and_released_with_its_guard() { + let root = std::env::temp_dir().join(format!( + "rail-worker-lock-{}-{}", + std::process::id(), + super::now_millis() + )); + fs::create_dir_all(&root).unwrap(); + let path = root.join("worker.lock"); + let first = open_lock_path(&path, true).unwrap().unwrap(); + assert!(open_lock_path(&path, true).unwrap().is_none()); + drop(first); + assert!(open_lock_path(&path, true).unwrap().is_some()); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn session_ids_and_pids_fail_closed() { + for id in ["abc-123", "seed_0", "A1"] { + assert!(validate_session_id(id).is_ok()); + } + for id in ["", "../escape", "a/b", ".", "has space"] { + assert!(validate_session_id(id).is_err(), "accepted {id:?}"); + } + assert!(checked_pid(0).is_none()); + assert!(checked_pid(1).is_none()); + assert!(checked_pid(u32::MAX).is_none()); + } + + #[test] + fn private_storage_never_follows_symlinks() { + let root = std::env::temp_dir().join(format!( + "rail-private-symlink-{}-{}", + std::process::id(), + super::now_millis() + )); + let outside = root.join("outside"); + fs::create_dir_all(&outside).unwrap(); + let target = outside.join("target.txt"); + fs::write(&target, "keep me").unwrap(); + + let linked_dir = root.join("distill-link"); + symlink(&outside, &linked_dir).unwrap(); + assert!(secure_private_dir(&linked_dir).is_err()); + + let output_link = root.join("prompt-link"); + symlink(&target, &output_link).unwrap(); + assert!(write_private_file(&output_link, "overwrite").is_err()); + assert_eq!(fs::read_to_string(&target).unwrap(), "keep me"); + let _ = fs::remove_dir_all(&root); + } } diff --git a/src/ui.rs b/src/ui.rs index ff063b2..c008ca0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -239,9 +239,15 @@ fn bucket_of(activity: &HashMap, s: &SessionState) -> Bucket { // Has this distill session's output file been written yet? fn distill_done(s: &SessionState) -> bool { match s.distill_version { - Some(v) => state::distill_dir() - .join(format!("style-v{v:03}.md")) - .exists(), + Some(v) => { + let path = state::distill_dir().join(format!("style-v{v:03}.md")); + if !path.exists() { + return false; + } + // Codex creates the final profile with its own umask. Tighten it as + // soon as the manager observes completion. + state::restrict_private_file_to_owner(&path).is_ok() + } None => false, } } @@ -648,6 +654,9 @@ struct DistillJob { impl App { fn load() -> Result { + // Migrate private corpus/style artifacts written by older builds before + // the manager starts doing any other work. + state::ensure_private_distill_storage()?; // Reap rail's own stale worker trees up front (orphaned/exited/duplicate), // so leftover codex don't pile up and lock codex's shared ~/.codex sqlite // state — the "database is locked" failure. See reap_orphan_workers. @@ -1472,13 +1481,18 @@ fn run_slash(app: &mut App) -> Result { fn start_update(app: &mut App) { app.message = "checking for updates \u{2026}".to_string(); let (tx, rx) = mpsc::channel(); + let fake = env::var_os("CODEX_RAIL_FAKE_UPDATE").is_some(); thread::spawn(move || { - let msg = match update::newer_available() { - Some(_) => match update::apply() { - Ok(tag) => format!("updated to {tag} — restart rail to run it"), - Err(e) => format!("update failed: {e:#}"), - }, - None => "already up to date (or GitHub unreachable)".to_string(), + let msg = if fake { + "already up to date (test mode)".to_string() + } else { + match update::newer_available() { + Some(_) => match update::apply() { + Ok(tag) => format!("updated to {tag} — restart rail to run it"), + Err(e) => format!("update failed: {e:#}"), + }, + None => "already up to date (or GitHub unreachable)".to_string(), + } }; let _ = tx.send(msg); }); @@ -1818,31 +1832,58 @@ fn attach_current(app: &mut App, terminal: &mut TerminalSession) -> Result<()> { // exited. If the session has a captured codex_session_id, worker.rs will // pass it to `codex resume` instead of starting a brand-new conversation. fn relaunch_worker(session: &SessionState) -> Result<()> { - let mut session = session.clone(); - session.status = STATUS_STARTING.to_string(); - session.worker_pid = None; - session.child_pid = None; - session.exit_code = None; - session.last_error = None; - session.updated_at = state::now_secs(); - state::write_state(&session)?; - - let mut child = Command::new(env::current_exe().context("current executable")?) + state::validate_session_id(&session.id)?; + // Serialize bootstrap/remove across multiple manager processes. The guard is + // held until a worker is verifiably listening, so a second manager re-checks + // the winner's state instead of spawning another resume. + let _init_lock = state::acquire_session_init_lock(&session.id)?; + if let Some(current) = state::read_state_optional(&session.id)? { + if state::session_worker_is_running_under_init_lock(¤t) { + return wait_for_worker(&session.id, ¤t.socket, None); + } + } + + // Imported sessions have no rail state on disk yet; persist their resume + // identity once. Existing stopped sessions are deliberately left untouched: + // with two managers racing, either one rewriting state to "starting" could + // clobber the winning worker's fresh pid/status. The worker is the sole owner + // of runtime state and will transition it to running after taking its lock. + if state::read_state_optional(&session.id)?.is_none() { + let mut initial = session.clone(); + initial.status = STATUS_STARTING.to_string(); + initial.worker_pid = None; + initial.child_pid = None; + initial.exit_code = None; + initial.last_error = None; + initial.updated_at = state::now_secs(); + state::write_state(&initial)?; + if state::read_label(&session.id).is_none() { + state::write_label(&session.id, &session.title, session.title_pinned)?; + } + } + + let child = Command::new(env::current_exe().context("current executable")?) .arg("--worker") .arg(&session.id) .current_dir(Path::new(&session.cwd)) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .spawn() - .with_context(|| format!("spawn worker for {}", session.title))?; + .spawn(); + let mut child = match child { + Ok(child) => child, + Err(err) => { + record_unclaimed_launch_failure(&session.id, &err.to_string()); + return Err(err).with_context(|| format!("spawn worker for {}", session.title)); + } + }; + let spawned_pid = child.id(); thread::spawn(move || { let _ = child.wait(); }); - wait_for_socket(&session.socket); - Ok(()) + wait_for_worker(&session.id, &session.socket, Some(spawned_pid)) } // Recover a codex rollout path for sessions whose worker never captured one — @@ -2029,6 +2070,9 @@ fn adopt_codex_sessions( let Some((rcwd, sid)) = state::rollout_head(&path) else { continue; }; + if state::validate_session_id(&sid).is_err() { + continue; + } let path_str = path.to_string_lossy().to_string(); if exclude.contains(&sid) || exclude.contains(&path_str) || !seen.insert(sid.clone()) { continue; @@ -2066,6 +2110,8 @@ fn adopt_codex_sessions( status: STATUS_EXITED.to_string(), worker_pid: None, child_pid: None, + worker_lock_protocol: false, + worker_token: None, socket: state::socket_path(&sid).to_string_lossy().to_string(), created_at: created, updated_at: mtime_secs.max(created), @@ -2286,6 +2332,8 @@ fn create_session_in( status: STATUS_STARTING.to_string(), worker_pid: None, child_pid: None, + worker_lock_protocol: false, + worker_token: None, socket: socket.to_string_lossy().to_string(), created_at: now, updated_at: now, @@ -2305,15 +2353,22 @@ fn create_session_in( // the worker's state.json writes never define it. state::write_label(&id, title, false)?; - let mut child = Command::new(env::current_exe().context("current executable")?) + let child = Command::new(env::current_exe().context("current executable")?) .arg("--worker") .arg(&id) .current_dir(Path::new(&session.cwd)) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .spawn() - .with_context(|| format!("spawn worker for {title}"))?; + .spawn(); + let mut child = match child { + Ok(child) => child, + Err(err) => { + record_unclaimed_launch_failure(&id, &err.to_string()); + return Err(err).with_context(|| format!("spawn worker for {title}")); + } + }; + let spawned_pid = child.id(); // The worker writes its own worker_pid/status once it has bound the // socket, so this is just for the value returned below; don't persist @@ -2327,18 +2382,53 @@ fn create_session_in( let _ = child.wait(); }); - wait_for_socket(&session.socket); + wait_for_worker(&session.id, &session.socket, Some(spawned_pid))?; Ok(session) } -fn wait_for_socket(socket: &str) { +fn wait_for_worker(id: &str, socket: &str, expected_pid: Option) -> Result<()> { let path = Path::new(socket); for _ in 0..30 { - if path.exists() { - return; + match state::read_state(id) { + Ok(current) if current.status == STATUS_FAILED => { + anyhow::bail!( + "worker failed: {}", + current.last_error.as_deref().unwrap_or("unknown error") + ); + } + Ok(current) if current.status == STATUS_RUNNING => { + let claimed = expected_pid + .map(|pid| { + current.worker_pid == Some(pid) + || state::session_worker_is_running_under_init_lock(¤t) + }) + .unwrap_or(true); + if !claimed { + std::thread::sleep(Duration::from_millis(80)); + continue; + } + // Existence alone can be a stale inode from a crashed worker. A + // successful connect proves the claimed worker is listening. + if UnixStream::connect(path).is_ok() { + return Ok(()); + } + } + _ => {} } std::thread::sleep(Duration::from_millis(80)); } + anyhow::bail!("worker did not open socket {}", path.display()) +} + +fn record_unclaimed_launch_failure(id: &str, message: &str) { + if let Ok(mut current) = state::read_state(id) { + if current.worker_pid.is_none() { + current.status = STATUS_FAILED.to_string(); + current.last_error = Some(format!("spawn worker: {message}")); + current.updated_at = state::now_secs(); + let _ = state::write_state(¤t); + } + } } // Ctrl-X does one of two things depending on whether the session is live: @@ -2353,7 +2443,7 @@ fn stop_with_confirmation(app: &mut App) -> Result<()> { return Ok(()); }; - let live = session.status == STATUS_STARTING || state::worker_is_running(session.worker_pid); + let live = state::session_worker_is_running(&session); let confirmed = app .stop_confirm @@ -2383,6 +2473,8 @@ fn stop_with_confirmation(app: &mut App) -> Result<()> { .is_ok(); if sent { app.message = "stop requested".to_string(); + } else if state::request_worker_stop(&session.id).is_ok() { + app.message = "stop requested (socket gone — using worker control file)".to_string(); } else if kill_session_pids(&session) { app.message = "stopped (socket gone — killed the worker)".to_string(); } else { @@ -2416,32 +2508,77 @@ fn stop_with_confirmation(app: &mut App) -> Result<()> { Ok(()) } -// Kill a session's worker AND its codex tree by the pids recorded in its own state -// file — the fallback when the worker's socket is gone. Signals the whole process -// GROUP of each pid (codex's launcher runs the real codex + sub-agents as -// descendants; killing only the direct pid leaks them). SIGTERM only. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ProcRef { + pid: i32, + start_time: u64, +} + +// /proc//stat fields after the final `)` begin at field 3 (state). PPID is +// field 4 and starttime is field 22. The starttime makes a pid snapshot immune +// to pid reuse between discovery and signal. +fn proc_identity(pid: i32) -> Option<(i32, u64)> { + if pid <= 1 { + return None; + } + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let rp = stat.rfind(')')?; + let fields: Vec<&str> = stat[rp + 1..].split_whitespace().collect(); + let ppid = fields.get(1)?.parse().ok()?; + let start_time = fields.get(19)?.parse().ok()?; + Some((ppid, start_time)) +} + +fn proc_ref(pid: i32) -> Option { + let (_, start_time) = proc_identity(pid)?; + Some(ProcRef { pid, start_time }) +} + +fn same_process(proc: ProcRef) -> bool { + proc_identity(proc.pid) + .map(|(_, start)| start == proc.start_time) + .unwrap_or(false) +} + +fn proc_age_secs(proc: ProcRef) -> Option { + let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + if ticks <= 0 { + return None; + } + let uptime: f64 = std::fs::read_to_string("/proc/uptime") + .ok()? + .split_whitespace() + .next()? + .parse() + .ok()?; + let started = proc.start_time as f64 / ticks as f64; + Some((uptime - started).max(0.0) as u64) +} + +// Kill a session's worker AND its codex tree after a strong cmdline/data-dir +// identity check. The start-time snapshot is rechecked immediately before every +// signal so a recycled pid can never inherit the old session's SIGTERM. fn kill_session_pids(session: &SessionState) -> bool { - let mut any = false; - unsafe { - let my = libc::getpgrp(); - for pid in [session.child_pid, session.worker_pid].into_iter().flatten() { - if pid <= 1 { - continue; - } - let pgid = libc::getpgid(pid as libc::pid_t); - if pgid > 1 && pgid != my { - libc::kill(-pgid, libc::SIGTERM); - } - libc::kill(pid as libc::pid_t, libc::SIGTERM); - any = true; - } + if !state::worker_matches_session(session.worker_pid, &session.id) { + return false; + } + let Some(worker_pid) = session.worker_pid.and_then(state::checked_pid) else { + return false; + }; + let Some(worker) = proc_ref(worker_pid) else { + return false; + }; + if !state::worker_matches_session(session.worker_pid, &session.id) || !same_process(worker) { + return false; } - any + // Derive the codex descendants from the verified worker instead of trusting + // persisted child_pid/PGID values that may be stale or malformed. + kill_worker_tree(worker) } // Direct children of `pid`, read from /proc PPid. Used to find a worker's codex // launcher so its whole group can be killed. -fn proc_children_of(pid: i32) -> Vec { +fn proc_children_of(worker: ProcRef) -> Vec { let mut kids = Vec::new(); let Ok(rd) = std::fs::read_dir("/proc") else { return kids; @@ -2454,15 +2591,12 @@ fn proc_children_of(pid: i32) -> Vec { else { continue; }; - if let Ok(stat) = std::fs::read_to_string(format!("/proc/{cpid}/stat")) { - // comm (2nd field, in parens) may contain spaces/parens — parse the - // fields after the last ')': state, then ppid. - if let Some(rp) = stat.rfind(')') { - let mut it = stat[rp + 1..].split_whitespace(); - let _state = it.next(); - if it.next().and_then(|s| s.parse::().ok()) == Some(pid) { - kids.push(cpid); - } + if let Some((ppid, start_time)) = proc_identity(cpid) { + if ppid == worker.pid { + kids.push(ProcRef { + pid: cpid, + start_time, + }); } } } @@ -2472,20 +2606,36 @@ fn proc_children_of(pid: i32) -> Vec { // Kill a rail worker's whole codex tree, then the worker itself. codex's launcher // child runs the real codex (+ sub-agents) in its own process group, so -pgid on // that child takes the subtree; then SIGTERM the worker. SIGTERM only. -fn kill_worker_tree(worker_pid: i32) { +fn kill_worker_tree(worker: ProcRef) -> bool { + if !same_process(worker) { + return false; + } + let children = proc_children_of(worker); unsafe { let my = libc::getpgrp(); - for child in proc_children_of(worker_pid) { - let pgid = libc::getpgid(child as libc::pid_t); - if pgid > 1 && pgid != my { + for child in children { + if proc_identity(child.pid) != Some((worker.pid, child.start_time)) { + continue; + } + let pgid = libc::getpgid(child.pid); + // Only broadcast to a group led by this exact child. Otherwise signal + // the verified child pid alone rather than risk an unrelated group. + if pgid == child.pid + && pgid != my + && proc_identity(child.pid) == Some((worker.pid, child.start_time)) + { libc::kill(-pgid, libc::SIGTERM); } - libc::kill(child as libc::pid_t, libc::SIGTERM); + if proc_identity(child.pid) == Some((worker.pid, child.start_time)) { + libc::kill(child.pid, libc::SIGTERM); + } } - if worker_pid > 1 { - libc::kill(worker_pid as libc::pid_t, libc::SIGTERM); + if same_process(worker) { + libc::kill(worker.pid, libc::SIGTERM); + return true; } } + false } // True only when the `rail --worker` at `pid` belongs to THIS manager's data dir, @@ -2516,6 +2666,44 @@ fn worker_in_my_data_dir(pid: i32, my_data: &Path) -> bool { their_data == *my_data } +fn worker_executable_matches_manager(pid: i32, manager_exe: &Path) -> bool { + let Ok(exe) = std::fs::read_link(format!("/proc/{pid}/exe")) else { + return false; + }; + let manager_name = manager_exe.file_name().and_then(|n| n.to_str()); + let name_ok = exe + .file_name() + .and_then(|n| n.to_str()) + .map(|n| { + let n = n.strip_suffix(" (deleted)").unwrap_or(n); + Some(n) == manager_name || n.starts_with(".nfs") + }) + .unwrap_or(false); + name_ok && exe.parent() == manager_exe.parent() +} + +fn worker_candidate_still_matches( + proc: ProcRef, + id: &str, + my_data: &Path, + manager_exe: &Path, +) -> bool { + if !same_process(proc) + || !worker_executable_matches_manager(proc.pid, manager_exe) + || !worker_in_my_data_dir(proc.pid, my_data) + { + return false; + } + let Ok(cmd) = std::fs::read(format!("/proc/{}/cmdline", proc.pid)) else { + return false; + }; + let parts: Vec<&[u8]> = cmd + .split(|b| *b == 0) + .filter(|p| !p.is_empty()) + .collect(); + parts.len() == 3 && parts[1] == b"--worker" && parts[2] == id.as_bytes() +} + // Reap rail's OWN stale worker processes so leftover codex don't pile up and lock // codex's shared ~/.codex sqlite state (the "database is locked" failure). A worker // is stale when its session dir is gone (removed), its session already @@ -2526,7 +2714,11 @@ fn worker_in_my_data_dir(pid: i32, my_data: &Path) -> bool { fn reap_orphan_workers() -> usize { let me = std::process::id() as i32; let my_data = state::data_dir(); - let mut by_id: std::collections::HashMap> = std::collections::HashMap::new(); + let Ok(manager_exe) = env::current_exe() else { + return 0; + }; + let mut by_id: std::collections::HashMap> = + std::collections::HashMap::new(); let Ok(rd) = std::fs::read_dir("/proc") else { return 0; }; @@ -2549,9 +2741,14 @@ fn reap_orphan_workers() -> usize { .filter(|s| !s.is_empty()) .map(|s| String::from_utf8_lossy(s).into_owned()) .collect(); - let is_worker = parts.first().map(|p| p.ends_with("rail")).unwrap_or(false) - && parts.iter().any(|p| p == "--worker"); - if !is_worker { + // argv[0] may be NFS silly-renamed to `.nfs…` after a self-update, so its + // basename is not stable. Require rail's exact hidden-worker argv shape, + // the same executable directory, and the data-dir boundary. A random + // same-user process merely containing `--worker` must never be signalled. + if parts.len() != 3 || parts.get(1).map(String::as_str) != Some("--worker") { + continue; + } + if !worker_executable_matches_manager(pid, &manager_exe) { continue; } // SAFETY-CRITICAL: only ever consider workers that belong to THIS manager's @@ -2562,27 +2759,90 @@ fn reap_orphan_workers() -> usize { if !worker_in_my_data_dir(pid, &my_data) { continue; } - if let Some(pos) = parts.iter().position(|p| p == "--worker") { - if let Some(id) = parts.get(pos + 1) { - by_id.entry(id.clone()).or_default().push(pid); + let Some(proc) = proc_ref(pid) else { + continue; + }; + if let Some(id) = parts.get(2) { + if state::validate_session_id(id).is_ok() { + by_id.entry(id.clone()).or_default().push(proc); } } } let mut reaped = 0; for (id, pids) in by_id { - let st = state::read_state(&id).ok(); - let exists = st.is_some(); + // A relaunching manager holds init.lock from bootstrap through socket + // readiness. Never classify that in-between worker from the old exited + // state. Locks live in jobs/.locks (outside removable session dirs), so + // the inode remains stable through remove/relaunch races. + let _init_lock = match state::try_acquire_session_init_lock(&id) { + Ok(Some(lock)) => lock, + Ok(None) => continue, + Err(err) => { + eprintln!("skip stale-worker check for {id}: {err:#}"); + continue; + } + }; + // A current worker holds this for its whole lifetime. If busy, the + // process may be in the tiny claim window before state.json changes; + // never infer staleness from the previous exited snapshot. If acquired, + // keep it through classification+kill so no worker can start mid-check. + let _worker_lock = match state::try_acquire_worker_lock(&id) { + Ok(Some(lock)) => lock, + // A busy lifetime lock is stronger evidence of a live worker than a + // transient missing/stale NFS state file is evidence of an orphan. + // Fail closed and leave it alone; a later startup can retry. + Ok(None) => continue, + Err(err) => { + eprintln!("skip stale-worker check for {id}: {err:#}"); + continue; + } + }; + let st = match state::read_state_optional(&id) { + Ok(st) => st, + Err(err) => { + // Fail closed: a transient NFS/permission/JSON error is not proof + // that the worker is orphaned, so never turn uncertainty into kill. + eprintln!("skip stale-worker check for {id}: {err:#}"); + continue; + } + }; + let no_state = st.is_none(); let exited = st .as_ref() .map(|s| matches!(s.status.as_str(), STATUS_EXITED | STATUS_FAILED)) .unwrap_or(false); - let cur = st.as_ref().and_then(|s| s.worker_pid).map(|p| p as i32); - for pid in pids { - if exists && !exited && cur == Some(pid) { + let starting_timed_out = st + .as_ref() + .map(|s| { + s.status == STATUS_STARTING + && s.worker_pid.is_none() + && state::now_secs().saturating_sub(s.updated_at) > 15 + }) + .unwrap_or(false); + let cur = st.as_ref().and_then(|s| { + if state::worker_matches_session(s.worker_pid, &id) { + s.worker_pid.and_then(state::checked_pid) + } else { + None + } + }); + for proc in pids { + if !exited && cur == Some(proc.pid) { continue; // the live session's current worker — keep it } - kill_worker_tree(pid); - reaped += 1; + if proc_age_secs(proc).map(|age| age <= 15).unwrap_or(true) { + continue; // claim/relaunch grace; uncertainty is never stale proof + } + let proven_stale = no_state + || exited + || starting_timed_out + || cur.is_some_and(|owner| owner != proc.pid); + if proven_stale + && worker_candidate_still_matches(proc, &id, &my_data, &manager_exe) + && kill_worker_tree(proc) + { + reaped += 1; + } } } reaped diff --git a/src/worker.rs b/src/worker.rs index c1d21e2..67019ea 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -7,6 +7,7 @@ use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; use std::collections::HashSet; use std::fs::{self, OpenOptions}; use std::io::{self, BufRead, Read, Seek, SeekFrom, Write}; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::process; @@ -19,40 +20,108 @@ const INITIAL_COLS: u16 = 80; const TAIL_BYTES: usize = 96 * 1024; pub fn run_worker(id: &str) -> Result<()> { + state::validate_session_id(id)?; unsafe { libc::setsid(); } + state::ensure_base_dirs()?; + // Hold one kernel-backed lock for the worker's entire lifetime. Two manager + // processes can race to resume the same stopped/imported session; without + // this guard they would start two `codex resume` writers and the later worker + // would unlink the first worker's live socket. + let Some(_worker_lock) = state::try_acquire_worker_lock(id)? else { + // Another worker won the resume race. This is a normal idempotent + // outcome: do not mark the winning worker's session failed. + return Ok(()); + }; + match run_worker_inner(id) { Ok(()) => Ok(()), Err(err) => { - mark_failed(id, &format!("{err:#}")); + mark_failed_if_owner(id, &format!("{err:#}")); Err(err) } } } fn run_worker_inner(id: &str) -> Result<()> { - state::ensure_base_dirs()?; let mut session = state::read_state(id)?; + let state_before_claim = session.clone(); let socket_path = state::socket_path(id); if socket_path.exists() { - fs::remove_file(&socket_path).ok(); + // Compatibility with workers started by an older rail build (which do + // not hold worker.lock): never unlink a socket that still has a listener. + match UnixStream::connect(&socket_path) { + Ok(_) => return Ok(()), + Err(err) + if matches!( + err.raw_os_error(), + Some(libc::ENOENT) | Some(libc::ECONNREFUSED) | Some(libc::ENOTSOCK) + ) => {} + Err(err) => { + return Err(err) + .with_context(|| format!("probe existing socket {}", socket_path.display())) + } + } } - let listener = UnixListener::bind(&socket_path) - .with_context(|| format!("bind {}", socket_path.display()))?; - state::restrict_file_to_owner(&socket_path)?; - listener - .set_nonblocking(true) - .context("set listener nonblocking")?; - + // No live legacy listener and we own worker.lock: claim runtime state now so + // every subsequent setup failure is attributable and can be persisted. session.socket = socket_path.to_string_lossy().to_string(); session.worker_pid = Some(process::id()); - session.status = STATUS_RUNNING.to_string(); + session.child_pid = None; + session.worker_lock_protocol = true; + session.worker_token = Some(state::new_session_id()); + session.status = crate::state::STATUS_STARTING.to_string(); + session.exit_code = None; + session.last_error = None; session.updated_at = state::now_secs(); persist_state(&session)?; + if socket_path.exists() { + let meta = fs::symlink_metadata(&socket_path) + .with_context(|| format!("inspect stale socket {}", socket_path.display()))?; + if !meta.file_type().is_socket() { + anyhow::bail!( + "refuse to remove non-socket at worker path {}", + socket_path.display() + ); + } + fs::remove_file(&socket_path) + .with_context(|| format!("remove stale socket {}", socket_path.display()))?; + } + + let listener = match UnixListener::bind(&socket_path) { + Ok(listener) => listener, + Err(err) + if err.raw_os_error() == Some(libc::EADDRINUSE) + && UnixStream::connect(&socket_path).is_ok() => + { + // A legacy worker can bind in the narrow probe→bind window. Undo our + // provisional claim only if nobody else has already overwritten it, + // then treat that legacy listener as the idempotent winner. + if state::read_state(id) + .ok() + .and_then(|s| s.worker_pid) + == Some(process::id()) + { + state::write_state(&state_before_claim).ok(); + } + return Ok(()); + } + Err(err) => { + return Err(err).with_context(|| format!("bind {}", socket_path.display())) + } + }; + state::restrict_file_to_owner(&socket_path)?; + let socket_identity = fs::symlink_metadata(&socket_path) + .map(|m| (m.dev(), m.ino())) + .with_context(|| format!("identify socket {}", socket_path.display()))?; + listener + .set_nonblocking(true) + .context("set listener nonblocking")?; + let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { @@ -115,6 +184,7 @@ fn run_worker_inner(id: &str) -> Result<()> { drop(pair.slave); session.child_pid = child_pid; + session.status = STATUS_RUNNING.to_string(); session.updated_at = state::now_secs(); persist_state(&session)?; @@ -193,7 +263,7 @@ fn run_worker_inner(id: &str) -> Result<()> { session.status = STATUS_EXITED.to_string(); session.updated_at = state::now_secs(); persist_state(&session).ok(); - fs::remove_file(&session.socket).ok(); + remove_socket_if_same(Path::new(&session.socket), socket_identity); return Ok(()); } Ok(WorkerEvent::ClientInput(client_id, bytes)) => { @@ -220,18 +290,18 @@ fn run_worker_inner(id: &str) -> Result<()> { } } Ok(WorkerEvent::Stop) => { - if stop_requested_at.is_none() { - session.status = STATUS_STOPPING.to_string(); - session.updated_at = state::now_secs(); - persist_state(&session).ok(); - signal_child(child_pid, libc::SIGTERM); - stop_requested_at = Some(Instant::now()); - } + begin_stop(&mut session, child_pid, &mut stop_requested_at); } Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => break, } + if stop_requested_at.is_none() + && state::take_worker_stop_request(&session).unwrap_or(false) + { + begin_stop(&mut session, child_pid, &mut stop_requested_at); + } + if let Some(started) = stop_requested_at { if started.elapsed() > Duration::from_secs(5) { signal_child(child_pid, libc::SIGKILL); @@ -243,6 +313,30 @@ fn run_worker_inner(id: &str) -> Result<()> { Ok(()) } +fn begin_stop( + session: &mut SessionState, + child_pid: Option, + stop_requested_at: &mut Option, +) { + if stop_requested_at.is_some() { + return; + } + session.status = STATUS_STOPPING.to_string(); + session.updated_at = state::now_secs(); + persist_state(session).ok(); + signal_child(child_pid, libc::SIGTERM); + *stop_requested_at = Some(Instant::now()); +} + +fn remove_socket_if_same(path: &Path, expected: (u64, u64)) { + let same = fs::symlink_metadata(path) + .map(|m| m.file_type().is_socket() && (m.dev(), m.ino()) == expected) + .unwrap_or(false); + if same { + fs::remove_file(path).ok(); + } +} + fn accept_connections( listener: &UnixListener, tx: &Sender, @@ -412,7 +506,7 @@ fn is_current_client(attached: &Option<(u64, UnixStream)>, client_id: u64) -> bo } fn signal_child(pid: Option, signal: libc::c_int) { - if let Some(pid) = pid { + if let Some(pid) = pid.and_then(state::checked_pid) { unsafe { // Signal the child's whole process GROUP, not just the pid. codex's npm // launcher runs the real codex binary as a grandchild (and codex spawns @@ -420,11 +514,11 @@ fn signal_child(pid: Option, signal: libc::c_int) { // codex accumulate and lock codex's shared ~/.codex sqlite state. The PTY // gave the child its own session/group, so -pgid can't hit us — but guard // against ever signalling our own group just in case. - let pgid = libc::getpgid(pid as libc::pid_t); + let pgid = libc::getpgid(pid); if pgid > 1 && pgid != libc::getpgrp() { libc::kill(-pgid, signal); } - libc::kill(pid as libc::pid_t, signal); + libc::kill(pid, signal); } } } @@ -547,13 +641,18 @@ fn persist_state(session: &SessionState) -> Result<()> { state::write_state(session) } -fn mark_failed(id: &str, message: &str) { +fn mark_failed_if_owner(id: &str, message: &str) { if let Ok(mut session) = state::read_state(id) { + if session.worker_pid != Some(process::id()) { + return; + } session.status = STATUS_FAILED.to_string(); session.last_error = Some(message.to_string()); session.updated_at = state::now_secs(); state::write_state(&session).ok(); - fs::remove_file(&session.socket).ok(); + // Do not unlink here: after an EADDRINUSE race the pathname may belong + // to a legacy live worker. A refused stale socket is safely removed by + // the next owner during its probe phase. } } diff --git a/tests/cockpit.py b/tests/cockpit.py index fd2ef9d..429d8be 100644 --- a/tests/cockpit.py +++ b/tests/cockpit.py @@ -21,10 +21,8 @@ REPO = os.path.dirname(HERE) RAIL = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 and not sys.argv[1].startswith("-") \ else os.path.join(REPO, "target/release/rail") -JOBTMP = os.environ.get("CLAUDE_JOB_DIR", "/tmp") + "/tmp" if os.environ.get("CLAUDE_JOB_DIR") \ - else "/data/wuyitao/folder_113_train/.docker/home/.claude/jobs/ea952819/tmp" FAKE_STREAM = os.path.join(HERE, "fakecodex") # streams "tick" forever (a live turn) -FAKE_SLEEP = JOBTMP + "/fakecodex_sleep.sh" # quiet long-lived child +FAKE_SLEEP = os.path.join(HERE, "fakecodex_sleep") # quiet long-lived child COLS, ROWS = 110, 40 @@ -68,8 +66,9 @@ def __init__(self, rail=RAIL, root=None, codex=FAKE_SLEEP): self._zombies = [] # ---- lifecycle ----------------------------------------------------------- - def boot(self, settle=1.8): - shutil.rmtree(self.root, ignore_errors=True) + def boot(self, settle=1.8, reset=True): + if reset: + shutil.rmtree(self.root, ignore_errors=True) for d in (self.jobs, self.run, self.home): os.makedirs(d, exist_ok=True) env = os.environ.copy() @@ -269,7 +268,8 @@ def png(self, path, scale=1): def _base(self, sid, title, **over): now = int(time.time()) st = {"id": sid, "title": title, "cwd": "/tmp", "codex": self.codex, "status": "running", - "worker_pid": None, "child_pid": None, "socket": f"{self.run}/{sid}.sock", + "worker_pid": None, "child_pid": None, + "socket": f"{self.run}/codex-rail/{sid}.sock", "created_at": now, "updated_at": now, "exit_code": None, "last_error": None, "codex_session_id": None, "codex_rollout_path": None, "initial_prompt": None, "title_pinned": False, "last_output_at": 0} @@ -413,7 +413,7 @@ def snap(c, tag): c = Cockpit(rail, codex=FAKE_STREAM).boot() try: c.new("investigate the thing") - attached = any("tick" in r for r in c.rows()) + attached = c.wait_until(lambda: any("tick" in r for r in c.rows()), timeout=8) snap(c, "02_attached") c.key(b"\x1a", 1.0) # Ctrl-Z detach listed = c.row_with("investigate the thing") is not None @@ -665,9 +665,12 @@ def secs(a): cfg = os.path.join(c.home, ".codex", "config.toml") distill_dir = os.path.join(c.home, ".config", "codex-rail", "distill") trust_ok = os.path.exists(cfg) and f'[projects."{distill_dir}"]' in open(cfg).read() + private = all((os.stat(d).st_mode & 0o777) == 0o700 + for d in (os.path.dirname(distill_dir), distill_dir, corpus)) \ + and all((os.stat(p).st_mode & 0o777) == 0o600 for p in chunks) check("distill (Ctrl+D): corpus aggregated + autonomous session + dir pre-trusted", - made and shown and args_ok and trust_ok, - f"chunks={len(chunks)} shown={shown} args_ok={args_ok} trust_ok={trust_ok}") + made and shown and args_ok and trust_ok and private, + f"chunks={len(chunks)} shown={shown} args_ok={args_ok} trust_ok={trust_ok} private={private}") snap(c, "15_distill") finally: c.close() @@ -937,24 +940,34 @@ def sel(): c.close() # 26) a live session whose socket is GONE (e.g. XDG_RUNTIME_DIR was cleared - # while the worker stayed alive) can still be stopped — Ctrl+X twice falls - # back to killing the worker by its recorded pid instead of wedging. + # while the worker stayed alive) can still be stopped. Poison the persisted + # pid too: the generation-scoped control marker must work without /proc, + # which is the recovery path used on macOS. c = Cockpit(rail, codex=FAKE_SLEEP).boot() try: c.seed_running_worker("sock-gone", "STUCK") c.wait_until(lambda: c.row_with("STUCK") is not None, timeout=8) + worker = c._pid("sock-gone", "worker_pid") child = c._pid("sock-gone", "child_pid") - sock = json.load(open(f"{c.jobs}/sock-gone/state.json"))["socket"] + state_path = f"{c.jobs}/sock-gone/state.json" + persisted = json.load(open(state_path)) + sock = persisted["socket"] if sock and os.path.exists(sock): os.remove(sock) # vanish the socket + persisted["worker_pid"] = 4294967295 # unusable as a pid + json.dump(persisted, open(state_path, "w"), indent=2) c.key(b"\x18", 0.5); c.key(b"\x18", 1.2) # Ctrl+X twice -> stop def _alive(p): try: os.kill(p, 0); return True except OSError: return False killed = child is not None and not _alive(child) - check("stop: socket-gone live session is killed by the pid fallback", + check("stop: socket-gone worker stops via generation-scoped control (no pid/proc)", killed, f"child={child} killed={killed}") finally: + try: + if worker: os.kill(worker, signal.SIGKILL) + except Exception: + pass c.close() # 27) SAFETY: the startup orphan-reaper must ONLY touch its own data dir. A @@ -981,10 +994,12 @@ def _alive(p): "CODEX_RAIL_CODEX": FAKE_SLEEP}) byw = subprocess.Popen([rail, "--worker", bsid], cwd="/tmp", env=byenv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + bychild = None try: for _ in range(40): try: - if json.load(open(f"{byjobs}/{bsid}/state.json")).get("child_pid"): + bychild = json.load(open(f"{byjobs}/{bsid}/state.json")).get("child_pid") + if bychild: break except Exception: pass @@ -1001,8 +1016,114 @@ def _alive(p): finally: try: byw.kill() except Exception: pass + if bychild: + try: os.kill(bychild, signal.SIGKILL) + except Exception: pass shutil.rmtree(byroot, ignore_errors=True) + # 28) Two managers can race to resume the same session. Only one worker may + # own it: the loser exits without spawning a second codex, rewriting + # state, or unlinking the winner's socket. + c = Cockpit(rail, codex=FAKE_SLEEP).boot() + try: + sid = "lock-race" + c.seed_running_worker(sid, "LOCK RACE", codex=FAKE_SLEEP) + before = json.load(open(f"{c.jobs}/{sid}/state.json")) + socket_before = os.stat(before["socket"]).st_ino + env = os.environ.copy() + env.update({"XDG_DATA_HOME": c.data, "XDG_RUNTIME_DIR": c.run, "HOME": c.home, + "CODEX_RAIL_CODEX": FAKE_SLEEP}) + loser = subprocess.Popen([rail, "--worker", sid], cwd="/tmp", env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + loser_exited = loser.wait(timeout=5) == 0 + time.sleep(0.4) + after = json.load(open(f"{c.jobs}/{sid}/state.json")) + unchanged = all(after.get(k) == before.get(k) + for k in ("worker_pid", "child_pid", "status", "last_error")) + same_socket = os.path.exists(after["socket"]) \ + and os.stat(after["socket"]).st_ino == socket_before + winner_alive = c.alive(sid, "worker_pid") and c.alive(sid, "child_pid") + check("worker lock: duplicate worker loses without touching the live session", + loser_exited and unchanged and same_socket and winner_alive, + f"loser_exited={loser_exited} unchanged={unchanged} same_socket={same_socket} winner_alive={winner_alive}") + finally: + c.close() + + # 29) A non-socket at the canonical path is never unlinked. The worker owns + # its state by this point, so the setup failure must be visible instead + # of leaving a silent/stuck `starting` row. + c = Cockpit(rail, codex=FAKE_SLEEP).boot() + try: + sid = "blocked-socket" + c.seed(sid, "BLOCKED SOCKET", status="starting", codex=FAKE_SLEEP) + socket_path = f"{c.run}/codex-rail/{sid}.sock" + os.makedirs(os.path.dirname(socket_path), exist_ok=True) + open(socket_path, "w").write("do not delete") + env = os.environ.copy() + env.update({"XDG_DATA_HOME": c.data, "XDG_RUNTIME_DIR": c.run, "HOME": c.home, + "CODEX_RAIL_CODEX": FAKE_SLEEP}) + failed = subprocess.Popen([rail, "--worker", sid], cwd="/tmp", env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + failed.wait(timeout=5) + after = json.load(open(f"{c.jobs}/{sid}/state.json")) + visible = after.get("status") == "failed" and "non-socket" in (after.get("last_error") or "") + preserved = os.path.isfile(socket_path) and open(socket_path).read() == "do not delete" + check("worker setup failure: non-socket path is preserved and reported", + visible and preserved, f"visible={visible} preserved={preserved} state={after.get('status')}") + finally: + c.close() + + # 30) Two full manager TUIs racing Enter on the same stopped row must converge + # on one worker/one codex start. init.lock protects bootstrap state while + # worker.lock protects the lifetime owner. + shared = "/tmp/rc-two-managers-" + str(os.getpid()) + count_file = shared + "/codex-starts" + os.environ["CODEX_RAIL_FAKE_COUNT"] = count_file + c1 = c2 = None + try: + c1 = Cockpit(rail, root=shared, codex=FAKE_SLEEP).boot() + c2 = Cockpit(rail, root=shared, codex=FAKE_SLEEP).boot(reset=False) + sid = "manager-race" + c1.seed(sid, "MANAGER RACE", status="exited", codex=FAKE_SLEEP) + c1.wait_until(lambda: c1.row_with("MANAGER RACE") is not None, timeout=6) + c2.wait_until(lambda: c2.row_with("MANAGER RACE") is not None, timeout=6) + os.write(c1.m, b"\r") + os.write(c2.m, b"\r") + end = time.time() + 8 + starts = [] + while time.time() < end: + if os.path.exists(count_file): + starts = [line for line in open(count_file).read().splitlines() if line] + if starts: + break + time.sleep(0.1) + time.sleep(0.5) + if os.path.exists(count_file): + starts = [line for line in open(count_file).read().splitlines() if line] + os.write(c1.m, b"\x1a") + os.write(c2.m, b"\x1a") + time.sleep(0.8) + if os.path.exists(count_file): + starts = [line for line in open(count_file).read().splitlines() if line] + state_after = json.load(open(f"{c1.jobs}/{sid}/state.json")) + one_start = len(starts) == 1 + owned = state_after.get("status") == "running" \ + and state_after.get("worker_lock_protocol") is True \ + and state_after.get("worker_pid") \ + and state_after.get("child_pid") + check("manager race: two simultaneous resumes start exactly one codex", + one_start and owned, + f"starts={starts} status={state_after.get('status')} worker={state_after.get('worker_pid')} child={state_after.get('child_pid')}") + finally: + os.environ.pop("CODEX_RAIL_FAKE_COUNT", None) + if c1: + c1.close() + if c2: + c2.close() + shutil.rmtree(shared, ignore_errors=True) + # ---- summary npass = sum(1 for _, ok, _ in results if ok) print(f"\n==== {npass}/{len(results)} checks PASS ====") diff --git a/tests/fakecodex_sleep b/tests/fakecodex_sleep new file mode 100755 index 0000000..d75e3fc --- /dev/null +++ b/tests/fakecodex_sleep @@ -0,0 +1,6 @@ +#!/bin/sh +# Quiet stand-in for codex: stay alive until rail stops it. +if [ -n "${CODEX_RAIL_FAKE_COUNT:-}" ]; then + printf '%s\n' "$$" >> "$CODEX_RAIL_FAKE_COUNT" +fi +exec sleep 3600 diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..21ee303 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +pyte==0.8.2 From 8bf74c912f55ce1649d48d5fb013abaf109b95cc Mon Sep 17 00:00:00 2001 From: wuyitao Date: Mon, 13 Jul 2026 18:15:22 +0800 Subject: [PATCH 2/6] Deep-harden Rail session lifecycle and release safety --- .github/workflows/release.yml | 94 +- README.md | 143 +- build.rs | 19 +- docs/rail-demo.gif | Bin 0 -> 138380 bytes docs/rail-demo.png | Bin 56148 -> 50450 bytes src/autopilot.rs | 117 +- src/distill.rs | 1224 ++++++++++++++- src/guardian.rs | 123 ++ src/main.rs | 59 +- src/process_tree.rs | 808 ++++++++++ src/progress.rs | 6 +- src/protocol.rs | 51 + src/state.rs | 539 ++++++- src/ui.rs | 2588 +++++++++++++++++++++++++++----- src/update.rs | 515 ++++++- src/worker.rs | 1330 +++++++++++++--- tests/cockpit.py | 1800 ++++++++++++++++++++-- tests/demo_capture.py | 160 ++ tests/distill_realcodex.py | 74 +- tests/fakecodex_prompt_tui | 110 ++ tests/fakecodex_spawn_detached | 34 + tests/regress.py | 419 +----- tests/requirements.txt | 1 + 23 files changed, 8801 insertions(+), 1413 deletions(-) create mode 100644 docs/rail-demo.gif create mode 100644 src/guardian.rs create mode 100644 src/process_tree.rs create mode 100755 tests/demo_capture.py create mode 100755 tests/fakecodex_prompt_tui create mode 100755 tests/fakecodex_spawn_detached diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6763d5..7f9d586 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,39 +6,56 @@ name: release on: push: branches: [main] + pull_request: workflow_dispatch: permissions: - contents: write + contents: read concurrency: - group: release - cancel-in-progress: true + # PR quality runs may supersede themselves. A release never may: cancelling + # while assets are being replaced can leave the rolling release half-updated. + group: ${{ github.event_name == 'pull_request' && format('quality-pr-{0}', github.event.pull_request.number) || 'release' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: quality: name: quality gate runs-on: ubuntu-latest + timeout-minutes: 15 + env: + RUSTFLAGS: -Dwarnings steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: "3.12" - name: Install cockpit dependencies run: python -m pip install --requirement tests/requirements.txt - name: Check - run: cargo check --locked + run: cargo check --locked --all-targets - name: Test - run: cargo test --locked + run: cargo test --locked --all-targets -- --test-threads=1 - name: Build cockpit binary run: cargo build --release --locked - name: Run cockpit audit - run: python tests/cockpit.py ./target/release/rail + run: python tests/cockpit.py ./target/release/rail --png artifacts/cockpit + - name: Upload cockpit PNGs + if: always() + uses: actions/upload-artifact@v4 + with: + name: cockpit-png-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/cockpit + if-no-files-found: warn build: name: build ${{ matrix.asset }} + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' needs: quality runs-on: ${{ matrix.os }} + timeout-minutes: 30 + env: + RUSTFLAGS: -Dwarnings strategy: fail-fast: false matrix: @@ -48,15 +65,9 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-musl asset: rail-x86_64-linux - - os: ubuntu-latest + - os: ubuntu-24.04-arm target: aarch64-unknown-linux-musl asset: rail-aarch64-linux - # macOS: Apple Silicon covers every Mac since 2020. (Intel / - # x86_64-apple-darwin on macos-13 is omitted — those runners are scarce - # and queue for ages; add it back if an Intel-Mac user needs it.) - - os: macos-latest - target: aarch64-apple-darwin - asset: rail-aarch64-macos steps: - uses: actions/checkout@v4 - name: Install rust target @@ -67,14 +78,22 @@ jobs: sudo apt-get update sudo apt-get install -y musl-tools if [ "${{ matrix.target }}" = "aarch64-unknown-linux-musl" ]; then - sudo apt-get install -y gcc-aarch64-linux-gnu - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" - echo "CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc" >> "$GITHUB_ENV" + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc" >> "$GITHUB_ENV" + echo "CC_aarch64_unknown_linux_musl=musl-gcc" >> "$GITHUB_ENV" fi - name: Build - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --release --locked --target ${{ matrix.target }} - name: Stage asset run: cp "target/${{ matrix.target }}/release/rail" "${{ matrix.asset }}" + - name: Smoke staged asset + shell: bash + run: | + file "${{ matrix.asset }}" + if [[ "${{ matrix.target }}" == *-linux-musl ]]; then + file "${{ matrix.asset }}" | grep -Eq 'statically linked|static-pie linked' + fi + "./${{ matrix.asset }}" --version | grep -q '^rail ' + grep -aFq -- "CODEX_RAIL_BUILD_SHA=${GITHUB_SHA}" "${{ matrix.asset }}" - uses: actions/upload-artifact@v4 with: name: ${{ matrix.asset }} @@ -83,17 +102,48 @@ jobs: publish: name: publish latest release + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest + timeout-minutes: 15 permissions: contents: write steps: - uses: actions/download-artifact@v4 with: + pattern: rail-* path: dist merge-multiple: true - - name: List artifacts - run: ls -la dist + - name: Verify complete artifact set + run: | + test -f dist/rail-x86_64-linux + test -f dist/rail-aarch64-linux + test "$(find dist -maxdepth 1 -type f | wc -l)" -eq 2 + ls -la dist + - name: Remove unsupported legacy release assets + uses: actions/github-script@v7 + with: + script: | + try { + const {data: release} = await github.rest.repos.getReleaseByTag({ + owner: context.repo.owner, + repo: context.repo.repo, + tag: "latest", + }); + for (const asset of release.assets) { + if (asset.name === "rail-aarch64-macos") { + await github.rest.repos.deleteReleaseAsset({ + owner: context.repo.owner, + repo: context.repo.repo, + asset_id: asset.id, + }); + } + } + } catch (error) { + // A first-ever publish has no rolling release yet. Any other API + // failure must stop publication rather than leave stale assets. + if (error.status !== 404) throw error; + } - name: Publish rolling 'latest' release uses: softprops/action-gh-release@v2 with: @@ -103,4 +153,6 @@ jobs: files: dist/* body: | Rolling build of the latest `main`. `rail update` fetches the binary - for your platform (rail--). + for supported Linux platforms (rail--linux). macOS is not + published because its forced-cleanup path lacks Linux-equivalent + process-generation verification. diff --git a/README.md b/README.md index bc1cb94..663b6c9 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,21 @@ close to Claude Code's agent view: one manager screen, background sessions grouped by what they need from you, fast attach/detach, and no tmux or split panes. -![Codex Rail manager screen](docs/rail-demo.png) +![Codex Rail manager showing status groups, mouse hover, and the command palette](docs/rail-demo.gif) ## What it does -- **Runs sessions in the background.** Each session is its own worker process +- **Runs sessions in the background.** Each session has a guardian plus a worker holding a PTY that runs real `codex`. Closing the manager (or your terminal) - leaves sessions running. + leaves sessions running; if the worker crashes, the guardian reaps its whole + owned process generation before Rail permits a resume. - **Groups by status, like Claude Code's agents panel.** Sessions bucket into **Needs input** / **Working** / **Stopped**, with the ones wanting your attention floating to the top. Each row shows codex's latest message. - **Start a session with a first message.** Type your first instruction and - press Enter; `rail` launches `codex` with it so the turn starts immediately. - Leave it empty for a blank, auto-numbered session. + press Enter; `rail` waits for Codex's composer and submits it through the PTY, + so private prompt text is never placed in a process argument. Use `Ctrl+N` for + a blank, auto-numbered session. - **Resume exited sessions.** `rail` records each session's codex id and can `codex resume` it, restoring the full conversation. - **Live titles.** A session's title follows its first codex message @@ -27,12 +29,12 @@ panes. ## How it works -One **manager** process draws the UI; each session gets its own **worker** -process. They stay decoupled through per-session files, a Unix socket for live -attach, and codex's own transcript files (read-only). Crucially the two -processes write **disjoint** files: the worker owns `state.json` (runtime), the -manager owns `label.json` (the title). Nothing they each write can clobber the -other. +One **manager** process draws the UI; each session gets a **guardian** and a +**worker**. They stay decoupled through per-session files, a Unix socket for +live attach, and Codex's own transcript files (read-only). The worker owns +`state.json` runtime fields, while title changes are serialized through +`label.json`; per-session leases prevent duplicate workers, guardians, title +writes, removals, and autopilot drivers. ``` ┌───────────────────────────────────────────────────────────────┐ @@ -47,9 +49,9 @@ other. ▲ ▲ write │ status / codex id / timestamps PTY │ input & output ┌──────┴────────────────────────────────────────┴───────────────┐ -│ rail --worker (one per session; outlives the manager) │ -│ owns a PTY running real codex; binds the Unix socket │ -│ captures codex's rollout path + session id; reaps its child │ +│ rail --guardian (owns and reaps one process generation) │ +│ └─ rail --worker (PTY + socket + state owner) │ +│ captures and exclusively claims Codex's rollout │ └──────────────────────────┬────────────────────────────────────┘ runs codex (real CLI, in the PTY) │ writes @@ -60,17 +62,18 @@ other. Design points: -- **Process isolation.** The manager only reads/writes state files and draws; - the worker owns the codex process. If the manager dies, sessions keep - running; if a worker dies, the manager marks it Stopped on its next refresh. -- **The title can't be clobbered.** A rename (and automatic title sync) writes - only `label.json`, which the manager owns; the worker only ever writes - `state.json`. Since the label always wins on load, a rename sticks instantly - even while an old — or duplicate — worker keeps rewriting `state.json`. -- **Preview/status self-heal.** If a worker never recorded a rollout path (a - session started blank, a slow cold start, an older build), the manager - recovers it by matching codex's own `session_meta` cwd and start time, so the - row still shows a live status and codex's latest message instead of a bare path. +- **Crash-safe process ownership.** The manager can die without stopping + sessions. On Linux, each guardian is a subreaper and each worker generation + carries a unique token. Stop, resume, and removal fail closed until a process + census proves that Codex and all of its MCP/helper descendants are gone. +- **The title can't be clobbered.** Rename and automatic title sync serialize + updates to `label.json`; a pinned user title always wins over worker runtime + state and stale automatic sync attempts. +- **Rollouts cannot cross-wire.** A worker identifies the rollout opened by its + own Codex process, checks the exact session id, and claims that path with a + no-replace file operation. Rail never guesses by working directory or launch + time, so simultaneous sessions in the same directory cannot steal each + other's resume id. - **Status without guessing.** Activity is read from codex's rollout lifecycle (`task_started` … `task_complete`), tracked incrementally: each refresh scans only the bytes appended since the last one and latches the most recent marker. @@ -100,10 +103,13 @@ Design points: Manager screen (the bottom line always shows the relevant keys): -- `↑` / `↓` (or `w` / `s`): move selection -- `Enter` (or `→` / `d`, or mouse click): attach the selected session -- `e`, or just start typing: compose a new session — your text becomes codex's - first message (empty → a blank, auto-numbered session) +- `↑` / `↓`: move selection +- `Enter` / `→` (or mouse click): attach the selected session +- mouse hover: softly highlight the row under the pointer without changing the + keyboard selection; the wheel still moves the selection +- just start typing: compose a new session — printable keys such as `w`, `s`, + `d`, `e`, and Space are text, never hidden manager shortcuts +- `Ctrl+N`: start a blank, auto-numbered session - `Ctrl+R`: rename the selected session (pins the title against auto-sync) - `Ctrl+X` twice within 2s: stop the selected session; press it twice again on an already-stopped session to **remove** it from the list (deletes its state, so @@ -111,7 +117,15 @@ Manager screen (the bottom line always shows the relevant keys): - `Ctrl+D`: distill your response style **and problem-solving logic** from your past codex *and* Claude Code conversations (see below) - `Esc` twice within 2s: leave the manager (sessions keep running) -- `Space`: toggle **autopilot** on the selected session (see below) +- `Ctrl+A`: toggle **autopilot** on the selected session (see below) +- `/`: open Rail's command palette. A unique prefix runs the highlighted command, + so `/di` then `Enter` runs `/distill`. Text that is not a Rail command (for + example `/review`) remains an ordinary prompt and is sent to Codex on `Enter`. +- `/import 15d`: extend this manager's Codex-history scan to rollouts modified in + the last 15 days. Replace `15` with the window you need. +- `/import `: import one exact Codex session regardless of age. It + must still belong to the manager's current working directory; using an exact id + also restores a session you previously removed from Rail's imported rows. Only sections that actually have sessions are shown (an empty *Working* or *Needs input* block is hidden rather than drawn as "none"), and the list floats @@ -146,25 +160,26 @@ keeps each of your turns next to a compressed lead-in of what the assistant had just said or done, so the distiller can see *why* you steered the way you did. The raw transcripts are huge (hundreds of MB of codex, GBs of Claude, mostly re-injected context and tool output), so rail ranks your richest sessions and -packs them into a small, **fully readable** corpus of numbered chunks under -`~/.config/codex-rail/distill/corpus/`. The launched codex session is told to +packs them into a small, **fully readable** corpus of numbered chunks under a +unique `~/.config/codex-rail/distill/runs/run-*/corpus/`. The launched Codex session is told to read every chunk end-to-end (not grep or sample) and echo back a marker from -each, so a complete read is verifiable rather than assumed. +each, giving Rail a machine-checkable completion signal that rejects stale or +partial outputs (without pretending model-side reading is cryptographically provable). The scan runs on a background thread — the manager stays live and shows a "Preparing … Ns" status while it works — then launches a session tagged **`[distill vN]`**. It shows an elapsed-time / rough-ETA hint while running and -flips to a **Done** status once `style-vNNN.md` lands (it's a one-shot task, not -a chat waiting on you). Attach to watch it, or just check the file. Each run -writes the next version number, so your profile can be re-distilled and compared -over time. (Everything stays local under `~/.config/codex-rail` and is never -committed.) +flips to **Done** only after Codex is waiting, the exact coverage contract has +validated, the output is durably marked, and the one-shot worker has exited. +Concurrent managers share one lifetime lock and reserve versions atomically, so +they cannot overwrite a corpus or style version. (Everything stays local under +`~/.config/codex-rail` and is never committed.) Prompts are English-only for now. ## Autopilot -Press **`Space`** on a session to hand it to **autopilot** — rail answers it for +Press **`Ctrl+A`** on a session to hand it to **autopilot** — rail answers it for you while you're away. Under the hood rail launches a **pilot**: a real, visible codex session (it appears grouped right under its main as `↳ pilot · …`, and you can attach to it to watch or step in at any time). Each time the main session @@ -180,8 +195,9 @@ It stays in your control: - The pilot hands back (and autopilot stops) when the task looks **done**, when something **risky/irreversible** is proposed, when it's **unsure**, or after a **reply cap** (default 8; `CODEX_RAIL_AUTOPILOT_CAP`) — so it never runs away. -- Attach to the main at any time; while you're attached rail won't inject, so you - can just take over. `Space` again turns autopilot off. +- Attach to the main at any time; while you're attached Rail's explicit inject + handshake returns busy and sends no bytes, so you can take over safely. + `Ctrl+A` again turns autopilot off and cleans up the linked pilot. - The pilot runs **read-only** and at a lighter reasoning effort for speed (`CODEX_RAIL_PILOT_EFFORT`, `CODEX_RAIL_PILOT_MODEL` to override); it inherits your codex model otherwise. @@ -191,10 +207,9 @@ for high-stakes judgment calls — which is exactly what the hand-back rules pro ## Install -Download the prebuilt binary for your platform from the [latest -release](https://github.com/saofund/codex-rail/releases/latest) — `rail-x86_64-linux` -/ `rail-aarch64-linux` (static, any distro) or `rail-aarch64-macos` (Apple -Silicon) — then: +Download the static Linux binary for your architecture from the [latest +release](https://github.com/saofund/codex-rail/releases/latest) — +`rail-x86_64-linux` or `rail-aarch64-linux` — then: ```sh install -m755 rail-x86_64-linux ~/.local/bin/rail @@ -209,11 +224,11 @@ install -m755 target/release/rail ~/.local/bin/rail ## Update -`rail --version` shows the build. Run **`rail update`** (or **`/update`** in the -manager) to fetch the latest release binary for your platform and replace itself -in place; the manager also shows a quiet **"↑ update available"** in the header -when a newer build is out. Set `CODEX_RAIL_CODEX=/path/to/codex` if `codex` is not -on `PATH`. +`rail --version` shows the build. On Linux, run **`rail update`** (or +**`/update`** in the manager) to fetch the matching release asset, verify its +format and exact build identity, and replace the executable atomically; the +manager also shows a quiet **"↑ update available"** in the header. Set +`CODEX_RAIL_CODEX=/path/to/codex` if `codex` is not on `PATH`. ## Data layout @@ -223,11 +238,14 @@ on `PATH`. - `output.log` — per-session terminal output - `.locks/.init.lock` — serializes manager resume/remove for one session - `.locks/.worker.lock` — held for the lifetime of the session worker + - `.locks/.generation.lock` — held while a guardian owns/cleans a generation + - `.locks/.autopilot.lock` — lets one manager drive a main session's pilot - `.stop-.request` — socket-independent, generation-scoped stop fallback - Sockets: `$XDG_RUNTIME_DIR/codex-rail` (or `/tmp/codex-rail-$UID`) - Distillation: `$XDG_CONFIG_HOME/codex-rail/distill` (or `~/.config/codex-rail/distill`) - - `style-vNNN.md` — the versioned distilled style summaries - - `corpus/` — the aggregated, codex-readable message chunks (regenerated each run) + - `style-vNNN.md` + `.validated` — validated, versioned style summaries + - `runs/run-*/corpus/` — run-isolated message chunks + - `claims/style-vNNN.claim` — atomic, no-reuse version reservations State, socket, and distillation data are locked to the owner (`0600` files, `0700` directories). On startup rail also tightens permissions left by older @@ -235,12 +253,19 @@ versions without following symlinks outside its data directories. ## Limits -- Unix-like systems only. -- Rail can adopt Codex histories created in the current working directory as - resumable rows. Live process control is limited to sessions launched or - resumed through rail. +- Linux only. Rail intentionally refuses to start sessions on other Unix + targets: its no-orphan guarantee requires Linux subreapers and `/proc` process + identity/census support. In particular, macOS is not published as a supported + asset until an equivalent crash-cleanup proof exists. +- Rail automatically imports only Codex rollouts from the current working + directory that were modified in the last **7 days** and contain a genuine user + turn. Older, empty, synthetic-marker-only, and other-directory transcripts do + not clutter the initial list. The startup hint advertises `/import 15d` and + `/import ` when you need to widen that scope; an exact id bypasses + age, never the current-directory boundary. Live process control is limited to + sessions launched or resumed through Rail. - One active attachment per session; a second attach is refused until the first detaches. -- Status and title sync rely on codex's on-disk transcript format, which is - undocumented and may change between codex versions (verified against - codex-cli 0.142.5). +- Status and title sync rely on Codex's on-disk transcript format, which is + undocumented and may change between Codex versions; the fixture and live + compatibility gates must be refreshed when that format changes. diff --git a/build.rs b/build.rs index e50f8d2..6d77df5 100644 --- a/build.rs +++ b/build.rs @@ -1,10 +1,18 @@ -// Embed the git commit this binary was built from, so `rail --version` reports a -// real identity (the Cargo version is a static 0.1.0) and an update check can -// compare it against the latest commit on GitHub. +// Embed both a short display identity and the full git commit. The updater uses +// the full identity to reject a stale rolling-release asset without relying on +// a seven-character prefix. use std::process::Command; fn main() { - let sha = Command::new("git") + let full_sha = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + let short_sha = Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .ok() @@ -12,7 +20,8 @@ fn main() { .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| "unknown".to_string()); - println!("cargo:rustc-env=RAIL_GIT_SHA={sha}"); + println!("cargo:rustc-env=RAIL_GIT_SHA={short_sha}"); + println!("cargo:rustc-env=RAIL_GIT_SHA_FULL={full_sha}"); // Rebuild when HEAD moves so the embedded sha stays current. println!("cargo:rerun-if-changed=.git/HEAD"); println!("cargo:rerun-if-changed=.git/refs/heads/main"); diff --git a/docs/rail-demo.gif b/docs/rail-demo.gif new file mode 100644 index 0000000000000000000000000000000000000000..3eab762f0517ba6425b17eb02faaf23ecac3152a GIT binary patch literal 138380 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

uEd_bqVFoEnNtBj>@B%is3 zPN>YIS0bTd?jT+qKMK^NSMl6hthV3%>nMhcxGp1)Aq|0GoY=b4;I5 zan!qxBH=rh_)F*j7?!DnE7bwX4uZ8n#xl;uBRI{!kf}DG*3jdrqW+ltLFW7TqhOTG zR1wm&?A5Na%yA$v8%-*DHk@95WvNYMxyqh1jr6)|>gVN6o)>7?AV`3hTx>Uutj7q_ z3MmNUuZv+Dtw`oo?3qO}Z((ZKg)=7Xj=7jCVNC@LavQR13R_1CSDZ_?kuP`fTie`< zOCpN0{<186{@&J`LJ{Fxr<)~h8#`@bN8XsfJ;>v@t*)y-IWhpgaq@nhf=uOFce%v1 zqS{BLS7eHMkD9J5G%3h@RqSlt+UyGd@RxK{<$O`Xh*K=NbyCiGu6AQxu3Sd)+BDMt z>-xY`B=UHi-f{(^@IvMS=|-OCNYyaYi$&Q?a&`jN z@qH3pPWpk{M6vc*!3l#d4oa)+Dm)ZX ze0ruEfL1`*x0eKz$YtumB3E`rKG~l+CV}KJCZHg}RqyH(98;jco3vnAr(JPTjKn`a z$t1X4Sd^sbu2^;6nLjX7w#WZ^R3vHXlEIQ_cV~>e`;9hK zHCsrNEnMA>r^!x8-CnZEUQyjav&q3g-O;qk(MH|LrOD}vx^qC2bGW+8%_f&5b=TWX zuG#8t51QN_t6!{cy7*H4(wnABpVZwyH@Od}dyF-C%&1>pX}Y|ner3Pu3RT?`((DP> z@ZxFq64LONZ1z^v@X>7cG0^ZeZT7X%@N;SQyF%6Q4`}ue*9f@T9FU|Dc)K|;TO;T} zbI@artJTd{Uup!uX%7CR5%Re?WI!WytT}W>BMhP;q>E#6Bz>@duFhtw@RSTMq@^6u zmCMahikjAn7zTFX92FtWSdhms&|}0S6g9% z%+w9Ux@bS%f2Wd;E`(o#b`J}B;vf}Ug)XEviFXS{^Eqd|Vu-^}zJnOHXLvm$1#JPl z{vQgzK(J!k1OB_3>?7qAlFZ{_;-gCp*DsbU#xbb9uW$e=&b_a7IKtj^J?nc^^*vVp zkPN>MnB99{5C5m|3T^O+9MFv9xc>0k%`*bKK}9o8&E@anz6QZ&xvuRXOxhVry04I-TR#Rv1cD_@ciddv7D$VS8qeT?xcLh2bjp`5;ZCT@-d%IAZSZ zmp&g}KKpI`{I|WWfVF!CKi-`DeZ3O+?i3RsI;5ztG?Az=E&bKQz(ZQX=g}MFzAI*YRs3G*3!WA2cVBzU)76CqE7D9B5DG z^Ufh=JR= zC`o=Xod&9}Arq3*q`0tt&4JO5YN>v7+j_V{D7nh%LqX{GChkJiE$_00$C8pd#W7I` zYlt%RvwO_7?OtZMM6p<-_Z%Axp~6_rAkuD@#%}VL|KkFE3Zx|w*7{26j76J>TyY_E z=gC6J&`Wu_6AlIjD5oR(Wy_jr&zrXCplVQ-KIB=l6nA-}K>LAB376J7 zw|?Up~H{#mL0F*~FQMVAD$1Z$l7HBJI4xNHFowpR87i7-) zKv+k$C4Xj(Vrw2ZjIV19@aMW zs{Eras3pgB&t=1}-1}N55W6w-SqR5!-`$K|%}iS@{{HfR;-u(?&uS*bHI%9eR7O6P zf7c9NiL@83HZW>Xk!NN`VX~x|bLLP&%(S?-DDyZ*wEnaZi_>H=X zlDK8dVx>ND{k4dajS4YcQy&4;8jpp1lc^OvvNz3@sQWXlrW*Z+2BGrWk2-PTEfn6L z)|Au9Z634{I3ia;WnW+y`)C}f)Le%F_ct26*;Q{Sk5W)7&r&g&&AKB#-A%|qR2=)j zStR~^C5U4ggfDTVf_nIu|Ro2n-_>&PG%^%UC zzqwq!INtNLCSTFF%jR;{T7ap_=%=}7H~*q|$EX(MPOEfySolRxSgo!kYcz9zC!Be) zHdpBu_G%*FNlT8&S@yVxqp!XweBXGjXHK}i9P@TQL$H4v57969Uo`2hKFq2?D+wb{ zlbZfP*>K^@v7F3-czI^lG&^v8t09dhot8sq8EioRjOf7)gCxy_vXA?d5vFFK#?x+s z*i%cY&#qOA*h%&%JnCe4JitZIsS%g-6XWVOn7_^idqxE(pSuX3GvcH-SWUmpetJoT zef?q^eRiT$mUNiaS>Y!J5eMY8DE4Rb;N>4Of@MNE5A2Q!J=qiS$u!uME!1@N>*uwu z2(U=T7JTqtZ~N|>PkMjCZG~%CI7}iC`vTeV`w@|SqX1r#;zUb_GY6b~6H9ZRLtc)X(& zV|9I=19uZ5A|@;vKa1m;zQL%xje=YJ7e>m6yqaz*lE!X$fWVX0jijDhK$+;bKQv)G zqKZ|p{Cu}jbYgd)5C0MPk2pC%{3(*b5i9~!xC*HKgq(Q;fhC^WzN^P7*xdbS{>yj4 z%*-5_n{NqyM&vbjxd3+-LCI_EA=HUQVRoIMky#vN*R~ zj#Yp;{Naayk`mSDtKgtoPj9L9v?wS1^tC7rM7?k_TRH7xMUi^)iWZuRNofi2!cAWW6)~iy^>!-3A#!Ht z{bMR^ztztMOBny)Ev&~QD$0t&jYXKM-3(Xmqv6_0)7e!E$x<r-f&aX1|)%(G;#O%Z7`Bfo7r$WoemDhFkQZpDx=SS_tL){R>3g(2B*83 zy_LRSj$QlUc4fBytc|nl2e0(PSPD7v{F7Y|QLZN4kq>nj&&<3vmeij&ta*bE>N#vn z(NLSH8KhG_dJctZw_PsifIOo8-Sv@l%u;cEinXsS5R@S#Gmv5+Wlgvk;6EgmY613U63B`W-}!LcEt6 zG@9WJn>RjvaCuQV2<`TS+>3P+-tXUUy$1=4y!ZVIgAKGZAhG??(grMm7ztnXSeQ*b zo(V0jGIf6uo(Pv*Rp|$P_;F&9EXyx^;r3y}pa*y}jSQZf-uU?Q7yqi+qYc0PG~T#+ zgA}<7tnt+Ga$ZrrpyE?0W>Hr#cL547E@{-toQrj=yP8^=+7CGD$-J^F&*MaF!}H6r zhB*-&d04OOkQduCmG3dtyntACb%Y2{ETo%>^t({=?sCg~Y^?&4Rg)d~**7hEb*$3R z+EoiFH@KR73&((xBEz2rhh4I-(lt)YJ>xqYFNPr7Y4es2wU2-i=v5QT*}-Wp`+rm2 zXQPe!5)5~55}#$4$Qz%Y=X-ylaGI)ruwvtC*l}L)cgMwl@)r`B3Lg8Fote`&863E{ zNjeGPY7f?|5RZGVY2^5E?wPH%8^xO9%q zA+->GJe{paU*A`da?mGga*U-aw0{-L0Y@)h@!n$m0)xhQLT)NCA?8`{U?F#i+Vcu6 zQ^BT-Bql70sfz_tvMr2eiYeb_dKzI?`kdLtm{rH~8f=q3xjm#YhviiStD+oh$|Ca@ zCAQHBwuzh&(kxvU8WuqWSi?i6m0&CNu4O;Os*oupQPU zX?B4fcHteyeKh0cOb~x{h{P+TqB8Q^AY40AO9#!^fw&Wg;xygiuv3;dx8tm1sZi{uV^MGJ6oM=J9~NAkqJ;z`)y!O`5ek-X`zcr$l+bCvn>Bl(J6@li{5_{x;e zR7Re8`sz&G&Y4%rXPaJyh#87Y5l^rkt@PNuhh`u#k=o{0>ElF*37Y;o`q$txm7b;Y zat8MjyV?4y5L-{RXKSX77G`~l0t~~5r&wqlifIxKjrW8q%4KZ=tYRd9Rod(s8ZfdF zuG$V!NM}(#ClCnbGsvgQK1bXHJ+^N zv#>?~p>b>YbbrV(mNhDGCs`y5R(?$vZZ_K?s?!jn5ykd6k2@M*fUb&tt`ZN56t^*# zt(2FD_myaRDiPiw*UJdC7Gu7?1sALf=^@e6<55|*!Z|9rO4ZM(eb^d&Im`?t?(BU_ z3jOSFg6_*TP9jQMlmw_{B$a(MEy-s16va{v-3p%-+E~*T55}? zw^R%JN#I11E*3>^+hr_Ar>Zn)Xl|{KYJYt_+0@8LD@pYHx}oMyyDWRT;%Qqu{Q^Ae zcewMSL7Hq=^F5=D08{=Pj6$+jN;^8^JZAMb{7#=veY>_+vfj6Oz2JMwVX7+I*W^j} zpqoP)D+DFh^JqZK5MFVP&@T5BArGni*#G=OshD|UvN^Sn-a=$sc)g*O@#!<>C0k@C z#Eitc0obCX)gLi4f4eFV?X*Q0Q;rsGbXSzydmwR+CNqo1K7UNB(b5~9tWqTNu>&3v2nX!!4HT~kjr4M`1nKOX-R-kg!Ny(YfX8GnbEx-Mz9tE}Dm6^sYdr)H!z z1jL_WznX7~y;CuJe>=5n#apO=KrxIhl!QA63Eo>Fa2k3^YUqET?+c@Nhf5{f)-19r z2m@wVs99Q~n4g8SF!n|qWuCsE&F=vv$htd-rRJ(omA8vCB7by{lm>|-YS%*V&idbd zpLUmImNDC9+Gi#_9C&F7ngzW8NFar&BUrG#p-A^D_Y$NB{9YIbIEUXs;xe-egG-L( zeAw?B{O~q5PBci01d-T{fa4>q>e?8yE&KLv4Bv^OlA;87qQrUFJ2{01rM>80d{&To zV*X-!Rl0eWQ`niOG+DZ9+sgniT`9PybClJ zz9FQ&c}$XqzkguhB_5mkJh&I;*7n!!CAw<43Mw6{Fy>t?*jAlopLP8FnIIm-3$W@K z+92`aq1R2XTipQJ0PBMhnuX7(C|CUN=QeZ_t~(&F8Xx1SqntM5&D5>J=;)i*2`Ta9_}iy z32Me`jmT| zQ@Dk(?8tMgIba^QIyapA4ADxux8+D2dDmh%(&29ev{DeCiS+cg!X8+?but+ zU6fuF^uV%|y)6fRI9Q6_9~mA(v9&Od4=ZKfdeARAF9k;p1G9GbecZs4o^1Hga053{ zqWt|jb7B8FN*J3>_vdk>cV#Hsn7&Sh=|lGmL>Elmi0}K#z2|MVR-+md6;G@vfuU7o zZ%EXn3kz}Nz?RL31YMnlWstxcw|{=@rz4pxmiOPWQCN~_N_+QkF`{|SnfYe4%fkfC zpU}~n`OfE%iZx_gEp@`X_MTQ(DX`=Sa~(DO{`PB4m#a?!FmrzsReuC)Rah(yTTD!! zUFUOjYFd$z(1`4xx6fa>)gKQLozM8Zt}rROapr~3i}}DX7h`nwIdtNFOyjkzCi#CE z)n6ESbSewb0XgDTr`TUp3K>zC%2)bL*EctJ6^I|$qrMM3Ids_7C*BJj5xw{lxSTDY zb1q9f930}RY7#Cj3YVte@l zx(yywInY6l>8L@7t0;GQK$of!0cSoYeFyJTcHv^T)YL>7Dw98*#FOccAN(QxbJl(Q zhXefR9Wb%gf5Y9P4}Mbqgcf?1H*qIXV?jwDU;Yx+O-DBt9Rt-L-$yt%2mGrTMl;3F zLbEd42sy&>-*ai~QeFhpBcbyc{s?|G9-`?0-Ox_M%ZSyjQ%IX_2b9Mk2$^RrG)9Vmg!$7(>r=IdkHg#Ei)%4 zGa&t0hQwK>)>*dGS$6$7uEaUM);WRGIbr>I@x*!Q)_M8Ud1d_t^~443)&;%O1&scp zapK~I){!0I6Q{sOzTrH3 z4C((GP5d>{`s+vQbP*|u>S`R?=OR3x=v0UzmZiZdU1~SB`)BmG<9#n2yE?62mWK*$ z?VY9{oI)Q1+YAP~6KzbWBr}+Sywqc-T|o#f%%%59{!iCz8c~mh)GLeLqpRx%Zrs)()`Di9|@1v3budG!B4%oJAd=mQ(v^(d%|knk!NBJgR~fco!Oc|a+8^Ov{cW)G9#N=vM(5-Ev;J_V!)@bu{zoQ@X`oA!UPg z-ec&lsF7q4b+=ghcR&W0PW&)}{>yDhPmWD-^8Z=lx_d_Nj@r}zt;BV)<%|<7TTSRQ zaQH3N^pSy4$MJ>T#f5vKB7Uisuk+0~^@W?FLRnTME;fbG3OR3AZl7*cS-=i*OUbsATPDlFicdGw2C1*2OLWN*`r$3mA`_XC4h zT0?7QICxdH}x-cTcNa=2fS)1a6htnKGU zXtBo(hQ^ES+adchKMM;m?vRb(7Y0W;j5OtQJI=-*gNkGC6F{X|oHu*(rGqUS_Ntb;j$)tkSc`8m)?6j;oCJy<&8{XX;7F&%eio{s^Kk&@JLc zrZRHnKD@iir?!|C*2)x69Myjzpu}!ssQuyDy;)mMJ>C66w4!j$quVzT81d4$&BzY+ z(8ZxtuBm+C8e^z*$DpSl5NRS9eIK*Lcj=_8>#j~N(=fZ`xEF*=#JWn-G3S*3PO{L5tVhiE3f)N zWdkDGVFs*`9Y`C?$WHDn8IfIQ!uujW3nsBfb&F(MMtzZZoDtO{^Rh3hSK$-uwLX;r z%WGdXW-_kz>umL18_=h+ULQ1sTU{SA5z4$iY_9nA`ZsF>w&)RioBvV?WJXh-tmPWB zsdpdf5*Kag#I1#s?Kbl2+cnEI}!XrPjf1|x1@P8_OsRB9|>E(-b{x;`rpi6 zr^`B+O&59*vyfpBwm+YHPG5U5#v$?DQj$;WyX91>3oLHA?56(FO8H}Jo%O2f{-gC5 zJg{?f4HhrrHX7fAom+W1@FIS*^xtKj?WW(S=XS_vlVZ7trSpwh-6P73xd&{hxBiUS zgrDpUTD(m7I~H%FcQAg9s&_c|$e{J__veaR=bW=pA z#VVOY1CcD|UdAkRV93%nm@J>{#pV}tp1y0A(IOIbrjfA&#T*$B7=dfNgNN|+d8Ju~ zlykUfK*S})(=Be5bG=M6&a{iXW8YWK^9gD~#fP#>`>;OY8%Q(J&L5HrwtRAS2KxE1 zF<0tZ#uI_9w9ow`c2Y4lAi-VQjFY)RCgFI26O!I-B(p1(D(1t(!}P_>d>9qaYA!CB z{>8F&C(mH6Lc)N_TrRFb@=1sYEcIK_IbJe7DiBwih^K9rK|>7I&=7wv29{bgNuxlJ zwYRoKMFttX+Q1Bhi9K;h%}cMG`XY6AwVg=BniUoKf4)Caq*w3Bz?=?dMtfTk|6mLB z*RTvVMZF%&6%Tays+H1%@>Sgd!@uNZ6~ee>$^Bslz6hM!)AXW4uOyhY&d| z2T?yVK>Ly>_=epguITW;8{&FR)k;)oF%CK0jp#IEGFn@ED^&vZdeI zRkrc0<0*V;HFd>4+D0%N^s(HW4v-)pnu52F=!UUu68~!*aL8e)Xpue)7H7gGp5si?ktS|8!PUZ-tUy-Oo=UOHaPMFBp zrZwGU5KY?#NUgM7{GwkHx^sMOO0XFfanv! z=ypx}RpE9lL9s8@ZsXDIFwPrL_OO5UgU0H7t#qgmpVPmqLR6*#8Ow3su9GF|GWKBi zr+lbg2ir&h>||@=MxO)6UGhc*ODoy@^FyU;P2FUg^sXg4QlB*{G zmKNq#2MsZC=Nuy_r~+ZCW5>)+U`Q9zD0&6QJW1)8Id<;na7nw)heoK(xK1F8KK#XH z9C|4)^`KkHW#`fNmM?|}LNw4TfY`n`9caQnB-pc^H4yfdBNo|nQj)<%5?RNtS#q{$ z_A40vS>gMg-U0II8-tfZAUuutS>;i1w)vxIh13j%J$Il+I5i?y^#@lV6@3$pNxWQ9 z+AN2bLy(FSBlf^^qB1Q~{6v&|C*DVK7h;Cze}P1cNV0U;-j! zFa&!cLi2c;8cas`O1j2@iKH6xxnP+43gq^0n?wvK2jG0%5?f1_pe+F=rp z{NOrh|Gec-3TVWM3!_#06z)f54^g2dDI=kivd*$;n%|b**8O_YEU^g<2^}wavRhgv zF86EJAKI*Y{k3^MB(x8xLD)X%`7B+n`sdCUlbU^5PUkW+!_cv^*JC$UT{*1njNqGr z;=4~7YFbz$(W|zXUlRYw-eCC5J&WTT{~~u-=6`s^d5IBDSFn214b!3nH4+2Px!M_g zdl2t_k@%Bm3(8AYfF9e1T3;bQr1M&LYIlOQT#2XbBK4|a^7IF;7IL^m&u}_S5g*cX->-`Ry4uJ$B7f)Q z{h$dbX=*=8+sMZU3zD1Uhel%7JP_8wd|YHcG~Oqb<+gVRjM+XE_PTxW{Px96vIqH? z_dn#e!*T&w0qJ`_KLjluJ$g1;V=7d0Dzes-@fb>YJXrkrez17hPaZc5lFdz0>Yj!E z9q;*W5CP^Qi3hp3yDS&Gr(a}|M?&j7SiqBf`lGSPi_D_s4bM!zvdp_bBI5@7;^>{w z5N6!F-?@=E4ol@K{*9QD*0 zFQ)$uw~sv+6dR7Qbi`ir2k`rhGC)YQt&Hoj@fGhJd8F#ayq4`h^K)MAQk_mGTi3FQsg>NgR>*YHpvPj>( zXY0)>d=`K8EcI$szrbx22(Sc^!5_DBg_+STt025?A!*?me4z**!2o|9Lh#BwFmWn4 z_f49=N45+;JG4LJ7(K&CJ4Dsq8p;}dmH<&q#OowwYEU!1%C6{?r4$7|ZCZ&j&N`MT z0!r2ve_)?s_3_ksZOh}mCg+S2w|FBnub~tHw%KklY1ECWvb>LJu@>Wb+UhV@1hh+p zzxqwwoD#|#3-LuCm!vYX)>tX^rMX+$0{Wvs4M5(32r7(zUV0S7Ekxn*ah_f==k>`K zvOF1C#2-(}msrhL?#lNyrAS#89BWnw$Arn37pOR>D-IN#U@Jr{6sTJko@PUx^eNOX zFT58~sJmKdm@TW%R%BvXw8vv6?ZB^&fSL_3lcbWa2(VQLiyGogCsDUAsJNk4*TJXQ zaaz}IwfId}F?PDx-SWAo&U5eI#Xic>J}V=L+HdLH=u`CZYHu9GFf4$LE0V&7bX zn=WznDIpG&kl0F77Cm75J1F=`hMq*_M}vmX~Ljr%sns2g++#%c}><>Xa+$V=5ZTE7~l}3>S+!lqWeRIRddQ!3zk(U z3t@01WS0jhKFVhd0I~vrD-QhP0N{Cb7#Fojp9jj7Sa9u5)h?R=Ba|P32f!)7%p?E{ zGsTbRTXRt137|?zQ`b9S_7os10*C~H*&L~Ve#5w)iXhl|`PNW!hy3zlyvUYQh;5K1 zq2_8wjcI<3HV>2)01gI#SrcEH7`-&%snzNL@~rWR(yA`))JpHvSSQsP=L3y(smE3L zL+K2F+0z@!)KoekZj=(R*^bi!5`PcXPj8id8C!|0hd-ZOLer3ivg`d8SQM2 zb*~($fHi33A&(;-K>OYLriBkMRR573I2T)_#nYT>`+DhC>(=k~@X^;)c79RJOZ>aG z*N#mVNi8iiE$&urY_sjKoK_xW+QzNd($&@P*!gcA@^HkpKyP)nv$tN>ZB?@Fg5GI8 z-rD*y79d;Mqb|@j48m~)pp};6;|du#O;W8T+B88@>p-Rona^Uoc1KxJu_nOV9eDhVeGY z3lDQb)52-ZHixfPRUkpgegmT#yQeJ4?BFv)Ky&eDZ&dnIXZuttflep}%n?9MiUXjw z`*(d_J)iBj;NWH52F0mD#BTM;vI7wa=niXr>mg{zyVf|TFII0L9N!>H>4p;Ojqxy7 z8j$vzM`ot#jqf13aj;DsikWGc)vMw3MT{T822{H%W(U$aMxIy?c8d4vsCR7K8jhbG zdMiGRRBzT)rx_g$%s%X~CiG|fcI7)UN9hA4IaS$VBknB&Mn)qR>?4fUft>aJL(49Z z{y?(hXz3Bp&zzdUoLVkAfQM2VPJLTF#3#r}74{nt{lv%lXIML@pGUu``lw&Pxv?N; z4B9eQwm$s(Hwfwc_B+Qg+;`+q`K7V*+i?8&SZjS$?AXIr#>nZJk>ae8V~={gP;Hkh z>Q4qWCHTJ8-)NAU>sZd|3Z_F-o&p@{eOy=z!%q#yx*wMGUVjqjm3Dq571!3AG-=BT zPHwG^aU5}M`_Ljcf>iGaW(TIJG~p#0CB&<6zGA6)$qciK9?Km91z_R*98Wkrqg z9*)*L`e2p);nt@Qp0{QeT0bW1PWH{d*RO8s(e1Z;Q9J2a_hOiDFn(r4g8#L|ELv-} z)^FBFzpekzhZ~<}S6bh_i|0!{s*3b&g^#{$aelot`?5Hm!6!fXNoxJ$9LMxw?nq_Z zWGJO)dtl(i0`!w3)opEJ!+G*|4&b{pbt9}FPJvam6z#6{t^F})%c=eTV){BKZ6JVF*;cF=`$<_d(xifi)JuCAlC?Xpn#S z5thpo)Mmo*^E~ULQHONBoe>lN1<|1ear%PA=4ajNS!FINrnUP*6`$SHxs<^hPNs=xfL za$sM!2X?lL0Xv1z7Gd;4haf!d0M!51sJZIzsLWA#tGPA`xCu5+Jg zGP<)}^>>-gaD8oKJ3VhF{O=Nk(YourMc-UJ%Tf8cY8UObHMy}YCB5BJ#V2=bhxZ|! zn`7!&7c71VhCshMLH#&5ytdCV8NazH!M)wb$ygl7-Rra2ZF{otF?Rv&xCb=oiP)Gh zJn_=#^~UDLnA}%@zVnp!+z%<4J%YqeivH_3r&>Tor=e@j8L8>Poc(aXzT4LDFM~G! zv-G!W{EVuvE7M;tJzKr3|I+fr=MI+x8wSh>cK|m0VWhqz&9lcNb}%FHBbC87|M|e0 zaq#WW-pZqywNLwo)x+pdo0r(@gWmqEHvInl?QDhG2M5EYGXEdzF%xKsk17idK~%c1 z%4AStt<73T-_P;DGqaM1zNwAn5A_iYsC{i->gVG(0Y#{74Z3G$*l%_@9DPmMX+J4;gertyD`22#3m zP+U~`d)m<}9VHT{yN5*8K?89*pE4IOO%QBq?bDi<*cz%nbc(kba5(OHnbmqtNctY0 z@pgI=(oA(7NV{!PVsqqq=lwwB36CPSz544k{S+U>rNr;I=^c^W z3NCuqB*+2;GfM|r<*Fo#XnifwAOg2<}Kk@9p9a9L-E5)+3azAC~*4P z#gHHO>~6%K-nVnREpW!(gOg>2TBquJ-@&J%{#BLx%ZaOvz70S29Rs@DUpd|xJpI$@ z?t6(RPWRf}8=Qj|(jK^kuGRl^3Hv_r;QE!f>s;J8uwXDYg8j?^mc(~8la2@c_aY?{NJ&BN&gMY zm--LOM@W8vdvfn#?1TFcZ#@hTxF6~NugRBi3!Ua*{vRgaqnkPpZ)!6^J|@l=;Ck-A zGx;7|cMHPa@cl27&;B2i@4CnT2;_6IzUcTr2l;gWuR%V&e?Y$Dn%e092Oyu~e}Q~T z|C)Tq{xSKGOp{OMe{S;qFEZiyOo&lTs=PgzX~Ml1vJ+o@M~S3I{kJAu%{%oQq<>5} zr$*k(Z^ust`+Fr?rWHa(!@n*M4+PwrjJW(>*=4A)dZ9N_YD|5nYgj_TZGI&Fv6;zJ zLbQbZKD!+~s-555{J%^%$^SIre(3yb!f~qG-U_L9qV{cbh=Bgn zgabYh^Eu&*Ow0}Uow!B^E$W1GaOCt0;t>=CCFGhY4qDZNxO;oX`~X4X6Lcgj@0e z$0MGhZAfSpcJPlxBKlzZsl%&>GKbL=$KlK=Dp@B|Myk6F=@(O+YqahhFn9mBk!I+v zU8V^a_JI{W^#B;ex+2}ft3t;2NNwzvd}nbOu&x~j|8n|146dKvdL0hY3+RA}X?`E& z&xJcwPp>c;XWXXtO@Gl~C(+(%Z<@JO_dd{h^z@C2-D#!?_v(TnNoBN=d&9Mp7nIL5 z;qEUmoH1XRCR}et^+>sV2S|kZnZYL!ea#be-cGt@bE=}1?H2|n`6H?o!Xcz~%5 zC;jcU4YKq1n})31>zSeUsvWM&CCzp4XC+g*jV~)!pOthCqcdSR5)+2Ivi#e8>ov^q zXty1FB8(2XxbqJT=Z$<1Qa|zMJD)uhh9lTqW5RGO(M%Yw4VT+hMSLM~#S>G}9w@LS zQ1_?q=N5wju(fz-fRf9UUgWaUO85+)XAvafHB3z?pr28-%n=j;)Za03U{VgqrLg|` z=rW%z-@$$fh5uh;!e#Hoc|l|)5@|hWYTm)m2HX^zQ|#63seW>|dqH9;)7)V5UHl`+ zqSQAn3*8SqtOU75X+CcYeYKiIGD%KW_E(>!&TcZU&P`rj$>RL|z3M-I!Zx4n)8bdnuBPET$?tRKOg%Lxlxza>-si56 zzUeG|vIYhE#Jk4o+_03bqW_fvF4ByO8LHQ|KURQpfHg>l_bIHrb zy!m}r4}Xc_gf}Kc$2K+MMEw76!Z8s;kk|nY{;vr~hw2`5*k8eeFsFzR;Y1mpWxUy5 zB0zdsj?+-91MY)@!k=9Ovf>G7<*>QOZoyp=T&xgVUmn3lBwER1#9((BR~*-IzW`&bja<@ zcS$EYfaNZh(eEmG5<)^P5_5oq>XH^PaBR;>>Ir?~Nkuxxx%;%|*EoO#i-*XQrTbFi zbkKqpDA0Rn?gvUF`l{-prJFwE z+3hNqML68pSx22f9BK&C0<(bNvMU}p3zVjKeOSV27MxaFb^oRF`TLpL;(!2<_VP^A z5o7b`>n3pz7^3wHs}`}~Fg}B<*?hqf-;sG-;-mvi^z-i5;7mT`W&-RHa<7Ukg~Ni* zyD6Dw;2}I}^xc6i)uw!=8- z8R1zQ2zcijgiX4S<5TWou$~D{y6F{HOcv1t=8GMC5#Dy^!-mJ>wbnj|3-Fqh<9mhd z*`q^qUCpl_F(Ej>8*DV0^^9V}JB0?$lgl&?HC(!;F5|a$wZVSF1-^|S#v#2Vkg`5Fr3$<6j4E6Ba3i zjFe_Na1@e5Dbzavh@nP)H;=puk8-n%lC*<}xk0}_AVP%!{1oU-00=-Q{)Lf@45CVA77oT&9UwGnTIAph)*`k??h zgM{pVi(2;tgxNjb%!mdzMi<%Pcjh5N2xyW8d0pW#l1wgJCdAss?kdDKHpaGEglCY$ zvlYYb69ZZ4?t8G9HHDbVhoc_LD0J82EZvdJ(?UWY+s5g5pqabM-=q44X zq=Z!I$GMM!FYn!+_b}k%QB)q;+TMpO-PEi zQpVo3v}^q!dy!ZwJR_qYm(Y~4HkmGiNomW>);NgWe83`<2xcn)my|&};Ngxa@8=CH z-(fK~F`%GE+?E~i1_A;?N3)Lt)=LuCJfk;+a=PqugdXRxD&_8YLZJZwOBB?6JBr2% zV5fs`(!mCdOb|bs#T@|w(i40FAVzl33rOIUT@nBdoX*PI^UR$p%bjh)4SdY)JePCV zBJb;W)M*xd7J=upAV}^m+qCqmvc{12MbO6v^D_nn+q@e@K?2pkxv3Q_ZY_D&&H2@#Z&ja#j)2p-{2BKy|girM%clIs85&rX+Nr zJV`ZYkIT>?!6aKRo5L`M(y$9L#dLMhN=D1Z+I0-z^D zD0w?SpIiWhZv{v1lq7C7mNJ9^XF_uJN?v@?VV;`Gb@R%tl0-54sxj{*VG#iTIpElI zggYH*u2bQLA&Z74?>2z6X*i|>=R<|aQ=m>LAs>{SuTt`w`9BWa`U6O&7s#eBQkGh1 ziA5Tf^zKv zMzg1ug}yr02(iIHZ;pb66Vs}^p>6>{Ckj*=#nQNn>u&VoqCUNC!NMK@kkW<%P*2u8 z<00D6{&J`iwsNC4XU)7Zssc(rgiP82-yX04Foiqjz^{ETm@~x9UKS<=H`5n=0}Ftl zfJ(^bHS?FNY~gt-;eV8xCg1?bx#s7wq?Z*Gq5f!U3yzoGvSpE^(ig*UfC`Uh=7~~8 z*}`S;S>_0k54K`j$vw!ip{5h3jU+t^$-K-60D9q}LbN6odi9QHevo%c-x}`S!|L%0 zC>l^#5CWvDwEJM73TP4Q?U!pMuaDMV@0z#g41(+r+SY~IrJCB-Cfj`Qw6zjik}_@0 z9C~#H$fV#b4{`Vb+}2F(&LB-TG#YRO{^J-f=nE1K1B&W(Y`{7`l3(v?zHYK0qf|ST zT04)cc5=nNGN_DDz)-8Qpyu;2vH+Hl8QjBLp|}=Y`dR|1C4NSWg}og5^>^bCTl6Kp zH$I7#?1>vzwd?Q1QLl|m|dI4MSJ-i{T>W)x&Y17VP$;*fmK^)}qO49F0UDd4S&m1zvd z#HhCISaoeqX5gA?eDKiAi5W8Vn8A+&irWL34(&N%w5-+|y}14|@v^N+kO~57Q3gKV zificWuwLsZa_XO5dr`SQKvfN=sSf#|hk`-|n)Je-$Mv1a8Gf%fr0d%+d(^e)6mdEr z-^8igC!qV>5bomSZNw;ZWeJe72TZNvK6o{aliz$UhweM|@}dEMbCSNX0cL*VwhnNw zv*M~-1~~1L!ktE8f2yhvng3K)4#3~__eb+@SFSw(aQM7jGw)RUEfJvGWnhyv zoa&=+8^>X#Sg<|=K~_L$4u$ov!K$}Pss}$-=W*2dAZsSqaq67)%7Zm(%HiHvP;N}E z&G~SH;Ygj9_g0^9Exr>@hd4=ElOkrwCtxT%dr01qdf{n}{hyMH`r*#Qxa-d28@=y; zmZa|d9J}*qVtw*MkNC*2(+Ex?LID}MG8?lN*L^h~_Y@wKrx@PXnD_c$1nwq{MG2kj zjRFiGqz_KNO+5ZF^>{840pO)Tc@gP8Q28v5 zIc)yU=*^eZDaHH(+E0O{P-)&95P5pO8y({D2qKFw)LHE}2xXB&6bWtP=r4*+XP2o2 zuwZ^aUpwBg*@i1%W0CNoKZt?Cadlf6pG++wk^#@bpFex#eim?{x9dfo#z5Tw5GlY1 zEh?yI5GQf;JfgXX{BhDJfX*rz_0LF}Yw}#yoQFQ`JR3b{;M4T+tTX;Y>Rj_-FW!^R zRfIoUOc*dv0$yKv%XU{gsC4TA0a%y9dkp{O z;E~>@Kh5IWK;g2agsP892Vhw`PJR@;9u3t;f`qog2{S9tT*8Idfbs#EoHH-ETINl; zvZc@t>U*1oH6bXMDF}^B-6+|R-p=sBfN?7|&vBXFZR^7};q;M>&+T)Y4QZ2k zSyz?T^EX#0Y8yQ+Z8M)YObkZMo=jR8Y(uqJR8b(gQBW@HQsk$R4Hy(f1IC$eJ7#|4 ze@ob^A}Qjtgud)ZGIrL!Z6hy42^#M5W$vJap=c!3gL3n;-#;c?8AEwj;{x&6_3xbI zT~ucB9^tzI<9m^oz~wt&X3XYn_@2eHJ|1K>Y(USGQd=1Kw6 zjS=UmMykSFNEeRS-u=zp1?EgZ0EET2v}0D#e@(bf0v0KUhXXmfNEaY_ z+7@Os2v|T-n1V_)1|y(4tJ<0KXaZOX!Nr4lP9Q)%mf^IlTF@IJ%AJ{jK=pyr)wo}k zQENIm|y z?_-@b;|arimWZ^~edVh!!tVn<&)wgg7grJdu5D}#;SoneKYK^agV_{tzoQxtVZ$sM zLHQJT^^KTsgu79h@Qa0e45^`P?OOTHg2${phKnQjI34$ySDtz~0UD!2ky5f`0FUc0 z?nF^D(?rD@m@BsgTU+G;wxEBBeQ8)o`#}Hb;LV0mK?mwELG1fAF16c#^?svQKUCk6 zDs5ay)-8=;pQTBk(cDCB>HQ2S1?CS4Mp$bM6cv)m&=XjK)B689 z?0TpU(OF>&{hisu>a$|EyVcDl9xPUyi$6t>k;Ex_$78AI_yU+>0K^1H&a=f9G^uua zA>%XQ#(4gA1Uk-85kZMKlZT=yO0mI>pCH+`^A&({SYs~vX>l(CuNR5cMtf$@$VxL$ z3mZKs$b3kn!zco{QVcF1oE#-6D*_RV@&B2?MN86RNcl5cBRsCKpj=6-k8bL+90`!B6`pNuz(!9Tda~dL~E0Bd2AU zIREql=rFYfI~OJOBzQjNqgW%IXmSz*Q{O}s#t@BP@ffoj3cHd-dypgcUd4X5W4IZ- z10S&@`#lk_e346;uF)s(jjOrUf3=ql&ukvtOr7*-@oV@JrE7jF6eY`jG8DbQEi;TH zMx$owbMa!D!8O~Qf`uBgz*P$qfZAUa{_zRU#+wvguF{|gqM{vb7%A8}uAgaO?nS7A zTIPQ@#O`WHzrTE`4Fm?H(p0a1QlU`CP79s;0p|T9%YB{^EOBpw5&Td0BQZ?vgFtfb z{TZHYbi`P=e(4465BEF4I)b`$WU2)XkuTMJNQ`42JGjZso9<3#<4~RyUT+$aBExz4 zyXK;sI7oKJcB4e%5lwxti72jyNJRv*!etNQd9A4udz^mmK+xofl6g)2cf)+jlRT`heu< zXpTM^vGipSs}o&H^dMST`A(E)ki9V`po4jfMZ5#PDf~w3tcIFAhaS362x?rUH%@^0 z9)LgjFXB7(`+?*Z^86k>O3lb7EGOu)1Twm9sp%XirR(zzY+xU;&&Ueq8UZK=frgHC zC!PIeB3HqiMzq}u&C334#DVSu9d&SlDDR~PcB8F~FD1j2m}yE)Axkw<3V5feT#?F~ zle2jr?%f6A!T_aEf#!;(*w&%K7kW;}Q^ymZh~u$2yLOCKZWZ*~j`rRZ9SR*{n%Q}V z5kO*9Hq9Py#TN$#B}y)0)fS!l;A&q<2+B6-A+_uGUo_}6DBt8%EA94trZx1q6$fwv zY0MUyNMuWu!2t3f;zy6CyYqM)j4K$He7~SNV3yZirhtAE(#2`em(4l{W;>c3@)S&x zHaG1v2lHQ2eKP)7*!|gA_i3|iQLiF)2@8`!E!zUYiBg@_PVUjf=u-d!Tj&B%Y7^1u z*B^cMZskqTrdF|@!#Tji^+Mp^;8uf2w^NmrmeAPF#L;@s7w|iBR|7ih92_oH1-%*E z3H&wqCcs_V)67}vLe{{Qo-0+>x1K@RMgf4GqACV!(~?x+M?6cIk(%r;H{(qj>9sJO zUH8&;VZjs_N8Med7MA(Yz$8~9n;G-4n0MMkMjfj}LqfznwfQ0(CTz@e#r?kN-V=Kt z@*zlD>yxLUwh!_*r)|ENGIulj>LsYrwPwYjE|{s~J3dLxXWCcPVwD>%ht`m#nN4&q zp#maT(ucZO!irJf%zO66-WHK73t_{DPHU1AuNf@c7a!7UCASF{mx@=pD_8U2Zl{4f zw3}&={VN8c=PFYQzbt7qG%d7Uk!H^i*9Dw z-n425f0l@9!1H(Q0wPAX~ccBZ<-c8gO4=#_mydD3Fj+HjGOAy#!$mkr8QbOBb^h)mHVH;K9 zR&WEc;|m?la}ZMEXx2arB6l#X!gzwf_PTt(zIM2!Z}Upcs6ThZYfQ9w>66R-E@xJa zm!Oxj2BxPz?4vU=Oxg27nBLqO0Y>Yeion`nATO3Eil*AF<{u(;6$!%Y6cXr2%sfPx z@aEe*gg35(>s|5)TOb;y!!Ld2>kP-OVB;637o|C*FgKf-N#gri(pVXVrvC6j8_l1$ zzMke57BHaHR(uj_JzY2zcV8<4;Ls0owZIA-_1Xx=rLtW}_&q*(HX3ni2N~7wRj{U7 zU9{l4e09P8k4wRd5P)m7P%ytXU(YUyt#=sq~*_ z`_*aPDK{S}4m{3xu#R4>VNCUMcJHfp{3*N|mpCPc-4D8Ba!F8SZlVOO9x80ba&_f! zCj1o#{tvhmVU_erlk$^=+XrmN{7I55I_S;ly3R&hkhnMpYIGiwj6r6}*m@wnOO4>x z0gw-prjLZcjmOaO`X)?5GM<7-JDPd@*HIyEMLPyX6GhY1ekaOrbI>YkR}sK(6b=1g z3e#oT!vgppCbb_#m5uc*bD{T3^DVjPkCtq;3m6WgRT=MT%*jR<~7mlJ$7E&A%p`dRR9`?#(5qILU3i=(5S>z2jGX zxoz(Lf6s(l>?aK2r7g&EeE@M*FvP3b*aR;pTz=|+z=z4+SnDrM9q`bzjOoMYhp|@H zB^6W-ytEp~zrhr(h>s8PtT_X%rD>E5}x% z9GzIdHz-GsjTDZ^hYT4W^b-)4kUl`M9=YC$STTgxPPNi$92l?%Lq@H8Z7ZIo4t=2$ zJ5#M+%<>4${$s*j69@GS5jm*D3Atg4xaB)j>tcwtF2ZueX^;aBp0%}PRQVDs?w0Cf zt(Y56=~AM)m-U;)VIBOiq;_eEwZcX}EhI{ju{~Zsf$&Wqj{2~18P54{WA$Gra zy{9ffFysPpP`(ptz7F$ZXmoyYRCI^PB>22!-$@_4bk%WKno8sdh+kM_?GL+9DR*Jl zX;9?!z^?ev{?J8^$V-%>w^Nl#vItX`bm_Zrj0F)eF`Mu1|Z-T4_e>o?YxvraZz z#h1v{)??l8D#tH<!;jTzDT{?Sr{2vp}?CeS5h|`JxVZu$g99i|xR{fU= zH*uZi1D0vR9s6+O^oN^f|C(@r&Mxo)z;<{SX<2NhjhL^ z8falcIK5=Hr=k4FPY+kjlTm!`j2%eB4n(N8&k>k+7d)zNx^yfIktNos~9}N~A4PHJ^rnIHL z2fl6{W3NAL1U#s<2u#!HWA&FHciFw}MzqU_!CwP7z7^*A3RFKUeOwEO-4}^XqDnl= zeB7O@UPqH-hG9M|%y?blXnn)lHr$?O!9Eu|gVJ;Os|6GkNfOFR_^n0O+{QZzfI09y zzZJQ~2I4<0bB@NvEz_qNkq90kBO#Hd%;~AP5eo8W@bsRD5TgYzeFzp`78GAr-*kJz z_b~zRE75&!lK$Ix9K)H$_jw=w#W28mD(#ankh}PhIJ3n0g~GWk+WFohej+$+NYQ0> zG=6`=nSI4MGV#ln`^njR*QcppX6W3UM+p+UF29|>91S~5Iwh_8Bpx_p({7%tIS=QM z=an_$J=Vc9=8HtwCgBP|qUl%a2sr74?Lr+xg)D}L+tSj~}) zN77{^%3`2)M$`7q(Eeu+MclRTi?%L2zWi$OYShh3e~DM1ZbdCw1j>@F%k^tdZmLKN z*QvOZ4wlv&&T*WDbAAQ3?}*V@Cq(HA2sN-rMwy()uR4KsC?Gy)FMKq#5s5 zo*y}7rwk_y^n)43s`*bQr2b|#^Vu2wyrUFpw=6aKWrcDZc*UlaaKtHNlhvo`=R6GHp8kcJF|z8#NeqtB zH(1A$6ji{*xw!>-uvdz-A|9dt4J-Qm#F6Dxko8Q9XjNd zptc_WV|`U@G&u%O~X#+oQk9hW1C&s^)uJnS4 zfBpuuK?IaD3=!o)yLcZqw-Wbj4{|+BuEKwO9Eoh~G#q^i{n|wPAs;aP{F9(^6vdz9 z*ioJht+?`KYGFIG4 z-y|MvewBo0PuxDZf38lRZ0Oik9y4fTc2U4=iXco3mi4>;B%srUgN-UFY(oxu&N<=$ zcQVNS@CQFH%Ik?@72tWlodOo3B;s3vX?1*$7S7yl73L zLoa&#oklS3Ud%E?1nw@MPK(b-sPp-ZBj5 zJyA~&nqA$R*YOBa+slyKRzI^{+I$+)kR*G4?AX#J-n?Q~Ex^V>tksG~>(|E?zav1) z_MC=fg~K$Pmwd7&HRqjcPN+M53u&{;a(O72*PHu8C(A|Qb;UfT>LxIJua+8(A7iY7A5v3N(AVB611D z&8wX*UCZ#$i$T&Nz4kp}b&=R0%lA|FOUS<8YI3c%3`bAd@LhZK(i`{X{$K1Va~0`` zXB(GmWf&ydgQyQb(|zxHo<*Hm$DI0Od75R~o=uRV2Lft=bZ7+7!QR-{$NE|TSL4TT zKRA*L7h=BaCnn9G)Z*<0f&*NTBll$58}6huXxVEyaet${)$AfSK28ZKP6&zD z{r2GUE8G4%H*ZJiqrnm=!WG;5QnzIcPy_)KQ2CbB$Mh}jJS}dott2JAN|V;IuBK$& zrefVGlEvqRtKKTF@rzf_+D(rUGZP`G1wWa6>$S}8(Uv-c?tZ|I|N zyZwL*b(@9EXZra_>7ZnQg$NCD@><5sC9#|GYuufpB6$R%4;K~tzQI{}OalNHy~dE8 zDdDYDxa&MDJHp$iQ4drUWvq`rYhhbPg|j{j?{h)~B`*u-Lx-KFZhkY)Hk51SzZ=-u z^;jOq_8Vwf0Pl@>kWPUUKrzPYYOc!1OZVNai98C={aA$xdm>yppL_Gn8}n4V4KoV- zbZft!Zxz%1`w?tw%9HHZo34KgwQwWr)cL9IU*9}E|B*r(V~d!&1? zJwKzSsLs3~-COM~^s4#GygS|f_TD_N(ctgV@1Nhdwiw~K>$b=!aV`Etemtvksko?F z8^gx>YX5(TP2zrWUN>To^!1X z?I6*VTsv7FXZy%Wd7SDX@+x03zZQB6q8SA+={rUf3LFdJ6#(f~yM)kp`oBEuxH7XK zsY=B|Ns5a5p9W&_c2QN^JGMigqz)dbO}_Or`$*}2dNgVsM;Y{ZVW8GrU9^Q`ZIv9^gMFs?P&Wqj4NI*O>kDH~n;W*>TM^NqQn#wL}yUAt+L zi7@7jPOs2q*9usTGFh3g5Bpw#?1F;HE-4gg*i zKKK_?gY1)rvuL^1Wg;M2oN8yoEsE>}sQ~3sBQ_)hnY;2AuQTzZiZdWU#cM}C<2ps2)N!*GZTyAh9SHu|PRyOu-i4%~0C?HzH zuF~nYoe?+I6LD9`EqkKX!!-v5QlBzn6(&E&APT_hvql_Ow8$lmE9cv&gO6wAOf=aw zSdKMHbKgW5xe9)b)T<=Gzm7de&jdoqW3B|lmW)!LZQ@%bw>G=qvWaXc;qldRC*I;x zGxms!B(6Q~E8r)(FCL$d)ELi4xX-b4(j(c?Ku#gl2W!62oRr^nDD6nkD`!zrI^uJA zeyjz?QNdIhK~J{Jm*E0>0g<}Fb71g<0>ki6KYu>&L}||rA4`G2_s74M#7e4zu5{Za zu4WQA^x#CXnw#PpZb+~R%GZ@=F-6yo*`6x}%Zp(X`MWjzLkIh7`MotF^^5>#`-#BM z@;UZ-!+LD?Gzf<=P~WVA%Nqd@`ilkd3qaDs$xoFC-}~MdZ*pg~m>5~2Y7E8cY2kq5 zeb&Yf*4g{M$&pU(Cu8%u12#0|$Rkn#s7p`hX)Z^`%rDX!MRf`QKIN=XG#&CO!m9zP zKpB>#u!@t&FOiDN{#OpnFe$T>Ey}-uEv0xMeOKu9i+3+C9HAmbOR1;!vIR<3=|JUf z4salV8_zC-1rhJj6jpBImKZUoa;h|2W=@}r$_j*O2b_kS1?~{khtyRsl^)Gh-{~f0<2U(MR=hU!`~dF3)_!8nkmxBj+X0djZ4ye@uidzQfws;$b1B zU~c(wu&ih_w}{t5$p6OPeFZi3hyQ~=2?+_IBoKNHMWpvCAPK#9=}5KFqzMQpp(7mx zML-FHNbevDXd*}lX(9q@P!v?UfPg?Ye&;v;i`|`_*`4{{?d`cZbLPyc&*ypGuL7aC z85_d05d2--Pk3!-`wx*0=|Lg$7Q91Li@lC94OArM*^0t@5!$PP2s3tRt?nT)+6kwC zCx25kn6Er*SzGrlaDr)27Y$>p9-4vO@VL^B=dqEkSHJNZsRSDhOqtm^w#@ZuOQqQw z%Eg(mp8+GQq33*2PCON}Jj@EM^k4kdfh7sXZ8N@I{X~km zn8th?le5b{8H%1rio<@*$IH6Hyo6KcXI}yx?PCE6J}#KwOiOhS1KcGfFI|ofeg22&Gx{Z+wGsRFsej+wn#*bj_ScjuVDeHFPpD9; z@fQcEB3Vrk;v%nVM43pqK=(i`+faA;y3*mWJAZEeGtKWj$kylk3mYGjj;{o<>^#j_ zq<`>i@Ow(=(M-1KZhXqYg|jk!Y3RvK(oiQ(C9pATWOvK-8e;Q_PQlX3`EL@>TELTX z4_9&ozt2rt^d|TA3(g7BggM5#Y;mf-ks%UAbGuBk9$iT~4lr+HLkPs=8A<^Q>kl41 zx%aba_MLLktYBWn(rgg#_y><75UMbUO)vOP(s#KOKmi7NbT9Z}<_TRk5SBVZ%ov^f z)LjUPeAu_Y3Lt_Uy^o!@*WW~cbL_j6x%bVqQC#n6%K>>5=M?+~u{e0{0R@<<^9ZW5 z!gY?U+_I}$jf0OmuV}puf|sct;+P-x@nZxzqRPzo2)H^txLu!o0%B(=+wgQJStqdw z8FD{fVpmx7?c}KV5mzgnxpRr_cR;~!5Nww%F<05{UU6_psuJ|Bo0`OFU;E7o} zW<>D81MlD|W?)=Eiks$q5zWO}zA6^B84z1xkqK`cz)NMC;Fa&DSb6c+zD=P!ak6ov zVB%w`smHiEA(y-e)@#xulStiqb(I4`_q?PGJrav_rRY%27l`gJZX8-)8<_=*^w^U+E6 zK{gQw-d((`x2$KstS{uXd2?Ae*~e_W!g8j%(N>w1I+4?>LRWj6QJ>_IMn$YXi?hEbtN` zYp&vt5+Mu^b=Cx~khp?UaWrj=a3eQd4Cv$TxQ{U6sQJr%X5(B*=~^F*sHn?SyNL)j8PB9#Uc^x!ff zcLWNlK`lcCm&NnL#d9pMtGiX%Ef4P}!_qt98Tr*k4dTTe)x|HxOZuxz#>F4bRzLhC z{^)!4qYd%W{p!+FaRO}()r2c!uPNh~cuWlrp{C`k)Rdo>s4%Fhu#k9SU-RUmMCIk0 z%0P*y;WbZhOH?J-RAo`4J8G&QNz_!<)HF!ccGT3ql&I^ksT-H5pRK9?B+>A_reQ;( zalfYVRHBKNnw=rp%wF5fFWDkm+ae>`s#4o}Ub4-gw#`Da-M+T{qGZQqzgt@*8Yd#1 zR2;*pA^lc)a0Mtj3(F{WucJY->okSMk%HgMjH9b!zOp;eDFmZm#&XY0!;U?>P9=%l zDFPWJ*3>x3n!c-?eLP`4xEaaV#b3c{pm4VzaA@6|i*+_RB)S=Zc?M5+@Xa_9`(`#7 znqN0$V2jScLo!HW_Fuq1>dz;e#9H}K)D=47T4;%r))zGxs$s=-cV zn1wtqkgB)lfnDTJ;UiG6jJ$Op!vj0A@Z_jOIAnm|l>|!^dB@)Ofg7^$GwHT@929Us zb_cG_$3Zh3IUTY5%W<>pkaOY#{A41XYvBs}=8H7o7JJ_+0=B?z_4AbUlU?su8vpum z;NE=0P>~eRmW=PQ4DTtP&aQepOSZiQ?|u&_c+|(jI?=^G!8ly7B-F6_Bk-$D>70bj zUM{ij8n4@G+&9qy19j;`rl!_p5F&<`j{x-6B+`+APJf@pFGP?=)w@q5w%vxgaA~ec z>|EA5t;;BDB7U=n#41O-@Irb~2*#QS46-!QYe=%@;8|SbSTcY_nIxvFIy$Pk^_cYe zRO(mF8eEJ50-j5u@}*FtYY5)$w}=fk@*34|V)h(`9{W#l!x|hDnP19rob}^X0-=;Z zxY3l68a=KEdG4p%u_f{+6^-1lJ&<@r~d`M$>S|7jLT8KCdP@iHg~ za_wTEwyy#P7F= zmnuj+ZILJr!8Eo=zEY4HXpwrWApO2Y`m=(}N{h^oh*PDRa; zR(auF;c_f=-%kI_idS76SAD1xH_q z)sqcz;!)r*3soD4^i(P|+v-bt|oprp@faMwsd zSU7J52^OPvp^;TkHSVdJg5}>JMuJMLX?ARDXZir?v0htTI)xTx7M`kcfRT+%$d2R% zI`O}r@k}_vJ@(yYLsE+Mvj=clXLAH=4{ra75G)5Bu$yr9;gVhYk_SMEJU0Y64pZ-U zCY7rcL?7h79eyBu2;)@GM(<=JLkj*{3iGJNyUZju0)oG==kGt)ExG9>drr8R=w3=h zq7+btSl+=jramiVNhEKWvT!;Ho^gmtA!!Dy09TdHML&B)j}1t`0x={b8UUIPLPM~0 z3?T>rV4`Zv5G;a00mPzk1Gy8F0H~Ah1kqq=AaPs_IL0cax{-7Bv8px55IP7JMdiK$ zJjC%&>)WV$zdyz)yKMuwcK7Ix>l!yBqT5<2VbIR7=vU81uppjDHdc5S6DkDRM#QWn zvsBt(Arj6=sU*8t@UsS4u8)Ppq;NfQ$M=vwI2M4cgL5;XCdXC0o-xmes!XCfzR zm~9c$AX^Y^7aFhM*JM(aSlF@*E|z;Z>54+1 z)hpl-NB_}U71eVU03M}=(RjcH5r7!xQ4ql+0Sck96N3u@I33n4oq+8{8K86 z8R)BNSDS;A#Kg{Xre@OTi=~Q`q}DJs3qYoQF-o`PND1^Z9@T`VehGLg!#9>ROz$V% zB?yMlM3IvLyPVtVBiU6*46xlT`a;9SC2R~~C=y#G!3|mDm{_BZb1@9`MPK57UR9dt zqz3;s6A+A16Jqg>j;0k#u8EVch0oa849EzYg<^z&Uq2;tjFhT|)$*H|^2QlR^vHobEoBtcw2L(FM$>8L|8{7EEBk)MZ&HWe3ska>mCRL}DPZ2!aZQdIoO%)pGI*q2gDZIP1uY|@_tgtc%X-Jen+V?4M0v!0#YB$KGwrkx+xj4vUvR$|om#-xQyKzlSMZdJ`ffw{@X@|(#8do~wR zdcx~MM1|8On)?I?@LiSEzo~P zbB8Jyj&x=`m0D$e)AUpfS=z?Y)cVRD!A;4AKcJkPuS}tZ1t^#;bUp0m{Ze6+KFj(Gvs8 zGfJgZLM_n@yO8%D8|3iTQExsmFxMP530WZ@S-$ucc9c2<~T?nyIgK}fVlDyL=>L`8{2D;XZHZb&#_AOtCP!HxrwVtnRKazF-eat|*SiVA-S||xL)Nd6=!Uf*8!b0H}yH8BY zumD&t?(i!8Q-eG#004G}-AbXnH+}7%M(M?Cf*hO*R-deiNtB2!LHN}`c?bn_46hGD z@&Hd4hH2JA6x1=UXWo_g|&Sp){GyMzrIV~&WlR1? zD=-a&4CwaR-Fvq9O)~q?7%Cj<8b*#|&*2QXcJcRvr9}bN)(7sFvwlDHHcL`WQ#wHh3~hP@daG7ES@iA$5V@Hcmx47i@|>Z$MoR+ObPSfWcIn|Rls}|xc6!R z_~{=j7SDmdi0_ZuyWGq-T^a$sbCJr-jwnC*jNwmE)dNznTZ%ZU3}!^$uN)%=3FUx3 zfW=6BFEMd6Rit-zC~9tU51wT;3cPG^AfR_U#Hlq7f{jez%fcswzlxqdV(J)+%Zxw6 z$@96s|9O2h`MLv*D;eT&IGOus*U}Ql5Z0;75lN&qZ>2`sPUvzz4)oK`i2D(PX@8>} z01N1vhd~d*hC|d-Gn)KX?ii0+0TQ7bQ20xa9dZ(4+2g;7z9ls)1{j|dWye+gR{ zUs;AtH0L4FwTt%B`C=2f3-Mnb({@#g2jR_;IN;J%Jc!#PS$n1oLN7)H+o1j+T#NU} zl{CMc+(9;>pMDcKP6XmTj%X?f)4rQw8?9r}=663)t0{j4!mW<^cm)o+&fb7p#p2(8 z@SGM`!)_^`M9tA-_lp8wZYe)Jo|e`AadU9>>79lj{L9$sh^w#h)?|pb{|iLZU#^!khC04AmuUowP8pRe)@3`!jG^6Y$8|j z&>(=m3=6;0M2E|S(l^ollD%XmNB^ve-Y}C^wrS(b*6B_*)8;VKNe;d07~)dS7DMiP zhE1T%W12wzST#;}+XF!$z}T(dP2eHJ9@E2kNFZ`XW)o?G1*$fYYL?(aEK?y7c1nbq z5E$0TbPOIawfeR3z-6;~R!ey{2W!BF$oPm1_ykYKT%V4?bKGL)RL*2`V`fjVMo=Ia z_!^c$41Y1A+=16-ybd=0AjHUBuIV;P8 z;j0lcCvTk+C7Nv{pYI@nO-6eHY!^G7^mnWq=hIvC| z%J&d-v5|Fek)^Q6_U1A4w@6CvNFsw3{MMPO2(XF`WF!GGN`Q?5KM(LfNAV1i7=}eW z>=X7~2mq%L>3w8o&dWm~7)5nixLyFK!{nOIIJ;@RipH2@4RSGYa(SW|6#siVrjhd- zW;w%l+6JrEItiSoo)KA#Ay^;vX<51t zZ>Oo=%&%-^OEO$30O_11=5%-Z%WuFKvk^pw$=LvBwwRX5U_Dt#A4bFwpf4aol%&m6 z7vc35#mGtW$>NIcah+u6s%UJw+=i1d*dWR(o^C+3%??K=aHbE=t0J>pKJ zBCBEUZ?&xHDa>yKd7h}K>bE@u6ihGUIaD+0^n%s6-dQU%+Eib+YJ6*@T+hgkU5LS0 z@vt3BU6>(Q+E9o?;3Y(KsSPaDN+t#5061K{%rJz}o>5B&5F zR!XHF%DJ-=-tHhvcA$9fYB@2khy)?m$l$q5NH<0#x9OBS2m(yf>EIy=@22JQ-{mQr z7AiZK=Q}EoIU24rS?wYlFpzvZ{kSG3*F<w<-+wfrOe;wGLfUfv~hPiKd|%R0AJk(L#<43 z>hE)V5wu_bAP+olC+z|L!JyNK8IudsF-VQ}yBZwi+v<#QhSEp|#~)z;-Ss`BtJkzW z{)BRjKSLo>*K-|6Q`6Wssrvkjs?v!=d5K!jF5Qw5F2CViyXX9M1V?ADqnxQD|9;vY zBkeSnq|BI%M7dEgNqZhiIL7iREX?-L^ga<1o|W)y7kNH1QGYHqNg)n5W)SQUENkaw zY-OabvK0nEzwe!|cAQC#Of+?jQfGt?I+|3oYVS3i_O)jXbn?G(j0PRflwh|{G285o z+kq-qZ12X{)?eQ(V6q}Rr>GzmHh{VMTjk;LXYhzYpQ*?TO0~A@sKMZ0BfOa)=mXZPH~g7%fo@Z*#v;nw2iDt z%a6NxFNNzc{DIspryo-ukTl|tG;$z?toWMD#JQ}cUC6XbfNxo5;c_7Tz;}k$?`a_= zBZnnUDh5kzIu?x1JnxeQIZ_O#kecjyz~ zQ8KVBgXxgyXW8WIt~km*@6vg8emct|S8e_I=980qSon|5B)`8MpQpR;E+md9yd+1@yhKp~k+#NR-VS%JF8kc4jYnG17Rkcjbdq z`~2&!Bh(~;4o`q(VD1U$P+8nBl`=7GH?O=qn)w3s7h{xNVg~QT%-tqKnwn;#G~Q=v z4y7LtU8rv%#0*z=57(CrpLpa11q@x69;qe|lVYYP2=G~K#Y6ScNzJi$F=Oc^BOj;@ z;F-uND@YCf`17~D}3Va^lSY0 z*Tnui*gWM-r5_%2QCPZo5D`w*;9hvp z<`U8<1iBocT$^$4Lg}9QF2d;Z?ogWQLbVzPjy!R=PQwb0}Pic&K%i5~b8Om@;6PVNv7}L#J3d`asEV{{P8@D^g-Y zDzU!-4ttYfR0{6(G44y>W3({W1us3t0yyh=&L=j~f4`q`Z1$l54{UFjm~LyDqJ2(7e}Yxpi^#H9?F+=dHKIgxNbBeFFi1FVjH}i##bMEJM3_wyzXJE zX4KYJnzd5DxP$wPYKs!nvEIq%5Ul2c-lP5=W68ujr%h1n?nc%f3N9T_{ehpJUj!X>DU! z-gFEEu?u>-MzGuO!URlr2@n@$zTGJnqt5050jro4n@A4n{fp@`FX7%9|Cw<2&8^?- zn`4;^op0Y_v&VTCC=;&5D&h3_>A%D8yW&peE8HSa$~fHJ-q=A=fr~c^@tnqvC}uJC znK@IdW5uigoxd*%6n>4ADmp3EqQoi833z=tls|%Oy8#t|c=2FOv+| zKzWlZr7S1-em?NFJ}v6(F&hJ zXTLz$F}W&MyANmE!v4IhuhX`~(F)CO5i!Ycbu5?DaC%Dr&x9-a&X;F!SuPkNJNm!Z zg!}k_-*Ez~%OD@bsPg_|3e|-3i8$H&aQSXe_*}sKhbjxf|4g_m<+uJUc)jn5_!RTC ze*be6jSO{mA9g!(DOHwg!iAkZRr{K&5%_Gm!1mL#Zx6gJ4wfHL+)odwCLB}LkH^Ip zM?Y$)CLFneo=Ic5>XppjpKX4hqSiV;kE?H%|2^Ne(L+zs*e>uUl~_6UHN9bX@|cX- z9n+S*wL9amskuAn?ymJ`D*AfQ{>SuxCY*Pv))CqA`uV>2W=?1z4nPkMI?*DB7|4-OS2hTKtG9lNw)m55FxKvP}}A!LF;e2aZF{5fo_GFCzl zP-RfLo0p{Ggp%p=N5s_tt8|k?<1<+yqGh~7$k2GOlD=pWkM8$N`$RCi9ReZN30DrD zqH&tozP>LS(J99mRv7Jp)BU$4d&A6oxsjlA#m%qINGI$Vb`JBKSGK z3p9|;W2X`ye)W%|n+q9#P^Bo!uxBjC_=1&hnF1Jq>sQ`waNV^Y$I);Tb<8(_k^GAU zcx;s}ymA+&=BQe!N^<4N!VD&+cTp)>$L(T z3#d_XjXw#{RaO(m$UDqsHTh4V)$ww{7>5M=e5H-HR9KNme5#(^eQC9Lcu7%r{C_5# zpHpuicr=X+GXdYx_(25ow#|xNC1NGrcV;`s+p7#uSpS)Dwf~uLzWaSM1$yp^@9la0Z?ly&mu><$8n4Tk+^!qM9#(?<#-{V=cP&{Pu+8Hz9o(&g{RfM2S4LcE$1 zSmY*(!M*oi$xikn96gdX@MbxJgCrfW?Mtc& z7gWd87nG9Y=+g!b*1bgk1$0&n1QUJzL{A=@Xz{?qES6I`Njk$*@)nkElM&=DbCk@s zi)k}B1Qqysd9l_OzWFn@d7DCZ2dG_?euJTS*l``gU9H!XJM@9FCYWowQx$cY(v;9h z-@f*8`O(qxQ}b2RWcrFY6A?HI%3#9zOd$ay7O9v&YKlH;Ar@*olFaT!!PH%ea)MWl zs^55Bl<*)k$xbRaUfH1ueuQt1hAUK?uS4v-@4S_DYS(vm%R%!B%J!f3)+Z(U%Eu7t z1ger!Uq}~loC-ZpYO8Q;6$QFv}wpETt z8VHINdu&F`M5{z%Jr#o2n&iH$ZN1>0ZK-VM6g&S*duCqs??wdflyCi7=nK`C$-6Tm znYqzfb~G-XZ8{D_n-ecMT&gM6`!{~-EHt!3p==Z0|1}ZS&3!+7Vpo7-L{2Jk_y#tq zp+Q(+6U9BMp8Olm{{}$HU$@Z44${zi%*WS?X_3uJhnNjc@=DY7S8sL#b8l{g9^UW4 zn05m9U-5Yq+wq8ge@UF#mL89{@~mp=GtH+E5`+hNMD>Dh;2Ow(&cI$`9N!;7JI4!( z(!)?Le)9VhEd)h}ku zPnGiBmme*@!$+*tT8R}>$KT~d#{zE0X^7cB(f;10ZHqipHX^cbV0g4^d(KQN)ss#V zMRJK(Jl%N(h0RM@n|GbwNKZVeO^j7AvnUNGv}&C~A5i{ys#DBj@H7HeG_)M7DepoN z-yB($Z*F?Urf|@Tk|Fk$rdbhI99FQr6lQ7~)<(+B(Sm57XTky)OT8u9kKyQp6y`aO zut!iOndPx6L^HkjzrY!y;TYfx zM?bztI~C3fxn#9!kFrf=riY`k9=`eS0zLn8;8@kJ?}cAC<4pBZJ9FDAS#|DGNrZK3 z3iy>hYi0`C-ze#=O0cY2f@5k}mLn?XAg)OD&UH=-Z}b&r_bMyxt4cUA<4ikr0j-79 zR>YFB{lyy>e?Z-Ha8OYcZL>YK2@I`;Pv^iv1;}3BRd$5JB$8T2h*bs@=joRN^V|Xr z1!sQf%#1Fi3B$tlF%CMfJ^8^ zNo#6E%b&xmmkT@F&%Bs;eJGOo_Ek~gt)Tcw=9CDLCOb!29K^Bm(i8;Bbdy;UmG#vz z>j6i6!v&ceLEwHR)-V(}3VuZr$d15d#~WtLevhw*B|PS2<`>Uej!YB&;C)b)9haOr zua?u4s?>4-E3bfxyrWgirIkj3^$M6(4JZOCdro>ywo)jtC{|o$JKuW9(aOugoS5za z&oo*Qa2_hS9t<Pi>oRjXViAzLW1K<2`xBWTnjyO2| z15d!=z2Iy4hQ{dzb`Ry7L(Z|XQp8{vp8wO}3VmQgSg+}8kLGVY)L=FV1!z5p#p)oJ zbw^ax5810$5I;F-0qld7q0**QO1{wpv=$kpJn`_;p==D%TxbH!@V=B0ag8!$NbTQ5 zODHCYu%_#kan+QGC6xK*KjJ|=7A%sd9F__BK0bRSClVDy1>vMk{nTvPHv~7AjH15 z$@Z6MdM#Hb^~j=|eKgP!BdqzwT=yj9O8zQP*}Ao#fFfD&c2{R;PBWONU6D*(TzsJ% zObc6RywC-*S^yz1ZG8BKmIY85w5vsfhGV<)3JSy)O|qYrDGrn0rlML4&)8ctCLf7xc$4#T?*GI{K;~Gh<8_P4w%Yzg%h4=g%y<2mLBMsYr%)#AnI?|c#j)}}>XxGIw>+YsNDagix zf(}4)1rKwPYlW7*FdgYw`_F_^VQ#sQ4$an}QCkEVk3K?-zK%B?g80ATX`(b$|TB1Mm4ULxf$FMQ~H}x5f3BR(Kct?AsS}#kX zS^Y=T>c-x>HcXj~ae!XP$`dazjD%uFOxDnabwg@WL*5hfOaj&*g*!dZjCa$&pS4~SZi+nPED&* z{-mMn*Z7boTKMl<&nClvs!wf0KblPXkMy>(BVtnzH}S z#kqN{9luO2&acp%gUZuj%+SKMJ$Yl##96WE9K_j=u*I?O3UYe)TM`U~-6eboVPj*dQ; zX?5;1zOHve-R&7q=vw%G(iFJZpg1unGEMtQvdOb+_Tw#PLdHBIXaGk4_I}AoU3vv7 z^Zmj{L;mxygk}_dyX0KzdM!bqg6mg#;Yv=Q$mzs;0{29?bZm3WI zWF-la3uM+lkJb%rqC<69@qZQ3YKg=W*NXKN1DTa&NbFjjJQkBNlHc^RUYdRancx0) zjvB!9sZl*^UYTJr%Hs>wlamT;72H_9coF5XfiC9zsNuholm6xQ-%d&Xp6Jsra-Itu z+aF~~FeYqMP~g}~{X+W27rnJ*Wx>@=^VNvks}fn1m7?*LtlKl3+nutjW_*AB!Bz7DcXKx5Vz4Z$V5B5F?uhcC#ng$pFUgK&@^;$ z{KCtgfIxCfKy&`W`nyFk2Wf44^x4nx4J6OzT2yzRYGLPST|IDHI2+AF`86f;^WDpjf~BvEPdi22(V;o> zh>6db;;pX_w@KT-)#x{O9{pnX+@_&CSS#7FrkZFhOe8iLg&6E`3;^c*h!GB>clMiWW}lib^4k)Tv<^!&(Wjk~g&rF}=>(rNC((i*QElh^o|^Rq3> zZi6c!Y-V!t!|^`Vfr4rsYfkN9>s}jXIZgI?DT1Q6dH$4tWy(22sq2mk{GM0rqPwWO zTW*k5yT>G*#w%&D^3p-5e(rC}6eV8&-Ei{Nc`4_w=ToxJx_>&px%-f$X9aX5n4IV1 zRa+&7sYK5_;cF`;8LlToz^q%yd3Hak_~cderugZx_gK{)>)>@6`1}HM!htlQ6v^bQ?aU% zmsm9sM^BNQ8q~IEyRLD#Go3r2{heSjPUQ6Nnn+BjoN#xKh@Fw{ zYf>Vf3k88;m!eo2mASeYN&Dv}Er_06W)6jLu0>DZeMIm!cRStdfZS8Mfy5|zk_3i%_kUKm){QiAo<@?I_Z{Nw}72@>JGBL8RcX*^N zy`}kCM(cw|k1I;=rDtUq{x2vlG5yi4^n%F<$DG(}ad(R260`q@h>Opka8zu;Kk>$k23#$5G}^tl${aW%Nm#pt#r z&iiuE6%Qwm%TydL*xlnA-uyfjkh`v@=H?aT>JjLC(a+iSl7sUVsuE}K=xyt8$x-LL zgSw)bqnphIPpb<)>KAa9w(b_zu2>s0b4zDa3nx=Edn3~e21YhIMwa^eW;(j&SRK>z zSmX2BMq1j&n%c%#Ev%-NzOwdt4Nd)Xnug~z4Wu*`)X(WUsLI-^%AZryINH&PC{H(LR3aZSV&AvM)Zt?h|pOfK~Z6hFjb5b;ujR)6XNF; z&Z z^8Xc(d-=m7VT|{T7Zs4JRvjbuGND6{HMXktCv$CU{{!UisWm z6CgU$_P4jl#OwSgrOL>i=`&S*x=^GsLj?_;Q5c@Y7(Qxv@fNGJ!vxZGlJo|W_+t;s zYaXZU$ru!aE5pwGnEjwrF$r4o)Sk%c=i~pWlH*D1x$-eXY4+;ZO!W&vi3pC!tyvv! z>hwM}cfBbYYpsdb#;^aoM~tlK3U z+{~~8*r>?~)U|77dq2Z^-1KwGLWF*P~?8hJy;8(t&b*1JPgKZn25)5#TFKS9z$9khKwpA z+geAJkUl?q4iWi%FJl>2Wr^UE#ov0uh_Jbm2rvJ$p`Gk{y^tBtnC%a%&`WV4M>5J%bIgP8SsS`DSJwHOUvDb#~%T z7{p}>!g2W^vNWs$RDvZLd@Cc+sjqrE*pil#zsZJNvYH8awsG{UDP4&cVlj#nXQo%T zPUH^vwNvsIJg9-pDM<7 zVB_h-FtnG&IKU(2L>kc`j0Dl?_PK8YNDS$`33~9ANF<5!U@P%|6ClT&aUB)U{l2Q7 ze;1$W7beWRaL}(lJehSP@hjh6p`psgWcIBLZ_E!hL&0O~oFq*V!R^999QRaiT8xkI z*`Yy`o!mSsM1qljZfq_zbvJ#qLQF&bKR~X+?1}h=JHr=VPyxAo5wS}*O`Kb%?ln>Y zIpHBwqocgL&)3e#MI4SeA*Ku8)P0jLcrfZBGyPzU;j+>lCnG^UJtn8YN|jc1sJOpv z@yBCP)gJNjpnG>qJ}X^0H~HNBM#tTU5MmBKT{bkF>>O4A8@%vpccK(=NwD`dAEbnB{-^tq|bdo$%{ zsosOb(c616tWTtFRhzg;Ocl=NSBkV>vk3OJdAOhdR1W&n?8k#7Mee<-{~wV1KLK)n z?*Q6VPDX~Dl63Mlp-4GAGAtySCk#t-7M_qWLchcg_j%3MF_?5FZ;3I%2Q1n#2)7GL zRzsD6aXj*2hZ9NMVI(g)d1_LL91rVz2_MC{ZT|Am~3P#FjLa+~zp4bw| zIED!@aE(B=L+&K?gW8r-jd7lepl}eK1UdI{d)= z13Ub9bW!%;!SLs<5PIh(4&nE}NYH9L$aQy>T`y`1JQTt~wz}NJIS+f_jq78d@=_l< zoLc|2`^3BCO5EB9N%?UjPO77%qk12lk}L;hjsK?dlr(6;1uuV)@JgHEFEWJRqUE;_ zpr%(b^IlLPlxTDL-2D%?qhvZrUbk}9BFPZHj=kVe^Gg?t3&B#@;8JhtYv<~sG3%8y zl~1KnstgaH%ACs8?F=3M-aIk1oc6Vpt)Ejtf33x~usnTZt|7W}i?Iiwd*X{Lu20Lp zr1Xz;GtS=E2#cGfHEPo#oLrCpdYzn23EwBukC!A^_90ctcy7+|MOgvo1zM1zK#7iL zLVF_yeV~rJcPr(RHvMSQQp6Db#r3b8gk_QpBSC;A@#&=N^Sp6oPu7Rm;AdARweh;# zzD<6YQ*NzYdjEfr_NMVry?^}wIkS&->^o!4zGr7;#{CGB9cL&+sCWaJ}%xA=>;TcId1gb?(;5 zbB6kdnvMM@XP|7G`e!AK55;}ftr z2eB^JA1O@ieR^=Bg?;Z%?>7%sDr5hrQ{&O{cp;Ju z5YY%(JAy$)^DVu?VF&eN{Wa1WNm+?-V@+O@MgN&|m~DLw()nigMw}=QDJ@>gnF*YC z#_-q1qlNi6g^jtFMOBtP1QmI!-2>T&$dC;(G#iS<18^n?@k4+ZNozhJfQdX?8Z+dM zF+&}4;E+gX2s|4CYrtF_1mJi8JqR;RPC!!=ZI=;JObU_-3eG{!EkkJvSXFm zvJ*YW6JdmS#~lj6022_D6r_j=McpcwiH~alu*92!cmQ^It8G3z78Q(T5LBZvI?b5< z4JmLFDM?a?(O?leSome^<{#vDn&i4PCI$l&JLGhcAe1DGK^Vm3&@(`VK9@d3cIzmG zfk&taNO%fhj=4mFN>J1Mvm{rCB!4zyH>EM#L+R-3R8bOWz)0;1N`;tX5%Jiu+H`1k z+Qo*n_Z~W^Z7Hd3WJD=}8wmUpu0!5JQVUX&_);gA)BA#?5H1;#37HJVj1R=r?&wr@ zt@QS2NT&hj4k~M$XugOt|Du>0;Yxv*Av8;2dJLGoFO&_&lazl^rjz_@386li5O?S_ zW`ZGuFc>2yE(?HoBxA>Q<&-cADLKk3G0`jojNvuRl`sH_frk#lejULWVgU9m*ne5D z-6K#DJmPW#43-6f48ewu9QrW;+Z`k)K2FINaH++Z{>>#A=0O^B<*(&nZZsDria~^9U>Ig9k_^RAp$^YL$h|aWO%q=XJkGbkPg@}_40*`63(!RRlZ@lA z_Z(*BLS*DUPRReKEuUWt`uh48rpVb{E*zaJ2r5^g8{TV9Nt-`^zxFT8ssRy&K?qY} zFdj;ZEcxMz{H^Z$UFdtSSt|fvEZJ4pUhhp0lRXM-RM#nYUBG4K|qyC&w4tYV^w2_)vi z8p6UGa$vMLSaw-lLz$8@@KOZu*G^Jy0R5@A8HE|53Z(YR*Kj=bDF^{mp)%!L6J+(@FwY-Ff*}EATY!BHQYf#5i zD(+Xq!8%)u>NsY67#!h;MP6!vX=+CBUqgliP#a#T;cpDrpGfo&IUox_Yl{7c0nYrb z1u@7t1_D+ZXE<0UZIs*nrrvh2-iMSBxB~`b0GCosN;1Z(y9OGL;0(LjgN(n($mPUB z7wu z0-vpHK7;T*H-3wW0>C4M&yGf@8}_P;rx_TiU}V{!!-o(C45`1_wb4-WknD47imuIS zBM4RBYQ;FRzvRZgd@cOkLhPA+A?9cyMyKF~%)e)6Ji+6=8sjJ3V;Z2#=Gie=q$VRx zFDwhgQ_ogLv0skq?n!Vkh>pCFFQbe}yx0t8i5w<#qp?sl6$%SW`*l4Lc{~r{SMNeq zx7|fv$$|wE0UylQ%t|~dYtb=R~1a_>id90F^O2L6-xKipayZ*+-s<1@ZXu_-Oy{}^bVf2>H8@OYPJPrp? z49UnxWIy%^D}y&S8!xwBEqD?W1|$#hPQv z%~uMUkv;agnjNgU9Q>Ti3lqFnv}lC6_8gO%DKG0;69`q?N^5khaNZSf)jcmemzi=SCOu- z*1^^rg$1%}m{V7S!jU;p!kte?pm1l{e_;r1X3lkI05_ie*zoW-+1LH94{{s=%ev|L z@GgocA1?wIVJ0Fr$ibzE6jZ!=7U+XTTH%2;gS$>4nCUeH!vQnCSFUT@-=j*J4;k?C zc&n*|Avh<5`X=5Ax*Kf^O!o18&+$LU1p2m+_N5@XUYmaklXS|oB^rDd4;2ba&ESBy zT9|amVu)e)?im)1{<{~{3Ra}_z_W)0d`&l5D|rEf`ZA^_?GYy3TPA&EB=jYt8+w9@{vAhHsH|* z^(*KE=5m=+P zjJjJO9;O!teJhJ0_xJJ-U4JTcgZQFNCt%i=2LibkbG&L^n?7&hhcGEcIGq9oI2Rn{ z7ql!F%xOq_03wWs?DQ=Jl`LXGWC#~>uzoQt7DM_3336VFid~8>S-SpVDQ0>p_F#!5 zxg2k{oZz#Z6uX?vDp^kbu$(@YN4*Oq**`|!Pf`umfE@8t(84Q8t^d{&!cSDQ;#-+WkoH@*5H^!qc(6?Ooi zJFc-aSF0ou>{tK@0}M5nr3zNqoq>m3py)723595q)(W^F$8W4EltQi^4B7*$jwA>M z54k#U_%lOXfAWKDR?V#mne_SjyJXdp0h;o{VkOsdN}vF9t>{DY3r8j?6ajZWbdDiJ z@W5aR@X7Ha1I`QGhAD150BG-waC8Q(2A2*Fmdx-_Gwd>;!7Oy#csj6svjhZux32g= zxTQ7{*SB*%XuD@^7;3CYur{QnzAJ`7YBDw_);F(kZD~K_Y^XV{WF062vK{gUvr z&TH+lYq36m?>_i@(g^^tK<=d_p9TONTr=@q8u!|*$wYjf1|B^C0!WC$kAIVXE}6Yv z%9Q%scnMK=X=li6x8cmvxxs%|VgdNz&drkDM{&Sojel_bzl6Ad%`JBTU*>Dp$G;_+ z2r2w7TmSMA07Bxb5%PuVoIz-^r1mlp0nQ-Ncj>Fzub5caE*HcmY$*T(6%PXef()Ce zZeVnDEkhbb(ABfvW{?PpB1r;jIVrT6czhDU$Yr?!(|-r4tT{;{g9w_iY?#+z7)7Ry zUs1LE6mK~MDGBQ}0YtYEZAHd6rY-NgDAClo5xg9Hk!Xt?h2wPrE42)I$Ck_DffvZO<*MH^g9!A<=o`8O^33D%02!?>ybQ{9o1(^hjs8i(3-+XQKk zyrt7Ucht^w6rI{SH&AREXPp|aLEt-Lov)jIrS&^nP5G)8#M+hnfs( z>-GWC2`W9c=E(Sf${}lUu{u0byRNx3`DK<%j_r{lVtKs0bv-uabCJg^D03eIy)7)+ zOiowSHApSJugaE&G}OnJ%W!`PQ2c2655}n^z?yv4m5zOQ9XNJ$B9hP1a3$u1gLXe` z4dgGvwxQ*PcXggOP1gZX4r@|*h2WvStFfx}g<_x9T}2(|V}`%-8mvEiZISf?-w%%- zGv0R@RNPV3Jr#I2u<6wK;>1~qba68^Mdw;z?#of-3SYemg$7RjDF8!Z1H$HS8vtF{ zS`b7j`{HL&S?ZRRCiV4(X($6y8Dd%9xw&xXmkYS5@LwwQot!ml+8{`O$M+FCUF{nNw=<96d(VnX{Z;fkb%WU~=o2>``Z) zhjLh7A*Tm-Ki$KM>H69yzHANtT#jKC4e%s?3u@&HkO5IA8NV>Z`GAc{~z} zN!f0BG%|X$=UuVdHKuquFIOwF9LoG)S9vTr%pm-hWgzTm#fb2zIf6Tqim=TRA%r!6 zl?h=9-1t0B^DLdus)0#F8ooF_jQsg9UDxb0)#YqMn?pa0GeEYBhqal2G-wPz9z*4S zL=<^F^jKSskUA|KXephJyrKli$s2ZmWa6RDnr)3mmb}`$m(jFG92)`t@4}Tb zuLYs-Y^6s`bqBdgqyAi~NCkY=PKQjdr>{EPnwrWuardp4RJbV7zEYfelOuc~u8V?; zU;cRajodvEE`S6zVO=JH_*8J>)gkrs%hftb@CwYad1%h&kU>6EL2Bv}W(8&T8%uk^$=^?)k3j6fTB-q*av z78rEGP37gyx{SJ#ZP9T9XCC1foGw1^&?v?-$S_((JThLHO?q>JwlW&_(%ai3N5<>; zL|(Y@T)jwWe&@sMTyF@E`!0v48GV(!6W)F^`1;6OpL5>Wx}ZS8k=*p)iaIT z@)Y!h3^!2C@#sxPZqcs9wzv5ri zPJeykc^&@2vgdKz{)dh}?fBsmI_Q`}K>v%GMY(ik$uaM7#3CZg=-9`V$y!y#^_MDV z1D5o2Q=Eew?ATyG3<}G!7JdyN?8F^m4&B$Tleh0w1zowYlH^|13?Hqdd1jmc`TJ2| zMdrqp_ZNM>S;9r5%@y=Zdknu`J-Xv<&;BlPE#%YJ&@b6pNP06U;nFwZFvAvmgIyW<(y7e;wKNmYj(N($8*Kyf;?+H{qc z%zYW0b%V)@N)zxFW}7)JZv85KUDlBM!cQyrnzBQ?In~2*Z0-}nxEsLZ)`wv|wZ2Q- z;xss;GP2sZ_>=+3)5?)L^uRq5W`5-Q86!r-)-;{{_=DXO{8d7!CS;iKAPFt{Vk0B2 zNZag+=SLCQVRxJROIT^+>PycOs`U3)Uaw!76<`f(GpH)z-`_O83cRQBf&^FFF@G-e zA$s8I^w!-CZ7lAoNN(nVj8OB7^LCf6kG`7z_0;K|>w`Df#>b|A=ZZtWhqkctMt*+K zdH?4oGZ7av>uLGhvFhf=otBtN*Hha)NVBbLm%LZpzwM4)Qf7bg5pr<$+rMwfxSe)f z?B?BXdrLNPyCW^Jzh8aZ|9LBJZvhv#JNE6Mhh=f_t0nH>&o>qeNd@?+pd1yVONCle zVJ=j702L8QMJ7;Dc~o>ImF)$UolfN#rgF|xxwfg?FdBxR#v@1L)ur)S()e9y0s%C^ zNSaUrO*oGxQb`kiK@+3X#D{4T^EAnAniQ-R%ik(3*D9miDr?y)=h7-4(5evGdL*G$ zF|SprvQ_y-s|vkUb+}b+zEyp@RRh*`2$0i~Ytz9 zIH}8LxaEo$l*MzHjN(DTm3HEyp=b=5;sQ+$=-i9wJoAod+=VmKw7p92K(3L*n4JZA zwkN$nxChzLnPgbooxk03Wsg!EKnxHklEk~O@Pm{Wkbuh0qs?6=<2Zjy>-|Tz*kq`h zGawsAgg3++>{yiqP-{u?_xO7o*DTcsDYa~PLa)Roc1VO-56!pVayYC)JTpY73v#e|^$`f7TTEd>(DepnLq6*Zn3ob!62273Fa=o)fv$bVCDJeen}8x-5@16y0dZ8n{*2<`>X5Cg zYtNHnaFNbWvK1Nzc^gicDMIB)pv*YV3GRBlow~|_hcu9Iz7*cW?Zpn&sGSNg_$-q* zVr&|3tOylYCh23Tc(@C$m?og<0+}as?m*3iq1=bqPI2g&A)F~d79PZjF-gYD<`~6z zUiz@m8*m(`@Ivz0$qKh3qF4mCvnSD3BdGE&TmllZwnH=X+Llr z2hgJ=P`&}wZ6wP(Q;X@g#kzy62zUK3a-qP)1Jf@L_J!DeDGFH9D^7g9&|W*v$?J#s!cP3^~nfb@TS4xD@Y2WYFUI5*HvQ-ZgZ= z{oI+brU0G;irgScGns;24gdqroljc4T0I}_7U&ig=y3oQCjpY0fo4ie5$j8r4$g;j zEwcR77yTud{+nvKX??z9*f-7ekONQAB9YZRpu*#mP+j{%%L_b2NB*W$928F>_1QQr z297!d^D?%HR{O`xGOOQ~MUd2ka9# z-(a1C3;SCutim~H*B7_}V0u_!`#pEPAD6y0g6r~ZzdgTyBc45I!iWll4B&h@r#Z*% zZ~X!`_MxWZfYU+C%S9ZfcIr6-WIZPj@7uO>QAb?e&RuZ}TEJQMk9p@)^rC@?cO)?~1gSVlb zlR^8ReYrkR3`pekba2lB-}SDm+hN32o&qD1?s0uD;JPRiHN%R4LP)MA1K$mOp#H&t zNNwl8g9)7=l7ZFdeT87q<;!Xa1z7gKcN=OLCb=)Wiu%@qe#!&}`#>BC^3;asNccga zKLq8eu0YD|zf=}Q!yQ0hKcwvSetofsdpm$L49Y&9w7UO!qg{d=pyy)TEVi#hUR-Yx z`-U@&2ILhdk+mCoRHLb(JP*B5%U>ih#T6W8{Gj*#`N=@jWC{y8xWMV`!Kt}=?E^4$ z01Z5#7yuymIQaVpwhKYm5?KZMJrhoj-mR{TXS4ykgIS9?hK4W3BId z{&E^yPL=`mw3uo}Gb~`%`COxKjnwKUq2x$Om&Zj*v2WK*Qs= zc=ZabG+tEWtc+eTiW@j63@dzjN{h-=tG=Mc^@r=opUWRxj0&QPU;e(D`6qbHPjCPy zFoxrM8?|5k^=ONySR9GPzWFiqtN?TEjaz`e`#IT<+pJ?k)OQQGBTKdEmLRVp6dD_# z>OK|wt3_j}MN^6@D-)r^g6hWoI_d;D^$kq+4k2qZB;2wimN|*8BSHTk|K!T1qZ_ zth2dZfAyNdtzXacuS~7u8e4Wf|FmpQ|MK~E($@*UzU|0vOat_{k5g! z?g9(?cc1UCmd2KjyT^J|z)ywvkpoKD8qRbXNJEerN2u#8+?S`oR~D4%wEoO}4MV+x z{dNF|$EN;<&VAgsWI<>3V&)b<|9%ix9><#fxx0)=#>FK^VuF~NC0tgp?^r+0BsQ|_ zS>bm)v%V-9q`rbz^M-6!mW)k~>V-|Yo;+puXCb!Fk~=arIqS$Y8!isLxRCTy_iTC} zNVBEy4dtAd1KAOUu&#|GL=rai1cr!hAiI*;pJfc6WB_Div7fr8%Q6%pLn{;$gK||K zsYmkG9``T%PUD5O7WMHkD$*fVUsy%ijrh`OxsY{9!DXQa-_|5}$+5RhKOXc1Pe{tG z4wrI348Jh3j9KNe$Lj8dF=3WYA_5X$g!4ABl_f}5z0DQPc#34k8*aTA;f+jQPTyI~ z!?23VADPZZr0mZMZ0$H%mv`6N=2}xI_PVuN-Tss=EpBQI{}xHZR-1d9el2YAW0G22 ziA$}_d3(4c>P0)$v#-$--uZvkgh1^#sn3Uk>?j%AAc-i%uiu_w5O1Qi1s57NL{Q0M zb`aS0cCfZzul(_jj^o##4JQU+ti=g<<|I+s$OBB!IUtlrOoeO7xC;+iu&SG3#%h}J^cDMRh?UrsmS|L z_O64@?cL2)4XSMk6^amD8}$2ZX7OJYf!{N|k)XfyE~3kkUH9C*^VLdfli|IluO?%t zVfxbpUvB@IikVEwbu@Xbhnz|j8&G(DOXY*XoKhkXIG1g2X1I{+?rpeu@8So;rQ#dY z)k2&JLkL&q%~+%F=a{8Q;;8&vMr)0|l7AMSe~mR>f4y4rT2h{L2haPK{fvQ>J&6RO ztGQcDwra+vO}2;Zai+gUJmXA%Ph4y<{WEogWx6wyhBN!SP#9;nyYi^T?B9>KEVI3h zUL20K|LbcU?%?lg%i-Za7H-`RLmJyBbAmf0dlP8RTNw6q@k!r#-oXN9cNtQmnS`b@ zUyhJwz>C!WFl8Y~utd$}c6M4dC$II!z?Ve<_BfggP^<}?|fuahit+LISBm|m_G{G2SqUUeu zTMAz{o3+52m2xN_&qN4ycFF05a#{&Pc}f%?No4Vc-ZYIncQO4kU$xI(&^NG+G|I_`#U z(98>>lxQ$rHk|UbU_n(M&~@$J6=*ESkl-=jT_H0^s`iw!j_Q^IuIM?U-7ln-U!of) zhTYsj)IK4&?o^zwB7R26>H6&V64#;gOOC#fEL|8{f=cB~|9eX%y%ZurD#A~bDC{M^ zP7!-;V%Ayl0y4R$uX1ENk%JwsG_oV@(#Awj03{(jBeg^UB1=R6#AtFHCq5S8 zo5}?5JeJ!KA||DXTgcpk#xkj=TxZ>=10(2gwKYG^XKr2nG%q7rTckH0* z9*6oNC3;c4FJvohYOYqQdU(O` zW!Z!(#!hIg*=gQ_`x*1r!WT3mP#DV5)R4q7d1%Ql)1cmL1@LDN`<0fJEqZa}JiP7` zuKGhSnSK5SrkY+iNjK_-C@c@~yp4!w7tXVs3QGcej(?not8#xo)S1He38rv4sAruIonPB*%s8q+EvXWdSb1dlHhn(hvgG~= zH=Iqu>mB1C7fx(?`f{P4aB63#da*3A=D4n@uUS`r4|```x}-Oik(Ma2=J(}9eYeCc zK;iBHnp9@mURgQs=FH!yJaxeWSpVT2ybE7M$6bxVo!#K)qvlH+LXA}f9-rbRRbL;v z46OT7J;JShe}1sH6E~ZQ+Lh-DM^P`NjtI}Iq@C!M(ptYF1x#~eqBIs)P{9gFZIMcf z3Wqtt>=S_l@B2po{IhiWnc;B|wsRj}01T27{#%~7G;}80l*%UW?P=)SM_@?30J&T^ zUh;d3lSs%lDqnQ>-otLFHP&{SCKv{!Z`_kM1t<0i;&@VV6$}x~fQFSK<4a4fFpx>u zz~iO+uf1gn`9OR>{2a>{9>NIhx4AiK6Pxb4e~Mu@?8`fiwcwXd8Kyyn8B$oRT)*ht z`_ZGWRO=u>T4V|y#11uc;Ir%F`GOtUr!A37CL>QCe(*O7BqJtZjOA`S4>Cj%zz_ip ztXv?=^4$gj#IUa)by0ZSV?%X6*X8Mh!AsCT5#{g@XBwMf&xijkw;mS8*ye3t$fohQ zi}*>|1$}dU!FTO7SsaE>+b5`AkU8?DBw4~z7BVcm$o74i-^mr7()YmsCgy-_i6ozd zGlp555<`0+c)3g{$1+kJrgc9}t!ji@a89ZeK<5#{Ch^osP*|LR@+HZ8&%F|svc=Ni zz8vfwEN6~_-nrrg{rEYl7OTbw%mAsg}Ag-GoQ)uNGU*qZ34uj)|Twd8aB(Hz zC0opgu7<~a*UOxZSdu96DQ?j>7p1CAniqDg4Z_)ovz{u zB6M&^jB535yLk4-`@%CJ*|q_A<10mFL~n$1k+FA?m2Ke8@r6zAz;=-ZYwW@jme@4; zX$*r#?L26rPM&;BLK3HP4E?RxU>`+%AhDK59q*r!q;n2diJMhRB~MB15KyqbekB(D zndu@?k>({0BB*mB>vtq3K?Jsl*NcQMw+s};o(rz74^yxiJa;D5(waW%_u*VefP!6< zU#!F8edc1|uEL$+J}G{mR*8~&TfU>$=7mG)htI+)sMlJv(vJ6h$k_x%T7X2fXJeII>3EY?Gn8UI(ewPsvVZD;@;!!3Bho0aafF9N6hZ*$|Mby zMG_eX`Cd#CVu?v|;#mtqBGL4d?PR12${b)2tVJRWY~B%(SA3H*GFY7$T2NGTS2i70 zfJf!zz)-c+!;6K(fK0kS^0iMbp-asah>lIjX(i@qc4gv;x!1e0;!$^tl5NEU3S`hx zZ)S2uEd`<<+P+h=lBv1PNX}4QB!pB(M^+LrT{ibEiR*_R*_111CHeF!V;L7x8dkn! zrQ9n;!>=y|i{Fh-K2id6y!M2yR1B-^T9A6tQ^sU_5|hvfMFaQt!)+b`!^n|`oLM;M4x8%$A0&Fd8JP3O@a|^;PkxT@4@e%KlRUz zJJFOy{-ysID^dFr>f&}ebFBKm!dkA?X2<;<0uoRAH~4t!&Ex4$GZ0c#idCF;smi>d8{9`f}2fffuMe1D?8+Vo`ggZf&>leGc$AEQrxOslUiK3QK^|M~mL&jWQP;wh6$<9|WF zk{X)|PdC*yw)CEEnQ3fWKiziJ_~rKWmygEpb5DPVYW#_M`X^RnC+X=2f5mStVxhcAoG!E!|@5D!Z0x}8o`-4@1<$4 zNbCL!A~9pgu?Egtbm7RuuA3Y3P@?SuaGuPEaC5`sO7`O62KKnL@g$pNEiPuG)ZKefeo(6B5nh}ORYmeqHtZ4z+IJe|zBaH0OuVDd*zC}F z)2Ro7gj)__@VDM*8cBvT1zPIDomVMYaoIK$&m#yTQI>DJ*kA^i?HJ4GJf_@F?fqct zk-LJ3f&@ty0vtFke(Txc2DeyoGZ{O@XMm@-lXNU1#0~Hl;>plqf&^njdScUd8Dxvb zPi$x#j{OXZwbXoI$zwqE>Si?(6rU67BXq>*IyPIny@aEDT0Bo_dN)LJUMEOR0>~X7 zL!rojIVA2^r+DJ&JbfcZA6?Hneo?%;sqCMv{NR}tI~_q}68bK;jy$txh=_^l+CI_{ zCXq_sgQzeiVkE#;Kxl9!WmMx)>043;pv2ckvCJ;XG+O#bB=%pVg9#13!y{%w3$NFa zH74oj(s{k`=67GXJksC`IsEQGo*>eBNlOtbfG_|K)9+dN1QdrBU6Oowir@z)EVZ}_ zj`C+xJ#TCru_2r~f(lsyxhzRSx%iWrmdJv%5XYl_nU}<{AeT77=hHEMilx7hUVu&1 zg2``d=ca%&EWL9=8G$&u`T3^vae6_wnu0R*F5GRp@IWv4Fo^uA-o;l<7j2w)bot}K z2X-%h%#SzyQj#Z*NBs@-3N3_%dODdbat#y@piJ?m&$?efN#a_|n;G;j6u)};m2J3< zo|q!YH4gCXsfnxs9Bb4XYsU~FaU${TZ}|Fu6@M<>wG12ck^A&J^wFOqb|1s$9IxD^ zix1q^Ywf=L)-pnhEAnl3>|6TM)T^2KKa1GrrQf8pV<3P1v~}QDi4c$jvmKnkM6GUG zr(d0fU7d^GQSNzl@bN8YVr{ojfPlN`;3+gI)P{zPOp+BX>3*EY|NM=vm%7emSY zQ`CItuTtAAz~;Fv*H2BhL*OsQ>&Euy8y9A~|FgOn3v-Jh*{A`2yIhusbGxP0I1F%X z=L7pb6UO<>wESaE{(VH0uzvrwUi_l!v1@n!{7gQ>pZl%I?kJ+D=CA!x7iBf4n`8Me z8G>SX;BGt!8yq@&Ef<+Gf>!-)18|H+a*QSPc}nEI5Lso3&k?Lf*XcwRQ!um&XNL@4RtNNvOF6)SfhcS;~oe=UVr4 z7aH*C6@|4+#9tp};%}K$G{>oap9Rpf)JQGxo$gqK)tFl@U8H71G!u5~?w_~!2#Vhm z@BNHAQ6${R5kyLqQc$)_DfZZ@8n=Axek0MP?ZMc4`5<{^#4T^DPU8ha-?f`b?~-P( zb;rF+`FHKYZ;|}9`+?Q?#AW}2aFKiW_z#%Jde$A44fhbOM$cHweW}Q% z_ifz=d&cYJ-^1JzBJKt+{L1RdIV2K`*kyki_(mPs!Y_nxaOt9v(e%%r3PVz&AJ3f1 ze=Yi;<9@5bm-2;};yb7U^83vG@4K2^F&+m6GSU2A)?|&bVi=k5a^T?c8py0UWF)z=i{|YM#E#*5o6fPOySHxoE$tQX77oPBe zkwn$UkKO7|1Pxk?m%6*gig&E)wSE1t+VYjT4|ZgmY&>{(iDKgROuknG=eGGZ#n_GG z*m*NI$>TO&628=YruT^S)WSU&t(2djTP!(sb#g_VOG|9{*QK<{mg zw^$EbZ?hox*sSCdGuVP{^Bk`a$UL3xZNBkTvovfX`@lt zB%>KssR@vWG7=mvM76>%b&6aVlku4paOyI*!e7C{O=5A23LR`tMa_AitmNg4NW0X=vllZaXe5p>krw z`k*Z%|BB6IP+DT}8OF+} z*WP8wO60%DC#K0%vVNj(LAi{oFJn&AY@S5EjSCU3Q0LggKYUiF-dlfURr`}H2;t4@ zdyJ^ua(pwayj%Km#?h1X<|_o{=Am_+9j|`6LM7w*YMy_FQNXqSt})lUR!>g-D0%$L zII%Ti`0MKd`%CYoZFH3raW19H-20*-f4~k%o{Do~d9-f^&KX}&v8@ZJ!Z_q63{cg|VY|3Gy|I-v=rfU4`75jQc;caE3yhU9~*O6?`TEglJu?kuSMO}(t2dhZ%z`ho0!qNG+^i#uhVn&Oqy3o z^K6<*>t9Aq%l$F48?14Hug1jg=-Trk$A3!lp|y`?#6w(?(c%Z=FFre2JWhk1)e&u@ zmz)@>|B|Y$NKJ#97rJ_`aFq_cUC%*L*&*5{jLDK12(rYJJmE@yTNH1!(>>)#rP)SUa3~E$a)mQs}yHJ0`i- zE|W_;CN(NeGq~hJ#PQU*czG-eI(PNtHok89!KP%)Mt1OW8K<1g-`d$srvrSR^@-&S z&f1uZyG?_C^2EF{u72q4ax8(oFV*~cy-9ZXH)FFUU9mxC`y^_!uKEKLo_L+aJ1x~? zo?o=*2m6>-vg%x5E-<=))??l?&u^flZeyjRw)kr;t%Nj5SnEpwZQVIhgmz_}`>t)P z)O|cj@Fx6p(}`pbR2D^#NdhLch#(i9)M(NOSOv9-1TdQW+N)B&?ow6`sQ2X>XppiS z1B-4J!0&K%+2DW*F_v@3=?OLil|8|=1MFLzNg2d7t4)xl%t%OOmO*71r203XK+Uc) z&f{1SCL6&k?#fx+%GtB$0(Rx zOOZ$%1t@CsWKEMs>FLWvA%zeU>NqwY;hf6Kp(e_Kt-PaBT_lyn<=_m{p}Al#42h{) zB_oK!L(N+$?ui5J0wNVb3=fwI(O&F-a=_z;`!)vUPe536T~vpin`wc-<4bZ=Qty~` zubxC6C-Dbl3(kXb4qM!WXcC9cGdQnTDO5|dZ66g0k@Uqwjk88X9K%2^7>HCqN$ZsK zb@q0f=TK;-Lh}`G$&wflX;iRN4rZK|8s&S6y%!ppl}!%Qm31;u^2$iL!$v>Wn0}=` zREqjFu>gz88=(n;o*<2l@A>@&RRaq#Uej&-Vc`a3`;@{v@@({Fig$>=)J0EDMw3jt z)g`OL+Avqa2|qTa9~bWKOk}cXmQAfOjG#x_)1&3vA8KD-I;a2j)V#7gbpf6ipmcG9 zeG?BAWq|axi>IOP0qB;6)mwygA#o~+E~l;_5(Q8s=9$$7@9f30NEF!^W%^MCr&oPR z{$f9c6WJEEzF=1Lg02H^R3DSF;)(*~E9AQ7Ha2p~!~>O_?)9*3m_u6!llZ$DkGl_S z<`Q`~^DK6MB;=goBh0}3wauc9qVuLdPhFSqIt$4dS2W!b zaU(BAl>CfYuA`Si6!Fi#2>%GOa=LGl9UJM*d+fshUx3^VUR8*ADYzoxBnEBhd~S6V z$gqfF8)=AVd&H>v7TnV!muK#hvHLYzM8Cp(5Sucz&@zu+e>qd!YG42&^|0~Jv+vIe z#2ElmwNL?GXW+~;P2d&_g|)>qe$^w%*V=xQjS++ERx*i zOZ`JxJ9xpG-J-^l4$kmntL~pZ{LbiBNP}ZOMmpzEKCq^Kgt4go-oRC%NiQah`Oh=x z7a5Y^sC*&eM(Ba_pT_C;8<|guvv=R`n2wb}M(h`YI#s47KCF2wX)e_4U=lcev1^W} zEZ%07?5>o+|8JjS-2d)ZeZ|RuXcDLL4azr_%ICQ+HRbMLJS>nwoKJ9|*dp*ne$?vE zqa!XqTnD1x4$LWTi^beGF4{T ztV2b1Ohs=Hb9jbgb^g9BPM@1C*qC*%jr&~Gnur(dN(C;b}2kSbOt@?sk z^cO7ptOY`w^dO%EC=9@(N^q!$H5 z2)$z{N);)BfPi$68hTei1VuzZlp-Q1A_9_=|NG9IbJm)(Bhm`B@i;9HL0DFdeh?2+yjox%>#7>f_=Ut6I&xq& z%EW8Rcutd9U&cqeq41UdmMkA)eIq;9jh4)_LcN2d^8@Ue8-&U3^WHZOl4!r; zX^r(zvHrAI_iqycRuv%Ur^l0?{n+#*I?5UT*3tix=+@*9d6&jNiu?3>m}iU&RY!%o+4&9y3K*`;Dvv)Q*6?SUD{mW+sO~j^k;a#VXvr zq|5;fllZk*X-g{`fztzPj z(8VZ7y3V`zLli{I!hSJ~e-~Qe7nZunf67m*3ZQ&;WxhGes%c6nHRIT*=6MwUM|+< zf!EI!zgfxffgB&$uVlD#!oXsOnL>z@a+TyFvm+fn;XNf6GR&k-`XyTI6r3%VRxvH= z|5L3uOJOcc4dwHS)|A@tE2JafjB(xgQS9&hb$$0?*L398j=;RG%QH3$BYgS7=l{)xWr=-?o0azWFjZs4L^8 zkR@@>0h=K9<+^P7(9d`&y&>~LCb9118d*LFBY~Q$KeaB4x{2w>%7NaR7%-YH6y;Kr zc*S^YF3CigYB?R~5Hm?j-YKL`-!`+9g|4y%ThU)K?Y#P0?}1+QQ~5z5M`le=8)T!H z<5lbC$X7<@Rq?Go%~DIUS_;it-)6P9zu0SM{~mp+y`g#IB<_Y%zj^tf^h?xj=Vrx1 z9S8MGx>^ri$QCT-tB!gX`^94_ZpOPBCd$2(j|X3GT78VRY!DZyU~#U7THUU=+Vo8a zcbl&NVK^U?VU8hH7~yZYE^ufqd|BOE>Cju^|Dv2aUpz|UKc z1J*SAQ(0LBq4Fw61iW}Dz3S_LuWMC>v;M4DG0dSuZ@=R&^H=789-28;r!w}86m2md z;de;@9T;iC;$g)a6aJR@!T|)|f^~i?l^`6E>Ud$mBRY9Z%5&a@|Dx;EY_6YM$*WmI zWwZ--GJm>7C{jd9{nvq~I6;%|c2l;D1u4AP!t%msh}5XdXF-8>Rw?@?fk*P*-fJ!M z7PoER-Xw?8Qh^5Fn4{xeiDo_`MjMU^VVensO%%=%LS6i!hgy{pM>kiTR`W}}?DOSX z69<>#lpc}P?ZgSOwSaDPY9&mB`&LYRVEHc(euR_E-8=gMFgA8BG#0Fq*500oE2;KT+>PNS~dLz z{-4!};+n~wM|$;EDZh7U&5JvANK}vd8AK-gu@xPRJGLz;on7BxGl*vT$&PTZu=_NW zAMag4R0Xl#Q<~G_t!c!Hw(dMI*D58o`Vv(qCHIax=I(ZOeCu4Y$ha8W5zXwa<4Tp_?=D)Qr_aTgS z3BKVff1Kj=cwYi8M=aV`dh$C@_1)yu^2lgZiQBy5P%9RVecq;gDm1+ zoTH%JH~^-Ho#az4yA)QY)KR8(T&AJ?^h(%M{f?(b$4^a_%Ws60TX&S-JT7-ou5byf z@aU-UKCbXreij_|EVSd<{o`j*%9U|pl}R0yX~&fr%2f};s`5Ij9v@egC|8$!s^Id9d+N2 z>-Lpj{0@8Zx8udX;};N>dc?hYs?K`4lX{Fw1IxVz_Ra?GlLlUuM!|cHqMePBCyg>H zO_%O9DRnlfoiu5vG+(*btl!yebkc09(n3j&TX(kHJZW)IX?3~R>e1QiebVZ$(iVKL zEwr=k{z+SuN_*VB_N31Cw3GG>m6s3iz0B);`S|2ziAqQLy^gBRj@pxs29?g1dz~*k zJ1P0?K9#PadtGClU6dxpj7s<0d)@ClyFZ?Euih(5A$(&jr^OdP)pYD(Do4{=dmrHq zFY*oHSBgG&lD@4;?`Ej(aQ2Er`~GzHzWLfa5qh6$i6qG-MJ(k8z8g9_2x_t zTnHch%vV8xp`@xW7dq1D(4rb~ZM&UlM*ZsE9Hfac))Q1Ht^A-h3+)ye0YRLS~Oo3>Oq zQCd8XcAFruQ8}JbOuJo7JLuN;8IQ`wE5WMV+n%HF`gEt(Q}36iSomnXNVMxKRCuh< z92d>{3at;R;PVR7KAgsj(C47~YQrwPA4k26Wq3tGcI7ESR0T+OlcwFq+OoQvU!_AF zQFnCFM=*Qw(sAzJ3_Bla@8GF?cJ(Z}=Y2&4oYmCUTBORH8OQ-9yoaTHjfCteTt&p= z@r9-6hX(zoYjbAYZ*wS^Q8eAI3Jpa7PG>UD@0!-H7#A+*+xdWWrWR~f6DY!Mc@-~_ zu{0uU^mu%FEJQ3*ZL&bAT#E*65GV(N%+2#X zUvP7lBL61NPCt_WtzJI+5_z`Od-k1twtt!YJCgjjm;8@RhO9jxMZsw}R1>93`r~Lh zmBZ08rGrV#;x=80GG)VQTqee$eHL2$|IGj6{K=roal3hQ_R9#v`ecT<@2gX#gY}7L1_=?}p@*Aq8}2Tg zJw6HA{@kX*5ZR*|_H%xr!0pG)?$g6xpWpQQUcP*Ee6s&%;X+X)1e#k!K#&>HpCYO0 z+gGCKghuhP^jFcVv8)z}tM@oMMtCEceKuF)upGG#2?FVf*kp$4qmm@ij&{CONUKSV zj>J=$8|lisqfQUh&(H!7JnBg(ouGO^mv~ib%sI70dJ+1rd9FoCuK6nQ9+(2p9xAuOfLHp$xlYN}E|0@@$K0MoRBQ=di8|AFFNx<# z7BP#ts*R!x5}@{YnrVF1!yrxF)44p~XWZjikyNZ`(UbJ6o>djQFWt4PipD*k*Y$9$ zS2K)J|2H6K(k$P6yN<2g4`vu}Fx`g`O4Kk#GpH6vYk@dYJkEfuTAZM%oa;wyoV14z z!*1thYtrgKzTJL#{WtaYV;Z_per+ss!)&Kt=C@29fmD-tE&~FN*u_vg?WF~(0zb~b zu_%~sp@K}-wh5*|G}u2QZ~F`xl{^PKBn(N>orq`aFY*sso3#<2sLikRo>v&GCkX|a ztoO}3rkG5y1mF9qCkD5h8)YSjGY?ah(2T&DPD#^T6H@;GhR8wxC?;6mKG3LG_@7zR zJ;L63%TyB$l%B*>xi>}(Ph(LmA;dvVXH|R*Qe}w%8M2R&l-DpO_-UYxk7TBo?~^5o zT)KpMc}4=cPZ#9C6JXSLBDoLW(RN2^H3+9hwJv&*8Rm|VY&+P$xa^I82|U7;_3hLG zL62vsbjw9lNelg55Po_-o_{xL4Z4Mk z-Vgr33%-i%lF_6N4tGYv3vU%E{n#L994>m3_wOtPF3??G3V36*e~@$`z57tX?{zyi zF+J!GQ{g98R5N^6+a2AxI-LZuRd5(E4McaFGfv#(Zhs{~qW|Aj^OnSTW}k8i%8V|6Y#lXF+6M;mIe`g_`vv!`m|G zR(AaMqWoDNqW1lE;MP(RSzjmJ7E$M9HYG&{lmQFv?a!ZZMX>pb!mlL?%4qF<8J4 zoSihNM$5#-J$Rvb&c(!~2l_N%5?Jxty4%>xF#JPq2vC~ZdP3@_=tNyhl7 z{eLBB4g$|>`s>r*yo>cBi!IywS18q=hrELK6FIP8d^~Or%XHuE-&gC`mzT_n4w^Ug zs2I(!(ci!xbhzuVEWVD^46L0ox4aVj2w4`qnopV8ePeeNPn(X%zI-0T#j`hcecPN@ zWR8S642@#DE>8d9bO719w?H~8vHhF^L#x)u2(RD3J{qcVMa?3)D!r|1n_=+jBNP){ zyb)GXqZy4ej$vbMh^!odnLM}Ig0!|UuA#npZfB!KYz$`9W8e_uM4ESP+kMgogU3x=Db{#A4dF9eq@1c$=lV z)|UfXiT7M?ugM9?k0N*)6A}Fh%;uEB;m5?CR9oWG+nr~y4AK1_DkQy#Mkp1`kA(+J z&P6U&zd4Lya;2q(61gcTHkA>7v(VW=PHmd&Ha%ajj?`VF6aJNDkXtOD^5SO&l zjX+Cr+xcU`Bd5=j#L$8F%M*2n+=WKqP1~?F(LOm1X^W}I3|t>`4S)tKHRx?-157q? z*biVFowzfCQ&^AvOMM)**FF}hYd?0aC?HC)1npU`HTk>iR}t9`%Wu-hNnR$wmaq6H z->3nGeAHhTimyL(Cc@aM4}azsfK;PFpWhhq1mBG=fts>tAr87SR8k3W*Gd=@*D>l* zpxp$@IvUBx(Zwe&Td!xuDnP@hf7K?y<<7{x=V|Fd<>L9PL1z|Rs>MH3yH=s^UD0oA z(}Qh1xu^ej|8S(ae^e&?csZr$;==pQ;K#p~Hr$@Ie=~60k1Bpn1BQ>X8~Q~x(Pe{v!}oP&xSg=th`8ic{_MvqI;7?}km+&{*Y z5!x*rD}+I@Ne7y`$KBG5jhq(u*+bkSMKeLvgMYMmPg zVqh!;sKID#(y%~y{RK+z%k)cpBswt*nvl+z80Vgt@Qyzx6H^kMn6{btfK4t1?LgE^ zdgz{X5t5JT46a(Sg#DH`MNPELM4nw*}SRQxWvK_unrQF4=e zN?UtUOJYjrXi5SaB|%DLnStJ#P8o7f^*TaG7GrLbpf(Mh4H&LzG-tC++H1YER!rK$ zCg=&|F{I(>LFxZ;x{BuszsSe6S~yb`bL5urlq( z=!4(y9{e3;laRLL$UyS(@gv#!1T%>e(L~_@BJ~!L<}Z( z8Ry?;aJ}c_Vb8=WW%9iz3gu-Aj%5m0WwN}_6yM5}p~|}GkwwR@F;bbOGM1&bm35gi z{VJNRrJsF;qOB!m>sMtPjAa{bWnZIuXe#>9OrQMlhQ~w8q=(j34{gUD-rRb4i|Ubs z=p&~*aaFv$8baW=5aWO&VN@2g*zZEMFVUlQ6ICJ|+#=!6juAvNdU;6rh+_PxFdBP+ z8i^@v`Z}C9R~{q96v!A6O7P9gq0hL-K!EU@#HmB`2<(`Ym!Ov)QdtmUMumY= z61=?r1)xKI@0N@JL?&CFS)HG8(7Isc?*)ljSIc~e;>v(VEmvsXW5Wd;f|)(ghJJf`O@ zoOhLlnHG~@N;BAixAy>(X{+-N#Vi?x0aeEH~&(r%z0NVtQibT$K(O)4?R?E3HDKtB9J zwKU01{&@<|P`O>N(p?4~o2e0E$|u(3T&nLm9*2L*QZInHoxyMx^Tn2nc9RR`w~}?G z1$~m8RY%QoXp^HjDqRsJ6{lL|l$p#=OzOXrFqRgeyXUix0hQ3|oO|QyTq~%z17KW- zpOW4)*Macwzm$4%F4@LNb99g;o_Xk%9;*|n8NW2CQ}0J?2R<$1xco=!a+~tyzk!M; zzbbAVUtU+h{r!VNYAbTBRyLbG3m2@yAFDj9zI&nCo9nm9)V7N4@$6J-py=ZMcDu`j>di|GJqjTbh8DFp91#||OuhP=Q zGMn1*je9ex=zXyP?{(;<9q7%=>OTz9M{NffFdIHT(C7b9pLX|Z)f`6n9(eyiH>$bu zbr|?44C>f!Putg^Y#Vktj;wj%t`2*%2KRvmM)!rh3TXb}Hirsh&HB~zoCeO?1~)#u z=zfayxThr=+r;)wpXNi8XKdK`prIaf^Oi!h_>E@fiN??zy$fO}V*qk}&v(+XIo7#e zsJUrKTgO-2(Cg$yNzv~Nu(!}94c7WmuX zYjz7j0UR_i7q+>(6?|Qkr6cDX7YLGxw^OXpF_YfdzA*xjv%f{YfV@ z>iVOJE)=9Ei5b;uPDX(E1p0;^({J@q)U|RUp~q#M@3*N_c8L$(%gD@;9FeCcP*X-#`S-s|0d-#F7DXC`oiy6{@pbK{-c zUAVUr+!CGRA3A9=`aj(6{e0X#J^u1dY1=n#&=6eNrGx@fR?FWXNRD59b$0tIY@Oo3Xpf3wpHvDwE8^mPt|wpEUt*0q#3+(6WZr? zdosk0t51`&JG|iL(TUmQTf5i4jb?v8Be=Q774|?&Mm`M<|FN*g(Dn^J97Jd|6L*TG zbe*sNf}fqpfkbj1LT2poxI6vCko}Uwq_Wj@t})v}=bMEM)R;lqndW=(B>2p@jEMWi z-0>pwF_@5ZVI2O6pj}kg7<-CcJneW=%?-98q%aP;fA1yru#G>hebF(h$}aGOp@d=}yZ3xpD-$T@WyKXwkXB(awzr@0k%k!QQi} zPClYHyT4BO?8<-WnZNz4cCq|G^6#B~_L;>?$BPR0!5{MTcL|Um1M25+uV4;~CI3*` zD<*q+OOS*D|#|7M?`q9zahMhZ;Zg-YHD zkr&6k!OwKq(c*FJn zc<_*?IMyZtfGY2q#ZA2?8-lje5C#(DM3quN4tAq+;g*JSO<+$hE4OZ(}WGK1gwQ7!(}&L#J|pw904_8-7j8YTG4 z%zC*cU-&bxZj(vwoSRRpM004|;5w&FsFV+AQWQ#znE%f0ZnLxDnf|t7A$Xjb98@#1 z!4BE5RNt^k2h$@~qinq=4geP0S_tBfQqNbTx~~^S)|DeLRZ<(7#v8Q}8_kkn;0okU zR=z}cfZ?m6K1yYmv?-^GNl?3=jhcQeH9fWQ9Hx=Gqym0(yU&H`GR(5<`*^?o8DVWX zLc1vZlUvNW$An4UnC%FaOCr%QndibdC(6+>_`64F_aBqv1bTE7HE)zJE^6Y9YPGo& zk{q>H^rIxgSJ>$e{VoWQbD+K9@JNtrGm5GSa`YZDl}ccYjENw@IO;E{tN*z0?z^Q~ zb)PUOHf_g=-rt1BY%T9|l|&Cvsn393gCF`GRxY=p?JaW93-M;MOtdFK`P zkP%x-PbeuR)Sv5p!92Ss3cH)>2bbkz-k=V@_h7ES&b#3I8$k)=KLEwaz5ewOzbBv< zSw6mC)yLzPtJb3ey?70oIPSAQahD%O`5q;`IMRqZO8Ig0fZ=b(g}+(7*$@c8`xlO` z1^_^U@&RIDqmcOj31Ct5tA&M+lan(P{c5MnZm#uyTU+;wx~9~&yyr#9g#{%Unfa-y z+5bC(75E@=KFns|j#+Zl-RR^(3Wi0Iu%e=qBI0u*;wTcZ}h zU|2zSW8FjV`2@vMFf4EX`yRgcJ-tI*Z--JOEFEu?MmOVHXTus7<9s{a$m=+Qks8Io z3ei{bzoO{u7KnGfgLn3KaPq$C;A@BXwzc=Pzv*CQ=V@u5>nueD(uKqWMrLJ~G8K*N-m z%SuU>!eE(Ql2TF8RFzZwUlP`Z|0!Wz_-_g80v|=flB7sj{Cq1I3&46)E%{DJH-TPfcW{o0*T7I!8ed-( z_&LtJfm3|?h-pJ-A>OW=v9P1H;qwTQ-=OFee1+oi-BcC5*ydw6;Dr?P8O<$Qc|j0< zR597n@@=8ssaH-`^TAF`C}!Eub9ES}ZiIMo25oJ_b;%y1vmvkt{coVRNZ{zXofOV- za(zQs`^xr*m4OuU`DTy%9q6hq52)a)10+veB5#XQROldrv9( z(LEm4vG4U%h%vJ;QUJ?StjD)rZdN?sO=qhSGr^pf5I|A!p=A(U&>0LQ8k&ZI#8Nr~ zKX8x_DHn2!=qTsXYCK8bs9;;u(KrNyp{Wm&_avclQtVKoDcxp>@4T$)TkjO8_r?u- zFU1iqjdl9$&mD3LUcaV1lcoo7*R-)Yns-whw5hm?_Y-;mA@06M8VolNa7Ikt2e=lo z$$FfzVwVs#=9+}b+_h`)e0Qi?r`?lC9tHQH9&YCtgeWxmX8`NojPBk0%*sAZvIXRn zU!MplDMe%D_7hYW!xm*05}F1MX(nH^H(5ug{2i%%x!V!EfHYN~;hy~-ym?-FKBLl- zJA$ngXOFW%aoQ(7bT@BO5Nl&g=%&_sDIpj5KhXu(qn-XN>! zSRT}QX7?4CvYu9t_9zb{PgVPyUx$T zu7A~dmiTaRUGUsXeO-A*s=W1eVXZBF{q)AHVr@ZDv(Ryi{GSgW1-VE#ROwsKXFe(& zbP;tMo3K5U&FNh#zmPy>=t$j^JE~eCYh7jFihPqd9P27u{&Mn$joHI@&0pkRR84uh zHsvkBHx!<*8waFc%>Sajq15)$*f)Za3R49;_f`LOs7)XB@24ATzdXp-!hZ%nAs@J@ zLzGRT=Cg`Xmnzj+7OzDFKuTzWMYK6NOp;^|O3pnN(UBaVNe*i+=0?yUOsE@|)b*g9`bnEgiECv)sexipxVDCc)J+rLG5+f-KdhSy#=U z{tT+R7F>O;MEOP)^kKDSOZD}ZMe~FJULv0Exz)(k`NsU;HSR{wZ5GDoTRVQ&`lUU$ z+qk;WvG}_#Wa_#7pYesB&{8pm@J?}n8R8;EH&&cX)o$88Q9 zbEj%t6(-)l3Oj5nrmJ<=HCUX@KWwfvs`a#-Se)-TY^hJH^>Q=#ut;7!Y;B*a^|?Fo zVfpy5jYL=Hmte58#_^|p)Tk~XXJYBA@}HN}X>~!*3_fn#{ONc%Rd=Ut;^S`EpUxG! z7a=1C%Rlq~bZr{F2wRv~KJ56@y_fbPe8b?=@#3GJqp24We2d3A@3LCCc+aC3^y{eB_m|Ug5cQk;dZ%EWNTw^Ra8Vph+L9FCr zV2T@jtcg)q_WTGl@3iSu0S%Nn4#`Zm9AZ7zO1Whx!EPBlEdO;B5^a~kRF9(zG|@y> ze0BQ$6GE#142&@b05n`Ad97i+)ZoVT6vYzTh$8}6U9+rRWknv!&>YRYE#Z8w>D7b_ zD_v1u4T`?d z`6|ueENm=LXB32pg&cSF(+B71tHo$F@lL*B)k{HSepe2(m5`GUsQwIM1TK7?fKoNr zb9aBg%yu%vicM_1Ad9-ea{PYv;bB45cVTu~CHnERe#kc9Cq&alAB_KvgVOok`czDy zk!hP;yYH3Ef6obaS7B&X(pShVJIpx=VXb8%JdcbYAnTACVu=254d}Glbm<7kw3ct7SgQOQG23F8Dl7b}9~> zUClcf*>8Hm-m^(f_-8e{tMq=gHjKS1*EzCCRb!CgW^@q_my@&m+IP=8Qj1IS-1(lL z-kGOgR4xlmMfMy#yY_F}mVEr5iQ2Z;_a*4D0ECS|hTQSXP=T+Yv_f%ESw~<`9A~DR zh{K>Yp}=Y}_V)wQKkxqN$Cw7s+ZmB3IX1RL7}A{pt}(*uA(4AXQ4m^2I^v_a50IQUhC7q6mu5Ll#q!}$Ax%_bjX%s;uF)GU;iX$p2 zZx%Vl3h^`n#n7?m6`A<;Gz8f=H4<$kL>PSzUH$KX7e&BJir{5D0K);iSkMCmruaca z5OCW=xd0Ixmf($$IO`)6f(S)4{0GVMSpneE02LNtj*h3sB{-l_y!bc-2o;WjS-1m= z(~{fgpl}>OCyk=TBm|PJ65u5L4Xrstu3C|$~i7#4md7gR5XULiqv zOyI4&phdl$1PLN09S{kopfo{00FEt&h~bfYyyp)b&NF77hc=x5nhW;prH*Yzc8jEw z+Ann+VONh*;FK)5blTf?38Q)qexjDHmKLVo^(;f~QDBum>UOU*4UkGDZE% zg^%vo6tjAk zqvB}EXq2K!eB>Ss0z!QO1RRW_o(5pNNw)^FpsQIp(d_jFK!-iaGzkpG15|zpKP-Z_ zJsVOC815kr2@xvw5I1R*=F7Rv3sAb zB?>xOKXR)|q7lgn+Dg*Ri=qbsh83h{UOcV@+P?;~gJssSWui5NwzGsHNwB3X@%DZp zAECJ!ze~!Wph`O?w-t2%RrSH6 zoJkO=1C;JNC6Ubn7ii%JrTpc;g;O5FXosTBsv=lM!MaEO`@DP_f7F0=;fO~bJfjdQ zroNF^Bp{IgkpigRAgGdHdV4XE)2!}zq!NhqA;RX-n3=;MYKnuR3s{!L%UeN_KrT!= zAV3iTQi7I%wSk0PP1(i60SDb+t z`s)uWW;-%F`O>56XQWw=l4DflcqMfo%$^_o9vc1VbERL0P|x-10ZmNJ;*%eLQP1+L zDGwLu{p4=XDn!Qf=8k8NTdkaM$Y20vN`x6Ku-?U@lvV_kaV`uF0VtDF+7Kia5op~< zLP+seOhCXM$`4zn_O}!+69wa~TTib3s*J?5K_lNGA$|zz28eG6s4iLs%Rthb0M5Bl zK6n7J2dDt`c2~<$eJ`jq%J;^_z8<5X2KBoMx%a9XZdT_$d6&gx34#=!CNDxTXZ-fSH(f!6&M1F z?f+B#wW-;v?2`O9b(W@9^h~QSCnP?r8Sc>Po&p*dfchfsjuUNcKpVqn^#?;J90BXy zhYAKtA5*1cN@|zHK&Oz%wX5;YiJ{8q^6%BzPiQ(IBzE{7jCv17IDfYmqk;eYF!Zi9>|9V;X3Z3Ue-kCXZ*8cL2D)T`Q5N?>wx)se*& z?vhG@e;l*{4B$R~*c*i8k_iqFJy&*H@ zE}fboyPcs|2E**_5J|DP1Qr;*%sR0Ycz$c5ND4BtZaPZIoi~tKG zaL|5Bs6lvN&ab?t7@ap{zD`l zunI+lZDk=%W4x!4PmhwEM3JWQz|)^y)TR#)X;DXpppV{DgZERPxLyS;y(ZIkvsc3H z&?s?fAbBj;2a9mrVLH=v@c<#KOwYLM$2M|j>7vvNs)Qc3UBnebNaVzc8-8lt z&r7KF-k(wnshyXd>{F8iO+W~vvXv158H`1}K8~5G8BnFg6#Q|0oB(Q`qCSa%xTnFk_J1U7M*oBM$yWVaY2nlfX(02>&j&y#DyD0-PAc z0kPOaIe?Jdgo$o8dtea5n$iw*9R=(?8V zV867$B(fo$!eEI3%g}}u;klK-?svbsR}a-z;3Y^gKTTa?Dt{4_OPUCk(UNo4LY6MK zb7@{~1_gZ9M0yqwC9A)uGL~{zzI=pyzNv|o0u|06!bR_! zn*w0bGjFPXMgtt)jF4Hvr3iJ6&_LmgigJ{}aGE`$m%b_JU_; z(0*q)VrR5yXS`=;a(U;~*-pFqPU$iLn0$Y`3{3lcbDjpE0Oc|c%d(HDS_Tls-xzUP zSZRt{1$&>q%@1HD2s@(Sk9hl86L80j1Z5;Z6QtmBvB>0}-3)p-4+s&Q-bFvz8?S@& z;{a(edg+7qldk}%Z_#vcv4-7wjGsHqn2uM=` z#9(?8!?0y94bvhbGt~E4dbfqo?>PL}1X;boDKUYKJuus4l0b_#QFY?;n@;R@UREA?0q+{0{`K$ z*w6rfFOu2>nBxKHNWbIge*##5!EaL(e~4W9ZfSy?_xb*Ack}rLMC~o$YA@0!Z_(ARG7lYxb6R}W z&P0qk$1%e8q|jQ_7|6UXiVm6*{{^C` znN*;3*JiS_&G%i4MuK|XJtari9+6Sz9h=}i&-vP$z46?(hh(qC7VimXhju(Qi&|vZ z9b1i%IQE)eE?m(mDd+8F;54xkPD*D1pk4xJCKs z{Z^E(9RL2lFd2#adh+*@8yciM;0SVn7kd9udlFP+)9YXH9of*}AbDT;Uw1|Gg!rVw z1-lpk7;NPS_1^qDEFpQ2(jS0O+41)wS-t$yKrZ4M?Z%P#rbfzB+f`Z-W@3M;{UZ@{0u$f1Lu_ANB9JUMJiXd{X%-t5D?Qx~O^hy#y?#|} zVFd!izG~NuQ5fKRV<`7_kS0brJbbiGv^Wu$ltdnu7K`S2RaqL-S=1r&f}iboaaptu znr{{@R9iaxf#j*7kTSb@y%vp>Jebx#=bbGqdUOBF3lM4P>%R-uK_D=J1M z7yQTIn$I7_UbiYiHy2yq?ta*0Ms-OJs(tHUmbEp1@mmW!!ScD;Ya@bfc7d<2v@MCr z5ujZODs+)c;j) zt&GZ~QMMLBa7A2mIT$=WK+}#}(ZSD?sjrsBj7WHvv|XyMzhg+LIFC_Lf%D9!4UJNX zCcm4jVV$XdhC9)U2o?f{-b2UwWVId^Vol5u>RtrTno|*6Y{DG1D?#l(~ zkJfXjH>y9nPlO)(blyjGUronR-3u67_Mm)zPX%R%L`Gk)%rJhy(zcL z*9q8U0Q}4WDu`dCvuzB_s+EIG9(oEvsNyyHivb8gibb2RupzNc;K~De{&yeq8-G?j zx7vnZ5sje_h-NWO!+V;L2<*r}B!*HRG$Pn&gx9y`uKOimrqixoeb!PhVJgH0_8ZBq z6v!2p+~(Fxp-)f9K^OOl${eg&R+#7vSqr3LtjpNXpHo`kx+0W&@y36R#eN-1wVGSo z$_&B09n&?J!D!IXGD0|8=_E!w&{&#)Sjl4_qT_xN4+b%ONiS3Cp}=RsPgX=lx%?ExU^!Best=het^JJ9)BL3te`aZ30I;V^Il(h<)-Nh z#p-c4kIEKoO|r6(v3$ISHk1P!Q%L%Y(M&Zfaz<=VW!NeArCbB6Vs<$n4rynk2*5O1 zNw_0q;(=+%fT6MVi+-Y^c-|ZQG||zFu@E5&NX@89M6!qkT=K|`2K7i$f30PN;FmTz zz9piz2S#WBh8fCoBTBJ5wE=Y)v4JdIlvlPA zx$UV?@u3z1d&Ch{$yFLV-YrCT-fHQCW55TyD>CKoOeuaBu0s=kzVdOUXqS`rz;gFY)b#PhcX+CC^Dji=B(^K)QyLIU{?JuWVJ=3$P1U}C% ztLxyZzdjZWTOcjX{yvwn^3OAxrE4X~16T8A2***JGT%oF$#0L(EAyXo$S7K#j@y@(# z{aEWDqgDQ2s>tx+mr~t_yq2l+q#GMzdGv-+> zocf_=FLsGyl`QNbSct>Smnr`{Vy=E4YyLDMJMPN{)|GKonWyB>5w)aaTDrY`a_Szs z{{+m1RJnfCx+4FIDOfSi8A69za(X359h~*EsB9AeBIj>Tjn^c_m%lh?-Pc2g5dk|E zXIg4BNeN{^B*5@1h)=Q}lF+#Oz9tezQVx)18(&~ah0g=zxSKre7sV7#cO6ZVCbMV7 z2&)#UK04wM+1*1<2d=TWQ5wCJ#{>}H)Hp^%ge<~_5Ac-HTS{_!1;)*lc^jWK#tt@N z#%8nyesOBe<}3%L%Z5o?=t9mkv($hm2O?_IIFs4D_}5nmC_7ONr0O*ET>2`U!-RS1 zi}Bq+NJ%Khw9i1;`;e*l=J$sUG_I%OOr4)^N{57r8dgx!1|e943p>-yCH#ThE$v=b z_Rcl3__qgf_6w9Nz|zgc?>-a&Yf&eL0Y`*@5}td_Pxm|rrO7dDlQ`Oq#=qbG17Iy6 zr*kBFyxNOSQwpPCYo${^^Df`trT|!jue&W{BJc0sz1-)Dt^Z@qe}7*$vggZx16X;^ zz0W_mQ2;D*J}YHiVl?OS-;K*`|9<=zfQ3kjf}}=qqW{kTtR>QA@c$107A5kzK4gC| zbnCwopbi4Vj&j3J+QZJ~!!FLlu0g|YvHt5!iR45$-VN$rywlRKOg@14L171hQ-+;d3MRo%`-MYfww;G6q4YO?2-B z>4s?cGvOhZa$$m_fZWPRV)kgbAqBv((_q5tG0H)hMD!w4HHBATn1dJwFgi(YENgIB zNiSNha#Ua?Mj$tanQQd2``Beb52HPT5Yc6<8I><4Ac_HnqnKbXUBzO6I&n;XT0dn_ zpD?G-R1d?f#}snur4MTGYmU{-jk{^r?Zg zE;~RGoM2YH8pt)8n*Dl0?sbs%>pO#^xvH;T7wTi=Ox|cuai}sT&b_7?GPyn(GhCw= zxC8CT9&3^_YMwOA*EX4w(^IT8dL90%G3a&b&P2W440||~P9%nQXod?h#CSs^$V*oN z`?^rz8q+0>Qs%zy=CN|S@rvfj)FoIoWkL@Qp&*Xj6acGS8>u!RGDHEet|#q4&$+|c z{|9?#76^6x{`=XO!7wxSWia+7`@Y4DeaTK)%N|)mM5WAPkFsTpj5S12NK(xRAvD<& zlAWxjQen>g{=aj3&h5E5b2azh`F`g4JkR@iy_ zH>O)Nrq?#6-#cb7F?M=o%<$)!5tB<4L!1M{XMQ3`GE`ZI1W^ruu`6Et%($7iNuvX! z9I)dZC&Bs|Q@wH9VPj4TD>@PI75mR$OdXjD?XC zyxbrJLz1Neej8=0-dunh2XoR0sAU4G18gD*^a(U}uW|LvqQ<{+&~^i%{-n@d8p5X@vY%A4>j?1!nlXYGb1h@#`nfiX9ysEJV=&++WJ(^J|h?4 zhS~z6_;IJw0NOr4#%upIhSaz>?$?XY=D`i328VkyMvh4y;UFX|qYgeLv4?MVBDj0w z2HU)b+Uyi{fHR>)R3nKlUBr5456qeAcDZcYNadU&W%`N~j@Oc4*Yzs%meuxmyb z_ntE%lM3anP?8sZn0?{dn)?z?JTUW~SOHG1fTwyZb6u$b!wZm+3rsM10BqMTY{cE%QJvyANLn%kkrR+^ElMJUCq@QKm0Mi@>Up_@}2AA2-biPvg~#z_J*8 zgxO-l@eCd2OVsuun$6wSM*0WXGW=LeoP^JvX8xK%{>UhQ5Oz}j^`ZhV&ZuvZ@nqz9 zslxnt(Rh+5KOkVGh}~^by081)F}UB?d$5B<=12p5m0IFCnnX3a8IMeJ zx`1`508KZl`}cGi%)P7CAb3AWk4A*AuZ*=#8Nn`~49@EZgE7cK&mc+f)fJwez^f;# z4kA<${CwQ}%4O+CF6RSfG1PpiH5d(a#e9wDXvIfl)z8q|h2g_G0n4ld)Uru*iEMzo zAvM}?EoNjbd3DW5HrQ8smAwEjPFu15vxa}Y&UtqIX02QJYS87Ki-fjyLV+)VhblM% z)KDRzVPNfkg3V3pw#ylE*pRQchwfRgGDUw>0ssX2YJ~UWF%9^dPckp?t8McyKYOWq zb&=~}rs*nmm){FjO-3m&T2tSk!P9RtJRk9fsw;haq($AOY@#%ljV$qcJ08#a+-rHM z58I8cN7zD-!GoIK_FCTZG=N^P9mnCS)5$XDFZ@UVPy~rX$r7D@0Wu76-AcGgG)@LX z;93Um3sb)aQUiFWP&$NY*Yli`u6%YlYgvgTO;=-ml( zV&3LC2tz6C7-7SD+rfRj)TM}Zn&DhOFLmJ724^F2p_ckFC2XK}%g7BjG#R!vxx)zu zZJvO?Y2kKGNWJk>Mh+k(lDfqk!D(=LmzO#lG}R-FOJv^pn=&jQjl2IEtlJN)7zeW5 zqkd`}{_KMDJN+M9R_(msgNe<@hBzJaCG%e!omau}*YU!8{j5)BG$QdJHxKn+o9eGW zXv6TUW;=jvl5S)Rs_R=@#6BNAOn`n4$+wR>+5^G%)tW-Pc&MEG_{KYnr>X;NcyWU~ zTbvq!s7A2liLL=NT65|h^qAig(hj(Q!Ooi z)HQymdhzwPAN;8~z$`@XNrj^hcFisySX`uPUj6~YpLej2(bu`hp!%wc;+*3SWaDBT z`Eah!Mb3U%a1af%eje+h?CV1hH`YG*CQbFd`@Nfc57ioCSVwv=zkMeQN8TB!FsGKd z&O}H>N!f4m1e|@N6wrP`((?f|N&#rcS0CCj)Ng~#v`wCi)Llk_bYZ4O5iDqN_GMq_ z4H;a*hu?sXk5}yDz;1E#rw@}JhPG>cryF6TBOk`eS1u&&KW{7JcnjbaEd1@uMa zr#?SPih=#bTi%0a#u(yiKV-zL0`L4j7*j)+BxNG zvxa|+4gXd@r!~rKH>X}({|WS26a2b!z186Vzc{5TIf9+i)4+99djQgv?)^QpVtyNyP_l~zrBCb{q7YQ2?OOD;j%%0e_{Ew(lOojnYgft;2iX&sQOn_n1v^GM1V;ZBqVC1H}vCc%2}@#`I|6o&gwshi`189_D%zU)Z!VUZGJ6G2}(kR7eI zE>sKOJdhi&vn|X%V;SpnADytd9RDLVRe*s@IkI|6=`B~fmdP=#;$V#zP0WWlfG+j| zm_VYE{L|Q7$I}ov9f`Nvv-2YAYo!%e1yK54#O|*01Y>dcwGhk)!W~3_K#dVd_6Ua^ zxUXR(2s?wr58P)5yRo6Y|Hf=ifcUg;p^KmU_os3pW>p#EGzsw4t?i?=C*$iD5-J}_ zwM?)lpe9pTy^L`|QUbR0kSz1{_a4}GvFGreRjTS8)dYhX7P%et6DDuiRrA=mp+1i{33WS^)@KsJ-GhlZpBKPm}l!7b3C z{l622n3J3@w~mjsx$hkP(Wb?(l*u^U)tiao#OaX%P@Kx3WmT35la3Do`t-MjU82TU zAYC9DErz>kqO0qlWzjA}%ZCU~=R(Z-efyxcFb(PaAis?VX_6&%oOU)^0usd-a}<@! z!4!~OVQ=|dwkk{SuWYrIcMYvNF`&)$+=XPOd+t_}6TCQT)sli`Tg;LL<^V>q0k%lH-Siplg z;LBmgG*10(MhJ5zw4?L89<8x$RL_Bh3F3f-C1||6RDIhV31k)rRLotSlN#@S1^jo4 zhC;z`NDAoRjlhBg^j9DhSGNFm`Yl(@#44CTHW38XR}lLw4kIxZN@mC&x+A+G|JNiQ z@#KmW^!4B>F7f5hNumhNbsWllv<1QXc%S$eS9#!QQljtKqGI|>k}Fif?2#_PGF!?5<6LAKyo z|6_~!Foef?Mf&7Rouw@O6IoGROT!mkIrd7rqJ_~bJZ?_I`#S3tHi1}J<$Z<#%Q6RZ zakJ^=iQZN%Nmzfo?Y{!79T$E?_CE7@-W|iPoeL#b9da9s(t*`Kt2yH&Mw5RA25h_CU2cyez}gk!wv99!P#yI&hXCK;@W za3vOmHaAN(fLL9>^bjo8cZUTHGZ7vnYfF7^N(YM(VCED9&N@`0i$=yEcW$quIe^`k zPN1>dT3D++7-6?jF{xoz)55f>I+a;l16$h)L8gN`nL^9ILFwE9waTpg@ynv)S8C8Z zv}<|QW#-J^nS~hmxd*K-kUGq>ufx%a57E~0Tu%p+s{8MqL7_GWt=^BVMC;q887m8E zrb=#AI44+kucdqk_<5KYB_0wU!yG!pnHJ!z3qt}Y4k;dGk;m8ue@C(52yDtk~{T5XQhdw*V_`{fMz-&OW#}B$D-1o=`pLc3=0NZ-W^gXn-vi zo0zbqJB;4$YtUlx4h|RmOM{ql$H`j)EoDa`$VfcY?uf)|$7lgH+hHOBAUeBivz&#z zCe|{>gfi}**K~Cvor5&pPvhm>=^!wn9SB#u^(LP#CgqxOopOgdrAlXGz!DSzV!KxI z8v;*D68iu&6ObFhAV1h0;zdm(-D?%vTkh60iYIG*>Tf7`O0-q@%B|H=5q$5_c^=CU zVK#U8J|*Jx0=DS9{!GpCuhuJVP2Qq?z-Jq-VjGhEdMu+OH4vPye*$sXl=h0SH}M3& z(sY?{+p}*|>tamy_Sh;*=k-nkG?MZV`*aZ^Vk*XZa5IB##4nX;NbF6p04o27QmJTIehq>H%c>{e z1)HJ*SUN!H>IL31y@wB*qfLl>9v~2w%KoG>D@FGV06~h^fZFX55rTN;H(#kQegJy8 zIg{YBz8pa`ip6Daf^>fcT-0Ge*gBG^PNTw&|BxiUU_rwC=8}e9h$`pN>eL*H6){Oji2Uy^1ZZxk&DeHfWt`HyjwJ8jKNb@Ps0N;E*fH?o{lL! zu4RqigNkN;lvDADsS*_?Sla*1$u7{SNskG^ahX1_D2~4Oq;$nuedc2#g~s2Ct0I@Mb{^EMN-w_vrz=@_e#)oI1z^NnDEJ3<K>i>(^e7U4>c|&xrmh2hFU;`V1i#8) zB?+sArGYf=EvZPt`i&H#Q%$@Ut!s65)UwYLAyn}9zhE}g<5X1k5X}63lAt^FL`bB_?}jm9$y-H4Ry8b0 z25!~9^H-A%L`-7iN$t8Au@`S1?mApc zu|)(z!Spg?FA));3gl8u-idWEK^q4BxMGq{IL6jhn0jqpdkkb*kbWA`GVO+X! zC_f5{)s!=X!nwM@Ur@pEeL?$}@ckn%e14!Q!I!^#C?%#pRY#1)Fqu!#7veS`w3;Mr z3MePz*;$j?K0eqE^!z2)yMr=?cA>un1LDlE9IO|Hrp3~>#Q1+ztSBRL!?D;F;Y}{ckESu*jiv=YCIbDmGHbq_T zVd+d1ddRFO3^VjGAE?9x`Zdl-8KQ@%U=CBr2~dS~=?a=n{o|t9Cq@hZgKOXf|4Prb z0bWEghBd3S={H==5TFmqpP5y>gN!K^Kgb0!;eOVx_;VR7m8)Byob(rnHKo z*@~}b)lOm6HtoZwIUr)SnWQ?W?Uqq-X)_6q9tou!$xR6yMN;~yq{a2>m)HE(U04OZ zMwW{Rs{z()8p(;nR1k%bD9l92GFQ+=zsNV&l{Zyt|AGz9hi3U$?orK;fubEn|AvdS z3T6l7Bmi1V0Q2eKG^$e={-bvacGhbIg~#rpBdFY7O4DA%DsSl^cj+F*%@HnxF|4Jt zUMKw>tl8E$pv<_Lha(d||3ct&MD3?^)0t22C~(t@9A=0i5nNZ0adnSLGvqpV@A$PK zCmmp*ZGf3^rS>PPa3lq$M_^8vaGGAV&dyZY2x8lm4kwyA$eNv2O6tJ%*aTS$j|@dv zL$6#~n{PdDvAiU90&K5HYgg@c*zCD;*dr;OMQrDBP=<&KO z0Lb*l1rQGkDS)+_P7szI zo#!67TUTCGh7&FYS~cH8r%~>3o9=n9-ROCjuq?6nHTBk^2V{+G1wKYf32=PA+~tAWBOwVQt9D4#_=aaq`(Hk64qs z{;(oQx?H-qa%Z@}((^%ulYPUO{nN4QN@h1w`qT2$I$}edqA#8y_E&pADrbZ$_*pBF z*0-!JPK%`-6pNoddFXtU>l8+@(zI>G+qlS?cGlhYt6OPfqR^6)nvw6p{A@Ud&?oKh zU-rBgJ^XTBZ>f34n&FhbEzDG3#M&UcV1F9VHAiUU<_T8_Z9%iOYE7Vz>6h|#f_M4} zNE(v0hqHj_I*!S5uee9(;PSK@XFb7n!-nQ(U3<=CJ|HBG?u8D-PzK@glz+?$r?!IG=lcIQRKGP-0-l~I3*lYkht&tts*EkRB>ESe8@ERVi zUoJi(yeAGP+)5kX`PSE<@IIyIg9pncUdShfDcxk#Ejf4|e@3?(RrCzsjjNu<2U)?6 z1-)=n8qsoev2g)C^tGd5v1ilSZDhqoR&G(K_VD4F#s`1yrX!t4GsKZi)9iQcpMXEZ z=b0lGI;mQdhXVZlW%{ot^b=(Ov>AX<0rE8iMO8pD%^+nJuy!-pNCo1FV2G4pSPE{O zuzfJ^%plX?vCulokXbYAuF9q?5E6_N&Za_T2eFi_Z^6yZkTgLZb7qJX%NG?6^<>d( zDt9K~Gcjo{IFA=ePhxw{0oP9EGMHQ13FFXi;X0KhfXA~kwvh1@w-gAoimEUcz!w)a zsD*=Ku~3&}j#2=F9R~qWv-9^EdYtUpei#%+14`SPFkX zj^S9oXUrs!G$#7yDc5tr%1mQeN|LB4mBnq3vuj_JdtYWEnj;NhhsFDH_p9*zaJ)|8 zS7flGa6M^BkjAIZL%@k+6@?2wz;OWO_fk;FWRUiE-e@90EGC^ zn>p&Pq_zG)W3ld8K*%&f*z&uKmAdmfkSz_n?18Zsm5FWb17uS-xe&zfAGp9o!A_2R^p0d zeJ4D=Tj!!z>M#0h9M=Js3!<@9?TG6z2yj%F%5g86+ZQAX{g<-t~GpD>+g z?C!E{av- zNLd<5-;F>iP=&^UJZ=|kR{(sH0UVhcE?bcZ&nw1|!$nh_&BG22HF`YA+#-oI{l}r> zs5<^qXh%$>j!V4GI{vUSE#5`;&xV`sw$GpKXzJ8YytOIrIuFp^#)8{Eiswz6@m{=f ze?EE+2a=_X0jr|rAs2?~S|Q`2)mZDC?7^QQcvl`{)b0MP6cZeG)~9P=2>1Q5e35Ee zo2+x$ed|ha8A%7_oW!>tG&geh~1Im$~-8wHhHS?f5 z?hShs@T>j9>iiQ009pBQa}eBp)IZov=T!UO3)h&s)%ctM^q0-&CVd93?lk?ymb!W* zn>|5rJcfP|O?%lQ@vqaS;9A90d@*l;;*Dn(ScR6C14Prt57$xff!KL{E_wu_Sp^v{ zoiM<6^V7NW5@r}gv{>TD!DQ2<8tTc56a&9F&n^Nt9PlDWHeInuxi%w5tca+}ou^xj zZAy4=AC&ZhIc%??_D9!y=fC(dZrVF@n{B3#_O&^r&d950)A0A}8X#<2Y0e@-wYK6f z{uDbNV(-TkLz_8X?$;(Pm6Wz%gbGFUX<4;MSSygrh<@QJNw4q-a~?RNuJ&{i5U{nAPx^LU=Tyl+>Nyo~1|0}-WE z-e9w{>q2k-e10UFtRR!bJN`7|JokxvDHj~e+JR2uqGZcDe=V8qy_SsbS$W%X`(suL z@y7FCY)Q(T7+bkj(ZkvE)kfMUUbNHke!tLX>NAd>(8q4v{VImSl4<*?n7f>i`e5>p zcO0p55Q{p@;X9V}^t0jt#T_{kI8ospqzkBWNmN8pYjdDOH4SPAao?M^^kaD^&&gY$ zAQxGQBPw1vNq^x8<+u|lUiYW!9f_;VUgl0ZHi({dmz*eW*Jz2XcdE6c1HSsCE^s0s z*LV%4_m_L`37MhA`dgvnM%q^*hO8sM^;DvNJGCA{{e{XjY#PRORGS9cu)c#y8NVZk zABNLoyl3(;fiJm?QjXNW%dozYl@UMbDv1r9dgA-8;Pj1HVT|SZoq?7WI6~bkdO9R; z=}sv+;$$5Qen}F!lc0ljMc!`IPCx=lF!AhQ@m{yh z9E`Aogk@X$G6u&e-f15nbG13`-^Jvgc|5S3xtNwDenJxmpYqMi0K>^emga)tF}Y{d zn;@0%6{3&8@3~n(PL|?XJt*50@pxEMCpjZ}P@d(wdIRS%>$8snqEheE5D%`a)jWsI z;Syxj8%9D)q*cr^ox+_QceM*cpEo%xaM= z^py0BIMgs@sp<}T@dvvW$I-fM=fm1Z9L8HZqb39(XXJPp))>TIO7HJB+L>^eyYuVV-#GCzyUrIQ9mu5bq^aabQ*NW}^kgFs zepz#IU1?5Tv}aY@GZ?QEkV&KTn;5M|r_FVIT!AezQ_6b5N4L%(7BuepO452X{lVb^ z2d~B*+Xm^%{)J2{{a;07a;nnINcvun@QWk$$_32`jCRmGmb*?{C$!2g;FG70;k5^?~kMM$P9%-9^rTxV5 zs>}|4m7udUE2A4x7w8=eMy&C8j6Td#=T8=d19_EL!f2ARiO$ll4k_O)QnnQOkxo`s zaR8H4qy?!za#dIbBy%jkOMp#|F}{J4GOXg?m2EHvW$Qo);hW_jksoie4i@~-IV9!> zc%a&lxqLsB3X5JeEHh9ca!IDSMFjA+au3V=dRu4wyPhP-zy-37EVYL_^6U>Gb}MuBBBN3O9t8Qq(^Aws;Xr_f zOIUIzspN?31jf+R(l^elr=Z9z9FjC*Ox=%8AgU+Mj?fKQ2Js}L0-)eSs?Z(E0|^ub z06}Kmpu@;X+SOm7+tK!~+@8HlS5Rhx_;d?tsQ@-&?_y42@X#jhcSbJXbBkw}DJOY= zlVFtFVil~qE}Ni0qn`0y0>kX~fKhWy5P$Mpp$3Q6!);5DSvE<)kOm=^;+Z1xCMb>O zHwAz0ULL?v$+gW%TGL{10UuEpKi&5pf159sf1}9X$R(g$LM0f)P8az@sBRk8-*tUO zne+lBQkPfen}bK*`2&2PlvnWEGf2H$V3<3inneUdLf^pv`3fZOf8JWv-rlhB;oU|! z>a3ZoW!PuURX}8POoT@qw1vH%NgSD^DX?9Vy>x<<=UJ)MPVXK~R~aj&d3`dR(xs#B zpN~1)3$Ih7BU4~W1P-fzqt>=Rp|YlA+s4*S*F>Hav~03OL_d!IyGjb0zc#bxwk@{- zIZ0lj0<1Nj{!OP6AOGqb;-U^8&Unx2wB22dlq?p&iX=W_U~Wb}_oFyf@as2!aO0-|+T7K=QYsxOS0EiH3gD{Rk zNW{$yt^ga)jawTWqUl(q7LmI;B7haO_tv-j$MhjTsHZ%hmIS{8JKiR@ufwVr<8W{NP* zP4b}?=#?pe7}WJ~^w1C{2@L~g1)i=7o2Mm-X!u@n(s~xg02^qO7Y`TeN8T6ZWrpX! zkP+7jIJ*XC2(VsAMAxc^%@19~Axdb;t5^6%uqoP&J^=D8PbJTAu!T^`=p&Yx8h>yq zmt~N|twSu2NNtFnf64d)S6ix*_a)aSB@<0{L#(U-CBc1bI- zTyg5#h24^A5l*e<3sv9zl<$8UzWVD~g>ZO?{r#DV_Fru+W#QqM?|+{A_3JrRI3nuq z{n-_*-!DFvMZ`Y2Kev7L_sg%sm#=-eKmW7+_pANkoH%wefP52HyN&>MJ=|VX zrDqqz1Pe@;>QYFO0~8LD3R#B0bf?^0+7@4C{^LIK-M{S zENm0W%)|{SYtAtSRMT=a!H=%roBz}Hc>yTeh2t;}#`Jin?Z20si|*{K>>x<{7Eqr9^Mx1TR)|a$9KpBaGMXhf6=*p#Q*BP zea_jep_4=bFx+~?RaOz5q1ZNueS`JF)d!UB3yz6#b)qP!~B8Jl;Knf zm1$~PH2B>i$1Mh9rk$S!#PQHOkDxjL$CO7forV6Z8!Q2#lPcZp zBfC=zDgGk6n#-4uJm5@>+jWHL@wEM(&LaDyj67Wup!@eppq_PKEe3pH6}0iB9k#2E zJ=5a?$l5~C4=yv7bRcQ~$U!J`6-(Bl)tK9Yc_o%9^pT`j_E97R^zrhQtIg1#HZlg; z;FQ~8A}XMYZ0Jpg=N*x+7z@xmA86MXZtAUFF}|o%iicKWSf^FAMk!#A5x4+jjx$}36h&Wc!KahaTlaX?e3p$G^i5vijBof+! ze7n4Q%ZH;s<1vNDt7Psn#q)=x|CuUVqFSh~4zj@+K??BalNz3$%H zQ}<^n9Q}B37)&CPQrHI=FC~LTRT0gnEIOkS+lwrEJ`61l%h5;S9MpKwQ)zZ7S$(J4J(R?|NZKZz zHDw%lOzTA$=A0(9abEmTx?;GGP7yZR- zK-fF*c%w7;ci3cdC9N#|`(YjOgkBbPqe6eC;aUNYDlM(^*C` z{TIz2Td!N>{(t{^4}=3WlgWL1?b66d)cZdKakh6(91 z0e)gRau*Q>_?!Gt32AAYS@8zDntxsr7Vd#I@!V3u8ex5XN^U)74FGfI++9B=Ta3Ev ziKKO5l1{8&#Puj^ZDu4$ZtNz|^14UdJp{rR&!XW7zQT*Cj! zrkK%1WptnyLwCkQDShTr5#kSAF>f_CZ)t#tF_yQvHD8)a@GV{@OVkap;3CyuT}PG!YWx z=Z&cG5-JFn=GA=~>-cksrTqu|jVya%a?I;31}0JBEYw*j8F1!Nixv?9*+3jp0Op$B zF2(B*;~!M6MgG$D_!S7XyP+3wQ6l|GjD-p`Kd3mBryO+s%)N1s1v_z!VLmpa zZXJ=)f5|b}*H!lJ4mr7D;?n2+w7%O~*k_*i7CoJRq@Q(G9Q0)Pm#x6qMw0joGW?W^JTGR>!e?L`0)cq%;6S$tz=jhY2L;SvI`S+|0NZTW{U$ga! zQrFqgpNp@WiC2_){XY+}7{A-^Ny{{x%#@wb98b%dg*m^l0>T!)gk8n*klAMzfD|cg zV8L~lbj_gWKR6jA=MV{lfseN^@dEYUaPrm5+Hsyy;2s~2e8AoB;A^RIPdeZ60P6#t zS<3F5%gHUgOFnnK{`tiCLdOE0BLj0O?^1n&bZj)Q88ArCZy>LMA-cO6N%KIxr-t_fVY+3{X?z-=v%`4sEdz3lHk@YX>;IVy3_^Fof{5Kp2o&dfb zR1&jfKzG+V?j|C#-EG-D$Y31+Q|*G(5xM_PSZCy!cQa=Zyp2=^rCz<-o`O^FIJW#g zf4i+q%-5}b+~JB(AGaiJbzGIBFgJ$Z+v=Hq|LSa2p0lnF?hkmkT_WNU$h+8*{?w)~ zOg1kIKHvZ@I#=7Jx6l=!(qo>P_`dq>j?L+2ccuvG*MmN<{W7}^O-f=vb$9-eYfyo? zP@tp5_vyA}s$uE%4e3qR>^+&#j0FuDzm<+mjC9+|d5NLEGpF`-E1sl5x8QF1P3QRk zj&OTSSo*Na{H;{gt2*5?);W4kTTc^K0A@|Pvd8`?dUj;U zuUR5v_MF$Ol26{bAuPzru3uF8y*7y54a?@@6IkQ}F{~OC4toF6tMC4Z)rXd*? zk>+Cn;6WoCKpvAb9pC`~dcXj*sfsWF03ri_DMOe606SGIkPL?d0PqF)Jb5h2dz_jI zWD1*{4GNsH&mWLxeMwU+jhv>FL9ozf4J{R@_NgP4NdJ=6{X5w&&qhiVb!d)aK^{90 z`u_}KP8%~J9e{*dptd+$d9&}S$XZ(yOPmjpgqI!5Y1|TDJiYi7 z3+={|e*PQvHUTpKxOOH%JpTxG3ji?2jM+Z}n$EG8;`L!5=$Rw-pz|Sd001X8Cm(P8 z7o9MkBYf@3U||apl`I=RD)Y6aibXovELTw3Nt8~J8va?%&MEdv_Om+)dRDsc&72U` z5k2q`{ShTLsai{)6_Zh2nvoG?W5Jx6D4 zpvP4AK1b~(I*E9iu-^ed4DeFUPFof!(zkOlT8UCd(g*HOZM6M1R&Lqc$^EuXTs)c= zsCR=IV%~`;t?Z%)j7;DFykE)DN+=4j0AMdTLIAMwk%lrNSio$wxKEsT%E>vld#Kz<{0@A4sp1_a84j(awPahQCo?*xA!#kXLht~U&6bY6 zGf8@v>Eh%+Z+(YE8BJ8U3#ePHhrlUE?6LH-GS7^uB-6+enGcd(C23S0<6dJKNoL;D zGkY5&clr1XGj;auk%$E5rwc95#9ULD5Yk?CGH?XDY~>IOoA%?`7ct*vUftGyGXE;! z-``_4ojp4(9@cZszRz)l%&UMcH(4gUur{Fkh-Gi-?qae&fbs|I^R}BEpaZtd%b|d& zSM*if8weE~X!y2ik)Nwr8O)CNPP)%UpWY8wvJ(3FmwSwb6`5b}i-Am%VAg!AVX~=7 zFfr$}-Jb;UrD=lr&;E4s>Rsr2!+bXA$ve%k`||?lpTDovGdGl=%=B$%8%GXkXLGgC zU!5!WT1bx@6J04QoWFkR#cvBUD(Wzma1a=C!OO+rH?#St@TocM%b!}b%has?vN(l; zep)=(x31zWx0-fgCVeI4qmaUEpGUSahgCq1xk!3IZY(N@-!F!-h#kbQ`D+@RV|(^f zU~#yX!$q+aEsqfIjP#sK)+2`=1Sp4wXE-O|5X#zVYq1s+(RHpcsz3Q%(3QkGJV?;w zC;5x0uu{xBew%y>)$01I973Ro<{{?#W{ZU{4+?FzRKl|zv+ngLd4d>ywQFDr9}=`4 z0=bHluXoEg_jypm*={jjyWiyDpZqwwF3;=qF?YUAn*89p)ys*HHEg=l%z6uru6Nym z^m61OPkG{kY~h_c=Crf?dcl!I&x;RkM|5sJejo7?tmfxtrY+` zoW@YFOtQ_{pr3bM`*=tum>gp)O&s3p!=T-oKEGo(E zLFDw^02()6WBJNN(fb5HE*;4Zet_lnvGa_}BmWO)kNA>Y{|=Eel`r|p~boHkLM5|%2BG#9=Iv56}Omr+3INbU+; zg&x`A3%5L7d%N;fSOuXWzyXOqCJiZUVNNZ{K1r|;;vY~9gpc91vgHcUw zFa&1HY}K7>mz69q1b%^0P|%A8f_7*%Qilc<(@`}E4fCH;``&M9rerXM~l!j$Z#_h%qEDc4=&k%ydXv&+`^${?5_?> zBd5{aBCmd}fsNt7$q>0Fg;lYrB2?^XePP_t5^6X7L&BzWq*Wgnct@z!c&RX z0m!`8cuKa?3pRe^ESq8-Q_0;_XQnvwL7jzuP>G&fGuGGZxl4?xmQ<{_vhOU+ln<$r z4VQaqx;Yw4)u`2Is<*wYXR1Z&8^JWa2UVWNKBxOM_R(x{p!=q}w zd|u{`mHHdLfVr|mt=Dh@HJ*+k@gbjc3>=$8vAq4V_LmRNzY!YRJAH0|1A?eyzis#B z<=8J{x9;7V<{OGb&TxxECS;Ius?5<>*3`ppiIK>3?O9)TMs`f*ylR>|e2MS9TaRk( zex{X|Kl;mbk1nJBa6Izo`yAh|C;jwp7Cbtk1$ zW9!XsUUG^n8L994QRh8($^Ko*#G_knjiIMQJ;!FoJ9admBxYT@02P{Ukk)EU_TKV6 zrT=Nju)XE-@s_vp=TBoPSKDgl!a^?!eIB20fBdHGTcpIx&&!inU%Uw4iK)6Vw;=Mo z^RsYx`~}bXdEU6L?PKEbuNnl8<<|MXnK?!`v}tM58ZqqNk!Ez?hhY@(EeY?L(|;O!lKm+$;2 zt-43MvGe}j@9597-=9B!(*)+Hr-Xht!WgWGY9EN6$B=ZAemAQW9>l-VkG=mb;hWd) zU)RJDzUCWUANO1p{LN0S*H(G0SYOb9%VRzXU=2nOg_T7-JPA{m=DsEDzWkCbfAucg z;ck+0*VpGFhh35|$IEBqM`nMXI_^&?kWT#4$PpkRf>sUsc_cu{mIR)T zScn@R|1|P-AaI$FkDZ)&x%$@8MS@f?alM9cC5H(2O}tUa_|F2!iW57HQ`U_<61S37v(giz)zl63V0lshO5^j$yB=Nz$j1 zm@T6s_W@w3Bnh`9_u!O^u(O^;D1&L@m5tdpf@#0FUO)a=r&b0(`5N} zJnHr>-`j8NGT+xJwZvx*!LmNWZZAM@+R0{3rDZ9uWR4eQ&8=r)+Oj4Ovz9FH{Nui} zl6GgK?)I!s;#S?AUD(|nPH6p4KJ|sm`)POo6e;FpFF3Kd)@>0Z~_IEx&>~3?)hCS2q-SllLJGT`NQ^@ zE-WyeM;68k6kZ7_j7=}RCRZ3UP0wuM&CAY7YG!&QQz9@P8siZi)(; zE6e?Mx%+MP_n*JG|6<_&n~nR_^y0VIihJElURjmCV<{c@Q_>w$Iy_L?B2d<$Th{-l zbo5%;cyU?72I22|*%y`vU*#SwSv^<@d9arLV59!Qw}A)S8xM9_%J=2Ue^{0O3@QJe zUjC=P{NF(N@dmw|&O+vug$#`X&ufxnoyfdqps^?o^KQ^-EwNkil9nEP)}4Ij)=oy&k~#o;NIp>KUe({8>c)>%`oNlNsA^MI zC%Zu;W(9AOQeq$Db>LQFoC+Vjw<8tex|M`6X+aXD(1%EJ#2F z^!Pv|=F-E<3lGPrwRo}Gre_a(51Fv5-Lf0O{|L(>2JQiQlfW+-5L|2(ip+GC#vJG` zDiwPAzf9YC7nGt6RN3aKNq0-4LzTMcvmgnB9usH~CfT>8^&*Yl>QwYh|MRo|3eFk+ zayt}I_R&+8i5grt7{$cr>`(*8G4Ugfpv$MQr&=BK4V=EU?!Im3F;({`Gokh%J2TAU z70(mCJy)oHW^msMx6pz)^^|E82pl&tKlMx_^cgYgKhpI7xX*=vB1>ST3$qBhA;syb z0a?+E#tm_CK};aA7@#o{7d}?w8rtFZs6*mg#bqo=&*zDLRBLXi);*zi@W+={r@C@% zx+2vLLqE3zh#l@PJAAH_V>neks66L^ye99FRxR8Qzn$U|Vk9&aBB|i3&ZzlQomW4e zmJs6ms4qOm_8R_CH5T<|@tcgNnE8c$k>_|P{KIA;LphD9~zS& zz8~+AurunCzMQhE%;G?*Ob#c%>T6AxSBdyHmj1jQ{@igWN_535rcrKgFHYV@P6b~D z57I04U^!USqeLJC9qD4@Q2EIEjbOr?seeKNDj-8-2LJwRHpgy(=uWdOZm}WE(h_C- z^IJjB)&WOt?e|ETMWMZ~rvzKMlyfl^-_ajch5xFezk2dXbiLNi6rTAg&Q>9FEgF3q zBs_`}*8E=qLIb`0bR&N9##hW6Sv;y^3m0gtAQdVX2FwthvLVG0#|>e~q_iH4Y#Cup z$JIf_%CjTj@&{f_7l6DISezoj;m8wW6Qg>fBv1rxn*RY@%ojjFAz2Us-qEFyJjQlh z#s3-s9Do7p3m2ID2b@gHE5gPwa>>7(ATeafHZsdZI>jM_Lx+;C5nwj-6Bo~tELO}A zfFdY=Kq%JK5Lx;%YAlDy5-op_$B3K});uVZtjq&3&L&b+AL_?|0V{Sv$O}=-fJ7JT zj3)Ep$e(P^*o@9qd@67vC(<&_fzixv@X5I1wNP=s4e`e^;%{lTm z198)TFft``GTO`#KP?zVdp5zsz;dzFKz%?a^Z(Dv<16}$(_sNIL?SivoVHfcGe2X` zbJ5g1!!`SCK2{Ud0|C_@J7Gq=X`)^a%8$Fn@m^E}a$**D}Yw;e1c^w|*VI#eAP z;bXpMyVVo4yrC_(#6#Q6jkjd&OcHY1yB*cO{RcZFKm!5WD-;;QeL~G-Gq(ak>=U}J z-4HxLsFE5Otn0e$(>BG;5Z9gBz0EB_D)t8=0HGQ2-6Z7MmG-TMC zEu!o@9iL&@e}GMF3Qm3WM?1aA+*D2sG2sJ|NQ-m?B8A}{e$=A$8RyhZ48f~2x)Uqz z;flma+08AaTB+1h;o^iSaWqGQflPNX&+IfzhWgduQ>rRJE!5;n)Lq_|@!>I!-+rP8 zbyQCRg5n#_8aIv_2@OrCq)HqPN!bL$q5Iw_a0K+ayKvM2a`fUL{!umkrDbr-a8czs zjpgH%;&yK3@-!VlUQG+G-5Q&T4%KxtH5Xxk0eA}NiN1)0zN;s2y^0R$c*y8JiUGPB R>6eb_nXc)Z9-jdL06W_PHktqc literal 0 HcmV?d00001 diff --git a/docs/rail-demo.png b/docs/rail-demo.png index c2ec064ad0c2922fcbac3bd69952d1b9d18b5386..86ff0c8b0b516dc7fa91d29cbf2de90ab169b1ca 100644 GIT binary patch literal 50450 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 literal 56148 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

AR)UO(KODx)CR3NEnZd~2r0=@7tN4YwCNaKt+F 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 u32 { pub enum Phase { /// Watching the main session; fire when it completes a new turn. Idle, + /// Durable intent recorded immediately before creating a pilot session. + StartingPilot, + /// Durable intent recorded immediately before sending work to an existing + /// pilot. Recovery pauses rather than issuing the same nudge twice. + Nudging, /// Pilot is producing a reply; waiting for its turn to finish. Generating, /// Reply is ready; trying to inject it into the main session (retries if a /// human is attached and the worker refuses our headless client). Delivering, + /// The delivery intent was durably recorded immediately before injection. + /// Seeing this after a manager crash is ambiguous, so Rail hands control + /// back instead of risking a duplicate user message. + Injecting, } fn phase_idle() -> Phase { @@ -45,9 +55,13 @@ fn phase_idle() -> Phase { } /// Persisted autopilot control for one main session. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AutopilotState { pub enabled: bool, + /// Marker semantics version. Missing in older state files (=0), which is + /// handled by pausing instead of replaying an ambiguous completed turn. + #[serde(default)] + pub marker_version: u8, #[serde(default)] pub pilot_id: Option, #[serde(default)] @@ -71,12 +85,20 @@ pub struct AutopilotState { /// Why autopilot paused/handed back (shown to the user). #[serde(default)] pub last_reason: Option, + /// A disabled autopilot still owns its pilot until STOP + guardian cleanup + /// are verified. Keeping this durable link prevents a hung pilot becoming + /// an unowned ordinary session after restart. + #[serde(default)] + pub cleanup_pending: bool, + #[serde(default)] + pub phase_started_at: u64, } impl Default for AutopilotState { fn default() -> Self { AutopilotState { enabled: false, + marker_version: 2, pilot_id: None, replies: 0, cap: default_cap(), @@ -85,10 +107,23 @@ impl Default for AutopilotState { pilot_marker: String::new(), pending_reply: String::new(), last_reason: None, + cleanup_pending: false, + phase_started_at: 0, } } } +impl AutopilotState { + pub fn enter_phase(&mut self, phase: Phase) { + self.phase = phase; + self.phase_started_at = if phase == Phase::Idle { + 0 + } else { + state::now_secs() + }; + } +} + pub fn path(main_id: &str) -> PathBuf { state::job_dir(main_id).join("autopilot.json") } @@ -98,11 +133,10 @@ pub fn load(main_id: &str) -> Option { serde_json::from_str(&s).ok() } -pub fn save(main_id: &str, st: &AutopilotState) { +pub fn save(main_id: &str, st: &AutopilotState) -> Result<()> { let p = path(main_id); - if let Ok(bytes) = serde_json::to_vec_pretty(st) { - let _ = state::write_private_file(&p, bytes); - } + let bytes = serde_json::to_vec_pretty(st).context("serialize autopilot state")?; + state::write_private_file_atomic(&p, bytes) } pub fn remove(main_id: &str) { @@ -113,25 +147,41 @@ pub fn remove(main_id: &str) { /// exactly the bytes a human would type. Returns `Ok(false)` when a human is /// already attached (the worker refuses a second client) so the caller can retry /// later; `Ok(true)` once delivered. -pub fn inject(socket: &str, bytes: &[u8]) -> std::io::Result { +pub fn inject(socket: &str, message: &str) -> std::io::Result { let mut stream = UnixStream::connect(socket)?; - write!(stream, "ATTACH 24 80\n")?; + write!(stream, "INJECT\n")?; stream.flush()?; - // The worker sends "already attached elsewhere" immediately (before any log - // tail) and drops us if a human holds the session. - stream - .set_read_timeout(Some(Duration::from_millis(250))) - .ok(); - let mut buf = [0_u8; 512]; - if let Ok(n) = stream.read(&mut buf) { - if n > 0 && String::from_utf8_lossy(&buf[..n]).contains("already attached") { - return Ok(false); + // Headless delivery has an explicit two-phase handshake. Guessing from a + // partial log-tail read allowed a delayed/split BUSY response to look like + // success, silently dropping a reply while incrementing the durable count. + stream.set_read_timeout(Some(Duration::from_secs(2)))?; + match protocol::read_line(&mut stream)?.as_str() { + "READY" => {} + "BUSY" => return Ok(false), + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected injection handshake: {other:?}"), + )) + } + } + let wire = protocol::bracketed_submission(message); + protocol::write_input_frame(&mut stream, &wire)?; + match protocol::read_line(&mut stream)?.as_str() { + "DELIVERED" => {} + "FAILED" => { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "worker could not write injection to the PTY", + )) + } + other => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected injection acknowledgement: {other:?}"), + )) } } - stream.set_read_timeout(None).ok(); - protocol::write_input_frame(&mut stream, bytes)?; - // Let the worker forward to the PTY before we drop the client. - std::thread::sleep(Duration::from_millis(120)); let _ = protocol::write_detach_frame(&mut stream); Ok(true) } @@ -271,7 +321,10 @@ mod tests { #[test] fn parse_reply_sentinels_and_text() { - assert_eq!(parse_reply(" 先查重,别急 "), Reply::Send("先查重,别急".to_string())); + assert_eq!( + parse_reply(" 先查重,别急 "), + Reply::Send("先查重,别急".to_string()) + ); assert_eq!(parse_reply("[DONE]"), Reply::Done); assert_eq!(parse_reply("[DONE] task finished"), Reply::Done); match parse_reply("[HANDBACK] proposes rm -rf") { @@ -281,7 +334,10 @@ mod tests { // empty -> handback, never an empty send assert!(matches!(parse_reply(" "), Reply::HandBack(_))); // leaked sentinel mid-text -> handback, not a malformed send - assert!(matches!(parse_reply("sure, go ahead [HANDBACK] wait"), Reply::HandBack(_))); + assert!(matches!( + parse_reply("sure, go ahead [HANDBACK] wait"), + Reply::HandBack(_) + )); } #[test] @@ -291,6 +347,19 @@ mod tests { assert_eq!(AutopilotState::default().phase, Phase::Idle); } + #[test] + fn injecting_phase_survives_restart_for_duplicate_suppression() { + let mut state = AutopilotState::default(); + state.enabled = true; + state.marker_version = 2; + state.phase = Phase::Injecting; + state.pending_reply = "send exactly once".to_string(); + let encoded = serde_json::to_vec(&state).unwrap(); + let decoded: AutopilotState = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded.phase, Phase::Injecting); + assert_eq!(decoded.pending_reply, "send exactly once"); + } + #[test] fn clip_respects_char_boundaries() { assert_eq!(clip("abc", 5), "abc"); diff --git a/src/distill.rs b/src/distill.rs index b562ac3..b97b613 100644 --- a/src/distill.rs +++ b/src/distill.rs @@ -13,20 +13,30 @@ //! re-injected context, tool output and reasoning. We scan the most-recent files, //! rank sessions by richness (number of user turns = number of decision points), //! and pack the richest into a bounded, **fully-readable** corpus split into -//! numbered chunks. A per-chunk marker the launched codex session must echo back -//! proves the read was complete, not sampled. +//! numbered chunks. A per-chunk marker echoed in a machine-readable footer is a +//! strong completion contract (it rejects stale/partial outputs), not a claim +//! that arbitrary model behavior can cryptographically prove every byte read. use crate::state; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; +use std::collections::HashSet; +use std::fmt::Write as FmtWrite; +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard}; use std::{cmp::Reverse, fs}; // ---- tunables (all env-overridable so tests can force small shapes) ---------- -const MSG_CAP_DEFAULT: usize = 700; // chars kept per USER turn -const LEAD_CAP_DEFAULT: usize = 220; // chars kept per assistant lead-in (context) -const CHUNK_BYTES_DEFAULT: usize = 200_000; // per corpus chunk -const BUDGET_BYTES_DEFAULT: usize = 4_000_000; // total corpus size target +// Chars kept per user turn and assistant lead-in, plus the per-chunk and total +// corpus byte budgets. +const MSG_CAP_DEFAULT: usize = 700; +const LEAD_CAP_DEFAULT: usize = 220; +const CHUNK_BYTES_DEFAULT: usize = 200_000; +const BUDGET_BYTES_DEFAULT: usize = 4_000_000; // Tiered sampling: rather than read the whole multi-GB archive, we stat every // file (cheap) and then READ only a bounded candidate set — the BIGGEST files // (most content = a richness proxy) plus the NEWEST (current working style), @@ -34,19 +44,37 @@ const BUDGET_BYTES_DEFAULT: usize = 4_000_000; // total corpus size target // those candidates. This keeps the scan lean and bounded no matter how large the // archive grows. All caps are env-overridable; HUGE_FILE_BYTES skips a single // pathological transcript before reading it. -const SCAN_FILES_DEFAULT: usize = 1000; // max candidate files READ per source -const SCAN_BYTES_DEFAULT: usize = 500 * 1024 * 1024; // read cap per source (newest read first) +const SCAN_FILES_DEFAULT: usize = 1000; +const SCAN_BYTES_DEFAULT: usize = 500 * 1024 * 1024; // Skip a single transcript bigger than this before reading it: past ~12 MB a // session is almost all tool output, not user turns, so it's expensive to read // and turn-poor — skipping it lets many more turn-dense sessions fit the budget. const HUGE_FILE_BYTES: u64 = 12 * 1024 * 1024; -const TURN_CAP: usize = 64; // max turns kept per session (head + tail if longer) +// Maximum turns kept per session (head + tail if longer). +const TURN_CAP: usize = 64; // A line this long is only ever a giant tool_call/tool_result blob — a human turn // or an assistant lead-in never approaches it. Skipping such lines (see // skippable_blob) before JSON-parsing is what keeps prepare() fast on multi-MB // transcripts, WITHOUT dropping the user's long pasted turns (those lack the tool // markers, so they're kept and capped later). const MAX_LINE_BYTES: usize = 24_000; +static TRUST_THREAD_LOCK: Mutex<()> = Mutex::new(()); +static TRUST_TMP_COUNTER: AtomicU64 = AtomicU64::new(0); +static DISTILL_RUN_COUNTER: AtomicU64 = AtomicU64::new(0); +pub const DISTILL_LOCK_FD_ENV: &str = "CODEX_RAIL_DISTILL_LOCK_FD"; + +struct TrustLock { + _thread: MutexGuard<'static, ()>, + _file: fs::File, +} + +struct TrustTemp(PathBuf); + +impl Drop for TrustTemp { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} fn msg_cap() -> usize { env_usize("CODEX_RAIL_DISTILL_MSG_CAP", MSG_CAP_DEFAULT) @@ -121,38 +149,92 @@ pub struct Chunk { pub marker: String, // the id echoed on this chunk's trailing <> line } +struct RunCorpusGuard { + corpus_rel: String, + armed: bool, +} + +impl RunCorpusGuard { + fn cleanup(&mut self) -> Result<()> { + if self.armed { + cleanup_run_corpus(&self.corpus_rel)?; + self.armed = false; + } + Ok(()) + } + + fn commit(&mut self) { + self.armed = false; + } +} + +impl Drop for RunCorpusGuard { + fn drop(&mut self) { + if self.armed { + let _ = cleanup_run_corpus(&self.corpus_rel); + } + } +} + pub struct DistillPrep { - pub workdir: PathBuf, // distill_dir(); the launched session's cwd - pub version: u32, // next style version (1-based) - pub output_file: String, // "style-v001.md", relative to workdir - pub sessions: usize, // sessions included in the corpus - pub messages: usize, // user turns included - pub codex_sessions: usize, // of `sessions`, how many are from codex + pub workdir: PathBuf, // distill_dir(); the launched session's cwd + pub version: u32, // next style version (1-based) + pub output_file: String, // "style-v001.md", relative to workdir + pub sessions: usize, // sessions included in the corpus + pub messages: usize, // user turns included + pub codex_sessions: usize, // of `sessions`, how many are from codex pub claude_sessions: usize, // …and from Claude Code - pub scanned: usize, // transcript files actually read - pub available: usize, // sessions with >=1 real user turn found while scanning + pub scanned: usize, // transcript files actually read + pub available: usize, // sessions with >=1 real user turn found while scanning pub chunks: Vec, + pub corpus_rel: String, + pub run_lock: fs::File, + corpus_guard: RunCorpusGuard, } -/// Aggregate the archives into `distill_dir()/corpus/` and return a plan the -/// launcher turns into a codex session. Regenerates the corpus every call. +impl DistillPrep { + /// Transfer cleanup ownership to the durable session state/worker after a + /// successful launch (or deliberately keep a headless prepared corpus). + pub fn commit_corpus(&mut self) { + self.corpus_guard.commit(); + } + + pub fn cleanup_corpus(&mut self) -> Result<()> { + self.corpus_guard.cleanup() + } +} + +/// Aggregate the archives into a unique `distill_dir()/runs/run-*/corpus/` and +/// return a plan the launcher turns into a Codex session. pub fn prepare() -> Result { // This tree contains verbatim excerpts from private Codex/Claude history. // Migrate artifacts created by older builds before reading or rewriting it. state::ensure_private_distill_storage()?; + let run_lock = acquire_run_lock()?; let workdir = state::distill_dir(); - let corpus_dir = workdir.join("corpus"); - fs::create_dir_all(&corpus_dir).context("create distill corpus dir")?; - state::restrict_to_owner(&corpus_dir)?; - // Clear any corpus from a previous run so stale chunks can't linger. - if let Ok(entries) = fs::read_dir(&corpus_dir) { - for e in entries.flatten() { - let p = e.path(); - if p.extension().map(|x| x == "md").unwrap_or(false) { - let _ = fs::remove_file(&p); - } - } - } + let run_counter = DISTILL_RUN_COUNTER.fetch_add(1, Ordering::Relaxed); + let run_id = format!( + "run-{:x}-{:x}-{run_counter:x}", + state::now_millis(), + std::process::id() + ); + // Every run gets immutable input paths. The lifetime lock still serializes + // version allocation and active jobs, while unique directories make a + // crashed/old session incapable of reading a later run's rewritten corpus. + let corpus_rel = format!("runs/{run_id}/corpus"); + let runs_dir = workdir.join("runs"); + let run_dir = runs_dir.join(&run_id); + let corpus_dir = run_dir.join("corpus"); + // create_dir_all would expose intermediate `runs/` and `run-*` directories + // through the process umask (commonly 0755). Validate and chmod each level + // before creating the next because every one contains private history. + state::ensure_private_directory(&runs_dir)?; + state::ensure_private_directory(&run_dir)?; + let mut corpus_guard = RunCorpusGuard { + corpus_rel: corpus_rel.clone(), + armed: true, + }; + state::ensure_private_directory(&corpus_dir)?; let cap = msg_cap(); let lead = lead_cap(); @@ -204,11 +286,14 @@ pub fn prepare() -> Result { inc_claude += 1; c }; + let kept_user_turns = clip_turns(&c.turns, TURN_CAP) + .into_iter() + .filter(|turn| matches!(turn.role, Role::User)) + .count(); let block = format_convo(c, idx, cap, lead); - // Count the user turns that ACTUALLY land in the corpus (a very long - // session is clipped to a head+tail arc), so the reported number matches - // what the codex session reads and echoes back — not the pre-clip total. - user_msgs += block.lines().filter(|l| l.starts_with("> ")).count(); + // Count roles in the clipped structure, never rendered line prefixes: + // a multiline user paste may itself contain lines beginning `> `. + user_msgs += kept_user_turns; if take_codex { cx_bytes += block.len(); } else { @@ -217,6 +302,12 @@ pub fn prepare() -> Result { body_blocks.push(block); } let included = body_blocks.len(); + if included == 0 || user_msgs == 0 { + corpus_guard + .cleanup() + .context("clean empty private distillation corpus before returning")?; + bail!("no genuine user history was found to distill"); + } // Pack blocks into chunk-sized buffers. A session may span a chunk boundary — // fine, codex reads every chunk in order. @@ -236,7 +327,7 @@ pub fn prepare() -> Result { // Write each file with a header and a trailing marker whose id is unique to // this run (so a stale style file can't pass verification). let n = buffers.len(); - let salt = state::now_millis(); + let salt = format!("{run_id}:{}", state::now_millis()); let mut chunks = Vec::with_capacity(n); for (i, body) in buffers.into_iter().enumerate() { let k = i + 1; @@ -250,7 +341,10 @@ pub fn prepare() -> Result { chunks.push(Chunk { file, marker }); } - let version = next_version(&workdir); + // Permanently reserve the version before launching Codex. A failed v001 + // must not let a later run reuse v001, because the old unique-corpus session + // may still be resumed and publish its own output later. + let version = reserve_version(&workdir, &run_id)?; Ok(DistillPrep { workdir, version, @@ -262,9 +356,263 @@ pub fn prepare() -> Result { scanned, available, chunks, + corpus_rel, + run_lock, + corpus_guard, }) } +fn run_lock_path() -> PathBuf { + state::distill_dir().join(".active.lock") +} + +/// Hold this file for the complete prepare -> Codex -> validation lifetime. +/// `flock` survives unlink/path races only while the inode remains, so the lock +/// file is permanent and private rather than removed on completion. +pub fn acquire_run_lock() -> Result { + state::ensure_private_distill_storage()?; + let path = run_lock_path(); + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + .with_context(|| format!("open distill lock {}", path.display()))?; + let meta = file.metadata()?; + if !meta.is_file() || meta.uid() != unsafe { libc::geteuid() } { + bail!("distill lock is not a private owned regular file"); + } + file.set_permissions(fs::Permissions::from_mode(0o600))?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + let err = std::io::Error::last_os_error(); + let raw = err.raw_os_error(); + if raw == Some(libc::EWOULDBLOCK) || raw == Some(libc::EAGAIN) { + bail!("another style distillation is already preparing or running"); + } + return Err(err).context("lock distillation run"); + } + Ok(file) +} + +/// Clear close-on-exec so the manager can hand the already-held open-file +/// description to its worker without a lock-release gap. +pub fn make_run_lock_inheritable(file: &fs::File) -> Result<()> { + let fd = file.as_raw_fd(); + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()).context("read distill lock fd flags"); + } + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 { + return Err(std::io::Error::last_os_error()).context("make distill lock inheritable"); + } + Ok(()) +} + +/// Take the lock inherited from the launching manager, or acquire it on an +/// explicit resume. The fd is validated against the canonical lock inode before +/// ownership is accepted, then made close-on-exec again so Codex never inherits +/// the lease itself. +pub fn worker_run_lock() -> Result { + let Some(raw) = std::env::var_os(DISTILL_LOCK_FD_ENV) else { + return acquire_run_lock(); + }; + std::env::remove_var(DISTILL_LOCK_FD_ENV); + let raw = raw + .to_str() + .and_then(|value| value.parse::().ok()) + .filter(|fd| *fd >= 3) + .context("invalid inherited distill lock fd")?; + // SAFETY: the manager deliberately inherited this unique descriptor and + // ownership is transferred exactly once at worker startup. + let file = unsafe { fs::File::from_raw_fd(raw) }; + let fd_meta = file.metadata().context("inspect inherited distill lock")?; + let path_meta = fs::symlink_metadata(run_lock_path()).context("inspect distill lock path")?; + if !fd_meta.is_file() + || path_meta.file_type().is_symlink() + || !path_meta.is_file() + || fd_meta.dev() != path_meta.dev() + || fd_meta.ino() != path_meta.ino() + || fd_meta.uid() != unsafe { libc::geteuid() } + || fd_meta.mode() & 0o077 != 0 + { + bail!("inherited distill lock does not match the trusted lock inode"); + } + // flock belongs to the open-file description and survives fork/exec. A + // second nonblocking LOCK_EX both confirms the genuine inherited + // description and safely acquires the lease if a manually invoked worker + // supplied the canonical inode without a lock. It fails closed when any + // other description owns the lifetime lease. + claim_inherited_run_lock(&file)?; + let flags = unsafe { libc::fcntl(raw, libc::F_GETFD) }; + if flags < 0 || unsafe { libc::fcntl(raw, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 { + return Err(std::io::Error::last_os_error()).context("seal inherited distill lock fd"); + } + Ok(file) +} + +fn claim_inherited_run_lock(file: &fs::File) -> Result<()> { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err(std::io::Error::last_os_error()) + .context("inherited distill descriptor does not own the lifetime lock"); + } + Ok(()) +} + +/// Validate the machine-readable footer required by the distillation prompt. +/// A non-empty file alone is not success: every per-run marker must appear +/// exactly once and the claimed USER-turn count must equal the prepared corpus. +pub fn validate_output_contract( + path: &Path, + expected_markers: &[String], + expected_user_turns: usize, +) -> Result<()> { + if expected_markers.is_empty() { + bail!("distill completion contract has no chunk markers"); + } + const MAX_STYLE_BYTES: u64 = 8 * 1024 * 1024; + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("open distilled style {}", path.display()))?; + let meta = file.metadata()?; + if !meta.is_file() || meta.len() == 0 || meta.len() > MAX_STYLE_BYTES { + bail!("distilled style is empty, oversized, or not a regular file"); + } + let mut bytes = Vec::with_capacity(meta.len() as usize); + file.take(MAX_STYLE_BYTES + 1).read_to_end(&mut bytes)?; + let text = std::str::from_utf8(&bytes).context("distilled style is not UTF-8")?; + let coverage_at = text + .lines() + .enumerate() + .filter(|(_, line)| line.trim() == "## Coverage") + .map(|(index, _)| index) + .last() + .context("distilled style is missing an exact ## Coverage section")?; + let coverage: Vec<&str> = text.lines().skip(coverage_at + 1).map(str::trim).collect(); + let mut found = Vec::new(); + let mut turns = Vec::new(); + for line in coverage { + if let Some(marker) = line.strip_prefix("CHUNK_ID=") { + found.push(marker.to_string()); + } else if let Some(value) = line.strip_prefix("USER_TURNS_READ=") { + turns.push( + value + .parse::() + .context("invalid USER_TURNS_READ value")?, + ); + } + } + let expected: HashSet<&str> = expected_markers.iter().map(String::as_str).collect(); + let actual: HashSet<&str> = found.iter().map(String::as_str).collect(); + if found.len() != expected_markers.len() || actual.len() != found.len() || actual != expected { + bail!("Coverage chunk markers are missing, duplicated, or from another run"); + } + if turns.as_slice() != [expected_user_turns] { + bail!( + "Coverage USER turn count does not match prepared corpus (expected {expected_user_turns})" + ); + } + state::restrict_private_file_to_owner(path)?; + Ok(()) +} + +pub fn mark_output_validated(version: u32) -> Result<()> { + let marker = state::distill_dir().join(format!("style-v{version:03}.validated")); + state::write_private_file_atomic(&marker, b"validated-by-codex-rail\n") +} + +/// Return a style only when both it and Rail's durable validation marker are +/// private owned regular files and the marker has the exact format written +/// above. A directory entry named `.validated` is not itself a trust signal. +pub fn validated_style_file(version: u32) -> Option { + validated_style_file_at(&state::distill_dir(), version) +} + +fn validated_style_file_at(root: &Path, version: u32) -> Option { + const MARKER: &[u8] = b"validated-by-codex-rail\n"; + let marker_path = root.join(format!("style-v{version:03}.validated")); + let mut marker = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&marker_path) + .ok()?; + let marker_meta = marker.metadata().ok()?; + if !marker_meta.is_file() + || marker_meta.uid() != unsafe { libc::geteuid() } + || marker_meta.mode() & 0o077 != 0 + || marker_meta.len() != MARKER.len() as u64 + { + return None; + } + let mut marker_bytes = Vec::with_capacity(MARKER.len()); + marker.read_to_end(&mut marker_bytes).ok()?; + if marker_bytes != MARKER { + return None; + } + + let style_path = root.join(format!("style-v{version:03}.md")); + let style = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&style_path) + .ok()?; + let style_meta = style.metadata().ok()?; + if !style_meta.is_file() + || style_meta.uid() != unsafe { libc::geteuid() } + || style_meta.mode() & 0o077 != 0 + || style_meta.len() == 0 + || style_meta.len() > 8 * 1024 * 1024 + { + return None; + } + Some(style_path) +} + +/// Remove only the immutable run directory shape minted by `prepare`. Never +/// accept an absolute path, `..`, or a symlinked run root from persisted state. +pub fn cleanup_run_corpus(corpus_rel: &str) -> Result<()> { + state::ensure_private_distill_storage() + .context("validate private distill root before corpus cleanup")?; + let rel = Path::new(corpus_rel); + let parts: Vec<_> = rel.components().collect(); + let valid = matches!( + parts.as_slice(), + [ + std::path::Component::Normal(runs), + std::path::Component::Normal(run), + std::path::Component::Normal(corpus) + ] if *runs == std::ffi::OsStr::new("runs") + && run.to_string_lossy().starts_with("run-") + && *corpus == std::ffi::OsStr::new("corpus") + ); + if !valid { + bail!("refuse invalid distill corpus path {corpus_rel:?}"); + } + let runs_dir = state::distill_dir().join("runs"); + // Reject a symlinked intermediate before remove_dir_all sees it. This is + // both an old-layout migration guard and protection against a corrupted + // persisted corpus path redirecting deletion outside Rail's private root. + state::ensure_private_directory(&runs_dir) + .context("validate private distill runs directory before cleanup")?; + let run_dir = runs_dir.join(match &parts[1] { + std::path::Component::Normal(run) => run, + _ => unreachable!("validated above"), + }); + let meta = match fs::symlink_metadata(&run_dir) { + Ok(meta) => meta, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err).context("inspect distill run directory"), + }; + if meta.file_type().is_symlink() || !meta.is_dir() || meta.uid() != unsafe { libc::geteuid() } { + bail!("refuse unsafe distill run directory {}", run_dir.display()); + } + fs::remove_dir_all(&run_dir) + .with_context(|| format!("remove completed distill corpus {}", run_dir.display())) +} + // Scan both sources (a biggest+newest candidate sample per source) into // contextualized conversations. Returns the convos with >=1 real user turn and // how many files were actually read. @@ -563,7 +911,11 @@ fn format_convo(c: &Convo, idx: usize, cap: usize, lead: usize) -> String { let nuser = c.user_turns(); let clipped = c.turns.len() > TURN_CAP; let kept = clip_turns(&c.turns, TURN_CAP); - let proj = if c.project.is_empty() { "-" } else { &c.project }; + let proj = if c.project.is_empty() { + "-" + } else { + &c.project + }; let note = if clipped { format!(" · (long: {} of {} turns)", kept.len(), c.turns.len()) } else { @@ -581,9 +933,12 @@ fn format_convo(c: &Convo, idx: usize, cap: usize, lead: usize) -> String { for t in kept { match t.role { Role::User => { - s.push_str("> "); - s.push_str(&cap_chars(t.text.trim(), cap)); - s.push('\n'); + let text = cap_chars(t.text.trim(), cap); + for (line_index, line) in text.lines().enumerate() { + s.push_str(if line_index == 0 { "> " } else { "> | " }); + s.push_str(line); + s.push('\n'); + } } Role::Assistant => { let mut line = String::from("[assistant] "); @@ -753,9 +1108,54 @@ fn next_version(dir: &Path) -> u32 { } } } + let claims = dir.join("claims"); + if let Ok(entries) = fs::read_dir(claims) { + for entry in entries.flatten() { + if let Some(name) = entry.file_name().to_str() { + if let Some(rest) = name.strip_prefix("style-v") { + if let Some(num) = rest.strip_suffix(".claim") { + if let Ok(version) = num.parse::() { + max = max.max(version); + } + } + } + } + } + } max + 1 } +fn reserve_version(dir: &Path, run_id: &str) -> Result { + let claims = dir.join("claims"); + fs::create_dir_all(&claims).context("create distill claims directory")?; + state::restrict_to_owner(&claims)?; + let mut version = next_version(dir); + loop { + let path = claims.join(format!("style-v{version:03}.claim")); + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + { + Ok(mut file) => { + file.write_all(format!("run_id={run_id}\n").as_bytes())?; + file.sync_all()?; + let parent = fs::File::open(&claims)?; + parent.sync_all()?; + return Ok(version); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + version = version.checked_add(1).context("distill version overflow")?; + } + Err(err) => { + return Err(err).with_context(|| format!("reserve {}", path.display())); + } + } + } +} + // FNV-1a → 8 hex chars. Deterministic, dependency-free; used only to mint an // opaque per-chunk marker for read-coverage verification (not security). fn short_hash(s: &str) -> String { @@ -776,49 +1176,325 @@ fn short_hash(s: &str) -> String { /// the session still works, it just waits for a one-time approval. pub fn ensure_trusted(dir: &Path) -> Result<()> { let cfg = state::codex_home_dir().join("config.toml"); - let marker = format!("[projects.\"{}\"]", dir.to_string_lossy()); - let existing = fs::read_to_string(&cfg).unwrap_or_default(); - if existing.contains(&marker) { - return Ok(()); // already trusted - } - let mut updated = existing; - if !updated.is_empty() && !updated.ends_with('\n') { - updated.push('\n'); - } - updated.push_str(&format!("\n{marker}\ntrust_level = \"trusted\"\n")); - if let Some(parent) = cfg.parent() { - let _ = fs::create_dir_all(parent); - } - // Write atomically (temp + rename) so an interrupted write can't corrupt the - // user's codex config. - let tmp = cfg.with_extension("toml.rail-tmp"); - fs::write(&tmp, &updated).with_context(|| format!("write {}", tmp.display()))?; - fs::rename(&tmp, &cfg).with_context(|| format!("install {}", cfg.display()))?; - Ok(()) + ensure_trusted_at(&cfg, dir) +} + +fn toml_basic_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{08}' => out.push_str("\\b"), + '\u{0c}' => out.push_str("\\f"), + c if c <= '\u{1f}' || ('\u{7f}'..='\u{9f}').contains(&c) => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + _ => out.push(c), + } + } + out.push('"'); + out +} + +fn config_lock_path(cfg: &Path) -> Result { + let parent = cfg + .parent() + .context("codex config has no parent directory")?; + let name = cfg + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("config.toml"); + Ok(parent.join(format!(".{name}.rail.lock"))) +} + +fn acquire_trust_lock(cfg: &Path) -> Result { + let thread = TRUST_THREAD_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let path = config_lock_path(cfg)?; + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + .with_context(|| format!("open codex config lock {}", path.display()))?; + if !file.metadata()?.is_file() { + bail!( + "codex config lock is not a regular file: {}", + path.display() + ); + } + file.set_permissions(fs::Permissions::from_mode(0o600))?; + loop { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0 { + break; + } + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + return Err(err).with_context(|| format!("lock codex config {}", path.display())); + } + Ok(TrustLock { + _thread: thread, + _file: file, + }) +} + +fn read_config(cfg: &Path) -> Result> { + let mut file = match fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(cfg) + { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("open {}", cfg.display())), + }; + let meta = file.metadata()?; + if !meta.is_file() { + bail!("codex config is not a regular file: {}", cfg.display()); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .with_context(|| format!("read {}", cfg.display()))?; + let text = + String::from_utf8(bytes).with_context(|| format!("decode {} as UTF-8", cfg.display()))?; + Ok(Some((text, meta.permissions().mode() & 0o777))) +} + +fn private_config_mode(existing: Option) -> u32 { + // Preserve private owner bits (including a deliberately read-only config), + // but never carry group/other access into the replacement. A new or + // nonsensically mode-000 file becomes the normal owner-readable/writable 0600. + let owner = existing.unwrap_or(0o600) & 0o700; + if owner == 0 { + 0o600 + } else { + owner + } +} + +fn create_unique_config_file(cfg: &Path) -> Result<(PathBuf, fs::File)> { + let parent = cfg + .parent() + .context("codex config has no parent directory")?; + let name = cfg + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("config.toml"); + for _ in 0..64 { + let n = TRUST_TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!(".{name}.rail-tmp-{}-{n:x}", std::process::id())); + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + { + Ok(file) => return Ok((path, file)), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err).with_context(|| format!("create {}", path.display())), + } + } + bail!("could not allocate a unique codex config file") +} + +fn is_project_header(line: &str, marker: &str) -> bool { + let Some(rest) = line.trim_start().strip_prefix(marker) else { + return false; + }; + let rest = rest.trim_start(); + rest.is_empty() || rest.starts_with('#') +} + +enum ProjectTrustEdit { + Missing, + AlreadyTrusted, + Updated(String), +} + +fn trust_assignment_value(line: &str) -> Option<&str> { + let trimmed = line.trim_start(); + let rest = trimmed + .strip_prefix("trust_level") + .or_else(|| trimmed.strip_prefix("\"trust_level\""))?; + let rest = rest.trim_start().strip_prefix('=')?; + Some(rest.trim_start()) +} + +fn trusted_value(value: &str) -> bool { + let value = value.trim_end_matches(['\r', '\n']); + let rest = value + .strip_prefix("\"trusted\"") + .or_else(|| value.strip_prefix("'trusted'")); + rest.is_some_and(|tail| { + let tail = tail.trim_start(); + tail.is_empty() || tail.starts_with('#') + }) +} + +fn edit_existing_project_trust(existing: &str, marker: &str) -> ProjectTrustEdit { + let lines: Vec<_> = existing.split_inclusive('\n').collect(); + let Some(header) = lines + .iter() + .position(|line| is_project_header(line, marker)) + else { + return ProjectTrustEdit::Missing; + }; + let end = lines + .iter() + .enumerate() + .skip(header + 1) + .find(|(_, line)| line.trim_start().starts_with('[')) + .map(|(index, _)| index) + .unwrap_or(lines.len()); + + if let Some((assignment, value)) = lines + .iter() + .enumerate() + .take(end) + .skip(header + 1) + .find_map(|(index, line)| trust_assignment_value(line).map(|value| (index, value))) + { + if trusted_value(value) { + return ProjectTrustEdit::AlreadyTrusted; + } + let line = lines[assignment]; + let indent_len = line.len() - line.trim_start().len(); + let indent = &line[..indent_len]; + let newline = if line.ends_with("\r\n") { + "\r\n" + } else if line.ends_with('\n') { + "\n" + } else { + "" + }; + let mut updated = String::with_capacity(existing.len() + 16); + updated.extend(lines[..assignment].iter().copied()); + updated.push_str(indent); + updated.push_str("trust_level = \"trusted\""); + updated.push_str(newline); + updated.extend(lines[assignment + 1..].iter().copied()); + return ProjectTrustEdit::Updated(updated); + } + + let header_line = lines[header]; + let newline = if header_line.ends_with("\r\n") { + "\r\n" + } else { + "\n" + }; + let mut updated = String::with_capacity(existing.len() + 30); + updated.extend(lines[..=header].iter().copied()); + if !header_line.ends_with('\n') { + updated.push_str(newline); + } + updated.push_str("trust_level = \"trusted\""); + updated.push_str(newline); + updated.extend(lines[header + 1..].iter().copied()); + ProjectTrustEdit::Updated(updated) +} + +fn ensure_trusted_at(cfg: &Path, dir: &Path) -> Result<()> { + let parent = cfg + .parent() + .context("codex config has no parent directory")?; + let dir = dir + .to_str() + .context("cannot add a non-UTF-8 project path to Codex config")?; + let marker = format!("[projects.{}]", toml_basic_string(dir)); + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + state::restrict_to_owner(parent)?; + let _lock = acquire_trust_lock(cfg)?; + + // The lock serializes every rail process. The small retry also notices a + // non-cooperating Codex process that rewrites config.toml while rail is + // preparing its replacement, instead of blindly clobbering that update. + for _ in 0..4 { + let snapshot = read_config(cfg)?; + let (existing, old_mode) = snapshot + .as_ref() + .map(|(text, mode)| (text.clone(), Some(*mode))) + .unwrap_or_else(|| (String::new(), None)); + let mode = private_config_mode(old_mode); + let updated = match edit_existing_project_trust(&existing, &marker) { + ProjectTrustEdit::AlreadyTrusted => { + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(cfg) + .with_context(|| format!("open {}", cfg.display()))?; + file.set_permissions(fs::Permissions::from_mode(mode))?; + return Ok(()); + } + ProjectTrustEdit::Updated(updated) => updated, + ProjectTrustEdit::Missing => { + let mut updated = existing; + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + updated.push_str(&format!("\n{marker}\ntrust_level = \"trusted\"\n")); + updated + } + }; + + let (tmp, mut file) = create_unique_config_file(cfg)?; + let cleanup = TrustTemp(tmp.clone()); + file.write_all(updated.as_bytes()) + .with_context(|| format!("write {}", tmp.display()))?; + file.set_permissions(fs::Permissions::from_mode(mode))?; + file.sync_all() + .with_context(|| format!("sync {}", tmp.display()))?; + drop(file); + + if read_config(cfg)? != snapshot { + drop(cleanup); + continue; + } + let parent_dir = fs::File::open(parent) + .with_context(|| format!("open codex config directory {}", parent.display()))?; + fs::rename(&tmp, cfg).with_context(|| format!("install {}", cfg.display()))?; + parent_dir + .sync_all() + .with_context(|| format!("sync codex config directory {}", parent.display()))?; + drop(cleanup); + return Ok(()); + } + bail!("codex config kept changing while rail tried to update it") } /// The English instruction handed to the launched codex session. It must read -/// every chunk fully and echo back each chunk's marker id, so completeness can be -/// checked afterwards. Distills BOTH the user's voice and their problem-solving +/// every chunk fully and echo back each chunk's marker id, so stale/partial +/// outputs can be rejected afterwards. Distills BOTH the user's voice and their problem-solving /// logic, using the `[assistant]` context lines to interpret each user turn. pub fn distill_prompt(prep: &DistillPrep) -> String { let n = prep.chunks.len(); let out = &prep.output_file; + let corpus = &prep.corpus_rel; format!( "You are studying the user's OWN past conversations to distill BOTH (a) how they \ write and (b) how they think and solve problems — their decision logic, not just their tone.\n\n\ The material is {sessions} of the user's richest real sessions ({codex} from the codex CLI, \ {claude} from Claude Code), split into {n} files in reading order:\n \ -corpus/corpus-01.md, corpus/corpus-02.md, …, corpus/corpus-{n:02}.md\n\n\ +{corpus}/corpus-01.md, {corpus}/corpus-02.md, …, {corpus}/corpus-{n:02}.md\n\n\ FORMAT of each file — a series of session transcripts. Within a session:\n \ • lines beginning `[assistant]` are COMPRESSED CONTEXT: a short lead-in of what the assistant \ had just said or done (and which tools it ran). They exist only so the user's next turn is \ interpretable — do NOT study the assistant's style.\n \ -• lines beginning `> ` are the USER's OWN turns, verbatim (long ones truncated). THIS is the \ -person you are distilling. Read each `> ` turn IN THE CONTEXT of the `[assistant]` line(s) \ +• lines beginning `> ` start the USER's OWN turns; `> | ` continues the same multiline turn \ +(long ones truncated). THIS is the person you are distilling. Read each USER turn IN THE CONTEXT of the `[assistant]` line(s) \ above it: what were they reacting to, and what did they decide to do about it?\n\n\ Do the following IN ORDER and skip nothing:\n\n\ -1. Read EVERY file corpus-01.md through corpus-{n:02}.md, IN FULL, top to bottom — do NOT grep, \ +1. Read EVERY file {corpus}/corpus-01.md through {corpus}/corpus-{n:02}.md, IN FULL, top to bottom — do NOT grep, \ search, sample, or skim. Each file ends with a line `<>`. Record every id.\n\n\ 2. As you read, study TWO things:\n \ • VOICE — tone and directness; the Chinese/English code-switching; sentence shape and length; \ @@ -833,14 +1509,17 @@ How they give feedback / push back; Problem-solving logic & decision patterns; H task (open → steer → approve → close); What triggers approval vs. pushback; Recurring phrases & \ tics (quote short real snippets); Values & priorities; Do / Don't for imitating them (cover BOTH \ voice AND reasoning). Be specific — quote short real snippets, avoid generic filler.\n\n\ -4. End `{out}` with a `## Coverage` section that lists EVERY chunk id you recorded in step 1 \ -(all {n}, one per line) and the total number of USER turns you actually read. This verifies a \ -complete read.\n\n\ +4. End `{out}` with this exact machine-readable footer: a line `## Coverage`; then exactly one \ +line `CHUNK_ID=` for EVERY chunk id recorded in step 1 (all {n}, no duplicates or extra ids); \ +then exactly one line `USER_TURNS_READ={messages}`. The turn count is the {messages} `> ` USER \ +turns in the prepared corpus. Do not vary these key names. This is Rail's completion contract.\n\n\ Write everything in English. When finished, confirm the file was written and how many chunks you \ covered.", sessions = prep.sessions, codex = prep.codex_sessions, claude = prep.claude_sessions, + corpus = corpus, + messages = prep.messages, ) } @@ -853,11 +1532,19 @@ mod tests { assert!(is_real_user("看下我的 dataset,刷新 sota")); assert!(is_real_user("fix the bug, then re-run the tests please")); assert!(!is_real_user(" ")); - assert!(!is_real_user(" {\"agent_path\":\"x\"}")); - assert!(!is_real_user("cwd=/tmp")); - assert!(!is_real_user("be nice")); + assert!(!is_real_user( + " {\"agent_path\":\"x\"}" + )); + assert!(!is_real_user( + "cwd=/tmp" + )); + assert!(!is_real_user( + "be nice" + )); // Claude-side blobs - assert!(!is_real_user("Caveat: The messages below…")); + assert!(!is_real_user( + "Caveat: The messages below…" + )); assert!(!is_real_user("compact")); assert!(!is_real_user( "You are analyzing the user's OWN past messages to distill…" @@ -952,9 +1639,146 @@ mod tests { fs::write(dir.join("style-v004.md"), "x").unwrap(); fs::write(dir.join("notes.md"), "x").unwrap(); assert_eq!(next_version(&dir), 5); + fs::create_dir_all(dir.join("claims")).unwrap(); + fs::write(dir.join("claims/style-v009.claim"), "run_id=test\n").unwrap(); + assert_eq!(next_version(&dir), 10); let _ = fs::remove_dir_all(&dir); } + #[test] + fn coverage_contract_requires_exact_markers_and_turn_count() { + let dir = std::env::temp_dir().join(format!( + "rail-distill-contract-{}-{}", + std::process::id(), + state::now_millis() + )); + fs::create_dir_all(&dir).unwrap(); + let output = dir.join("style-v001.md"); + let markers = vec!["a1b2c3d4".to_string(), "ffeedd00".to_string()]; + fs::write( + &output, + "# profile\n\n## Coverage\nCHUNK_ID=a1b2c3d4\nCHUNK_ID=ffeedd00\nUSER_TURNS_READ=7\n", + ) + .unwrap(); + validate_output_contract(&output, &markers, 7).unwrap(); + + fs::write( + &output, + "# profile\n## Coverage\nCHUNK_ID=a1b2c3d4\nUSER_TURNS_READ=7\n", + ) + .unwrap(); + assert!(validate_output_contract(&output, &markers, 7).is_err()); + fs::write( + &output, + "# profile\n## Coverage\nCHUNK_ID=a1b2c3d4\nCHUNK_ID=ffeedd00\nCHUNK_ID=ffeedd00\nUSER_TURNS_READ=7\n", + ) + .unwrap(); + assert!(validate_output_contract(&output, &markers, 7).is_err()); + fs::write( + &output, + "# profile\n## Coverage\nCHUNK_ID=a1b2c3d4\nCHUNK_ID=ffeedd00\nUSER_TURNS_READ=8\n", + ) + .unwrap(); + assert!(validate_output_contract(&output, &markers, 7).is_err()); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn inherited_run_lock_must_share_or_acquire_the_exclusive_flock() { + let path = std::env::temp_dir().join(format!( + "rail-distill-inherited-lock-{}-{}", + std::process::id(), + state::now_millis() + )); + fs::write(&path, b"").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let owner = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + assert_eq!( + unsafe { libc::flock(owner.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }, + 0 + ); + + // dup/try_clone shares the locked open-file description and is valid. + let inherited = owner.try_clone().unwrap(); + claim_inherited_run_lock(&inherited).unwrap(); + + // A fresh open of the same trusted inode is not proof of ownership. + let separate = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + assert!(claim_inherited_run_lock(&separate).is_err()); + drop(inherited); + drop(owner); + + // Once the true owner releases, claiming the fresh descriptor is safe. + claim_inherited_run_lock(&separate).unwrap(); + drop(separate); + let _ = fs::remove_file(path); + } + + #[test] + fn validated_style_requires_exact_private_regular_artifacts() { + use std::os::unix::fs::symlink; + + let dir = std::env::temp_dir().join(format!( + "rail-validated-style-{}-{}", + std::process::id(), + state::now_millis() + )); + fs::create_dir_all(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + let style = dir.join("style-v007.md"); + let marker = dir.join("style-v007.validated"); + fs::write(&style, b"# style\n").unwrap(); + fs::write(&marker, b"wrong\n").unwrap(); + fs::set_permissions(&style, fs::Permissions::from_mode(0o600)).unwrap(); + fs::set_permissions(&marker, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(validated_style_file_at(&dir, 7).is_none()); + + fs::write(&marker, b"validated-by-codex-rail\n").unwrap(); + assert_eq!(validated_style_file_at(&dir, 7), Some(style.clone())); + fs::set_permissions(&marker, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(validated_style_file_at(&dir, 7).is_none()); + fs::set_permissions(&marker, fs::Permissions::from_mode(0o600)).unwrap(); + + let victim = dir.join("victim"); + fs::write(&victim, b"not a style\n").unwrap(); + fs::set_permissions(&victim, fs::Permissions::from_mode(0o600)).unwrap(); + fs::remove_file(&style).unwrap(); + symlink(&victim, &style).unwrap(); + assert!(validated_style_file_at(&dir, 7).is_none()); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn multiline_user_turns_have_unambiguous_continuations() { + let convo = Convo { + src: Src::Codex, + date: "2026-07-13".to_string(), + project: "rail".to_string(), + turns: vec![Turn { + role: Role::User, + text: "first\n> quoted\nthird".to_string(), + tools: Vec::new(), + }], + }; + let rendered = format_convo(&convo, 1, 700, 220); + assert!(rendered.contains("> first\n> | > quoted\n> | third\n")); + assert_eq!( + clip_turns(&convo.turns, TURN_CAP) + .into_iter() + .filter(|turn| matches!(turn.role, Role::User)) + .count(), + 1 + ); + } + #[test] fn date_parsing() { let p = std::path::Path::new("/x/2026/07/03/rollout-2026-07-03T22-45-01-uuid.jsonl"); @@ -969,4 +1793,256 @@ mod tests { assert_eq!(short_hash("123:1"), short_hash("123:1")); assert_ne!(short_hash("123:1"), short_hash("123:2")); } + + #[test] + fn toml_project_keys_escape_quotes_backslashes_and_controls() { + assert_eq!( + toml_basic_string("a\"b\\c\n\t\u{7f}"), + "\"a\\\"b\\\\c\\n\\t\\u007f\"" + ); + } + + #[test] + fn ensure_trusted_preserves_content_and_private_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "rail-trust-private-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = dir.join(".codex/config.toml"); + fs::create_dir_all(cfg.parent().unwrap()).unwrap(); + fs::write(&cfg, "model = \"gpt-test\"\n").unwrap(); + fs::set_permissions(&cfg, fs::Permissions::from_mode(0o600)).unwrap(); + let project = Path::new("/tmp/a\"quote\\tail\nnext"); + + ensure_trusted_at(&cfg, project).unwrap(); + let content = fs::read_to_string(&cfg).unwrap(); + assert!(content.starts_with("model = \"gpt-test\"\n")); + assert!(content.contains("[projects.\"/tmp/a\\\"quote\\\\tail\\nnext\"]")); + assert_eq!( + fs::metadata(&cfg).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(cfg.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn a_commented_project_table_does_not_count_as_trusted() { + let dir = std::env::temp_dir().join(format!( + "rail-trust-commented-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = dir.join("config.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + &cfg, + "# [projects.\"/tmp/project\"]\n# trust_level = \"trusted\"\n", + ) + .unwrap(); + + ensure_trusted_at(&cfg, Path::new("/tmp/project")).unwrap(); + let content = fs::read_to_string(&cfg).unwrap(); + assert_eq!(content.matches("[projects.\"/tmp/project\"]").count(), 2); + assert!(content + .lines() + .any(|line| line == "[projects.\"/tmp/project\"]")); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn project_table_with_an_inline_comment_is_recognized() { + let dir = std::env::temp_dir().join(format!( + "rail-trust-inline-comment-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = dir.join("config.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + &cfg, + "[projects.\"/tmp/project\"] # managed by the user\ntrust_level = \"untrusted\"\n", + ) + .unwrap(); + + ensure_trusted_at(&cfg, Path::new("/tmp/project")).unwrap(); + let content = fs::read_to_string(&cfg).unwrap(); + assert_eq!(content.matches("[projects.\"/tmp/project\"]").count(), 1); + assert!(content.contains("# managed by the user")); + assert!(content.contains("trust_level = \"trusted\"")); + assert!(!content.contains("trust_level = \"untrusted\"")); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn non_utf8_project_path_fails_closed_without_touching_config() { + use std::os::unix::ffi::OsStringExt; + let dir = std::env::temp_dir().join(format!( + "rail-trust-non-utf8-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = dir.join("config.toml"); + fs::create_dir_all(&dir).unwrap(); + let original = "model = \"gpt-test\"\n"; + fs::write(&cfg, original).unwrap(); + let project = PathBuf::from(std::ffi::OsString::from_vec(b"/tmp/bad-\xff".to_vec())); + + assert!(ensure_trusted_at(&cfg, &project).is_err()); + assert_eq!(fs::read_to_string(&cfg).unwrap(), original); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn ensure_trusted_preserves_private_owner_mode() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!( + "rail-trust-mode-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = dir.join("config.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write(&cfg, "model = \"gpt-test\"\n").unwrap(); + fs::set_permissions(&cfg, fs::Permissions::from_mode(0o400)).unwrap(); + + ensure_trusted_at(&cfg, Path::new("/tmp/project")).unwrap(); + assert_eq!( + fs::metadata(&cfg).unwrap().permissions().mode() & 0o777, + 0o400 + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn ensure_trusted_never_turns_a_read_error_into_an_empty_config() { + let dir = std::env::temp_dir().join(format!( + "rail-trust-read-error-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = dir.join("config.toml"); + fs::create_dir_all(&dir).unwrap(); + fs::write(&cfg, [0xff, 0xfe, 0xfd]).unwrap(); + + assert!(ensure_trusted_at(&cfg, Path::new("/tmp/project")).is_err()); + assert_eq!(fs::read(&cfg).unwrap(), [0xff, 0xfe, 0xfd]); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn concurrent_trust_updates_are_idempotent_and_do_not_clobber() { + let dir = std::env::temp_dir().join(format!( + "rail-trust-race-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = std::sync::Arc::new(dir.join("config.toml")); + fs::create_dir_all(&dir).unwrap(); + fs::write(&*cfg, "model = \"gpt-test\"\n").unwrap(); + + let threads: Vec<_> = ["/tmp/project-a", "/tmp/project-b"] + .into_iter() + .map(|project| { + let cfg = cfg.clone(); + std::thread::spawn(move || ensure_trusted_at(&cfg, Path::new(project)).unwrap()) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + + let content = fs::read_to_string(&*cfg).unwrap(); + assert_eq!(content.matches("model = \"gpt-test\"").count(), 1); + assert_eq!(content.matches("[projects.\"/tmp/project-a\"]").count(), 1); + assert_eq!(content.matches("[projects.\"/tmp/project-b\"]").count(), 1); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn concurrent_trust_updates_across_processes_do_not_clobber() { + const CHILD_CFG: &str = "CODEX_RAIL_TEST_TRUST_CFG"; + const CHILD_PROJECT: &str = "CODEX_RAIL_TEST_TRUST_PROJECT"; + const CHILD_READY: &str = "CODEX_RAIL_TEST_TRUST_READY"; + const CHILD_GO: &str = "CODEX_RAIL_TEST_TRUST_GO"; + + if let Some(cfg) = std::env::var_os(CHILD_CFG) { + let project = std::env::var_os(CHILD_PROJECT).unwrap(); + let ready = PathBuf::from(std::env::var_os(CHILD_READY).unwrap()); + let go = PathBuf::from(std::env::var_os(CHILD_GO).unwrap()); + fs::write(&ready, b"ready").unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !go.exists() { + assert!( + std::time::Instant::now() < deadline, + "parent barrier timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + ensure_trusted_at(Path::new(&cfg), Path::new(&project)).unwrap(); + return; + } + + let dir = std::env::temp_dir().join(format!( + "rail-trust-process-race-{}-{}", + std::process::id(), + state::now_millis() + )); + let cfg = dir.join("config.toml"); + let go = dir.join("go"); + fs::create_dir_all(&dir).unwrap(); + fs::write(&cfg, "model = \"gpt-test\"\n").unwrap(); + let harness = std::env::current_exe().unwrap(); + let spawn = |project: &str, ready: &Path| { + std::process::Command::new(&harness) + .args([ + "--exact", + "distill::tests::concurrent_trust_updates_across_processes_do_not_clobber", + ]) + .env(CHILD_CFG, &cfg) + .env(CHILD_PROJECT, project) + .env(CHILD_READY, ready) + .env(CHILD_GO, &go) + .spawn() + .unwrap() + }; + let ready_a = dir.join("ready-a"); + let ready_b = dir.join("ready-b"); + let mut child_a = spawn("/tmp/project-a", &ready_a); + let mut child_b = spawn("/tmp/project-b", &ready_b); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while !ready_a.exists() || !ready_b.exists() { + assert!( + std::time::Instant::now() < deadline, + "child barrier timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(2)); + } + fs::write(&go, b"go").unwrap(); + assert!(child_a.wait().unwrap().success()); + assert!(child_b.wait().unwrap().success()); + + let content = fs::read_to_string(&cfg).unwrap(); + assert_eq!(content.matches("model = \"gpt-test\"").count(), 1); + assert_eq!(content.matches("[projects.\"/tmp/project-a\"]").count(), 1); + assert_eq!(content.matches("[projects.\"/tmp/project-b\"]").count(), 1); + + let _ = fs::remove_dir_all(dir); + } } diff --git a/src/guardian.rs b/src/guardian.rs new file mode 100644 index 0000000..ec7810b --- /dev/null +++ b/src/guardian.rs @@ -0,0 +1,123 @@ +//! Per-session process guardian. +//! +//! The worker owns the PTY, but it is not a sufficient lifetime boundary: an +//! OOM/SIGKILL can kill the worker while Codex and detached MCP helpers remain. +//! The manager therefore launches this small subreaper as the worker's parent. +//! If the worker disappears for any reason, Linux reparents its remaining +//! generation here, where PPID ownership is available even when `/proc/environ` +//! is hidden on a shared host. + +use crate::process_tree::{self, CleanupReport, RunGuard}; +use crate::state::{self, STATUS_FAILED, STATUS_RUNNING, STATUS_STARTING, STATUS_STOPPING}; +use anyhow::{Context, Result}; +use std::path::Path; +use std::process::{Command, ExitStatus, Stdio}; +use std::thread; +use std::time::Duration; + +pub fn run_guardian(id: &str) -> Result<()> { + state::validate_session_id(id)?; + unsafe { + libc::setsid(); + } + state::ensure_base_dirs()?; + let _generation_lock = state::acquire_session_generation_lock(id)?; + let subreaper = process_tree::enable_subreaper(); + + let session = state::read_state(id)?; + let mut child = Command::new(std::env::current_exe().context("current executable")?) + .arg("--worker") + .arg(id) + .current_dir(Path::new(&session.cwd)) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("spawn guarded worker")?; + let worker_pid = child.id(); + let mut generation = RunGuard::new(state::new_session_id(), subreaper); + generation.track_root(Some(worker_pid)); + + let status = loop { + match child.try_wait().context("poll guarded worker")? { + Some(status) => break status, + None => { + generation.refresh_if_due(Duration::from_millis(250)); + thread::sleep(Duration::from_millis(50)); + } + } + }; + generation.note_root_exited(); + let mut report = generation.terminate(Duration::from_secs(2), Duration::from_secs(2)); + if !subreaper + && state::read_state_optional(id)?.is_some_and(|session| session.worker_token.is_some()) + { + // The guardian token is intentionally not inherited by Codex (the + // worker installs its own generation token). Without subreaper support, + // an empty guardian lineage is not proof about daemonized helpers. + report.verified = false; + report.survivors = report.survivors.max(1); + } + record_guardian_finish(id, worker_pid, status, report)?; + if report.is_clean() { + Ok(()) + } else { + anyhow::bail!( + "guardian could not verify generation cleanup: {} survivor(s)", + report.survivors + ) + } +} + +fn record_guardian_finish( + id: &str, + worker_pid: u32, + status: ExitStatus, + report: CleanupReport, +) -> Result<()> { + let Some(mut session) = state::read_state_optional(id)? else { + return Ok(()); + }; + // A different pid means state no longer describes our child. This should be + // impossible while generation.lock is held, but fail closed if a broken + // network flock violates the lease. + if session + .worker_pid + .is_some_and(|current| current != worker_pid) + { + return Ok(()); + } + + let worker_left_runtime_state = matches!( + session.status.as_str(), + STATUS_STARTING | STATUS_RUNNING | STATUS_STOPPING + ); + session.worker_pid = None; + session.updated_at = state::now_secs(); + let worker_already_certified_cleanup = + session.worker_token.is_none() && !worker_left_runtime_state; + if worker_already_certified_cleanup { + return state::write_state(&session); + } + if report.is_clean() { + session.child_pid = None; + session.worker_token = None; + if worker_left_runtime_state { + session.status = STATUS_FAILED.to_string(); + session.last_error = Some(match status.code() { + Some(code) => format!( + "worker exited unexpectedly with status {code}; guardian cleaned its process generation" + ), + None => "worker was killed unexpectedly; guardian cleaned its process generation" + .to_string(), + }); + } + } else { + session.status = STATUS_FAILED.to_string(); + session.last_error = Some(format!( + "guardian process cleanup incomplete: {} survivor(s), census verified={}", + report.survivors, report.verified + )); + } + state::write_state(&session) +} diff --git a/src/main.rs b/src/main.rs index 13aa9a2..ae3e017 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,8 @@ mod attach; mod autopilot; mod distill; +mod guardian; +mod process_tree; mod progress; mod protocol; mod state; @@ -14,14 +16,39 @@ use anyhow::{bail, Result}; fn main() -> Result<()> { let mut args = std::env::args().skip(1); + let command = args.next(); - match args.next().as_deref() { + // Rail's crash-safety contract currently depends on Linux subreapers and + // /proc generation censuses. Other Unix targets can compile the shared + // code, but must not start a manager/worker that cannot uphold verified + // descendant cleanup after SIGKILL or OOM. Keep help/version available so + // an accidentally copied binary explains itself instead of failing opaquely. + #[cfg(not(target_os = "linux"))] + if !matches!( + command.as_deref(), + Some("--help") | Some("-h") | Some("--version") | Some("-V") + ) { + bail!( + "Codex Rail is supported only on Linux: verified process-generation cleanup requires Linux subreapers and /proc" + ); + } + + match command.as_deref() { Some("--worker") => { let Some(id) = args.next() else { bail!("missing worker session id"); }; worker::run_worker(&id) } + Some("--guardian") => { + let id = args + .next() + .ok_or_else(|| anyhow::anyhow!("missing session id"))?; + if args.next().is_some() { + bail!("unexpected extra guardian arguments"); + } + guardian::run_guardian(&id) + } Some("--help") | Some("-h") => { print_help(); Ok(()) @@ -35,16 +62,16 @@ fn main() -> Result<()> { Ok(()) } Some("update") => { - println!("rail {} ({})", env!("CARGO_PKG_VERSION"), update::build_sha()); + println!( + "rail {} ({})", + env!("CARGO_PKG_VERSION"), + update::build_sha() + ); match update::newer_available() { Some(latest) => { println!("newer version available: {latest} — downloading…"); - match update::apply() { - Ok(tag) => { - println!("updated to {tag}. Restart rail to run the new version.") - } - Err(e) => println!("update failed: {e:#}"), - } + let tag = update::apply()?; + println!("updated to {tag}. Restart rail to run the new version."); } None => println!("already up to date (or offline / GitHub unreachable)."), } @@ -53,13 +80,13 @@ fn main() -> Result<()> { // Diagnostic/headless: aggregate the archive corpus without launching a // codex session, and print the plan (used by tests and for timing). Some("--distill-prepare") => { - let prep = distill::prepare()?; + let mut prep = distill::prepare()?; // Also drop the exact prompt the UI would launch codex with, so a // headless/real-codex test can drive the same thing. let prompt_path = prep.workdir.join(".last-distill-prompt.txt"); state::write_private_file(&prompt_path, distill::distill_prompt(&prep))?; println!( - "distill: {} sessions ({} codex + {} claude) · {} user turns · {} chunk(s) · scanned {} files, {} rich sessions available -> {}/corpus", + "distill: {} sessions ({} codex + {} claude) · {} user turns · {} chunk(s) · scanned {} files, {} rich sessions available -> {}/{}", prep.sessions, prep.codex_sessions, prep.claude_sessions, @@ -67,13 +94,21 @@ fn main() -> Result<()> { prep.chunks.len(), prep.scanned, prep.available, - prep.workdir.display() + prep.workdir.display(), + prep.corpus_rel + ); + println!( + "next output: {}/{}", + prep.workdir.display(), + prep.output_file ); - println!("next output: {}/{}", prep.workdir.display(), prep.output_file); println!("prompt: {}", prompt_path.display()); for c in &prep.chunks { println!(" {} id={}", c.file, c.marker); } + // Headless prepare intentionally leaves the corpus for the caller + // (for example tests/distill_realcodex.py) to consume. + prep.commit_corpus(); Ok(()) } Some(other) => { diff --git a/src/process_tree.rs b/src/process_tree.rs new file mode 100644 index 0000000..a3de511 --- /dev/null +++ b/src/process_tree.rs @@ -0,0 +1,808 @@ +//! Generation-scoped process supervision for one Rail worker. +//! +//! A Codex process does not stay in one process group: MCP servers and helper +//! Codex instances may call `setsid(2)` or otherwise create their own groups. +//! Consequently, signalling only the PTY child PGID is not a complete stop. +//! +//! On Linux every process launched for a run inherits a unique environment +//! token. We combine that marker with start-time-qualified `/proc` identities +//! and recursive PPID discovery. Previously observed identities remain owned +//! by the run even after reparenting, while PID reuse is rejected by starttime. +//! Shutdown is TERM -> rediscover -> KILL -> rediscover/verify. +//! +//! Other Unix platforms deliberately use a narrower, fail-closed fallback: the +//! freshly spawned root process group is signalled, but unrelated processes are +//! never guessed from PIDs after the root exits. + +use std::io; +use std::thread; +use std::time::{Duration, Instant}; + +pub const RUN_TOKEN_ENV: &str = "CODEX_RAIL_RUN_TOKEN"; + +const POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// Best-effort cleanup result. `verified` means the platform could enumerate +/// the generation after the final signal; an empty survivor set on an +/// unsupported platform only proves that the owned root is gone. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct CleanupReport { + pub term_signals: usize, + pub kill_signals: usize, + pub survivors: usize, + pub verified: bool, +} + +impl CleanupReport { + pub fn is_clean(self) -> bool { + #[cfg(target_os = "linux")] + { + // Linux promises a full generation census. If /proc enumeration + // failed, an empty in-memory set is uncertainty, not proof. + self.survivors == 0 && self.verified + } + + #[cfg(not(target_os = "linux"))] + { + // Other Unix platforms intentionally promise only that the freshly + // owned root process group is gone; detached setsid descendants are + // not guessed from unqualified PIDs. + self.survivors == 0 && self.verified + } + } +} + +/// Ask Linux to reparent orphaned grandchildren to this worker instead of PID +/// 1. This lets the worker reap helpers after their intermediate MCP launcher +/// exits. Failure is non-fatal: token + lineage discovery still performs the +/// kill, but zombie reaping becomes the host init's responsibility. +pub fn enable_subreaper() -> bool { + #[cfg(target_os = "linux")] + { + unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 } + } + + #[cfg(not(target_os = "linux"))] + { + false + } +} + +/// Clean a generation after its worker itself was SIGKILLed/OOM-killed. The +/// persisted worker token is sufficient on Linux even though no trustworthy +/// root PID remains. Callers must still serialize against worker startup (the +/// manager does so with its init + worker locks) before invoking this function. +/// +/// On platforms without `/proc//environ`, token-only cleanup cannot be +/// performed safely and returns an explicitly unclean, unverified report. +pub fn terminate_generation_by_token( + token: &str, + term_grace: Duration, + kill_grace: Duration, +) -> CleanupReport { + if !valid_token(token) { + return CleanupReport { + survivors: 1, + verified: false, + ..CleanupReport::default() + }; + } + + #[cfg(target_os = "linux")] + { + let mut guard = RunGuard::new(token.to_string(), false); + // There is intentionally no root identity: the full token census is the + // ownership proof. Mark armed so terminate performs that first scan. + guard.strict_token_census = true; + guard.armed = true; + guard.terminate(term_grace, kill_grace) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (term_grace, kill_grace); + CleanupReport { + survivors: 1, + verified: false, + ..CleanupReport::default() + } + } +} + +fn valid_token(token: &str) -> bool { + !token.is_empty() + && token.len() <= 128 + && token + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')) +} + +/// Owns the process generation spawned with `token` for its entire lifetime. +/// If a worker leaves through any error path after spawn, Drop performs a short +/// bounded cleanup rather than abandoning Codex/MCP descendants. +pub struct RunGuard { + token: String, + armed: bool, + root_pid: Option, + root_pgid: Option, + root_exited: bool, + last_refresh: Instant, + may_reap_adopted: bool, + strict_token_census: bool, + + #[cfg(target_os = "linux")] + root: Option, + #[cfg(target_os = "linux")] + seen: std::collections::HashSet, + #[cfg(target_os = "linux")] + live: std::collections::HashSet, +} + +impl RunGuard { + pub fn new(token: String, may_reap_adopted: bool) -> Self { + Self { + token, + armed: false, + root_pid: None, + root_pgid: None, + root_exited: false, + last_refresh: Instant::now(), + may_reap_adopted, + // Without subreaper support a daemonized descendant can leave the + // root's PPID tree before the next refresh. In that degraded mode + // shutdown must fail closed unless the token census itself was + // complete; silently claiming a clean generation would be worse. + strict_token_census: !may_reap_adopted, + #[cfg(target_os = "linux")] + root: None, + #[cfg(target_os = "linux")] + seen: std::collections::HashSet::new(), + #[cfg(target_os = "linux")] + live: std::collections::HashSet::new(), + } + } + + /// Record the freshly spawned PTY child. Call immediately after spawn, + /// before any other fallible worker setup. + pub fn track_root(&mut self, pid: Option) { + let Some(pid) = pid.and_then(checked_pid) else { + return; + }; + self.root_pid = Some(pid); + let pgid = unsafe { libc::getpgid(pid) }; + if pgid > 1 { + self.root_pgid = Some(pgid); + } + self.armed = true; + + #[cfg(target_os = "linux")] + if let Some(info) = read_proc_info(pid) { + self.root = Some(info.reference); + self.seen.insert(info.reference); + if info.is_live() { + self.live.insert(info.reference); + } + } + } + + /// The portable-pty waiter has reaped the direct child. On non-Linux this + /// also prevents a later PID reuse from ever being signalled. + pub fn note_root_exited(&mut self) { + self.root_exited = true; + } + + /// Periodically remember descendants while the run is healthy. This scan + /// follows PPIDs only and is intentionally cheap; the full environment-token + /// scan is reserved for shutdown, when independently reparented groups matter. + pub fn refresh_if_due(&mut self, interval: Duration) { + if self.last_refresh.elapsed() < interval { + return; + } + self.last_refresh = Instant::now(); + #[cfg(target_os = "linux")] + { + let _ = self.refresh_linux(false); + } + } + + /// Stop every process owned by this run. TERM is given `term_grace`; any + /// surviving/newly-discovered identities then receive KILL and are verified + /// for up to `kill_grace`. + pub fn terminate(&mut self, term_grace: Duration, kill_grace: Duration) -> CleanupReport { + if !self.armed { + return CleanupReport { + verified: cfg!(target_os = "linux"), + ..CleanupReport::default() + }; + } + + #[cfg(target_os = "linux")] + { + self.terminate_linux(term_grace, kill_grace) + } + + #[cfg(not(target_os = "linux"))] + { + self.terminate_process_group(term_grace, kill_grace) + } + } + + #[cfg(target_os = "linux")] + fn refresh_linux(&mut self, include_tokens: bool) -> bool { + let scan = match scan_proc( + include_tokens, + &self.token, + &self.seen, + self.strict_token_census, + self.may_reap_adopted + .then_some(std::process::id() as libc::pid_t), + ) { + Ok(snapshot) => snapshot, + Err(_) => return false, + }; + let snapshot = scan.processes; + + // If the child was so short-lived that track_root could not read stat, + // recover its qualified identity from this snapshot when possible. + if self.root.is_none() { + if let Some(root_pid) = self.root_pid { + if let Some(info) = snapshot.iter().find(|p| p.reference.pid == root_pid) { + self.root = Some(info.reference); + self.seen.insert(info.reference); + } + } + } + + let selected = select_owned( + &snapshot, + &self.seen, + self.may_reap_adopted + .then_some(std::process::id() as libc::pid_t), + ); + self.seen.extend(selected.iter().copied()); + self.live = selected; + self.reap_adopted_children(); + !include_tokens || scan.token_census_complete + } + + #[cfg(target_os = "linux")] + fn terminate_linux(&mut self, term_grace: Duration, kill_grace: Duration) -> CleanupReport { + let mut report = CleanupReport { + verified: true, + ..CleanupReport::default() + }; + let mut term_sent = std::collections::HashSet::new(); + let term_deadline = Instant::now() + term_grace; + + loop { + report.verified &= self.refresh_linux(true); + if self.live.is_empty() { + self.armed = false; + return report; + } + for proc in self.signal_order() { + if term_sent.insert(proc) && signal_if_same(proc, libc::SIGTERM) { + report.term_signals += 1; + } + } + if Instant::now() >= term_deadline { + break; + } + thread::sleep( + POLL_INTERVAL.min(term_deadline.saturating_duration_since(Instant::now())), + ); + } + + // A TERM handler can fork or daemonize. Rediscover on every KILL poll and + // signal identities not present in the previous snapshot. + let mut kill_sent = std::collections::HashSet::new(); + let kill_deadline = Instant::now() + kill_grace; + loop { + report.verified &= self.refresh_linux(true); + if self.live.is_empty() { + self.armed = false; + return report; + } + for proc in self.signal_order() { + if kill_sent.insert(proc) && signal_if_same(proc, libc::SIGKILL) { + report.kill_signals += 1; + } + } + if Instant::now() >= kill_deadline { + break; + } + thread::sleep( + POLL_INTERVAL.min(kill_deadline.saturating_duration_since(Instant::now())), + ); + } + + report.verified &= self.refresh_linux(true); + report.survivors = self.live.len(); + if report.survivors == 0 { + self.armed = false; + } + report + } + + #[cfg(target_os = "linux")] + fn signal_order(&self) -> Vec { + let mut members: Vec<_> = self.live.iter().copied().collect(); + // Signal the PTY root last, giving already-known helpers a chance to + // handle TERM before their parent disappears/reparents them. + members.sort_by_key(|proc| usize::from(Some(*proc) == self.root)); + members + } + + #[cfg(target_os = "linux")] + fn reap_adopted_children(&self) { + if !self.may_reap_adopted { + return; + } + for proc in &self.seen { + if Some(*proc) == self.root { + // portable-pty's waiter owns the direct child. + continue; + } + let mut status = 0; + unsafe { + // If it has not been reparented to our subreaper, waitpid simply + // returns ECHILD; never wait for or reap an unrelated process. + libc::waitpid(proc.pid, &mut status, libc::WNOHANG); + } + } + } + + #[cfg(not(target_os = "linux"))] + fn terminate_process_group( + &mut self, + term_grace: Duration, + kill_grace: Duration, + ) -> CleanupReport { + let mut report = CleanupReport { + // No /proc token census exists here, so detached setsid descendants + // cannot be verified without risking unrelated PIDs. + verified: false, + ..CleanupReport::default() + }; + if self.root_exited { + // The waiter already reaped the only PID that qualified this PGID. + // An absent group proves there is nothing left; an extant group + // cannot be signalled safely because the numeric PGID may since + // have been reused. Report uncertainty instead of leaking silently + // or risking an unrelated process group. + if self + .root_pgid + .is_some_and(|pgid| !process_group_exists(pgid)) + { + report.verified = true; + } else { + report.survivors = 1; + } + self.armed = false; + return report; + } + let (Some(pid), Some(pgid)) = (self.root_pid, self.root_pgid) else { + return report; + }; + if process_group_still_owned(pid, pgid) && signal_group(pgid, libc::SIGTERM) { + report.term_signals = 1; + } else { + report.survivors = 1; + return report; + } + if wait_group_gone(pgid, term_grace) { + report.verified = true; + self.armed = false; + return report; + } + if signal_group(pgid, libc::SIGKILL) { + report.kill_signals = 1; + } + if wait_group_gone(pgid, kill_grace) { + report.verified = true; + self.armed = false; + } else { + report.survivors = 1; + } + report + } +} + +impl Drop for RunGuard { + fn drop(&mut self) { + if self.armed { + // Error/panic paths must not leak a run forever, but Drop is kept + // bounded so worker teardown itself cannot wedge indefinitely. + let _ = self.terminate(Duration::from_millis(200), Duration::from_millis(800)); + } + } +} + +fn checked_pid(pid: u32) -> Option { + let pid = libc::pid_t::try_from(pid).ok()?; + (pid > 1).then_some(pid) +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct ProcRef { + pid: libc::pid_t, + start_time: u64, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Debug)] +struct ProcInfo { + reference: ProcRef, + ppid: libc::pid_t, + state: u8, + token_match: bool, +} + +#[cfg(target_os = "linux")] +impl ProcInfo { + fn is_live(&self) -> bool { + !matches!(self.state, b'Z' | b'X' | b'x') + } +} + +#[cfg(target_os = "linux")] +fn parse_proc_stat(stat: &str) -> Option { + let lp = stat.find('(')?; + let rp = stat.rfind(')')?; + if lp >= rp { + return None; + } + let pid = stat[..lp].trim().parse().ok()?; + let fields: Vec<&str> = stat[rp + 1..].split_whitespace().collect(); + let state = fields.first()?.as_bytes().first().copied()?; + let ppid = fields.get(1)?.parse().ok()?; + // starttime is proc(5) field 22; fields[0] here is field 3. + let start_time = fields.get(19)?.parse().ok()?; + Some(ProcInfo { + reference: ProcRef { pid, start_time }, + ppid, + state, + token_match: false, + }) +} + +#[cfg(target_os = "linux")] +fn read_proc_info(pid: libc::pid_t) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + parse_proc_stat(&stat) +} + +#[cfg(target_os = "linux")] +struct ProcScan { + processes: Vec, + token_census_complete: bool, +} + +#[cfg(target_os = "linux")] +fn scan_proc( + check_token: bool, + token: &str, + previously_seen: &std::collections::HashSet, + strict_token_census: bool, + adopted_parent: Option, +) -> io::Result { + use std::os::unix::fs::MetadataExt; + + let mut out = Vec::new(); + let mut token_census_complete = true; + let euid = unsafe { libc::geteuid() }; + for entry in std::fs::read_dir("/proc")? { + let Ok(entry) = entry else { continue }; + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()) + else { + continue; + }; + if pid <= 1 || pid == std::process::id() as libc::pid_t { + continue; + } + let Some(info) = read_proc_info(pid) else { + continue; + }; + out.push(info); + } + + // PPID/starttime evidence is sufficient for already observed descendants. + // Scope strict recovery to processes proven to share our PID namespace: + // shared/container hosts can expose hundreds of same-UID processes from + // sibling namespaces whose environ and namespace links are intentionally + // unreadable. Requiring all of those would make every recovery permanently + // unverifiable even though they cannot be this Rail generation. + let definitely_owned = select_owned(&out, previously_seen, adopted_parent); + let worker_pid = std::process::id() as libc::pid_t; + for info in &mut out { + if check_token && info.is_live() { + let strict_scope = if strict_token_census { + match same_pid_namespace(info.reference.pid) { + Ok(same) => same, + // A process exiting between snapshots cannot survive as a + // hidden generation member. Any other namespace-identity + // error is uncertainty and must prevent a verified result. + Err(err) if err.kind() == io::ErrorKind::NotFound => false, + Err(_) => { + token_census_complete = false; + false + } + } + } else { + false + }; + let token_read_is_required = strict_scope + || definitely_owned.contains(&info.reference) + || info.ppid == worker_pid; + let proc_path = format!("/proc/{}", info.reference.pid); + let same_user = match std::fs::metadata(proc_path) { + Ok(meta) => meta.uid() == euid, + Err(err) if err.kind() == io::ErrorKind::NotFound => false, + Err(_) => { + if token_read_is_required { + token_census_complete = false; + } + false + } + }; + if same_user { + match environ_has_token(info.reference.pid, token) { + Ok(found) => info.token_match = found, + // Exiting between stat and environ is not an incomplete + // census; it cannot remain as a live survivor. + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(_) if token_read_is_required => token_census_complete = false, + Err(_) => {} + } + } + } + } + Ok(ProcScan { + processes: out, + token_census_complete, + }) +} + +#[cfg(target_os = "linux")] +fn same_pid_namespace(pid: libc::pid_t) -> io::Result { + use std::os::unix::fs::MetadataExt; + + let ours = std::fs::metadata("/proc/self/ns/pid")?; + let theirs = std::fs::metadata(format!("/proc/{pid}/ns/pid"))?; + Ok(ours.dev() == theirs.dev() && ours.ino() == theirs.ino()) +} + +#[cfg(target_os = "linux")] +fn environ_has_token(pid: libc::pid_t, token: &str) -> io::Result { + use std::io::Read; + + const MAX_ENV_BYTES: u64 = 4 * 1024 * 1024; + let file = std::fs::File::open(format!("/proc/{pid}/environ"))?; + let mut bytes = Vec::new(); + file.take(MAX_ENV_BYTES + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_ENV_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "process environment exceeds census bound", + )); + } + let prefix = format!("{RUN_TOKEN_ENV}="); + Ok(bytes.split(|b| *b == 0).any(|entry| { + entry + .strip_prefix(prefix.as_bytes()) + .map(|value| value == token.as_bytes()) + .unwrap_or(false) + })) +} + +#[cfg(target_os = "linux")] +fn select_owned( + snapshot: &[ProcInfo], + previously_seen: &std::collections::HashSet, + adopted_parent: Option, +) -> std::collections::HashSet { + let mut selected: std::collections::HashSet = snapshot + .iter() + .filter(|info| { + info.is_live() + && (info.token_match + || previously_seen.contains(&info.reference) + // When this worker successfully became a child subreaper, + // an orphan directly reparented to it is generation-owned + // even if PR_SET_DUMPABLE made /proc/environ unreadable. + || adopted_parent == Some(info.ppid)) + }) + .map(|info| info.reference) + .collect(); + + loop { + let parent_pids: std::collections::HashSet = + selected.iter().map(|proc| proc.pid).collect(); + let before = selected.len(); + for info in snapshot { + if info.is_live() && parent_pids.contains(&info.ppid) { + selected.insert(info.reference); + } + } + if selected.len() == before { + break; + } + } + selected +} + +#[cfg(target_os = "linux")] +fn signal_if_same(proc: ProcRef, signal: libc::c_int) -> bool { + let Some(current) = read_proc_info(proc.pid) else { + return false; + }; + if current.reference != proc || !current.is_live() { + return false; + } + unsafe { libc::kill(proc.pid, signal) == 0 } +} + +#[cfg(not(target_os = "linux"))] +fn process_group_still_owned(pid: libc::pid_t, pgid: libc::pid_t) -> bool { + if pid <= 1 || pgid <= 1 { + return false; + } + unsafe { libc::kill(pid, 0) == 0 && libc::getpgid(pid) == pgid } +} + +#[cfg(not(target_os = "linux"))] +fn signal_group(pgid: libc::pid_t, signal: libc::c_int) -> bool { + if pgid <= 1 || pgid == unsafe { libc::getpgrp() } { + return false; + } + unsafe { libc::kill(-pgid, signal) == 0 } +} + +#[cfg(not(target_os = "linux"))] +fn process_group_exists(pgid: libc::pid_t) -> bool { + if pgid <= 1 { + return false; + } + if unsafe { libc::kill(-pgid, 0) } == 0 { + return true; + } + io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(not(target_os = "linux"))] +fn wait_group_gone(pgid: libc::pid_t, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + if !process_group_exists(pgid) { + return true; + } + if Instant::now() >= deadline { + return false; + } + thread::sleep(POLL_INTERVAL.min(deadline.saturating_duration_since(Instant::now()))); + } +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::{ + parse_proc_stat, select_owned, terminate_generation_by_token, valid_token, CleanupReport, + ProcInfo, ProcRef, + }; + use std::collections::HashSet; + use std::time::Duration; + + fn info(pid: i32, ppid: i32, start_time: u64, token_match: bool) -> ProcInfo { + ProcInfo { + reference: ProcRef { pid, start_time }, + ppid, + state: b'S', + token_match, + } + } + + #[test] + fn parses_starttime_after_a_tricky_comm() { + // fields after ')' are 3..22; the command may itself contain spaces and ')'. + let middle = std::iter::repeat("0") + .take(17) + .collect::>() + .join(" "); + let stat = format!("42 (mcp helper) odd)) S 7 {middle} 98765"); + let parsed = parse_proc_stat(&stat).expect("valid synthetic stat"); + assert_eq!( + parsed.reference, + ProcRef { + pid: 42, + start_time: 98765 + } + ); + assert_eq!(parsed.ppid, 7); + } + + #[test] + fn token_and_ppid_find_reparented_and_independent_groups() { + // PID 20 represents an MCP that called setsid: process-group changes do + // not matter because ownership follows the token/lineage, not PGID. + // PID 30 was already seen before reparenting to PID 1 and scrubbed its env. + let old_reparented = ProcRef { + pid: 30, + start_time: 300, + }; + let previously_seen = HashSet::from([old_reparented]); + let snapshot = vec![ + info(10, 2, 100, true), + info(20, 10, 200, false), + info(21, 20, 210, false), + info(30, 1, 300, false), + info(40, 2, 400, false), + ]; + let selected = select_owned(&snapshot, &previously_seen, None); + assert_eq!(selected.len(), 4); + assert!(selected.contains(&ProcRef { + pid: 10, + start_time: 100 + })); + assert!(selected.contains(&ProcRef { + pid: 20, + start_time: 200 + })); + assert!(selected.contains(&ProcRef { + pid: 21, + start_time: 210 + })); + assert!(selected.contains(&old_reparented)); + assert!(!selected.contains(&ProcRef { + pid: 40, + start_time: 400 + })); + } + + #[test] + fn pid_reuse_does_not_inherit_ownership() { + let previously_seen = HashSet::from([ProcRef { + pid: 50, + start_time: 500, + }]); + let snapshot = vec![info(50, 1, 999, false)]; + assert!(select_owned(&snapshot, &previously_seen, None).is_empty()); + } + + #[test] + fn subreaper_adopted_children_are_owned_without_readable_environment() { + let snapshot = vec![info(70, 7, 700, false), info(71, 70, 710, false)]; + let selected = select_owned(&snapshot, &HashSet::new(), Some(7)); + assert_eq!(selected.len(), 2); + assert!(selected.contains(&ProcRef { + pid: 70, + start_time: 700 + })); + assert!(selected.contains(&ProcRef { + pid: 71, + start_time: 710 + })); + } + + #[test] + fn failed_linux_census_is_not_clean() { + let report = CleanupReport { + survivors: 0, + verified: false, + ..CleanupReport::default() + }; + assert!(!report.is_clean()); + } + + #[test] + fn token_only_cleanup_rejects_an_unscoped_token() { + assert!(!valid_token("")); + assert!(!valid_token("contains/slash")); + let report = terminate_generation_by_token("", Duration::ZERO, Duration::ZERO); + assert!(!report.is_clean()); + assert!(!report.verified); + } +} diff --git a/src/progress.rs b/src/progress.rs index 32e3358..6ea9af0 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -43,7 +43,11 @@ pub fn fill(width: u16, fraction: f64) -> Fill { let eighths = (frac * width as f64 * 8.0).round() as u32; let full = (eighths / 8) as u16; let rem = (eighths % 8) as usize; - let partial = if rem == 0 { None } else { Some(EIGHTHS[rem - 1]) }; + let partial = if rem == 0 { + None + } else { + Some(EIGHTHS[rem - 1]) + }; let used = full + partial.is_some() as u16; Fill { full, diff --git a/src/protocol.rs b/src/protocol.rs index 7eaa4c5..7b2a712 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -52,6 +52,39 @@ pub fn write_input_frame(stream: &mut UnixStream, bytes: &[u8]) -> io::Result<() stream.flush() } +pub fn sanitize_submission_text(message: &str) -> String { + message + .chars() + .filter(|c| { + let value = *c as u32; + *c == '\n' + || *c == '\t' + || !matches!( + value, + 0x0000..=0x001f + | 0x007f..=0x009f + | 0x200b..=0x200c + | 0x200e..=0x200f + | 0x202a..=0x202e + | 0x2060..=0x2069 + | 0xfeff + ) + }) + .collect() +} + +/// One logical multiline composer submission. Bracketed paste prevents embedded +/// newlines from becoming separate Enter keypresses; sanitization prevents a +/// model/user string from closing the paste or synthesizing terminal controls. +pub fn bracketed_submission(message: &str) -> Vec { + let safe = sanitize_submission_text(message); + let mut wire = Vec::with_capacity(safe.len() + 16); + wire.extend_from_slice(b"\x1b[200~"); + wire.extend_from_slice(safe.as_bytes()); + wire.extend_from_slice(b"\x1b[201~\r"); + wire +} + pub fn write_resize_frame(stream: &mut UnixStream, rows: u16, cols: u16) -> io::Result<()> { stream.write_all(&[FRAME_RESIZE])?; stream.write_all(&rows.to_be_bytes())?; @@ -118,3 +151,21 @@ fn read_exact_or_eof(reader: &mut R, buf: &mut [u8]) -> io::Result Err(err), } } + +#[cfg(test)] +mod tests { + use super::{bracketed_submission, sanitize_submission_text}; + + #[test] + fn logical_submission_preserves_multiline_unicode_and_blocks_control_injection() { + let message = "第一行\n\tsecond 👩‍💻\u{1b}[201~evil\u{202e}"; + assert_eq!( + sanitize_submission_text(message), + "第一行\n\tsecond 👩‍💻[201~evil" + ); + assert_eq!( + bracketed_submission(message), + "\u{1b}[200~第一行\n\tsecond 👩‍💻[201~evil\u{1b}[201~\r".as_bytes() + ); + } +} diff --git a/src/state.rs b/src/state.rs index 096e57e..4f7ed4f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,15 +1,16 @@ +use crate::process_tree; use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use std::env; use std::fs; use std::io::{self, BufRead, Write}; use std::os::fd::AsRawFd; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub const APP_DIR: &str = "codex-rail"; pub const STATUS_STARTING: &str = "starting"; @@ -54,6 +55,11 @@ pub struct SessionState { // then cleared so a later resume/restart doesn't replay it. None = plain. #[serde(default)] pub initial_prompt: Option, + // True only during the tiny crash-sensitive PTY delivery window. A worker + // that inherits this state must fail closed instead of replaying a prompt + // whose external side effect may already have happened. + #[serde(default)] + pub initial_prompt_injecting: bool, // Set once the user renames via Ctrl+R. Pins the title so the automatic // "sync from codex's first message" pass leaves their chosen name alone. #[serde(default)] @@ -74,6 +80,17 @@ pub struct SessionState { // lands. None for ordinary sessions. #[serde(default)] pub distill_version: Option, + // Machine-verifiable completion contract for a distillation run. Old + // states intentionally deserialize to an empty contract and can never be + // auto-classified Done from a merely non-empty style file. + #[serde(default)] + pub distill_expected_markers: Vec, + #[serde(default)] + pub distill_expected_user_turns: Option, + #[serde(default)] + pub distill_corpus_rel: Option, + #[serde(default)] + pub distill_validated: bool, // True for an IMPORTED codex session — one discovered in ~/.codex/sessions for // the manager's cwd but not started by rail. In-memory only (never serialized): // the row is a resumable snapshot until the user attaches, at which point it's @@ -157,10 +174,10 @@ pub fn config_dir() -> PathBuf { home_dir().join(".config").join(APP_DIR) } -// Working root for archive distillation: the versioned `style-vNNN.md` summaries -// live here, and `corpus/` holds the freshly-aggregated, codex-readable chunks -// (regenerated every run). Also the cwd of the launched distill codex session, -// so codex only ever reads/writes inside this dir (never ~/.codex directly). +// Working root for archive distillation: versioned `style-vNNN.md` summaries +// live here, while `runs/run-*/corpus/` keeps each launched Codex session on +// immutable inputs. Also the cwd of the distill session, so Codex only ever +// reads/writes inside this directory (never ~/.codex directly). pub fn distill_dir() -> PathBuf { config_dir().join("distill") } @@ -176,10 +193,12 @@ fn worker_stop_request_path(id: &str, token: &str) -> Result { } pub fn ensure_base_dirs() -> Result<()> { - fs::create_dir_all(jobs_dir()).context("create jobs directory")?; - restrict_to_owner(&jobs_dir())?; - fs::create_dir_all(runtime_dir()).context("create runtime directory")?; - restrict_to_owner(&runtime_dir())?; + ensure_owner_dir(&jobs_dir())?; + // XDG_RUNTIME_DIR is not guaranteed in containers, so the fallback lives + // under world-writable /tmp. Refuse a pre-created symlink, non-directory, + // or directory owned by another uid before ever placing a control socket in + // it; chmod alone would follow a symlink and is not an ownership check. + ensure_owner_dir(&runtime_dir())?; Ok(()) } @@ -193,7 +212,31 @@ pub fn ensure_private_distill_storage() -> Result<()> { secure_private_dir(&distill_dir()) } +/// Create or validate one exact private directory without trusting umask or +/// following a pre-created symlink. Callers that build a nested tree use this +/// one level at a time so every intermediate is owner-only from creation. +pub fn ensure_private_directory(path: &Path) -> Result<()> { + ensure_owner_dir(path) +} + fn secure_private_dir(path: &Path) -> Result<()> { + ensure_owner_dir(path)?; + for entry in fs::read_dir(path).with_context(|| format!("read {}", path.display()))? { + let entry = entry?; + let kind = entry.file_type()?; + let child = entry.path(); + if kind.is_dir() { + secure_private_dir(&child)?; + } else if kind.is_file() { + restrict_file_to_owner(&child)?; + } + // Never follow symlinks while migrating an existing tree: a link may + // intentionally point outside rail's private storage. + } + Ok(()) +} + +fn ensure_owner_dir(path: &Path) -> Result<()> { match fs::symlink_metadata(path) { Ok(meta) if meta.file_type().is_symlink() => { bail!("refuse symlinked private directory {}", path.display()) @@ -210,19 +253,20 @@ fn secure_private_dir(path: &Path) -> Result<()> { } Err(err) => return Err(err).with_context(|| format!("inspect {}", path.display())), } - restrict_to_owner(path)?; - for entry in fs::read_dir(path).with_context(|| format!("read {}", path.display()))? { - let entry = entry?; - let kind = entry.file_type()?; - let child = entry.path(); - if kind.is_dir() { - secure_private_dir(&child)?; - } else if kind.is_file() { - restrict_file_to_owner(&child)?; - } - // Never follow symlinks while migrating an existing tree: a link may - // intentionally point outside rail's private storage. + let meta = fs::symlink_metadata(path).with_context(|| format!("inspect {}", path.display()))?; + if meta.file_type().is_symlink() || !meta.is_dir() { + bail!("private path is not a real directory: {}", path.display()); + } + let euid = unsafe { libc::geteuid() }; + if meta.uid() != euid { + bail!( + "private directory {} is owned by uid {}, expected {}", + path.display(), + meta.uid(), + euid + ); } + restrict_to_owner(path)?; Ok(()) } @@ -250,6 +294,63 @@ pub fn write_private_file(path: &Path, bytes: impl AsRef<[u8]>) -> Result<()> { .with_context(|| format!("write {}", path.display())) } +/// Atomically replace a sensitive file without ever exposing a partial JSON or +/// following a pre-created temporary symlink. The sibling temporary is 0600 +/// from creation, flushed before rename, and the containing directory is synced +/// so a successful return survives a crash as far as the filesystem permits. +pub fn write_private_file_atomic(path: &Path, bytes: impl AsRef<[u8]>) -> Result<()> { + let parent = path.parent().context("private output has no parent")?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + restrict_to_owner(parent)?; + + let mut tmp = None; + for _ in 0..16 { + let candidate = unique_tmp_path( + parent, + path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("private"), + ); + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&candidate) + { + Ok(file) => { + tmp = Some((candidate, file)); + break; + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err).with_context(|| format!("create {}", candidate.display())), + } + } + let (tmp_path, mut file) = tmp.context("could not allocate a private temporary file")?; + let result = (|| -> Result<()> { + if !file.metadata()?.is_file() { + bail!( + "private temporary is not a regular file: {}", + tmp_path.display() + ); + } + file.write_all(bytes.as_ref()) + .with_context(|| format!("write {}", tmp_path.display()))?; + file.sync_all() + .with_context(|| format!("sync {}", tmp_path.display()))?; + drop(file); + fs::rename(&tmp_path, path).with_context(|| format!("replace {}", path.display()))?; + fs::File::open(parent) + .and_then(|dir| dir.sync_all()) + .with_context(|| format!("sync {}", parent.display()))?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + result +} + pub fn restrict_private_file_to_owner(path: &Path) -> Result<()> { let file = fs::OpenOptions::new() .read(true) @@ -281,9 +382,13 @@ fn open_lock_path(path: &Path, nonblocking: bool) -> Result> { .read(true) .write(true) .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) .open(&path) .with_context(|| format!("open {}", path.display()))?; - restrict_file_to_owner(&path)?; + if !file.metadata()?.is_file() { + bail!("lock path is not a regular file: {}", path.display()); + } + file.set_permissions(fs::Permissions::from_mode(0o600))?; let flags = libc::LOCK_EX | if nonblocking { libc::LOCK_NB } else { 0 }; loop { if unsafe { libc::flock(file.as_raw_fd(), flags) } == 0 { @@ -301,12 +406,6 @@ fn open_lock_path(path: &Path, nonblocking: bool) -> Result> { } } -/// Serialize manager-side bootstrap/removal for one session. The returned file -/// must stay alive for the whole critical section. -pub fn acquire_session_init_lock(id: &str) -> Result { - open_session_lock(id, "init.lock", false)?.context("blocking lock returned no guard") -} - pub fn try_acquire_session_init_lock(id: &str) -> Result> { open_session_lock(id, "init.lock", true) } @@ -317,13 +416,41 @@ pub fn try_acquire_worker_lock(id: &str) -> Result> { open_session_lock(id, "worker.lock", true) } +/// A second, runtime-filesystem lease around the canonical socket pathname. +/// This remains independent from jobs/.locks so a broken/NFS data-dir flock +/// cannot let two workers both unlink/bind the same local control socket. +pub fn try_acquire_socket_lock(id: &str) -> Result> { + validate_session_id(id)?; + ensure_owner_dir(&runtime_dir())?; + open_lock_path(&runtime_dir().join(format!("{id}.socket.lock")), true) +} + +/// Guardian lifetime lease. A guardian outlives its worker long enough to reap +/// reparented Codex/MCP descendants, so resume/removal must wait for this lease +/// before touching the persisted generation token. +pub fn acquire_session_generation_lock(id: &str) -> Result { + open_session_lock(id, "generation.lock", false)? + .context("blocking generation lock returned no guard") +} + +pub fn try_acquire_session_generation_lock(id: &str) -> Result> { + open_session_lock(id, "generation.lock", true) +} + +/// Become the only manager allowed to advance an enabled autopilot for this +/// main session. Keeping the returned file alive is the lease. +pub fn try_acquire_autopilot_lock(id: &str) -> Result> { + open_session_lock(id, "autopilot.lock", true) +} + /// Ask a lock-aware worker to stop without relying on its Unix socket or pid. /// This is the portable fallback when a runtime directory has been cleared: /// the lifetime lock proves that this session still has a worker, while the /// generation-scoped marker prevents a late request from reaching a successor. pub fn request_worker_stop(id: &str) -> Result<()> { validate_session_id(id)?; - let _init_lock = acquire_session_init_lock(id)?; + let _init_lock = try_acquire_session_init_lock(id)? + .context("another manager is changing this session; retry stop")?; let current = read_state(id)?; if !current.worker_lock_protocol { bail!("worker does not support out-of-band stop requests"); @@ -354,7 +481,10 @@ pub fn take_worker_stop_request(state: &SessionState) -> Result { Err(err) => return Err(err).with_context(|| format!("inspect {}", path.display())), }; if !meta.is_file() { - bail!("worker stop request is not a regular file: {}", path.display()); + bail!( + "worker stop request is not a regular file: {}", + path.display() + ); } fs::remove_file(&path).with_context(|| format!("consume {}", path.display()))?; Ok(true) @@ -400,12 +530,11 @@ pub fn write_state(state: &SessionState) -> Result<()> { fs::create_dir_all(&dir).context("create job directory")?; restrict_to_owner(&dir)?; let path = state_path(&state.id); - let tmp = unique_tmp_path(&dir, "state.json"); let bytes = serde_json::to_vec_pretty(state).context("serialize state")?; - fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?; - restrict_file_to_owner(&tmp)?; - fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?; - Ok(()) + // state.json carries exactly-once prompt intent and the only recovery token + // for a killed worker. File+directory sync is part of correctness, not an + // optional durability optimization. + write_private_file_atomic(&path, bytes) } // The session's user-facing label — its title and whether the user pinned it @@ -425,27 +554,60 @@ struct Label { } pub fn read_label(id: &str) -> Option<(String, bool)> { - let bytes = fs::read(label_path(id)).ok()?; - let label: Label = serde_json::from_slice(&bytes).ok()?; - Some((label.title, label.title_pinned)) + read_label_result(id) + .ok() + .flatten() + .map(|label| (label.title, label.title_pinned)) } pub fn write_label(id: &str, title: &str, title_pinned: bool) -> Result<()> { validate_session_id(id)?; + let _lock = open_session_lock(id, "label.lock", false)? + .context("blocking label lock returned no guard")?; + write_label_unlocked(id, title, title_pinned) +} + +/// Refresh an automatically-derived title without racing a user rename from a +/// second Rail window. The caller's in-memory `title_pinned` bit is only a +/// snapshot; the label is re-read under the same lock used by Ctrl+R and a +/// pinned value always wins. +pub fn sync_label_if_unpinned(id: &str, title: &str) -> Result<(String, bool)> { + validate_session_id(id)?; + let _lock = open_session_lock(id, "label.lock", false)? + .context("blocking label lock returned no guard")?; + if let Some(label) = read_label_result(id)? { + if label.title_pinned { + return Ok((label.title, true)); + } + } + write_label_unlocked(id, title, false)?; + Ok((title.to_string(), false)) +} + +fn read_label_result(id: &str) -> Result> { + validate_session_id(id)?; + let path = label_path(id); + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("read {}", path.display())), + }; + serde_json::from_slice(&bytes) + .with_context(|| format!("parse {}", path.display())) + .map(Some) +} + +fn write_label_unlocked(id: &str, title: &str, title_pinned: bool) -> Result<()> { let dir = job_dir(id); fs::create_dir_all(&dir).context("create job directory")?; restrict_to_owner(&dir)?; let path = label_path(id); - let tmp = unique_tmp_path(&dir, "label.json"); let label = Label { title: title.to_string(), title_pinned, }; let bytes = serde_json::to_vec_pretty(&label).context("serialize label")?; - fs::write(&tmp, bytes).with_context(|| format!("write {}", tmp.display()))?; - restrict_file_to_owner(&tmp)?; - fs::rename(&tmp, &path).with_context(|| format!("rename {}", path.display()))?; - Ok(()) + write_private_file_atomic(&path, bytes) } fn unique_tmp_path(dir: &Path, stem: &str) -> PathBuf { @@ -657,9 +819,7 @@ fn legacy_worker_is_running(state: &SessionState) -> bool { // filesystem's flock service is broken or an old/new binary overlaps. return UnixStream::connect(&state.socket).is_ok(); } - let identity = state - .worker_pid - .map(|pid| worker_identity(pid, &state.id)); + let identity = state.worker_pid.map(|pid| worker_identity(pid, &state.id)); identity == Some(WorkerIdentity::Match) || (identity == Some(WorkerIdentity::Unknown) && matches!( @@ -681,14 +841,43 @@ pub fn worker_matches_session(pid: Option, id: &str) -> bool { /// session's dir would pull the ground out from under its worker. pub fn remove_session(id: &str) -> Result<()> { validate_session_id(id)?; - let _init_lock = acquire_session_init_lock(id)?; + let _init_lock = try_acquire_session_init_lock(id)? + .context("another manager is changing this session; retry removal")?; + let _generation_lock = try_acquire_session_generation_lock(id)? + .context("session guardian is still cleaning its process generation; retry removal")?; let Some(_worker_lock) = try_acquire_worker_lock(id)? else { bail!("session still has a live worker"); }; - if let Some(current) = read_state_optional(id)? { + if let Some(mut current) = read_state_optional(id)? { if legacy_worker_is_running(¤t) { bail!("session state still belongs to a live worker"); } + // A SIGKILLed worker can leave Codex/MCP descendants alive while the + // manager's reconciled row merely looks exited. Never delete the only + // persisted generation token until a token census proves the old run is + // empty; otherwise the leak becomes permanently unowned. + if let Some(token) = current.worker_token.clone() { + let report = process_tree::terminate_generation_by_token( + &token, + Duration::from_secs(2), + Duration::from_secs(2), + ); + if !report.is_clean() { + current.status = STATUS_FAILED.to_string(); + current.worker_pid = None; + current.updated_at = now_secs(); + current.last_error = Some(format!( + "refused removal because abandoned process cleanup was not verified: {} survivor(s), census verified={}", + report.survivors, report.verified + )); + write_state(¤t)?; + bail!("abandoned process generation is not verified clean; session kept"); + } + current.worker_pid = None; + current.child_pid = None; + current.worker_token = None; + write_state(¤t)?; + } } // Best-effort socket cleanup; a cleanly-exited worker already removed it. fs::remove_file(socket_path(id)).ok(); @@ -726,17 +915,39 @@ pub fn adopt_dismissed() -> std::collections::HashSet { } pub fn dismiss_adopted(id: &str) -> Result<()> { + validate_session_id(id)?; ensure_base_dirs()?; let mut cur = adopt_dismissed(); if cur.insert(id.to_string()) { let mut body: Vec = cur.into_iter().collect(); body.sort(); - fs::write(adopt_dismiss_path(), body.join("\n") + "\n") + write_private_file_atomic(&adopt_dismiss_path(), body.join("\n") + "\n") .context("write adopt dismiss list")?; } Ok(()) } +// An explicit `/import ` reverses a previous in-memory-row dismiss. +// The Codex transcript was never deleted; this only removes the id from Rail's +// private skip list. Returns whether an entry was actually removed. +pub fn restore_adopted(id: &str) -> Result { + validate_session_id(id)?; + ensure_base_dirs()?; + let mut cur = adopt_dismissed(); + if !cur.remove(id) { + return Ok(false); + } + let mut body: Vec = cur.into_iter().collect(); + body.sort(); + let bytes = if body.is_empty() { + String::new() + } else { + body.join("\n") + "\n" + }; + write_private_file_atomic(&adopt_dismiss_path(), bytes).context("write adopt dismiss list")?; + Ok(true) +} + fn home_dir() -> PathBuf { env::var_os("HOME") .map(PathBuf::from) @@ -779,11 +990,13 @@ pub fn codex_home_dir() -> PathBuf { pub fn codex_first_messages() -> std::collections::HashMap { let mut map: std::collections::HashMap = std::collections::HashMap::new(); let path = codex_home_dir().join("history.jsonl"); - let Ok(content) = fs::read_to_string(&path) else { + let Ok(file) = fs::File::open(&path) else { return map; }; - for line in content.lines() { - let Ok(value) = serde_json::from_str::(line) else { + // history.jsonl can grow for years. Stream it line-by-line instead of + // allocating a second full copy on every refresh/cache miss. + for line in std::io::BufReader::new(file).lines().map_while(Result::ok) { + let Ok(value) = serde_json::from_str::(&line) else { continue; }; let (Some(sid), Some(text)) = ( @@ -826,6 +1039,9 @@ pub fn rollout_head(path: &Path) -> Option<(String, String)> { let mut first = String::new(); reader.read_line(&mut first).ok()?; let value: serde_json::Value = serde_json::from_str(first.trim()).ok()?; + if value.get("type").and_then(|kind| kind.as_str()) != Some("session_meta") { + return None; + } let payload = value.get("payload")?; let cwd = payload.get("cwd").and_then(|c| c.as_str())?.to_string(); let sid = payload @@ -871,13 +1087,14 @@ pub fn is_synthetic_marker(s: &str) -> bool { // The first genuine user message in a rollout, to title an imported session when // codex's history.jsonl doesn't have it (an old session, or history was cleared). -// Reads only the top of the file (the first turn is near the start). Best-effort. -// Skips codex's injected context turns (, ) -// and slash-command echoes so the title is the user's real first line, not a marker. +// Returns as soon as it finds the first real turn, which is normally near the +// top, but does not impose a line-count cutoff: a long injected-context prelude +// must not make an otherwise real chat look empty. Best-effort. Skips codex's +// injected context turns and slash-command echoes so the title is real prose. pub fn rollout_first_user_message(path: &Path) -> Option { let file = fs::File::open(path).ok()?; let reader = std::io::BufReader::new(file); - for line in reader.lines().take(300).map_while(Result::ok) { + for line in reader.lines().map_while(Result::ok) { if !line.contains("user_message") { continue; } @@ -901,6 +1118,127 @@ pub fn rollout_first_user_message(path: &Path) -> Option { None } +/// Strictly determine whether a rollout already contains a genuine user turn. +/// Used when resuming a private initial prompt that was never marked delivered: +/// an existing user turn means a human/older worker already submitted it, while +/// no turn means Rail can safely retry. I/O or JSON ambiguity is an error, not +/// permission to duplicate a private prompt. +pub fn rollout_has_genuine_user_message(path: &Path) -> Result { + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .with_context(|| format!("open rollout {}", path.display()))?; + if !file.metadata()?.is_file() { + bail!("rollout is not a regular file: {}", path.display()); + } + for line in std::io::BufReader::new(file).lines() { + let line = line.with_context(|| format!("read rollout {}", path.display()))?; + if line.trim().is_empty() { + continue; + } + let value: serde_json::Value = serde_json::from_str(&line) + .with_context(|| format!("parse rollout {}", path.display()))?; + if value.get("type").and_then(|t| t.as_str()) != Some("event_msg") { + continue; + } + let payload = value.get("payload"); + if payload.and_then(|p| p.get("type")).and_then(|t| t.as_str()) != Some("user_message") { + continue; + } + if let Some(message) = payload + .and_then(|p| p.get("message")) + .and_then(|m| m.as_str()) + { + if !is_synthetic_marker(message) { + return Ok(true); + } + } + } + Ok(false) +} + +#[derive(Serialize, Deserialize)] +struct RolloutClaim { + session_id: String, + path: String, +} + +/// Atomically reserve one Codex rollout for one Rail session. Concurrent +/// same-cwd workers must never both persist the first path they happen to scan. +pub fn try_claim_rollout(session_id: &str, path: &Path) -> Result { + validate_session_id(session_id)?; + let canonical = fs::canonicalize(path) + .with_context(|| format!("canonicalize rollout {}", path.display()))?; + let path_text = canonical.to_string_lossy().to_string(); + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for byte in path_text.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + let dir = jobs_dir().join(".rollout-claims"); + ensure_owner_dir(&dir)?; + let claim_path = dir.join(format!("{hash:016x}.json")); + let claim = RolloutClaim { + session_id: session_id.to_string(), + path: path_text, + }; + let bytes = serde_json::to_vec(&claim).context("serialize rollout claim")?; + let tmp_path = unique_tmp_path(&dir, "rollout-claim"); + let mut tmp = fs::OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&tmp_path) + .with_context(|| format!("create rollout claim temp {}", tmp_path.display()))?; + tmp.write_all(&bytes)?; + tmp.sync_all()?; + drop(tmp); + let linked = fs::hard_link(&tmp_path, &claim_path); + let _ = fs::remove_file(&tmp_path); + match linked { + Ok(()) => { + fs::File::open(&dir)?.sync_all()?; + Ok(true) + } + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + let existing: RolloutClaim = serde_json::from_slice( + &fs::read(&claim_path) + .with_context(|| format!("read rollout claim {}", claim_path.display()))?, + ) + .context("parse rollout claim")?; + if existing.path != claim.path { + bail!("rollout claim hash collision"); + } + Ok(existing.session_id == session_id) + } + Err(err) => Err(err).with_context(|| format!("create {}", claim_path.display())), + } +} + +pub fn claimed_rollout_for_session(session_id: &str) -> Option<(String, String)> { + validate_session_id(session_id).ok()?; + let entries = fs::read_dir(jobs_dir().join(".rollout-claims")).ok()?; + for entry in entries.flatten() { + let Ok(bytes) = fs::read(entry.path()) else { + continue; + }; + let Ok(claim) = serde_json::from_slice::(&bytes) else { + continue; + }; + if claim.session_id != session_id { + continue; + } + let path = PathBuf::from(&claim.path); + let Some((_, codex_id)) = rollout_head(&path) else { + continue; + }; + return Some((codex_id, claim.path)); + } + None +} + // Every rollout JSONL under codex's sessions tree. pub fn list_rollout_files() -> Vec { let mut out = Vec::new(); @@ -917,9 +1255,12 @@ fn walk_rollouts(dir: &Path, depth: u32, out: &mut Vec) { }; for entry in entries.flatten() { let path = entry.path(); - if path.is_dir() { + let Ok(kind) = entry.file_type() else { + continue; + }; + if kind.is_dir() { walk_rollouts(&path, depth + 1, out); - } else if path.extension().and_then(|e| e.to_str()) == Some("jsonl") { + } else if kind.is_file() && path.extension().and_then(|e| e.to_str()) == Some("jsonl") { out.push(path); } } @@ -928,8 +1269,8 @@ fn walk_rollouts(dir: &Path, depth: u32, out: &mut Vec) { #[cfg(test)] mod tests { use super::{ - checked_pid, is_synthetic_marker, open_lock_path, secure_private_dir, - validate_session_id, write_private_file, + checked_pid, is_synthetic_marker, open_lock_path, secure_private_dir, validate_session_id, + write_private_file, write_private_file_atomic, }; use std::fs; use std::os::unix::fs::symlink; @@ -940,13 +1281,23 @@ mod tests { // codex/slash/system markers → skipped in titles and previews assert!(is_synthetic_marker("")); assert!(is_synthetic_marker("/effort")); - assert!(is_synthetic_marker("\ncwd: /x")); - assert!(is_synthetic_marker("be nice")); - assert!(is_synthetic_marker("done")); - assert!(is_synthetic_marker(" hi")); + assert!(is_synthetic_marker( + "\ncwd: /x" + )); + assert!(is_synthetic_marker( + "be nice" + )); + assert!(is_synthetic_marker( + "done" + )); + assert!(is_synthetic_marker( + " hi" + )); assert!(is_synthetic_marker("[external_agent_tool_result]")); assert!(is_synthetic_marker("[external_agent_tool_result: error]")); - assert!(is_synthetic_marker("[external_agent_tool_call: AskUserQuestion]")); + assert!(is_synthetic_marker( + "[external_agent_tool_call: AskUserQuestion]" + )); } #[test] @@ -954,8 +1305,12 @@ mod tests { // genuine user/assistant text must NOT be treated as a marker assert!(!is_synthetic_marker("看下idependent idea 16_commute_wm")); assert!(!is_synthetic_marker("Let's start by reading the file.")); - assert!(!is_synthetic_marker(" is a lowercase tag, real prose")); - assert!(!is_synthetic_marker("use the [brackets] like this in a sentence")); + assert!(!is_synthetic_marker( + " is a lowercase tag, real prose" + )); + assert!(!is_synthetic_marker( + "use the [brackets] like this in a sentence" + )); assert!(!is_synthetic_marker("")); } @@ -975,16 +1330,25 @@ mod tests { fs::set_permissions(&old, fs::Permissions::from_mode(0o644)).unwrap(); secure_private_dir(&root).unwrap(); - assert_eq!(fs::metadata(&root).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!( + fs::metadata(&root).unwrap().permissions().mode() & 0o777, + 0o700 + ); assert_eq!( fs::metadata(&nested).unwrap().permissions().mode() & 0o777, 0o700 ); - assert_eq!(fs::metadata(&old).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!( + fs::metadata(&old).unwrap().permissions().mode() & 0o777, + 0o600 + ); let new = nested.join("style-v001.md"); write_private_file(&new, "profile").unwrap(); - assert_eq!(fs::metadata(&new).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!( + fs::metadata(&new).unwrap().permissions().mode() & 0o777, + 0o600 + ); let _ = fs::remove_dir_all(&root); } @@ -1043,4 +1407,37 @@ mod tests { assert_eq!(fs::read_to_string(&target).unwrap(), "keep me"); let _ = fs::remove_dir_all(&root); } + + #[test] + fn atomic_private_replace_is_complete_private_and_does_not_follow_destination_symlink() { + let root = std::env::temp_dir().join(format!( + "rail-private-atomic-{}-{}", + std::process::id(), + super::now_millis() + )); + fs::create_dir_all(&root).unwrap(); + let victim = root.join("victim"); + let dest = root.join("autopilot.json"); + fs::write(&victim, "untouched").unwrap(); + symlink(&victim, &dest).unwrap(); + + write_private_file_atomic(&dest, br#"{"enabled":true}"#).unwrap(); + + assert_eq!(fs::read_to_string(&victim).unwrap(), "untouched"); + assert_eq!(fs::read_to_string(&dest).unwrap(), r#"{"enabled":true}"#); + assert!(!fs::symlink_metadata(&dest) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!( + fs::metadata(&dest).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert!(fs::read_dir(&root).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains(".tmp-"))); + let _ = fs::remove_dir_all(&root); + } } diff --git a/src/ui.rs b/src/ui.rs index c008ca0..97c3a4f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,12 +1,13 @@ use crate::attach; use crate::autopilot; use crate::distill; +use crate::process_tree; use crate::progress; -use crate::update; use crate::state::{ self, SessionState, STATUS_EXITED, STATUS_FAILED, STATUS_RUNNING, STATUS_STARTING, STATUS_STOPPING, }; +use crate::update; use anyhow::{Context, Result}; use crossterm::cursor::{Hide, MoveTo, Show}; use crossterm::event::{ @@ -22,6 +23,7 @@ use std::collections::{HashMap, HashSet}; use std::env; use std::fs; use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::os::fd::AsRawFd; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -31,8 +33,12 @@ use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const STOP_CONFIRM_WINDOW: Duration = Duration::from_secs(2); +const ADOPT_CONFIRM_WINDOW: Duration = Duration::from_secs(5); const EXIT_CONFIRM_WINDOW: Duration = Duration::from_secs(2); const REFRESH_INTERVAL: Duration = Duration::from_millis(700); +const DEFAULT_IMPORT_DAYS: u64 = 7; +const MAX_IMPORT_DAYS: u64 = 3650; +const DAY_SECS: u64 = 24 * 60 * 60; // How often to re-scan ~/.codex/sessions for newly-created sessions in this cwd. // The scan reads a header line per rollout, so it's throttled well above the // 700ms UI refresh; new codex sessions still appear within this window. @@ -45,15 +51,22 @@ fn adopt_interval() -> Duration { .unwrap_or(20_000), ) } + +fn days_before(now: SystemTime, days: u64) -> Option { + let seconds = days.checked_mul(DAY_SECS)?; + now.checked_sub(Duration::from_secs(seconds)) +} + +fn autopilot_phase_timeout_secs() -> u64 { + env::var("CODEX_RAIL_AUTOPILOT_PHASE_TIMEOUT_SECS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(15 * 60) +} // Preview tail: enough to contain the last agent_message even when codex writes // large events (a single message line can be tens of KB). const ROLLOUT_TAIL_BYTES: u64 = 256 * 1024; -// When recovering a rollout for a session whose worker never captured one, only -// accept a codex rollout that started within this window of the session's -// creation. Real prompted sessions start their rollout within seconds; a wide -// gap means we're not sure it's the same session, so we leave it unresolved -// (row falls back to its path) rather than mislabel it. -const ROLLOUT_MATCH_WINDOW_SECS: u64 = 3600; +const MAX_COMPOSER_BYTES: usize = 256 * 1024; // Truecolor palette, tuned for dark terminals. Kept as RGB rather than the // 8 legacy ANSI colors so it looks consistent regardless of the user's @@ -62,17 +75,66 @@ const ROLLOUT_MATCH_WINDOW_SECS: u64 = 3600; // The brand accent is a warm "terracotta", matching Claude Code's own chrome // so rail reads as part of the same toolset. Status colours (amber/green/grey) // stay distinct from the brand accent so they still carry meaning. -const C_ACCENT: Color = Color::Rgb { r: 214, g: 122, b: 90 }; // terracotta: title, prompt, selection bar -const C_ACCENT_DIM: Color = Color::Rgb { r: 150, g: 92, b: 74 }; // dim terracotta: input border -const C_TITLE: Color = Color::Rgb { r: 230, g: 230, b: 238 }; // primary text -const C_SELTITLE: Color = Color::Rgb { r: 255, g: 255, b: 255 }; // selected primary -const C_DIM: Color = Color::Rgb { r: 132, g: 140, b: 158 }; // secondary (paths, age, hints) -const C_FAINT: Color = Color::Rgb { r: 96, g: 102, b: 120 }; // rules -const C_SEL_BG: Color = Color::Rgb { r: 42, g: 38, b: 46 }; // selected row background (warm-tinted) -const C_NEEDS: Color = Color::Rgb { r: 236, g: 188, b: 92 }; // amber -const C_WORKING: Color = Color::Rgb { r: 122, g: 208, b: 142 }; // green -const C_DONE: Color = Color::Rgb { r: 100, g: 200, b: 210 }; // teal: finished task -const C_STOPPED: Color = Color::Rgb { r: 140, g: 148, b: 168 }; // grey (readable, not "black") +const C_ACCENT: Color = Color::Rgb { + r: 214, + g: 122, + b: 90, +}; // terracotta: title, prompt, selection bar +const C_ACCENT_DIM: Color = Color::Rgb { + r: 150, + g: 92, + b: 74, +}; // dim terracotta: input border +const C_TITLE: Color = Color::Rgb { + r: 230, + g: 230, + b: 238, +}; // primary text +const C_SELTITLE: Color = Color::Rgb { + r: 255, + g: 255, + b: 255, +}; // selected primary +const C_DIM: Color = Color::Rgb { + r: 132, + g: 140, + b: 158, +}; // secondary (paths, age, hints) +const C_FAINT: Color = Color::Rgb { + r: 96, + g: 102, + b: 120, +}; // rules +const C_SEL_BG: Color = Color::Rgb { + r: 42, + g: 38, + b: 46, +}; // selected row background (warm-tinted) +const C_HOVER_BG: Color = Color::Rgb { + r: 35, + g: 33, + b: 40, +}; // mouse hover: visible, but quieter than keyboard selection +const C_NEEDS: Color = Color::Rgb { + r: 236, + g: 188, + b: 92, +}; // amber +const C_WORKING: Color = Color::Rgb { + r: 122, + g: 208, + b: 142, +}; // green +const C_DONE: Color = Color::Rgb { + r: 100, + g: 200, + b: 210, +}; // teal: finished task +const C_STOPPED: Color = Color::Rgb { + r: 140, + g: 148, + b: 168, +}; // grey (readable, not "black") // Cap the content region so age/metadata don't fly to the far edge of an // ultra-wide terminal. The message-preview column fills the middle, so a wider @@ -80,7 +142,7 @@ const C_STOPPED: Color = Color::Rgb { r: 140, g: 148, b: 168 }; // grey (readabl // wide terminal fully while still reining in a 200-column one. const MAX_CONTENT_COLS: usize = 120; -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Activity { Active, Waiting, @@ -99,6 +161,26 @@ struct Lifecycle { path: String, offset: u64, last: Option, + completed_turns: u64, + partial_record: Vec, + discarding_oversized_record: bool, + caught_up: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct FileStamp { + path: PathBuf, + len: u64, + modified: Option, +} + +fn file_stamp(path: &Path) -> Option { + let meta = fs::metadata(path).ok()?; + meta.is_file().then(|| FileStamp { + path: path.to_path_buf(), + len: meta.len(), + modified: meta.modified().ok(), + }) } // Read the bytes appended to the rollout since we last looked and update the @@ -111,6 +193,10 @@ fn scan_lifecycle(lc: &mut Lifecycle, path: &str) -> Activity { lc.path = path.to_string(); lc.offset = 0; lc.last = None; + lc.completed_turns = 0; + lc.partial_record.clear(); + lc.discarding_oversized_record = false; + lc.caught_up = false; } let Ok(mut file) = fs::File::open(path) else { return lc.last.unwrap_or(Activity::Waiting); @@ -120,36 +206,79 @@ fn scan_lifecycle(lc: &mut Lifecycle, path: &str) -> Activity { // File shrank (rotated/truncated) — rescan from the top. lc.offset = 0; lc.last = None; + lc.completed_turns = 0; + lc.partial_record.clear(); + lc.discarding_oversized_record = false; + lc.caught_up = false; } if len > lc.offset && file.seek(SeekFrom::Start(lc.offset)).is_ok() { - let mut buf = Vec::new(); - if file.read_to_end(&mut buf).is_ok() { - // Only consume up to the last complete line; a trailing partial - // line (codex mid-write of a big event) is left for next time. - if let Some(nl) = buf.iter().rposition(|&b| b == b'\n') { - for line in buf[..=nl].split(|&b| b == b'\n') { - if line.is_empty() { - continue; - } - let Ok(value) = serde_json::from_slice::(line) else { - continue; - }; - match value - .get("payload") - .and_then(|p| p.get("type")) - .and_then(|t| t.as_str()) - { - Some("task_started") => lc.last = Some(Activity::Active), - Some("task_complete") => lc.last = Some(Activity::Waiting), - _ => {} + // Bound both one JSON record and work per UI tick. Giant tool events are + // irrelevant to lifecycle; discard them incrementally to their newline + // instead of reallocating/re-reading an ever-growing partial line. + const MAX_RECORD_BYTES: usize = 1024 * 1024; + const MAX_SCAN_BYTES_PER_TICK: usize = 8 * 1024 * 1024; + let mut scanned = 0usize; + let mut buf = [0_u8; 64 * 1024]; + while scanned < MAX_SCAN_BYTES_PER_TICK { + let want = buf.len().min(MAX_SCAN_BYTES_PER_TICK - scanned); + let Ok(read) = file.read(&mut buf[..want]) else { + break; + }; + if read == 0 { + break; + } + scanned += read; + lc.offset = lc.offset.saturating_add(read as u64); + for byte in &buf[..read] { + if lc.discarding_oversized_record { + if *byte == b'\n' { + lc.discarding_oversized_record = false; } + continue; + } + if *byte == b'\n' { + observe_lifecycle_record(lc); + lc.partial_record.clear(); + } else if lc.partial_record.len() < MAX_RECORD_BYTES { + lc.partial_record.push(*byte); + } else { + lc.partial_record.clear(); + lc.discarding_oversized_record = true; } - lc.offset += (nl + 1) as u64; } } } + lc.caught_up = + lc.offset >= len && lc.partial_record.is_empty() && !lc.discarding_oversized_record; // No marker ever seen (turn hasn't started) → waiting for you. - lc.last.unwrap_or(Activity::Waiting) + if lc.caught_up { + lc.last.unwrap_or(Activity::Waiting) + } else { + Activity::Active + } +} + +fn observe_lifecycle_record(lc: &mut Lifecycle) { + let Ok(value) = serde_json::from_slice::(&lc.partial_record) else { + return; + }; + match value + .get("payload") + .and_then(|payload| payload.get("type")) + .and_then(|kind| kind.as_str()) + { + Some("task_started") => lc.last = Some(Activity::Active), + Some("task_complete") => { + lc.last = Some(Activity::Waiting); + lc.completed_turns = lc.completed_turns.saturating_add(1); + } + Some("turn_aborted" | "thread_rolled_back") => { + // An interrupted turn is no longer working, but is not a completed + // reply edge for autopilot. + lc.last = Some(Activity::Waiting); + } + _ => {} + } } fn read_tail(path: &Path) -> Option { @@ -201,8 +330,16 @@ fn last_agent_message(path: &str) -> Option { // Collapse a message to a single tidy preview line: first non-empty line with // runs of whitespace squeezed to single spaces. fn preview_line(s: &str) -> String { - let line = s.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or(""); - line.split_whitespace().collect::>().join(" ") + let line = s + .lines() + .map(str::trim) + .find(|l| !l.is_empty()) + .unwrap_or(""); + line.split_whitespace() + .map(sanitize_terminal_text) + .filter(|part| !part.is_empty()) + .collect::>() + .join(" ") } // Sessions are grouped into these buckets in the list, mirroring Claude @@ -238,18 +375,10 @@ fn bucket_of(activity: &HashMap, s: &SessionState) -> Bucket { // Has this distill session's output file been written yet? fn distill_done(s: &SessionState) -> bool { - match s.distill_version { - Some(v) => { - let path = state::distill_dir().join(format!("style-v{v:03}.md")); - if !path.exists() { - return false; - } - // Codex creates the final profile with its own umask. Tighten it as - // soon as the manager observes completion. - state::restrict_private_file_to_owner(&path).is_ok() - } - None => false, - } + s.distill_validated + && s.status == STATUS_EXITED + && s.worker_token.is_none() + && s.last_error.is_none() } fn bucket_rank(b: Bucket) -> u8 { @@ -323,12 +452,65 @@ fn format_age(secs: u64) -> String { // Terminal display width of a char: CJK/fullwidth/most emoji occupy 2 cells. // Approximate (no unicode-width dependency) but covers the ranges that show // up in session titles so CJK columns line up instead of drifting right. +// Never let untrusted session metadata become terminal instructions. Codex +// messages, imported titles, paths, and pasted composer text all eventually +// pass through these helpers before the rendered frame is written to stdout. +// C0/C1 controls can start ANSI/OSC sequences; bidi/zero-width format controls +// can make the UI display something other than the stored text. +fn is_unsafe_terminal_char(c: char) -> bool { + matches!(c as u32, + 0x0000..=0x001f + | 0x007f..=0x009f + | 0x200b..=0x200c + | 0x200e..=0x200f + | 0x202a..=0x202e + | 0x2060..=0x2069 + | 0xfeff + ) +} + +fn sanitize_terminal_text(s: &str) -> String { + s.chars().filter(|c| !is_unsafe_terminal_char(*c)).collect() +} + +fn sanitize_composer_text(s: &str) -> String { + let normalized = s.replace("\r\n", "\n").replace('\r', "\n"); + normalized + .chars() + .filter(|c| *c == '\n' || *c == '\t' || !is_unsafe_terminal_char(*c)) + .collect() +} + +fn composer_display_text(s: &str) -> String { + s.chars() + .filter_map(|c| match c { + '\n' => Some('↵'), + '\t' => Some('⇥'), + c if is_unsafe_terminal_char(c) => None, + c => Some(c), + }) + .collect() +} + fn char_width(c: char) -> usize { let u = c as u32; - if u < 0x20 { + if is_unsafe_terminal_char(c) { + return 0; + } + if c == '\u{200d}' + || (0x0300..=0x036f).contains(&u) + || (0x1ab0..=0x1aff).contains(&u) + || (0x1dc0..=0x1dff).contains(&u) + || (0x20d0..=0x20ff).contains(&u) + || (0xfe00..=0xfe0f).contains(&u) + || (0xfe20..=0xfe2f).contains(&u) + || (0xe0100..=0xe01ef).contains(&u) + || (0x1f3fb..=0x1f3ff).contains(&u) + { return 0; } let wide = (0x1100..=0x115F).contains(&u) // Hangul Jamo + || (0x2600..=0x27bf).contains(&u) // emoji-presenting symbols || (0x2E80..=0x303E).contains(&u) // CJK radicals / Kangxi / symbols || (0x3041..=0x33FF).contains(&u) // Hiragana..CJK compat || (0x3400..=0x4DBF).contains(&u) // CJK Ext A @@ -349,7 +531,65 @@ fn char_width(c: char) -> usize { } fn display_width(s: &str) -> usize { - s.chars().map(char_width).sum() + display_clusters(s) + .into_iter() + .map(|(_, width)| width) + .sum() +} + +// A compact grapheme approximation for terminal layout: combining/variation/ +// skin-tone characters extend the previous cell, and emoji joined with ZWJ +// stay one cluster. This covers the user-visible sequences Rail renders without +// pulling a large Unicode dependency into the static binary. +fn display_clusters(s: &str) -> Vec<(String, usize)> { + let mut clusters: Vec<(String, usize)> = Vec::new(); + let mut join_next = false; + for c in s.chars() { + if is_unsafe_terminal_char(c) { + continue; + } + let width = char_width(c); + let regional = ('\u{1f1e6}'..='\u{1f1ff}').contains(&c); + if c == '\u{200d}' { + if let Some((text, _)) = clusters.last_mut() { + text.push(c); + join_next = true; + } + continue; + } + if width == 0 { + if let Some((text, _)) = clusters.last_mut() { + text.push(c); + } + continue; + } + if regional { + let joins_flag = clusters.last().is_some_and(|(text, _)| { + text.chars().count() == 1 + && text + .chars() + .next() + .is_some_and(|prior| ('\u{1f1e6}'..='\u{1f1ff}').contains(&prior)) + }); + if joins_flag { + if let Some((text, cluster_width)) = clusters.last_mut() { + text.push(c); + *cluster_width = 2; + } + continue; + } + } + if join_next { + if let Some((text, cluster_width)) = clusters.last_mut() { + text.push(c); + *cluster_width = (*cluster_width).max(width); + } + join_next = false; + } else { + clusters.push((c.to_string(), width)); + } + } + clusters } // Truncate to at most `width` display columns and pad with spaces to exactly @@ -357,12 +597,11 @@ fn display_width(s: &str) -> usize { fn fit_cols(s: &str, width: usize) -> String { let mut out = String::new(); let mut w = 0; - for c in s.chars() { - let cw = char_width(c); + for (cluster, cw) in display_clusters(s) { if w + cw > width { break; } - out.push(c); + out.push_str(&cluster); w += cw; } while w < width { @@ -381,25 +620,33 @@ fn fit_cols_tail(s: &str, width: usize) -> String { if width == 0 { return String::new(); } - let mut tail = String::new(); + let mut kept = Vec::new(); let mut w = 0; - for c in s.chars().rev() { - let cw = char_width(c); + for (cluster, cw) in display_clusters(s).into_iter().rev() { if w + cw > width.saturating_sub(1) { break; } - tail.push(c); + kept.push(cluster); w += cw; } - let tail: String = tail.chars().rev().collect(); + kept.reverse(); + let tail = kept.concat(); fit_cols(&format!("…{tail}"), width) } fn home_tilde(cwd: &str) -> String { match env::var("HOME") { - Ok(home) if !home.is_empty() && cwd.starts_with(&home) => { - format!("~{}", &cwd[home.len()..]) - } + Ok(home) if !home.is_empty() => Path::new(cwd) + .strip_prefix(Path::new(&home)) + .ok() + .map(|suffix| { + if suffix.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~/{}", suffix.to_string_lossy()) + } + }) + .unwrap_or_else(|| cwd.to_string()), _ => cwd.to_string(), } } @@ -453,6 +700,15 @@ fn manager_loop(terminal: &mut TerminalSession, app: &mut App) -> Result<()> { loop { if last_refresh.elapsed() >= REFRESH_INTERVAL { app.reload()?; + if app.refresh_pending_stops() { + app.reload()?; + } + // A distillation is a one-shot job. Once its output exists and the + // Codex turn is genuinely waiting, stop the worker tree so an idle + // Codex/MCP stack is not left behind indefinitely. + if app.stop_completed_distills() { + app.reload()?; + } // Advance any autopilots (create/nudge pilots, deliver replies). If it // acted, reload so a freshly-spawned pilot appears immediately. if app.drive_autopilot() { @@ -477,9 +733,6 @@ fn manager_loop(terminal: &mut TerminalSession, app: &mut App) -> Result<()> { app.merge_adopted(); app.refresh_derived(); app.sort_for_display(); - if app.message.starts_with("importing codex history") { - app.message.clear(); - } render(app)?; } else if app.message.is_empty() || app.message.starts_with("importing codex history") { if let Some(status) = app.adopt_status() { @@ -512,6 +765,10 @@ fn manager_loop(terminal: &mut TerminalSession, app: &mut App) -> Result<()> { match event::read()? { Event::Key(key) => { + // Keyboard interaction returns visual focus to the durable + // selection. Hover is purely pointer-local and must never leave + // a stale highlight after keyboard-driven scrolling/reordering. + app.hovered_row = None; if handle_key(key, app, terminal)? { return Ok(()); } @@ -527,9 +784,8 @@ fn manager_loop(terminal: &mut TerminalSession, app: &mut App) -> Result<()> { app.invalidate_frame(); render(app)?; } - // A bracketed paste arrives as one event (not char-by-char), so it can't - // be dropped or misread as keystrokes. Newlines collapse to spaces — the - // composer is a single line; codex still receives it as one message. + // Preserve multiline code/lists in the actual prompt. The one-line + // visual composer folds line breaks to a visible marker only. Event::Paste(text) => { if handle_paste(&text, app) { render(app)?; @@ -543,10 +799,7 @@ fn manager_loop(terminal: &mut TerminalSession, app: &mut App) -> Result<()> { // Insert pasted text into the composer. In Normal mode a paste opens the new- // session composer (like typing does); while composing/renaming it appends. fn handle_paste(text: &str, app: &mut App) -> bool { - let cleaned: String = text - .chars() - .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) - .collect(); + let cleaned = sanitize_composer_text(text); if cleaned.is_empty() { return false; } @@ -554,17 +807,33 @@ fn handle_paste(text: &str, app: &mut App) -> bool { Mode::Normal => { app.mode = Mode::New; app.input.clear(); - app.input.push_str(&cleaned); + append_composer_text(app, &cleaned); app.stop_confirm = None; app.exit_confirm = None; } Mode::New | Mode::Rename => { - app.input.push_str(&cleaned); + append_composer_text(app, &cleaned); } } true } +fn append_composer_text(app: &mut App, text: &str) { + let remaining = MAX_COMPOSER_BYTES.saturating_sub(app.input.len()); + let mut end = 0; + for (index, ch) in text.char_indices() { + let next = index + ch.len_utf8(); + if next > remaining { + break; + } + end = next; + } + app.input.push_str(&text[..end]); + if end < text.len() { + app.message = format!("input capped at {} KiB", MAX_COMPOSER_BYTES / 1024); + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Mode { Normal, @@ -587,6 +856,10 @@ struct App { // starts with '/'). slash_sel: usize, rows: Vec<(u16, usize)>, + // Physical screen row currently under the pointer. This is deliberately + // separate from `selected`: hover paints only; keyboard navigation, list + // scrolling, and attach ownership remain driven by `selected`. + hovered_row: Option, // Memoized rollout recovery per session id: Some(path) once resolved, None // once we've scanned and found no confident match. Keeps the (potentially // large) codex sessions directory from being re-scanned every refresh. @@ -597,6 +870,9 @@ struct App { activity: HashMap, lifecycle: HashMap, preview: HashMap, + preview_stamp: HashMap, + title_history: HashMap, + title_history_stamp: Option, // Previous rendered frame, one string per screen row, for diff-based // drawing (only changed rows are rewritten — no full clear, no flicker). prev_frame: Vec, @@ -611,6 +887,9 @@ struct App { // UI thread so the manager appears instantly and never looks frozen. adopted: HashMap, adopt_job: Option, + // A manual `/import ...` entered while the automatic scan is still running. + // Keep the user's explicit request instead of asking them to race/retry it. + adopt_pending: Option, adopt_started: Option, // throttles rescans adopt_since: SystemTime, // rescans read only rollouts modified after this // Background "is a newer build on GitHub?" check; its result (a newer short @@ -623,21 +902,52 @@ struct App { // Column span of the header "↑ update available" note (row 0), so a click on // it triggers the update. Set each render; None when no update is pending. update_click: Option<(u16, u16)>, - // Autopilot control per main session id (Space toggles it). Loaded from each + // Autopilot control per main session id (Ctrl+A toggles it). Loaded from each // session's autopilot.json every reload; the driver advances the reply cycle // each tick. `pilot_to_main` is the reverse link (a pilot session's id -> the // main it answers) so the list can render pilots grouped under their main. autopilot: HashMap, + // A live flock guard for every autopilot this manager is allowed to drive. + // Other Rail windows may render the same state but cannot inject messages. + autopilot_leases: HashMap, + // A persistence failure makes delivery state uncertain. Keep the lease but + // stop driving until an explicit user toggle successfully writes state. + autopilot_faults: HashSet, pilot_to_main: HashMap, + pending_stops: HashMap, +} + +#[derive(Clone, Debug)] +enum AdoptRequest { + Automatic { since: SystemTime, initial: bool }, + Days(u64), + Session(String), +} + +#[derive(Debug)] +enum ExactImportIssue { + NotFound, + AlreadyImported, + DifferentCwd(String), + NoConversation, + Ambiguous(usize), } -// A background codex-session scan in flight. The startup scan reads every -// rollout; a rescan reads only files modified since the last scan (cheap). +#[derive(Default)] +struct AdoptScanResult { + sessions: Vec, + exact_issue: Option, + ambiguous: usize, +} + +// A background codex-session scan in flight. The startup scan is capped to the +// last seven active days; later automatic scans use the incremental cursor. // Progress is shared so the UI can draw a real bar of what's being scanned. struct AdoptJob { - rx: mpsc::Receiver>, + rx: mpsc::Receiver, progress: Arc, scan_start: SystemTime, + request: AdoptRequest, } struct AdoptProgress { done: AtomicUsize, @@ -652,6 +962,11 @@ struct DistillJob { started: Instant, } +struct PendingStop { + requested: Instant, + escalated: bool, +} + impl App { fn load() -> Result { // Migrate private corpus/style artifacts written by older builds before @@ -660,7 +975,7 @@ impl App { // Reap rail's own stale worker trees up front (orphaned/exited/duplicate), // so leftover codex don't pile up and lock codex's shared ~/.codex sqlite // state — the "database is locked" failure. See reap_orphan_workers. - let reaped = reap_orphan_workers(); + let reaped = reap_orphan_workers() + reap_abandoned_generations(); let mut app = Self { sessions: Vec::new(), selected: 0, @@ -672,29 +987,38 @@ impl App { resume_confirm: None, slash_sel: 0, rows: Vec::new(), + hovered_row: None, rollout_cache: HashMap::new(), activity: HashMap::new(), lifecycle: HashMap::new(), preview: HashMap::new(), + preview_stamp: HashMap::new(), + title_history: HashMap::new(), + title_history_stamp: None, prev_frame: Vec::new(), distill: None, adopted: HashMap::new(), adopt_job: None, + adopt_pending: None, adopt_started: None, - adopt_since: UNIX_EPOCH, + adopt_since: days_before(SystemTime::now(), DEFAULT_IMPORT_DAYS).unwrap_or(UNIX_EPOCH), update_rx: None, update_available: None, update_apply: None, update_click: None, autopilot: HashMap::new(), + autopilot_leases: HashMap::new(), + autopilot_faults: HashSet::new(), pilot_to_main: HashMap::new(), + pending_stops: HashMap::new(), }; app.sessions = state::load_sessions()?; resolve_missing_rollouts(&mut app.sessions, &mut app.rollout_cache); - app.spawn_adopt_scan(); // off-thread; adopted rows merge in when it finishes - // Fire the update check off the UI thread; the header shows a note if a - // newer build lands. Skipped when CODEX_RAIL_NO_UPDATE_CHECK is set; - // CODEX_RAIL_FAKE_UPDATE forces the note (tests, without hitting GitHub). + app.refresh_titles_from_history(); + app.spawn_adopt_scan(true); // off-thread; adopted rows merge in when it finishes + // Fire the update check off the UI thread; the header shows a note if a + // newer build lands. Skipped when CODEX_RAIL_NO_UPDATE_CHECK is set; + // CODEX_RAIL_FAKE_UPDATE forces the note (tests, without hitting GitHub). let forced = env::var("CODEX_RAIL_FAKE_UPDATE") .ok() .filter(|s| !s.is_empty()); @@ -724,17 +1048,134 @@ impl App { fn load_autopilot(&mut self) { self.autopilot.clear(); self.pilot_to_main.clear(); - for s in &self.sessions { - if let Some(st) = autopilot::load(&s.id) { - if let Some(pid) = &st.pilot_id { - self.pilot_to_main.insert(pid.clone(), s.id.clone()); + let controls: Vec<(String, autopilot::AutopilotState)> = self + .sessions + .iter() + .filter_map(|session| { + autopilot::load(&session.id).map(|control| (session.id.clone(), control)) + }) + .collect(); + let mut pilot_owners: HashMap> = HashMap::new(); + for (main_id, control) in &controls { + if let Some(pilot_id) = &control.pilot_id { + pilot_owners + .entry(pilot_id.clone()) + .or_default() + .push(main_id.clone()); + } + } + let mut enabled = HashSet::new(); + for (main_id, mut control) in controls { + let corrupt_link = control.pilot_id.as_ref().is_some_and(|pilot_id| { + pilot_id == &main_id + || pilot_owners + .get(pilot_id) + .is_some_and(|owners| owners.len() != 1) + }); + if corrupt_link { + // Two mains must never drive or delete one ambiguous pilot. + // Drop both claims in this manager's reconciled view and leave + // the pilot visible for inspection/removal. + control.enabled = false; + control.pilot_id = None; + control.cleanup_pending = false; + control.enter_phase(autopilot::Phase::Idle); + control.pending_reply.clear(); + control.last_reason = + Some("corrupt autopilot ownership: pilot link is ambiguous".to_string()); + // Reconciliation does not own every affected main's lease, so + // it must not race a live manager by rewriting these files. An + // explicit Ctrl+A acquires one main's lease before severing its + // persisted ambiguous link and starting fresh. + self.autopilot_faults.insert(main_id.clone()); + self.message = + "autopilot ownership conflict disabled; ambiguous pilot kept for inspection" + .to_string(); + } else if let Some(pilot_id) = &control.pilot_id { + self.pilot_to_main.insert(pilot_id.clone(), main_id.clone()); + } + if control.enabled || control.cleanup_pending { + enabled.insert(main_id.clone()); + } + self.autopilot.insert(main_id, control); + } + self.autopilot_leases.retain(|id, _| enabled.contains(id)); + for id in enabled { + if self.autopilot_leases.contains_key(&id) { + continue; + } + if let Ok(Some(lease)) = state::try_acquire_autopilot_lock(&id) { + self.autopilot_leases.insert(id, lease); + } + } + + // `cleanup_pending` is a durable state machine, not a one-process + // callback. After a manager crash, reacquire the lease and either + // re-request STOP, finish a dead pilot, or clear a link whose pilot was + // already removed before the main control write committed. + let cleanup_jobs: Vec<(String, Option)> = self + .autopilot + .iter() + .filter(|(main_id, control)| { + control.cleanup_pending && self.autopilot_leases.contains_key(*main_id) + }) + .map(|(main_id, control)| (main_id.clone(), control.pilot_id.clone())) + .collect(); + for (main_id, pilot_id) in cleanup_jobs { + match pilot_id { + Some(pilot_id) => { + if let Some(pilot) = self + .sessions + .iter() + .find(|session| session.id == pilot_id) + .cloned() + { + if !self.pending_stops.contains_key(&pilot_id) { + if state::session_worker_is_running(&pilot) { + request_session_stop(&pilot).ok(); + } + self.pending_stops.insert( + pilot_id, + PendingStop { + requested: Instant::now(), + escalated: false, + }, + ); + } + } else if let Some(mut control) = self.autopilot.get(&main_id).cloned() { + control.pilot_id = None; + control.cleanup_pending = false; + if autopilot::save(&main_id, &control).is_ok() { + self.autopilot.insert(main_id.clone(), control); + self.autopilot_leases.remove(&main_id); + } + } + } + None => { + if let Some(mut control) = self.autopilot.get(&main_id).cloned() { + control.cleanup_pending = false; + if autopilot::save(&main_id, &control).is_ok() { + self.autopilot.insert(main_id.clone(), control); + self.autopilot_leases.remove(&main_id); + } + } } - self.autopilot.insert(s.id.clone(), st); } } } - // Space on the selected session turns autopilot on/off. On, rail will answer + fn acquire_autopilot_lease(&mut self, id: &str) -> Result { + if self.autopilot_leases.contains_key(id) { + return Ok(true); + } + let Some(lease) = state::try_acquire_autopilot_lock(id)? else { + return Ok(false); + }; + self.autopilot_leases.insert(id.to_string(), lease); + Ok(true) + } + + // Ctrl+A on the selected session turns autopilot on/off. On, rail will answer // that session for you (via a pilot session) whenever it finishes a turn. fn toggle_autopilot(&mut self) { let Some((id, adopted, title)) = self @@ -751,27 +1192,87 @@ impl App { self.message = "attach this imported session once before autopiloting it".to_string(); return; } - let on = self - .autopilot - .get(&id) - .map(|s| s.enabled) - .unwrap_or(false); + let on = self.autopilot.get(&id).map(|s| s.enabled).unwrap_or(false); + match self.acquire_autopilot_lease(&id) { + Ok(true) => {} + Ok(false) => { + self.message = + "autopilot is controlled by another Rail window; stop it there first" + .to_string(); + return; + } + Err(err) => { + self.message = format!("autopilot lock failed: {err:#}"); + return; + } + } if on { - autopilot::remove(&id); - self.autopilot.remove(&id); - self.message = format!("autopilot off for \u{201c}{}\u{201d}", clip_title(&title)); + let mut stopped = self.autopilot.get(&id).cloned().unwrap_or_default(); + let pilot_id = stopped.pilot_id.clone(); + stopped.enabled = false; + stopped.enter_phase(autopilot::Phase::Idle); + stopped.pending_reply.clear(); + stopped.last_reason = Some("stopped by user".to_string()); + stopped.cleanup_pending = pilot_id.is_some(); + if let Err(err) = autopilot::save(&id, &stopped) { + self.autopilot_faults.insert(id.clone()); + self.message = format!("couldn't stop autopilot safely: {err:#}"); + return; + } + let pilot_cleanup_error = pilot_id.as_deref().and_then(|pid| { + self.sessions + .iter() + .find(|s| s.id == pid) + .and_then(|pilot| { + let result = request_session_stop(pilot); + self.pending_stops.insert( + pid.to_string(), + PendingStop { + requested: Instant::now(), + escalated: false, + }, + ); + result.err() + }) + }); + self.autopilot.insert(id.clone(), stopped); + if pilot_id.is_none() { + self.autopilot_leases.remove(&id); + } + self.autopilot_faults.remove(&id); + self.message = match pilot_cleanup_error { + Some(err) => format!("autopilot off; pilot cleanup failed: {err:#}"), + None => format!("autopilot off for \u{201c}{}\u{201d}", clip_title(&title)), + }; } else { // (re-)enable: fresh cycle, keep any existing pilot to reuse. let mut st = autopilot::load(&id).unwrap_or_default(); + if self.autopilot_faults.contains(&id) { + st.pilot_id = None; + st.cleanup_pending = false; + st.pilot_marker.clear(); + } + if st.cleanup_pending { + self.message = + "pilot cleanup is still pending; wait before re-enabling autopilot".to_string(); + return; + } st.enabled = true; + st.marker_version = 2; st.replies = 0; - st.phase = autopilot::Phase::Idle; + st.enter_phase(autopilot::Phase::Idle); st.main_marker.clear(); st.pending_reply.clear(); st.last_reason = None; - autopilot::save(&id, &st); + st.cleanup_pending = false; + if let Err(err) = autopilot::save(&id, &st) { + self.autopilot_leases.remove(&id); + self.message = format!("couldn't persist autopilot state: {err:#}"); + return; + } + self.autopilot_faults.remove(&id); self.message = format!( - "autopilot ON \u{2014} I'll answer \u{201c}{}\u{201d} for you (up to {} replies) \u{00b7} Space to stop", + "autopilot ON \u{2014} I'll answer \u{201c}{}\u{201d} for you (up to {} replies) \u{00b7} Ctrl+A to stop", clip_title(&title), st.cap ); @@ -809,6 +1310,174 @@ impl App { .and_then(|s| s.codex_rollout_path.clone()) } + fn session_turn_marker(&self, id: &str) -> Option { + self.lifecycle + .get(id) + .filter(|lifecycle| lifecycle.caught_up && lifecycle.completed_turns > 0) + .map(|lifecycle| format!("v2:{}", lifecycle.completed_turns)) + } + + fn session_last_agent_message(&self, id: &str) -> Option { + self.session_rollout(id) + .and_then(|rollout| autopilot::last_agent_message_full(&rollout)) + } + + fn refresh_pending_stops(&mut self) -> bool { + let ids: Vec = self.pending_stops.keys().cloned().collect(); + let mut changed = false; + for id in ids { + let Some(session) = self + .sessions + .iter() + .find(|session| session.id == id) + .cloned() + else { + self.pending_stops.remove(&id); + changed = true; + continue; + }; + let live = state::session_worker_is_running(&session); + if !live { + match state::try_acquire_session_generation_lock(&id) { + Ok(Some(clean_guard)) => { + let worker_guard = match state::try_acquire_worker_lock(&id) { + Ok(Some(guard)) => guard, + Ok(None) => { + self.message = + "worker lock is still owned; stop not yet verified".to_string(); + continue; + } + Err(err) => { + self.message = format!("stop verification failed: {err:#}"); + continue; + } + }; + let mut persisted = match state::read_state(&id) { + Ok(state) => state, + Err(err) => { + self.message = format!("stop state verification failed: {err:#}"); + continue; + } + }; + if persisted.worker_token.is_some() { + if let Err(err) = recover_generation_before_relaunch(&mut persisted) { + self.message = format!( + "worker stopped but generation cleanup remains unverified: {err:#}" + ); + continue; + } + } + if persisted.worker_token.is_some() { + self.message = + "worker stopped but its generation token is still unresolved" + .to_string(); + continue; + } + drop(worker_guard); + drop(clean_guard); + if self.pilot_to_main.contains_key(&id) { + match self.finish_pilot_cleanup(&id) { + Ok(()) => { + self.pending_stops.remove(&id); + self.message = + "autopilot pilot stopped and removed".to_string(); + changed = true; + } + Err(err) => { + self.message = format!("pilot cleanup failed: {err:#}"); + } + } + } else { + self.pending_stops.remove(&id); + self.message = format!("stopped {}", clip_title(&session.title)); + changed = true; + } + } + Ok(None) => { + self.message = + "worker stopped; guardian is cleaning descendants…".to_string(); + } + Err(err) => { + self.message = format!("stop verification failed: {err:#}"); + } + } + continue; + } + + let Some(pending) = self.pending_stops.get_mut(&id) else { + continue; + }; + if !pending.escalated && pending.requested.elapsed() >= Duration::from_secs(8) { + pending.escalated = true; + if kill_session_pids_with_signal(&session, libc::SIGKILL) { + self.message = + "worker did not acknowledge completion; escalated to verified SIGKILL, guardian cleaning…" + .to_string(); + } else { + self.message = + "stop timed out; worker identity could not be proven, session kept" + .to_string(); + } + changed = true; + } else if pending.escalated && pending.requested.elapsed() >= Duration::from_secs(20) { + self.pending_stops.remove(&id); + self.message = + "stop remains unverified after escalation; session and token were kept" + .to_string(); + changed = true; + } + } + changed + } + + fn finish_pilot_cleanup(&mut self, pilot_id: &str) -> Result<()> { + let main_id = self + .pilot_to_main + .get(pilot_id) + .cloned() + .context("pilot has no durable main link")?; + state::remove_session(pilot_id).context("remove stopped pilot session")?; + let mut control = self + .autopilot + .get(&main_id) + .cloned() + .or_else(|| autopilot::load(&main_id)) + .context("load autopilot control for pilot cleanup")?; + if control.pilot_id.as_deref() == Some(pilot_id) { + control.pilot_id = None; + control.cleanup_pending = false; + autopilot::save(&main_id, &control)?; + self.autopilot.insert(main_id.clone(), control); + } + self.pilot_to_main.remove(pilot_id); + self.autopilot_leases.remove(&main_id); + Ok(()) + } + + fn stop_completed_distills(&mut self) -> bool { + let completed: Vec = self + .sessions + .iter() + .filter(|s| { + s.distill_version.is_some() + && s.status == STATUS_RUNNING + && self.activity.get(&s.id) == Some(&Activity::Waiting) + && s.distill_validated + }) + .cloned() + .collect(); + let mut requested = false; + for session in completed { + match request_session_stop(&session) { + Ok(()) => requested = true, + Err(err) => { + self.message = format!("completed distill cleanup failed: {err:#}"); + } + } + } + requested + } + // Advance the autopilot reply cycle for every enabled main session, once per // refresh. Returns true if it changed anything (spawned a pilot, injected a // reply, paused) so the caller can reload + repaint. Cheap when idle. @@ -819,7 +1488,11 @@ impl App { let mains: Vec = self .autopilot .iter() - .filter(|(_, st)| st.enabled) + .filter(|(id, st)| { + st.enabled + && self.autopilot_leases.contains_key(*id) + && !self.autopilot_faults.contains(*id) + }) .map(|(id, _)| id.clone()) .collect(); let mut changed = false; @@ -834,11 +1507,58 @@ impl App { Some(s) => s.clone(), None => return false, }; + let original = st.clone(); + let mut wrote_intermediate_intent = false; if !self.sessions.iter().any(|s| s.id == main_id) { + if let Some(pid) = st.pilot_id.as_deref() { + if let Some(pilot) = self.sessions.iter().find(|s| s.id == pid) { + let _ = request_session_stop(pilot); + } + } autopilot::remove(main_id); // main gone self.autopilot.remove(main_id); + self.autopilot_leases.remove(main_id); return true; } + if let Some(pilot_id) = st.pilot_id.as_deref() { + if !self.sessions.iter().any(|session| session.id == pilot_id) { + st.pilot_id = None; + st.cleanup_pending = false; + return self.pause_autopilot( + main_id, + &mut st, + "autopilot pilot session is missing; re-enable to create a new pilot" + .to_string(), + ); + } + } + if st.marker_version != 2 { + st.marker_version = 2; + return self.pause_autopilot( + main_id, + &mut st, + "autopilot state was upgraded; re-enable it after checking the transcript" + .to_string(), + ); + } + if st.phase != autopilot::Phase::Idle { + if st.phase_started_at == 0 { + return self.pause_autopilot( + main_id, + &mut st, + "autopilot phase has no durable start time; inspect before re-enabling" + .to_string(), + ); + } + let elapsed = state::now_secs().saturating_sub(st.phase_started_at); + if elapsed >= autopilot_phase_timeout_secs() { + return self.pause_autopilot( + main_id, + &mut st, + format!("autopilot phase timed out after {elapsed}s"), + ); + } + } if st.replies >= st.cap { let cap = st.cap; return self.pause_autopilot( @@ -847,52 +1567,82 @@ impl App { format!("reached the {cap}-reply limit \u{2014} your turn"), ); } - let main_msg = self.preview.get(main_id).cloned().unwrap_or_default(); + let main_msg = self.session_last_agent_message(main_id).unwrap_or_default(); + let main_turn = self.session_turn_marker(main_id).unwrap_or_default(); let mut changed = false; match st.phase { autopilot::Phase::Idle => { // fire on a NEW completed main turn (and only when idle-waiting) if self.session_needs_input(main_id) && !main_msg.is_empty() - && main_msg != st.main_marker + && !main_turn.is_empty() + && main_turn != st.main_marker { - st.main_marker = main_msg.clone(); + st.main_marker = main_turn; match st.pilot_id.clone() { - None => match self.spawn_pilot(main_id, &main_msg) { - Some(pid) => { - st.pilot_id = Some(pid); - st.pilot_marker.clear(); - st.phase = autopilot::Phase::Generating; - self.message = "autopilot: pilot thinking\u{2026}".to_string(); - changed = true; + None => { + let planned_pilot_id = state::new_session_id(); + st.pilot_id = Some(planned_pilot_id.clone()); + st.enter_phase(autopilot::Phase::StartingPilot); + if let Err(err) = autopilot::save(main_id, &st) { + self.autopilot_faults.insert(main_id.to_string()); + self.message = format!("autopilot intent write failed: {err:#}"); + self.autopilot.insert(main_id.to_string(), st); + return true; } - None => { - return self.pause_autopilot( - main_id, - &mut st, - "couldn't start the pilot session".to_string(), - ); + wrote_intermediate_intent = true; + match self.spawn_pilot(main_id, &main_msg, &planned_pilot_id) { + Some(pid) => { + st.pilot_id = Some(pid); + st.pilot_marker.clear(); + st.enter_phase(autopilot::Phase::Generating); + self.message = "autopilot: pilot thinking\u{2026}".to_string(); + changed = true; + } + None => { + return self.pause_autopilot( + main_id, + &mut st, + "couldn't start the pilot session".to_string(), + ); + } } - }, + } Some(pid) => { // baseline the pilot's current reply, then nudge it - st.pilot_marker = self - .session_rollout(&pid) - .and_then(|r| autopilot::last_agent_message_full(&r)) - .unwrap_or_default(); + st.pilot_marker = self.session_turn_marker(&pid).unwrap_or_default(); match self.session_socket(&pid) { Some(sock) => { + st.enter_phase(autopilot::Phase::Nudging); + if let Err(err) = autopilot::save(main_id, &st) { + self.autopilot_faults.insert(main_id.to_string()); + self.message = + format!("autopilot intent write failed: {err:#}"); + self.autopilot.insert(main_id.to_string(), st); + return true; + } + wrote_intermediate_intent = true; let prompt = autopilot::continue_prompt(&main_msg); - match autopilot::inject(&sock, format!("{prompt}\r").as_bytes()) - { + match autopilot::inject(&sock, &prompt) { Ok(true) => { - st.phase = autopilot::Phase::Generating; + st.enter_phase(autopilot::Phase::Generating); self.message = "autopilot: pilot thinking\u{2026}".to_string(); changed = true; } - // pilot busy/attached/transient -> retry next turn - _ => st.main_marker.clear(), + Ok(false) => { + st.enter_phase(autopilot::Phase::Idle); + st.main_marker.clear(); + } + Err(err) => { + return self.pause_autopilot( + main_id, + &mut st, + format!( + "pilot nudge failed and may be partial ({err}); check the pilot" + ), + ); + } } } None => st.main_marker.clear(), @@ -901,14 +1651,31 @@ impl App { } } } + autopilot::Phase::StartingPilot => { + return self.pause_autopilot( + main_id, + &mut st, + "pilot start was interrupted; check for an unlinked pilot before re-enabling" + .to_string(), + ); + } + autopilot::Phase::Nudging => { + return self.pause_autopilot( + main_id, + &mut st, + "pilot nudge was interrupted; inspect the pilot before re-enabling".to_string(), + ); + } autopilot::Phase::Generating => { match st.pilot_id.clone() { Some(pid) if self.session_needs_input(&pid) => { - let reply = self - .session_rollout(&pid) - .and_then(|r| autopilot::last_agent_message_full(&r)) - .unwrap_or_default(); - if !reply.is_empty() && reply != st.pilot_marker { + let reply = self.session_last_agent_message(&pid).unwrap_or_default(); + let pilot_turn = self.session_turn_marker(&pid).unwrap_or_default(); + if !reply.is_empty() + && !pilot_turn.is_empty() + && pilot_turn != st.pilot_marker + { + st.pilot_marker = pilot_turn; match autopilot::parse_reply(&reply) { autopilot::Reply::Done => { return self.pause_autopilot( @@ -922,7 +1689,7 @@ impl App { } autopilot::Reply::Send(text) => { st.pending_reply = text; - st.phase = autopilot::Phase::Delivering; + st.enter_phase(autopilot::Phase::Delivering); changed = true; } } @@ -936,25 +1703,77 @@ impl App { ); } Some(_) => {} // pilot still working - None => st.phase = autopilot::Phase::Idle, + None => st.enter_phase(autopilot::Phase::Idle), } } autopilot::Phase::Delivering => { + if !self.session_needs_input(main_id) { + // A human/new turn may have made the main busy while the + // pilot was thinking. Keep the durable reply pending but do + // not type into a working TUI. + self.autopilot.insert(main_id.to_string(), st); + return false; + } + let current_turn = self.session_turn_marker(main_id).unwrap_or_default(); + if current_turn != st.main_marker { + st.pending_reply.clear(); + return self.pause_autopilot( + main_id, + &mut st, + "main session advanced while the pilot was thinking; stale reply was not sent" + .to_string(), + ); + } if let Some(sock) = self.session_socket(main_id) { - // retries next tick if a human is attached (worker refuses us) - if let Ok(true) = - autopilot::inject(&sock, format!("{}\r", st.pending_reply).as_bytes()) - { - st.replies += 1; - st.pending_reply.clear(); - st.phase = autopilot::Phase::Idle; - self.message = format!("autopilot: replied ({}/{})", st.replies, st.cap); - changed = true; + // Record an intent before touching the PTY. If this manager + // crashes in the injection window, the next owner sees + // Injecting and hands back rather than sending a duplicate. + st.enter_phase(autopilot::Phase::Injecting); + if let Err(err) = autopilot::save(main_id, &st) { + self.autopilot_faults.insert(main_id.to_string()); + self.message = format!("autopilot state write failed: {err:#}"); + self.autopilot.insert(main_id.to_string(), st); + return true; + } + wrote_intermediate_intent = true; + match autopilot::inject(&sock, &st.pending_reply) { + Ok(true) => { + st.replies += 1; + st.pending_reply.clear(); + st.enter_phase(autopilot::Phase::Idle); + self.message = + format!("autopilot: replied ({}/{})", st.replies, st.cap); + changed = true; + } + Ok(false) => st.enter_phase(autopilot::Phase::Delivering), + Err(err) => { + return self.pause_autopilot( + main_id, + &mut st, + format!( + "delivery failed and may be partial ({err}); check the transcript" + ), + ); + } } } } + autopilot::Phase::Injecting => { + return self.pause_autopilot( + main_id, + &mut st, + "delivery was interrupted; check the transcript before continuing".to_string(), + ); + } + } + if st != original || wrote_intermediate_intent { + if let Err(err) = autopilot::save(main_id, &st) { + self.autopilot_faults.insert(main_id.to_string()); + self.message = format!("autopilot state write failed: {err:#}"); + self.autopilot.insert(main_id.to_string(), st); + return true; + } } - autopilot::save(main_id, &st); self.autopilot.insert(main_id.to_string(), st); changed } @@ -968,19 +1787,49 @@ impl App { reason: String, ) -> bool { st.enabled = false; - st.phase = autopilot::Phase::Idle; + st.enter_phase(autopilot::Phase::Idle); st.pending_reply.clear(); st.last_reason = Some(reason.clone()); - autopilot::save(main_id, st); + let pilot_id = st.pilot_id.clone(); + st.cleanup_pending = pilot_id.is_some(); + if let Err(err) = autopilot::save(main_id, st) { + self.autopilot_faults.insert(main_id.to_string()); + self.message = format!("autopilot pause write failed: {err:#}"); + self.autopilot.insert(main_id.to_string(), st.clone()); + return true; + } self.autopilot.insert(main_id.to_string(), st.clone()); - self.message = format!("autopilot paused: {reason}"); + if pilot_id.is_none() { + self.autopilot_leases.remove(main_id); + } + self.autopilot_faults.remove(main_id); + let cleanup_error = pilot_id.as_deref().and_then(|pid| { + self.sessions + .iter() + .find(|s| s.id == pid) + .and_then(|pilot| { + let result = request_session_stop(pilot); + self.pending_stops.insert( + pid.to_string(), + PendingStop { + requested: Instant::now(), + escalated: false, + }, + ); + result.err() + }) + }); + self.message = match cleanup_error { + Some(err) => format!("autopilot paused: {reason}; pilot cleanup failed: {err:#}"), + None => format!("autopilot paused: {reason}"), + }; true } // Create the pilot session for a main: a real, visible codex session (grouped // under its main in the list) whose task is to write the user's replies. Runs // read-only + autonomous, pinned to the main's cwd, inheriting the user's model. - fn spawn_pilot(&mut self, main_id: &str, main_msg: &str) -> Option { + fn spawn_pilot(&mut self, main_id: &str, main_msg: &str, planned_id: &str) -> Option { let (main_cwd, main_title, rollout) = { let m = self.sessions.iter().find(|s| s.id == main_id)?; ( @@ -993,11 +1842,9 @@ impl App { .map(|f| state::distill_dir().join(f).to_string_lossy().to_string()) .unwrap_or_else(|| "(no distilled style file yet — use good judgment)".to_string()); let prompt = autopilot::initial_prompt(&style_path, main_id, &rollout, main_msg); - let _ = distill::ensure_trusted(&main_cwd); // The pilot writes a reply — it doesn't need the user's deep-reasoning // default, so run it lighter (faster per turn). Overridable. - let effort = - env::var("CODEX_RAIL_PILOT_EFFORT").unwrap_or_else(|_| "medium".to_string()); + let effort = env::var("CODEX_RAIL_PILOT_EFFORT").unwrap_or_else(|_| "medium".to_string()); let mut codex_args = vec![ "-C".to_string(), main_cwd.to_string_lossy().to_string(), @@ -1018,7 +1865,14 @@ impl App { } } let title = format!("\u{21b3} pilot \u{00b7} {main_title}"); - match create_session_in(&title, Some(prompt), Some(main_cwd), codex_args, None) { + match create_session_in( + &title, + Some(prompt), + Some(main_cwd), + codex_args, + None, + Some(planned_id.to_string()), + ) { Ok(sess) => Some(sess.id), Err(err) => { self.message = format!("pilot launch failed: {err:#}"); @@ -1031,7 +1885,7 @@ impl App { let selected_id = self.current().map(|s| s.id.clone()); self.sessions = state::load_sessions()?; resolve_missing_rollouts(&mut self.sessions, &mut self.rollout_cache); - sync_titles_from_history(&mut self.sessions); + self.refresh_titles_from_history(); // Start a background rescan when idle and the throttle has elapsed (it // reads only rollouts modified since the last scan), then merge whatever // has already been imported. @@ -1041,7 +1895,7 @@ impl App { .map(|t| t.elapsed() >= adopt_interval()) .unwrap_or(false) { - self.spawn_adopt_scan(); + self.spawn_adopt_scan(false); } self.merge_adopted(); self.load_autopilot(); @@ -1066,28 +1920,60 @@ impl App { let items: Vec<(String, Option, bool)> = self .sessions .iter() - .map(|s| (s.id.clone(), s.codex_rollout_path.clone(), s.status == STATUS_RUNNING)) + .map(|s| { + ( + s.id.clone(), + s.codex_rollout_path.clone(), + s.status == STATUS_RUNNING, + ) + }) .collect(); for (id, path, running) in &items { match path { Some(p) if *running => { - let lc = self.lifecycle.entry(id.clone()).or_insert_with(|| Lifecycle { - path: String::new(), - offset: 0, - last: None, - }); + let lc = self + .lifecycle + .entry(id.clone()) + .or_insert_with(|| Lifecycle { + path: String::new(), + offset: 0, + last: None, + completed_turns: 0, + partial_record: Vec::new(), + discarding_oversized_record: false, + caught_up: false, + }); self.activity.insert(id.clone(), scan_lifecycle(lc, p)); } _ => { self.activity.insert(id.clone(), Activity::Waiting); } } - match path.as_deref().and_then(last_agent_message) { - Some(msg) => { - self.preview.insert(id.clone(), msg); + match path.as_deref() { + Some(p) => { + let stamp = file_stamp(Path::new(p)); + if stamp != self.preview_stamp.get(id).cloned() { + match last_agent_message(p) { + Some(msg) => { + self.preview.insert(id.clone(), msg); + } + None => { + self.preview.remove(id); + } + } + match stamp { + Some(stamp) => { + self.preview_stamp.insert(id.clone(), stamp); + } + None => { + self.preview_stamp.remove(id); + } + } + } } None => { self.preview.remove(id); + self.preview_stamp.remove(id); } } } @@ -1096,22 +1982,67 @@ impl App { self.activity.retain(|k, _| ids.contains(k)); self.lifecycle.retain(|k, _| ids.contains(k)); self.preview.retain(|k, _| ids.contains(k)); + self.preview_stamp.retain(|k, _| ids.contains(k)); self.rollout_cache.retain(|k, _| ids.contains(k)); } - // Start a background scan of ~/.codex/sessions for this cwd. First call is a - // full scan (adopt_since = epoch); later calls read only rollouts modified - // since the previous scan (cheap). Excludes sessions already in the list or - // dismissed — excludes by BOTH codex id and rollout path, since an old rail - // session may never have captured its codex id but resolve_missing_rollouts - // recovers its path, which matches the candidate's (so it isn't duplicated). - fn spawn_adopt_scan(&mut self) { + fn refresh_titles_from_history(&mut self) { + let history = state::codex_home_dir().join("history.jsonl"); + let stamp = file_stamp(&history); + if stamp != self.title_history_stamp { + self.title_history = state::codex_first_messages(); + self.title_history_stamp = stamp; + } + sync_titles_from_history(&mut self.sessions, &self.title_history); + } + + // Start the automatic current-cwd scan. Startup begins at the seven-day + // cutoff; subsequent scans begin at the last completed automatic/day scan. + fn spawn_adopt_scan(&mut self, initial: bool) { + self.spawn_adopt_request(AdoptRequest::Automatic { + since: self.adopt_since, + initial, + }); + } + + fn request_import(&mut self, request: AdoptRequest) { + if let AdoptRequest::Session(id) = &request { + let already_listed = self + .sessions + .iter() + .any(|session| &session.id == id || session.codex_session_id.as_ref() == Some(id)); + if already_listed { + self.message = format!("session {} is already in Rail", clip_title(id)); + return; + } + } + if self.adopt_job.is_some() { + self.adopt_pending = Some(request); + self.message = "import queued behind the current history scan".to_string(); + return; + } + self.spawn_adopt_request(request); + if let Some(status) = self.adopt_status() { + self.message = status; + } + } + + // Excludes by BOTH codex id and rollout path, since an old Rail session may + // not have captured its codex id but may still have recovered the exact path. + // A precise session request intentionally ignores the dismiss list: typing an + // exact id is an explicit request to restore a previously dismissed row. + fn spawn_adopt_request(&mut self, request: AdoptRequest) { if self.adopt_job.is_some() { return; } let cwd = env::current_dir().unwrap_or_default(); - let mut exclude: HashSet = state::adopt_dismissed(); + let mut exclude = if matches!(request, AdoptRequest::Session(_)) { + HashSet::new() + } else { + state::adopt_dismissed() + }; for s in &self.sessions { + exclude.insert(s.id.clone()); if let Some(id) = &s.codex_session_id { exclude.insert(id.clone()); } @@ -1119,7 +2050,6 @@ impl App { exclude.insert(p.clone()); } } - let since = self.adopt_since; let scan_start = SystemTime::now(); let progress = Arc::new(AdoptProgress { done: AtomicUsize::new(0), @@ -1128,13 +2058,15 @@ impl App { }); let prog = progress.clone(); let (tx, rx) = mpsc::channel(); + let scan_request = request.clone(); thread::spawn(move || { - let _ = tx.send(adopt_codex_sessions(&cwd, &exclude, since, &prog)); + let _ = tx.send(adopt_codex_sessions(&cwd, &exclude, &scan_request, &prog)); }); self.adopt_job = Some(AdoptJob { rx, progress, scan_start, + request, }); self.adopt_started = Some(Instant::now()); } @@ -1146,20 +2078,97 @@ impl App { let Some(job) = &self.adopt_job else { return false; }; + let request = job.request.clone(); + let scan_start = job.scan_start; match job.rx.try_recv() { - Ok(found) => { - self.adopt_since = job.scan_start; - for s in found { + Ok(mut found) => { + if !matches!(request, AdoptRequest::Session(_)) { + self.adopt_since = scan_start; + } + let imported = found.sessions.len(); + if let AdoptRequest::Session(id) = &request { + if imported > 0 { + if let Err(err) = state::restore_adopted(id) { + found.sessions.clear(); + found.exact_issue = Some(ExactImportIssue::AlreadyImported); + self.message = format!("could not restore imported session: {err:#}"); + } + } + } + for s in found.sessions { if let Some(id) = s.codex_session_id.clone() { self.adopted.insert(id, s); } } self.adopt_job = None; + if !self + .message + .starts_with("could not restore imported session") + { + self.message = match request { + AdoptRequest::Automatic { initial: true, .. } => { + if self.message.is_empty() + || self.message.starts_with("importing codex history") + { + format!( + "auto-imported {imported} chat(s) active in last {DEFAULT_IMPORT_DAYS}d · more: /import 15d or /import " + ) + } else { + self.message.clone() + } + } + AdoptRequest::Automatic { initial: false, .. } => { + if self.message.starts_with("importing codex history") { + String::new() + } else { + self.message.clone() + } + } + AdoptRequest::Days(days) => { + let suffix = if found.ambiguous > 0 { + format!("; skipped {} ambiguous id(s)", found.ambiguous) + } else { + String::new() + }; + format!( + "imported {imported} additional chat(s) active in last {days}d{suffix}" + ) + } + AdoptRequest::Session(id) => match found.exact_issue { + None if imported > 0 => format!("imported session {id}"), + Some(ExactImportIssue::AlreadyImported) => { + format!("session {id} is already in Rail") + } + Some(ExactImportIssue::DifferentCwd(cwd)) => format!( + "session {id} belongs to {cwd}; run Rail from that directory" + ), + Some(ExactImportIssue::NoConversation) => { + format!("session {id} has no genuine conversation to import") + } + Some(ExactImportIssue::Ambiguous(count)) => format!( + "session {id} is ambiguous ({count} rollouts claim it); not imported" + ), + Some(ExactImportIssue::NotFound) | None => { + format!("session {id} was not found in Codex history") + } + }, + }; + } + if let Some(pending) = self.adopt_pending.take() { + self.spawn_adopt_request(pending); + if let Some(status) = self.adopt_status() { + self.message = status; + } + } true } Err(mpsc::TryRecvError::Empty) => false, Err(mpsc::TryRecvError::Disconnected) => { self.adopt_job = None; + self.message = "codex history import failed unexpectedly".to_string(); + if let Some(pending) = self.adopt_pending.take() { + self.spawn_adopt_request(pending); + } true } } @@ -1171,17 +2180,22 @@ impl App { let job = self.adopt_job.as_ref()?; let total = job.progress.total.load(Ordering::Relaxed); let done = job.progress.done.load(Ordering::Relaxed); + let scope = match &job.request { + AdoptRequest::Automatic { initial: true, .. } => { + format!("chats active in last {DEFAULT_IMPORT_DAYS}d") + } + AdoptRequest::Automatic { initial: false, .. } => "new chats".to_string(), + AdoptRequest::Days(days) => format!("chats active in last {days}d"), + AdoptRequest::Session(id) => format!("session {}", clip_title(id)), + }; if total == 0 { - return Some("importing codex history \u{2026}".to_string()); - } - let bar = progress::render( - 16, - done as f64 / total as f64, - progress::Style { - fill: C_ACCENT, - track: C_FAINT, - }, - ); + return Some(format!("importing codex history ({scope}) \u{2026}")); + } + // `message` is plain text which is later sanitized. Embedding a + // pre-coloured ANSI bar here used to leave visible fragments such as + // "[38;2;...m" after sanitization; colour is applied only by the final + // renderer, so keep this intermediate status string escape-free. + let bar = progress::glyphs(16, done as f64 / total as f64, '\u{2591}'); let cur = job .progress .current @@ -1189,7 +2203,7 @@ impl App { .map(|s| s.clone()) .unwrap_or_default(); Some(format!( - "importing codex history {bar} {done}/{total} \u{00b7} {cur}" + "importing codex history ({scope}) {bar} {done}/{total} \u{00b7} {cur}" )) } @@ -1214,8 +2228,12 @@ impl App { for (sid, a) in &self.adopted { let dup = dismissed.contains(sid) || have.contains(&a.id) - || a.codex_session_id.as_ref().is_some_and(|c| have.contains(c)) - || a.codex_rollout_path.as_ref().is_some_and(|p| have.contains(p)); + || a.codex_session_id + .as_ref() + .is_some_and(|c| have.contains(c)) + || a.codex_rollout_path + .as_ref() + .is_some_and(|p| have.contains(p)); if !dup { adds.push(a.clone()); } @@ -1231,7 +2249,14 @@ impl App { let mut decorated: Vec<(u8, u64, u64, SessionState)> = self .sessions .drain(..) - .map(|s| (bucket_rank(bucket_of(&activity, &s)), s.updated_at, s.created_at, s)) + .map(|s| { + ( + bucket_rank(bucket_of(&activity, &s)), + s.updated_at, + s.created_at, + s, + ) + }) .collect(); decorated.sort_by(|a, b| { a.0.cmp(&b.0) @@ -1295,6 +2320,7 @@ impl App { fn clear_transient(&mut self) { self.stop_confirm = None; + self.resume_confirm = None; self.exit_confirm = None; if self.mode == Mode::Normal { self.message.clear(); @@ -1347,9 +2373,9 @@ fn handle_normal_key(key: KeyEvent, app: &mut App, terminal: &mut TerminalSessio } match key.code { - KeyCode::Up | KeyCode::Char('w') if key.modifiers.is_empty() => app.move_prev(), - KeyCode::Down | KeyCode::Char('s') if key.modifiers.is_empty() => app.move_next(), - KeyCode::Right | KeyCode::Enter | KeyCode::Char('d') if key.modifiers.is_empty() => { + KeyCode::Up if key.modifiers.is_empty() => app.move_prev(), + KeyCode::Down if key.modifiers.is_empty() => app.move_next(), + KeyCode::Right | KeyCode::Enter if key.modifiers.is_empty() => { attach_current(app, terminal)?; } KeyCode::Esc => { @@ -1362,19 +2388,21 @@ fn handle_normal_key(key: KeyEvent, app: &mut App, terminal: &mut TerminalSessio app.exit_confirm = None; app.message.clear(); } - KeyCode::Char('e') if key.modifiers.is_empty() => { + KeyCode::Char('n') if key.modifiers == KeyModifiers::CONTROL => { app.mode = Mode::New; app.input.clear(); app.stop_confirm = None; app.exit_confirm = None; } - // SPACE toggles autopilot on the selected session (rail answers it for - // you via a pilot session). Swallowed here so it never falls through to - // the "type any key to start a new session" arm below. - KeyCode::Char(' ') if key.modifiers.is_empty() => { + // Ctrl+A toggles autopilot. Printable keys — including w/s/d/e and a + // leading space — always enter the composer verbatim. + KeyCode::Char('a') if key.modifiers == KeyModifiers::CONTROL => { app.toggle_autopilot(); } - KeyCode::Char(ch) if key.modifiers.is_empty() && !ch.is_control() => { + KeyCode::Char(ch) + if (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) + && !ch.is_control() => + { app.mode = Mode::New; app.input.clear(); app.input.push(ch); @@ -1391,6 +2419,7 @@ fn handle_normal_key(key: KeyEvent, app: &mut App, terminal: &mut TerminalSessio // palette of rail actions, run rail-side instead of being sent to codex. #[derive(Clone, Copy)] enum SlashCmd { + Import, Distill, Update, Config, @@ -1403,6 +2432,11 @@ struct Slash { cmd: SlashCmd, } const SLASH_COMMANDS: &[Slash] = &[ + Slash { + name: "/import", + desc: "older chats: /import 15d or /import ", + cmd: SlashCmd::Import, + }, Slash { name: "/distill", desc: "distill your style + logic from codex/Claude history", @@ -1433,6 +2467,12 @@ const SLASH_COMMANDS: &[Slash] = &[ // Commands whose name starts with the current input (which begins with '/'). fn slash_matches(input: &str) -> Vec<&'static Slash> { let q = input.trim(); + if q == "/import" || q.starts_with("/import ") { + return SLASH_COMMANDS + .iter() + .filter(|command| command.name == "/import") + .collect(); + } SLASH_COMMANDS .iter() .filter(|c| c.name.starts_with(q)) @@ -1442,20 +2482,26 @@ fn slash_matches(input: &str) -> Vec<&'static Slash> { // Run the selected slash command (Enter in the composer while input starts with // '/'). Returns true to quit the manager. fn run_slash(app: &mut App) -> Result { - let matches = slash_matches(&app.input); - let picked = matches - .get(app.slash_sel) - .or_else(|| matches.first()) - .map(|c| c.cmd); let raw = app.input.clone(); + let trimmed = raw.trim(); + let picked = if trimmed == "/import" || trimmed.starts_with("/import ") { + Some(SlashCmd::Import) + } else { + SLASH_COMMANDS + .iter() + .find(|command| command.name == trimmed) + .map(|command| command.cmd) + }; app.mode = Mode::Normal; app.input.clear(); app.slash_sel = 0; let Some(cmd) = picked else { - app.message = format!("unknown command: {}", raw.trim()); + app.mode = Mode::New; + app.input = raw; return Ok(false); }; match cmd { + SlashCmd::Import => start_import(app, &raw), SlashCmd::Distill => start_distillation(app), SlashCmd::Update => start_update(app), SlashCmd::Config => { @@ -1468,7 +2514,7 @@ fn run_slash(app: &mut App) -> Result { } SlashCmd::Help => { app.message = - "↑↓ move · Enter attach · e new · Space autopilot · / commands · Ctrl+X twice stop · Esc twice quit" + "↑↓ move · Enter attach · Ctrl+N blank · Ctrl+A autopilot · / commands · Ctrl+X twice stop · Esc twice quit" .to_string(); } SlashCmd::Quit => return Ok(true), @@ -1476,9 +2522,73 @@ fn run_slash(app: &mut App) -> Result { Ok(false) } +#[derive(Clone, Debug, PartialEq, Eq)] +enum ImportSpec { + Days(u64), + Session(String), +} + +const IMPORT_USAGE: &str = "usage: /import 15d or /import "; + +fn looks_like_codex_session_id(value: &str) -> bool { + if value.len() != 36 { + return false; + } + value.bytes().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +fn parse_import_spec(input: &str) -> std::result::Result { + let mut parts = input.split_whitespace(); + if parts.next() != Some("/import") { + return Err(IMPORT_USAGE); + } + let Some(argument) = parts.next() else { + return Err(IMPORT_USAGE); + }; + if parts.next().is_some() { + return Err(IMPORT_USAGE); + } + if let Some(number) = argument + .strip_suffix('d') + .or_else(|| argument.strip_suffix('D')) + { + let days = number.parse::().map_err(|_| IMPORT_USAGE)?; + if days == 0 || days > MAX_IMPORT_DAYS || days.checked_mul(DAY_SECS).is_none() { + return Err(IMPORT_USAGE); + } + return Ok(ImportSpec::Days(days)); + } + if !looks_like_codex_session_id(argument) || state::validate_session_id(argument).is_err() { + return Err(IMPORT_USAGE); + } + Ok(ImportSpec::Session(argument.to_string())) +} + +fn start_import(app: &mut App, input: &str) { + let request = match parse_import_spec(input) { + Ok(ImportSpec::Days(days)) => AdoptRequest::Days(days), + Ok(ImportSpec::Session(id)) => AdoptRequest::Session(id), + Err(usage) => { + app.message = usage.to_string(); + return; + } + }; + app.request_import(request); +} + // Kick off `rail update` in the background (a curl check + download); the result // lands on the status line. Never blocks the UI. fn start_update(app: &mut App) { + if app.update_apply.is_some() { + app.message = "an update is already in progress".to_string(); + return; + } app.message = "checking for updates \u{2026}".to_string(); let (tx, rx) = mpsc::channel(); let fake = env::var_os("CODEX_RAIL_FAKE_UPDATE").is_some(); @@ -1507,7 +2617,8 @@ fn handle_input_key( ) -> Result { // While composing a NEW session, a leading '/' turns the box into a command // palette: navigate with ↑↓, run with Enter, complete with Tab. - let slashing = mode == Mode::New && app.input.starts_with('/'); + let slashing = + mode == Mode::New && app.input.starts_with('/') && !slash_matches(&app.input).is_empty(); if slashing { match key.code { KeyCode::Up => { @@ -1523,12 +2634,27 @@ fn handle_input_key( } KeyCode::Tab => { let matches = slash_matches(&app.input); - if let Some(c) = matches.get(app.slash_sel).or_else(|| matches.first()) { - app.input = c.name.to_string(); + if !app.input.trim().contains(' ') { + if let Some(c) = matches.get(app.slash_sel).or_else(|| matches.first()) { + app.input = c.name.to_string(); + } } return Ok(false); } - KeyCode::Enter => return run_slash(app), + KeyCode::Enter => { + let argument_command = { + let input = app.input.trim(); + input == "/import" || input.starts_with("/import ") + }; + if argument_command { + return run_slash(app); + } + let matches = slash_matches(&app.input); + if let Some(command) = matches.get(app.slash_sel).or_else(|| matches.first()) { + app.input = command.name.to_string(); + return run_slash(app); + } + } _ => {} } } @@ -1554,7 +2680,8 @@ fn handle_input_key( } KeyCode::Char(ch) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { if !ch.is_control() { - app.input.push(ch); + let mut encoded = [0_u8; 4]; + append_composer_text(app, ch.encode_utf8(&mut encoded)); app.slash_sel = 0; } } @@ -1564,7 +2691,7 @@ fn handle_input_key( } fn submit_input(app: &mut App, terminal: &mut TerminalSession, mode: Mode) -> Result<()> { - let text = app.input.trim().to_string(); + let raw = app.input.clone(); match mode { Mode::New => { @@ -1572,10 +2699,10 @@ fn submit_input(app: &mut App, terminal: &mut TerminalSession, mode: Mode) -> Re // Any text → that text is both the list title AND codex's first // message, started immediately on spawn (so the rollout, and an // accurate status, show up within a second or two). - let (title, prompt) = if text.is_empty() { + let (title, prompt) = if raw.trim().is_empty() { (default_session_title(&app.sessions), None) } else { - (text.clone(), Some(text.clone())) + (title_from_message(&raw), Some(raw.clone())) }; match create_session(&title, prompt) { Ok(session) => { @@ -1593,6 +2720,7 @@ fn submit_input(app: &mut App, terminal: &mut TerminalSession, mode: Mode) -> Re } } Mode::Rename => { + let text = raw.trim().to_string(); if text.is_empty() { app.message = "empty name ignored".to_string(); return Ok(()); @@ -1637,9 +2765,17 @@ fn handle_mouse( .find_map(|(known_row, index)| (*known_row == row).then_some(*index)); match kind { + // Hover is visual only. Keep the physical row, not the session index: + // if status sorting changes underneath a stationary pointer, the row + // currently under that pointer remains highlighted without mutating the + // keyboard selection or changing the scroll offset. + MouseEventKind::Moved => { + app.hovered_row = row_index.map(|_| row); + } // A LEFT CLICK on the header's "↑ update available" note runs the update; // on a row it selects + attaches. MouseEventKind::Down(MouseButton::Left) => { + app.hovered_row = None; if row == 0 { if let Some((a, b)) = app.update_click { if (a..b).contains(&col) { @@ -1654,12 +2790,16 @@ fn handle_mouse( } } // The wheel moves the selection (the view follows it), like ↑↓. Without - // this the wheel did nothing. - MouseEventKind::ScrollUp => app.move_prev(), - MouseEventKind::ScrollDown => app.move_next(), - // Deliberately ignore Moved: hover-to-select made any mouse twitch jerk - // the selection to whatever row was under the pointer — which, on a - // scrolled list, snapped it back to the top. + // this the wheel did nothing. Clear the physical-row hover because a + // scroll can move a different session underneath the stationary pointer. + MouseEventKind::ScrollUp => { + app.hovered_row = None; + app.move_prev(); + } + MouseEventKind::ScrollDown => { + app.hovered_row = None; + app.move_next(); + } _ => {} } Ok(false) @@ -1778,18 +2918,24 @@ fn attach_current(app: &mut App, terminal: &mut TerminalSession) -> Result<()> { return Ok(()); }; - // An imported session with a very recently-written rollout may be a codex - // running in another terminal; resuming it would put a SECOND codex on the - // same transcript. Confirm with a second Enter before doing that. - if adopted_maybe_live(&session) { + // Rail cannot prove that an imported Codex transcript has no live writer. + // A stale mtime is not an ownership lease, so every first takeover requires + // an explicit second Enter. Once Rail relaunches it, `adopted` is cleared in + // the persisted state and normal reattaches no longer prompt. + if session.adopted { let confirmed = app .resume_confirm .as_ref() - .map(|(id, at)| id == &session.id && at.elapsed() <= STOP_CONFIRM_WINDOW) + .map(|(id, at)| id == &session.id && at.elapsed() <= ADOPT_CONFIRM_WINDOW) .unwrap_or(false); if !confirmed { app.resume_confirm = Some((session.id.clone(), Instant::now())); - app.message = "this codex session looks active elsewhere — Enter again to resume anyway (starts a 2nd instance)".to_string(); + app.message = if adopted_maybe_live(&session) { + "external session may still be active elsewhere — Enter again to take over (starts another Codex)" + } else { + "external session ownership is unknown — Enter again to take over (starts another Codex)" + } + .to_string(); return Ok(()); } app.resume_confirm = None; @@ -1809,8 +2955,11 @@ fn attach_current(app: &mut App, terminal: &mut TerminalSession) -> Result<()> { }; } - terminal.leave()?; + // Keep raw mode enabled during the teaching countdown. Ctrl+Z typed as soon + // as the hint appears is then an ordinary byte (queued for attach/detach), + // not the terminal driver's VSUSP signal that would suspend the manager. show_detach_hint(); + terminal.leave()?; let result = attach::attach_session(&session); terminal.enter_again()?; app.invalidate_frame(); // the attach blanked the alt screen — force a full repaint @@ -1836,13 +2985,27 @@ fn relaunch_worker(session: &SessionState) -> Result<()> { // Serialize bootstrap/remove across multiple manager processes. The guard is // held until a worker is verifiably listening, so a second manager re-checks // the winner's state instead of spawning another resume. - let _init_lock = state::acquire_session_init_lock(&session.id)?; - if let Some(current) = state::read_state_optional(&session.id)? { - if state::session_worker_is_running_under_init_lock(¤t) { - return wait_for_worker(&session.id, ¤t.socket, None); + let _init_lock = state::try_acquire_session_init_lock(&session.id)? + .context("another manager is changing this session; retry resume")?; + let mut current = state::read_state_optional(&session.id)?; + if let Some(ref active) = current { + if state::session_worker_is_running_under_init_lock(active) { + return wait_for_worker(&session.id, &active.socket, None); } } + // Prove that a crashed predecessor's entire token-owned generation is gone + // before a successor can overwrite its token. This path matters while the + // manager remains open: startup recovery alone cannot protect an immediate + // Enter-to-resume after worker SIGKILL/OOM. + let old_generation_lock = state::try_acquire_session_generation_lock(&session.id)? + .context("previous guardian is still cleaning the session; retry resume")?; + let old_worker_lock = state::try_acquire_worker_lock(&session.id)? + .context("another worker is claiming this session; retry resume")?; + if let Some(ref mut abandoned) = current { + recover_generation_before_relaunch(abandoned)?; + } + // Imported sessions have no rail state on disk yet; persist their resume // identity once. Existing stopped sessions are deliberately left untouched: // with two managers racing, either one rewriting state to "starting" could @@ -1862,8 +3025,14 @@ fn relaunch_worker(session: &SessionState) -> Result<()> { } } + // The successor guardian takes the generation lease and its child worker + // takes worker.lock. Keep init.lock held while releasing both and spawning, + // so competing managers cannot enter the handoff gap. + drop(old_worker_lock); + drop(old_generation_lock); + let child = Command::new(env::current_exe().context("current executable")?) - .arg("--worker") + .arg("--guardian") .arg(&session.id) .current_dir(Path::new(&session.cwd)) .stdin(Stdio::null()) @@ -1886,16 +3055,70 @@ fn relaunch_worker(session: &SessionState) -> Result<()> { wait_for_worker(&session.id, &session.socket, Some(spawned_pid)) } +fn recover_generation_before_relaunch(session: &mut SessionState) -> Result<()> { + let Some(token) = session.worker_token.clone() else { + if session.worker_lock_protocol + && matches!( + session.status.as_str(), + STATUS_STARTING | STATUS_RUNNING | STATUS_STOPPING + ) + { + anyhow::bail!( + "previous worker died without a recoverable generation token; refusing to start a duplicate Codex" + ); + } + return Ok(()); + }; + + let report = process_tree::terminate_generation_by_token( + &token, + Duration::from_secs(2), + Duration::from_secs(2), + ); + session.worker_pid = None; + session.updated_at = state::now_secs(); + if !report.is_clean() { + session.status = STATUS_FAILED.to_string(); + session.last_error = Some(format!( + "abandoned process cleanup incomplete before resume: {} survivor(s), census verified={}; refusing to start another Codex", + report.survivors, report.verified + )); + state::write_state(session)?; + anyhow::bail!("previous Codex generation cleanup was not verified"); + } + + let had_uncertain_prompt = session.initial_prompt_injecting; + session.child_pid = None; + session.worker_token = None; + session.initial_prompt_injecting = false; + if had_uncertain_prompt { + session.initial_prompt = None; + session.status = STATUS_FAILED.to_string(); + session.last_error = Some( + "initial prompt delivery was interrupted and may have been submitted; abandoned generation cleaned before resume" + .to_string(), + ); + } else if matches!( + session.status.as_str(), + STATUS_STARTING | STATUS_RUNNING | STATUS_STOPPING + ) { + session.status = STATUS_FAILED.to_string(); + session.last_error = Some( + "worker died unexpectedly; abandoned process generation cleaned before resume" + .to_string(), + ); + } + state::write_state(session) +} + // Recover a codex rollout path for sessions whose worker never captured one — // a blank session (codex writes no rollout until the first turn), a slow cold // start that outran the worker's watcher, or a session started by an old rail -// build. Without a rollout path such rows show neither a real status nor a -// message preview (they fall back to the cwd), which is the "right side shows -// the path" bug. We correlate by codex's own session_meta.cwd plus start time -// (UUIDv7 embeds it): the earliest unclaimed rollout in the session's cwd that -// began at/after it was created, within a confidence window. Resolved paths are -// applied to the in-memory session only (used for status + preview); they are -// never written back, so this cannot re-introduce the manager/worker write race. +// build. Recovery is exact by Codex session id. A same-cwd/time-window guess is +// not an ownership proof: a blank Rail session and an unrelated direct Codex +// launched seconds later would otherwise be cross-wired, and autopilot could +// answer one transcript through the other's PTY. Sessions lacking an id simply +// wait for their live worker watcher instead of borrowing a plausible rollout. fn resolve_missing_rollouts( sessions: &mut [SessionState], cache: &mut HashMap>, @@ -1907,18 +3130,28 @@ fn resolve_missing_rollouts( .unwrap_or(true) }; - // Only pay for the sessions-dir scan when there's an undecided session. + // A prior no-id miss must not suppress an exact lookup after the worker + // later captures its Codex session id. + for session in sessions.iter() { + if missing(session) + && session.codex_session_id.is_some() + && cache.get(&session.id) == Some(&None) + { + cache.remove(&session.id); + } + } + + // Only pay for the sessions-dir scan when an exact-id lookup is undecided. if sessions .iter() - .any(|s| missing(s) && !cache.contains_key(&s.id)) + .any(|s| missing(s) && s.codex_session_id.is_some() && !cache.contains_key(&s.id)) { - // Index every rollout once: (cwd, start_secs, path). - let index: Vec<(String, u64, String)> = state::list_rollout_files() + // Index every rollout once by the id from session_meta. + let index: HashMap = state::list_rollout_files() .into_iter() .filter_map(|path| { - let (cwd, sid) = state::rollout_head(&path)?; - let start = state::session_id_start_secs(&sid)?; - Some((cwd, start, path.to_string_lossy().to_string())) + let (_, sid) = state::rollout_head(&path)?; + Some((sid, path.to_string_lossy().to_string())) }) .collect(); @@ -1928,36 +3161,20 @@ fn resolve_missing_rollouts( .filter_map(|s| s.codex_rollout_path.clone()) .collect(); - // Resolve oldest-created first so an earlier session gets first pick of - // a shared-cwd rollout before a later one can claim it. - let mut order: Vec = (0..sessions.len()).collect(); - order.sort_by_key(|&i| sessions[i].created_at); - - for i in order { - let (id, cwd, created) = { - let s = &sessions[i]; + for s in sessions.iter() { + let (id, codex_id) = { if !missing(s) || cache.contains_key(&s.id) { continue; } - (s.id.clone(), s.cwd.clone(), s.created_at) - }; - let mut best: Option<(u64, &str)> = None; // (gap, path) - for (rcwd, start, path) in &index { - if rcwd != &cwd || claimed.contains(path.as_str()) { - continue; - } - if *start + 5 < created { - continue; // rollout predates the session — not it - } - let gap = start.saturating_sub(created); - if gap > ROLLOUT_MATCH_WINDOW_SECS { + let Some(codex_id) = s.codex_session_id.clone() else { continue; - } - if best.map(|(g, _)| gap < g).unwrap_or(true) { - best = Some((gap, path.as_str())); - } - } - let chosen = best.map(|(_, p)| p.to_string()); + }; + (s.id.clone(), codex_id) + }; + let chosen = index + .get(&codex_id) + .filter(|path| !claimed.contains(path.as_str())) + .cloned(); if let Some(p) = &chosen { claimed.insert(p.clone()); } @@ -1965,6 +3182,15 @@ fn resolve_missing_rollouts( } } + for session in sessions.iter() { + if missing(session) + && session.codex_session_id.is_none() + && !cache.contains_key(&session.id) + { + cache.insert(session.id.clone(), None); + } + } + // Apply memoized resolutions to the in-memory sessions (display only). for s in sessions.iter_mut() { if missing(s) { @@ -1982,8 +3208,7 @@ fn resolve_missing_rollouts( // change, so steady state is just a cheap history read per refresh. Only // sessions that already carry a codex_session_id are touched, so this can't // clobber a rollout id the worker is about to capture. -fn sync_titles_from_history(sessions: &mut [SessionState]) { - let firsts = state::codex_first_messages(); +fn sync_titles_from_history(sessions: &mut [SessionState], firsts: &HashMap) { for s in sessions.iter_mut() { if s.title_pinned { continue; @@ -2021,45 +3246,71 @@ fn sync_titles_from_history(sessions: &mut [SessionState]) { // Sync is unpinned by definition; write the manager-owned label so // this never contends with the worker's state.json writes (and it // wins over the junk state.json title going forward). - state::write_label(&s.id, &title, false).ok(); + if let Ok((authoritative_title, pinned)) = + state::sync_label_if_unpinned(&s.id, &title) + { + s.title = authoritative_title; + s.title_pinned = pinned; + } } } } } -// Discover the user's EXISTING codex sessions whose cwd matches the manager's -// launch dir and import them as resumable (exited) rows — so rail manages the -// whole project's codex history, not only sessions it started. Cheap: reads each -// rollout's one-line session_meta header. Skips any codex session already backed -// by a real rail session (`exclude`). Titles come from codex's own history; the -// rows are in-memory until attached, when they resume + persist like any exited -// session (attach -> relaunch_worker -> codex resume ). +// Discover the user's EXISTING Codex sessions for this working directory and +// import them as resumable rows. Automatic/day scans use rollout mtime as the +// last-activity boundary, then require a genuine user turn so empty bootstrap +// rollouts do not pollute the list. An exact-id scan bypasses time, but not cwd: +// Rail stays a per-project cockpit and tells the user where a foreign id lives. fn adopt_codex_sessions( cwd: &Path, exclude: &HashSet, - since: SystemTime, + request: &AdoptRequest, progress: &AdoptProgress, -) -> Vec { +) -> AdoptScanResult { + struct Candidate { + path: PathBuf, + modified: SystemTime, + sid: String, + title: String, + } + + let mut result = AdoptScanResult::default(); let target = std::fs::canonicalize(cwd).ok(); if target.is_none() { - return Vec::new(); + if matches!(request, AdoptRequest::Session(_)) { + result.exact_issue = Some(ExactImportIssue::NotFound); + } + return result; } let codex = env::var("CODEX_RAIL_CODEX").unwrap_or_else(|_| "codex".to_string()); - let firsts = state::codex_first_messages(); - // Stat every rollout, then only READ the ones modified since the last scan - // (a rescan reads a handful instead of the whole archive). Cheap: stat only. + let cutoff = match request { + AdoptRequest::Automatic { since, .. } => Some(*since), + AdoptRequest::Days(days) => days_before(SystemTime::now(), *days), + AdoptRequest::Session(_) => None, + }; + // Stat every rollout, then only read headers inside the requested activity + // window. Exact-id imports intentionally inspect all headers. let mut files: Vec<(PathBuf, SystemTime)> = Vec::new(); for p in state::list_rollout_files() { let mt = std::fs::metadata(&p) .and_then(|m| m.modified()) .unwrap_or(UNIX_EPOCH); - if mt >= since { + if cutoff.is_none_or(|since| mt >= since) { files.push((p, mt)); } } + files.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); progress.total.store(files.len(), Ordering::Relaxed); - let mut out = Vec::new(); - let mut seen: HashSet = HashSet::new(); + let exact_id = match request { + AdoptRequest::Session(id) => Some(id.as_str()), + _ => None, + }; + let mut exact_matches = 0_usize; + let mut exact_excluded = false; + let mut exact_other_cwd = None; + let mut exact_no_conversation = false; + let mut candidates: HashMap> = HashMap::new(); for (path, mt) in files { progress.done.fetch_add(1, Ordering::Relaxed); if let Some(name) = path.file_name().and_then(|n| n.to_str()) { @@ -2073,16 +3324,71 @@ fn adopt_codex_sessions( if state::validate_session_id(&sid).is_err() { continue; } + if let Some(wanted) = exact_id { + if sid != wanted { + continue; + } + exact_matches += 1; + } let path_str = path.to_string_lossy().to_string(); - if exclude.contains(&sid) || exclude.contains(&path_str) || !seen.insert(sid.clone()) { + if exclude.contains(&sid) || exclude.contains(&path_str) { + if exact_id.is_some() { + exact_excluded = true; + } continue; } // Compare resolved (symlink-free) paths so a trailing slash or a symlinked // project dir still lines up; a session whose cwd is gone is skipped. match std::fs::canonicalize(&rcwd).ok() { Some(r) if Some(&r) == target.as_ref() => {} - _ => continue, + _ => { + if exact_id.is_some() { + exact_other_cwd.get_or_insert(rcwd); + } + continue; + } + } + let Some(first_message) = state::rollout_first_user_message(&path) else { + if exact_id.is_some() { + exact_no_conversation = true; + } + continue; + }; + let title = title_from_message(&first_message); + if title.is_empty() { + if exact_id.is_some() { + exact_no_conversation = true; + } + continue; + } + candidates.entry(sid.clone()).or_default().push(Candidate { + path, + modified: mt, + sid, + title, + }); + } + + if exact_matches > 1 { + result.exact_issue = Some(ExactImportIssue::Ambiguous(exact_matches)); + return result; + } + + for (_sid, mut group) in candidates { + // Multiple rollouts claiming one id are an identity ambiguity, not a + // reason to pick whichever directory traversal happened to return first. + if group.len() != 1 { + result.ambiguous += 1; + continue; } + let candidate = group.pop().expect("one candidate"); + let Candidate { + path, + modified: mt, + sid, + title, + } = candidate; + let path_str = path.to_string_lossy().to_string(); let created = state::session_id_start_secs(&sid).unwrap_or(0); // Rollout mtime doubles as the row's age and as the live-session signal: // a very recently written rollout may be a codex running elsewhere. @@ -2090,19 +3396,7 @@ fn adopt_codex_sessions( .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); - let title = firsts - .get(&sid) - .map(|m| title_from_message(m)) - .filter(|t| !t.is_empty()) - .or_else(|| { - // history.jsonl didn't have it — fall back to the rollout's own - // first user message so the row still gets a meaningful title. - state::rollout_first_user_message(Path::new(&path_str)) - .map(|m| title_from_message(&m)) - .filter(|t| !t.is_empty()) - }) - .unwrap_or_else(|| format!("codex {}", sid.chars().take(8).collect::())); - out.push(SessionState { + result.sessions.push(SessionState { id: sid.clone(), title, cwd: cwd.to_string_lossy().to_string(), @@ -2120,19 +3414,36 @@ fn adopt_codex_sessions( codex_session_id: Some(sid.clone()), codex_rollout_path: Some(path_str), initial_prompt: None, + initial_prompt_injecting: false, title_pinned: false, last_output_at: mtime_secs, codex_args: Vec::new(), distill_version: None, + distill_expected_markers: Vec::new(), + distill_expected_user_turns: None, + distill_corpus_rel: None, + distill_validated: false, adopted: true, }); } - out + if exact_id.is_some() && result.sessions.is_empty() { + result.exact_issue = Some(if exact_matches == 0 { + ExactImportIssue::NotFound + } else if exact_excluded { + ExactImportIssue::AlreadyImported + } else if let Some(other_cwd) = exact_other_cwd { + ExactImportIssue::DifferentCwd(other_cwd) + } else if exact_no_conversation { + ExactImportIssue::NoConversation + } else { + ExactImportIssue::NotFound + }); + } + result } -// An imported session whose rollout was written very recently — it may be a -// codex running in another terminal. Resuming it would start a second writer on -// the same transcript, so attach warns + confirms before resuming. +// Recent activity is a visual warning only. It must never be treated as proof +// that an older imported transcript is safe to take over without confirmation. const ADOPT_LIVE_WINDOW_SECS: u64 = 180; fn adopted_maybe_live(s: &SessionState) -> bool { s.adopted && state::now_secs().saturating_sub(s.last_output_at) < ADOPT_LIVE_WINDOW_SECS @@ -2140,13 +3451,12 @@ fn adopted_maybe_live(s: &SessionState) -> bool { // First non-empty line of a message, length-capped, for use as a list title. fn title_from_message(msg: &str) -> String { - msg.lines() + let line = msg + .lines() .map(str::trim) .find(|l| !l.is_empty()) - .unwrap_or("") - .chars() - .take(120) - .collect() + .unwrap_or(""); + sanitize_terminal_text(line).chars().take(120).collect() } // Shorten a title for a one-line status message. @@ -2170,6 +3480,9 @@ fn latest_style_file() -> Option { .and_then(|r| r.strip_suffix(".md")) .and_then(|r| r.parse::().ok()) { + if distill::validated_style_file(v).is_none() { + continue; + } if best.as_ref().map(|(bv, _)| v > *bv).unwrap_or(true) { best = Some((v, name)); } @@ -2191,7 +3504,15 @@ fn default_session_title(sessions: &[SessionState]) -> String { } fn create_session(title: &str, initial_prompt: Option) -> Result { - create_session_in(title, initial_prompt, None, Vec::new(), None) + create_session_in(title, initial_prompt, None, Vec::new(), None, None) +} + +struct DistillLaunch { + version: u32, + expected_markers: Vec, + expected_user_turns: usize, + corpus_rel: String, + run_lock: fs::File, } // Ctrl+D — archive distillation. Aggregate the user's own past codex messages @@ -2254,15 +3575,14 @@ fn poll_distillation(app: &mut App) -> Result { // Launch the autonomous codex session for a ready corpus. codex runs in the // corpus dir and only reads/writes there; `-a never -s workspace-write` keeps it // autonomous, and pre-trusting the dir in codex's config stops its first-run -// "trust this folder?" gate from stalling the unattended run (best-effort). The +// "trust this folder?" gate from stalling the unattended run. The // session is tagged with its distill version (drives its list label + "Done" // status) and is NOT auto-attached — it shows up like any session; attach to watch. -fn finish_distillation(app: &mut App, prep: distill::DistillPrep) -> Result { +fn finish_distillation(app: &mut App, mut prep: distill::DistillPrep) -> Result { if prep.messages == 0 { app.message = "no history found to distill".to_string(); return Ok(true); } - let _ = distill::ensure_trusted(&prep.workdir); let workdir = prep.workdir.to_string_lossy().to_string(); let codex_args = vec![ "-C".to_string(), @@ -2274,15 +3594,42 @@ fn finish_distillation(app: &mut App, prep: distill::DistillPrep) -> Result lock, + Err(err) => { + let cleanup = prep.cleanup_corpus().err(); + app.message = match cleanup { + Some(cleanup) => format!( + "distill launch failed: clone lifetime lock: {err}; private corpus cleanup failed: {cleanup:#}" + ), + None => format!("distill launch failed: clone lifetime lock: {err}"), + }; + return Ok(true); + } + }; + let launch = DistillLaunch { + version: prep.version, + expected_markers: prep + .chunks + .iter() + .map(|chunk| chunk.marker.clone()) + .collect(), + expected_user_turns: prep.messages, + corpus_rel: prep.corpus_rel.clone(), + run_lock, + }; + let session_id = state::new_session_id(); match create_session_in( &title, Some(prompt), Some(prep.workdir.clone()), codex_args, - Some(prep.version), + Some(launch), + Some(session_id.clone()), ) { Ok(session) => { + // The worker/session state now owns removal of this immutable run. + prep.commit_corpus(); app.reload()?; if let Some(pos) = app.sessions.iter().position(|s| s.id == session.id) { app.selected = pos; @@ -2297,7 +3644,21 @@ fn finish_distillation(app: &mut App, prep: distill::DistillPrep) -> Result { - app.message = format!("distill launch failed: {err:#}"); + // create_session_in writes state before spawning. If that durable + // association exists, keep the corpus for retry/removal; otherwise + // this preparer is the only owner and must erase it now. + if std::fs::symlink_metadata(state::state_path(&session_id)).is_ok() { + prep.commit_corpus(); + app.message = format!("distill launch failed: {err:#}"); + } else { + let cleanup = prep.cleanup_corpus().err(); + app.message = match cleanup { + Some(cleanup) => format!( + "distill launch failed: {err:#}; private corpus cleanup failed: {cleanup:#}" + ), + None => format!("distill launch failed: {err:#}"), + }; + } } } Ok(true) @@ -2312,14 +3673,24 @@ fn create_session_in( initial_prompt: Option, cwd_override: Option, codex_args: Vec, - distill_version: Option, + distill_launch: Option, + id_override: Option, ) -> Result { state::ensure_base_dirs()?; - let id = state::new_session_id(); + let id = id_override.unwrap_or_else(state::new_session_id); + state::validate_session_id(&id)?; + if state::read_state_optional(&id)?.is_some() { + anyhow::bail!("session id already exists: {id}"); + } let cwd = match cwd_override { Some(p) => p, None => env::current_dir().context("current directory")?, }; + // Starting a session from Rail is the user's explicit decision to run Codex + // in this directory. Persist that trust before spawning so a private prompt + // can wait for the real composer instead of landing on a first-run trust + // dialog. Config updates are locked, escaped, private, and atomic. + distill::ensure_trusted(&cwd).context("trust Rail session directory in Codex config")?; let codex = env::var("CODEX_RAIL_CODEX").unwrap_or_else(|_| "codex".to_string()); let socket = state::socket_path(&id); let now = state::now_secs(); @@ -2342,10 +3713,22 @@ fn create_session_in( codex_session_id: None, codex_rollout_path: None, initial_prompt, + initial_prompt_injecting: false, title_pinned: false, last_output_at: 0, codex_args, - distill_version, + distill_version: distill_launch.as_ref().map(|launch| launch.version), + distill_expected_markers: distill_launch + .as_ref() + .map(|launch| launch.expected_markers.clone()) + .unwrap_or_default(), + distill_expected_user_turns: distill_launch + .as_ref() + .map(|launch| launch.expected_user_turns), + distill_corpus_rel: distill_launch + .as_ref() + .map(|launch| launch.corpus_rel.clone()), + distill_validated: false, adopted: false, }; state::write_state(&session)?; @@ -2353,14 +3736,22 @@ fn create_session_in( // the worker's state.json writes never define it. state::write_label(&id, title, false)?; - let child = Command::new(env::current_exe().context("current executable")?) - .arg("--worker") + let mut worker_command = Command::new(env::current_exe().context("current executable")?); + worker_command + .arg("--guardian") .arg(&id) .current_dir(Path::new(&session.cwd)) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); + .stderr(Stdio::null()); + if let Some(launch) = distill_launch.as_ref() { + distill::make_run_lock_inheritable(&launch.run_lock)?; + worker_command.env( + distill::DISTILL_LOCK_FD_ENV, + launch.run_lock.as_raw_fd().to_string(), + ); + } + let child = worker_command.spawn(); let mut child = match child { Ok(child) => child, Err(err) => { @@ -2431,6 +3822,33 @@ fn record_unclaimed_launch_failure(id: &str, message: &str) { } } +fn request_session_stop(session: &SessionState) -> Result<()> { + let socket_result = UnixStream::connect(&session.socket).and_then(|mut stream| { + stream.set_read_timeout(Some(Duration::from_secs(1)))?; + stream.set_write_timeout(Some(Duration::from_secs(1)))?; + stream.write_all(b"STOP\n")?; + stream.flush()?; + let response = crate::protocol::read_line(&mut stream)?; + if response == "STOPPING" { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected STOP acknowledgement: {response:?}"), + )) + } + }); + if socket_result.is_ok() { + return Ok(()); + } + state::request_worker_stop(&session.id).with_context(|| { + format!( + "worker socket unavailable ({}) and control-file stop failed", + socket_result.unwrap_err() + ) + }) +} + // Ctrl-X does one of two things depending on whether the session is live: // a running session is *stopped* (SIGTERM via the worker socket); an // already-stopped one is *removed* from the list (its job dir is deleted, so @@ -2463,22 +3881,20 @@ fn stop_with_confirmation(app: &mut App) -> Result<()> { app.stop_confirm = None; if live { - // Ask the worker to shut down cleanly over its socket. If the socket is - // gone (e.g. $XDG_RUNTIME_DIR was cleared while the worker stayed alive — - // connect fails with a file error and there's nothing to STOP), fall back - // to killing the worker by its recorded pid so the row can still be - // stopped instead of being wedged "running" forever. - let sent = UnixStream::connect(&session.socket) - .and_then(|mut s| s.write_all(b"STOP\n").and_then(|_| s.flush())) - .is_ok(); - if sent { - app.message = "stop requested".to_string(); - } else if state::request_worker_stop(&session.id).is_ok() { - app.message = "stop requested (socket gone — using worker control file)".to_string(); - } else if kill_session_pids(&session) { - app.message = "stopped (socket gone — killed the worker)".to_string(); - } else { - app.message = "stop failed: worker unreachable and no pid to kill".to_string(); + match request_session_stop(&session) { + Ok(()) => { + app.pending_stops.insert( + session.id.clone(), + PendingStop { + requested: Instant::now(), + escalated: false, + }, + ); + app.message = "stop accepted; waiting for verified cleanup…".to_string(); + } + Err(err) => { + app.message = format!("stop request failed: {err:#}"); + } } app.reload()?; } else if session.adopted { @@ -2492,9 +3908,20 @@ fn stop_with_confirmation(app: &mut App) -> Result<()> { app.invalidate_frame(); app.reload()?; } else { + if !prepare_autopilot_removal(app, &session.id)? { + return Ok(()); + } + let distill_corpus = session.distill_corpus_rel.clone(); match state::remove_session(&session.id) { Ok(()) => { - app.message = "session removed".to_string(); + let corpus_cleanup = match distill_corpus.as_deref() { + Some(corpus) => distill::cleanup_run_corpus(corpus), + None => Ok(()), + }; + app.message = match corpus_cleanup { + Ok(()) => "session removed".to_string(), + Err(err) => format!("session removed; private corpus cleanup failed: {err:#}"), + }; // The list just shrank; force a clean full repaint so no stale // row is left behind below the shortened list. app.invalidate_frame(); @@ -2508,6 +3935,57 @@ fn stop_with_confirmation(app: &mut App) -> Result<()> { Ok(()) } +/// Disable and fully retire a main session's internal pilot before deleting the +/// main's only autopilot link. Deleting autopilot.json first would make a live +/// pilot indistinguishable from an ordinary session and leak it forever. +fn prepare_autopilot_removal(app: &mut App, main_id: &str) -> Result { + let Some(mut control) = app.autopilot.get(main_id).cloned() else { + return Ok(true); + }; + if !app.acquire_autopilot_lease(main_id)? { + app.message = "remove blocked: autopilot is controlled by another Rail window".to_string(); + return Ok(false); + } + control.enabled = false; + control.enter_phase(autopilot::Phase::Idle); + control.pending_reply.clear(); + control.last_reason = Some("main session is being removed".to_string()); + control.cleanup_pending = control.pilot_id.is_some(); + autopilot::save(main_id, &control).context("disable autopilot before removal")?; + + if let Some(pilot_id) = control.pilot_id.as_deref() { + if let Some(pilot) = app.sessions.iter().find(|s| s.id == pilot_id).cloned() { + if state::session_worker_is_running(&pilot) { + let stop = request_session_stop(&pilot); + app.pending_stops.insert( + pilot.id.clone(), + PendingStop { + requested: Instant::now(), + escalated: false, + }, + ); + app.message = match stop { + Ok(()) => { + "stopping linked autopilot pilot; remove the main again once it stops" + .to_string() + } + Err(err) => format!( + "pilot stop acknowledgement failed ({err:#}); watchdog will escalate" + ), + }; + app.autopilot.insert(main_id.to_string(), control); + return Ok(false); + } + state::remove_session(&pilot.id).context("remove linked autopilot pilot")?; + } + } + autopilot::remove(main_id); + app.autopilot.remove(main_id); + app.autopilot_leases.remove(main_id); + app.autopilot_faults.remove(main_id); + Ok(true) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ProcRef { pid: i32, @@ -2558,7 +4036,7 @@ fn proc_age_secs(proc: ProcRef) -> Option { // Kill a session's worker AND its codex tree after a strong cmdline/data-dir // identity check. The start-time snapshot is rechecked immediately before every // signal so a recycled pid can never inherit the old session's SIGTERM. -fn kill_session_pids(session: &SessionState) -> bool { +fn kill_session_pids_with_signal(session: &SessionState, signal: libc::c_int) -> bool { if !state::worker_matches_session(session.worker_pid, &session.id) { return false; } @@ -2573,7 +4051,7 @@ fn kill_session_pids(session: &SessionState) -> bool { } // Derive the codex descendants from the verified worker instead of trusting // persisted child_pid/PGID values that may be stale or malformed. - kill_worker_tree(worker) + kill_worker_tree_signal(worker, signal) } // Direct children of `pid`, read from /proc PPid. Used to find a worker's codex @@ -2584,11 +4062,7 @@ fn proc_children_of(worker: ProcRef) -> Vec { return kids; }; for e in rd.flatten() { - let Some(cpid) = e - .file_name() - .to_str() - .and_then(|s| s.parse::().ok()) - else { + let Some(cpid) = e.file_name().to_str().and_then(|s| s.parse::().ok()) else { continue; }; if let Some((ppid, start_time)) = proc_identity(cpid) { @@ -2607,6 +4081,10 @@ fn proc_children_of(worker: ProcRef) -> Vec { // child runs the real codex (+ sub-agents) in its own process group, so -pgid on // that child takes the subtree; then SIGTERM the worker. SIGTERM only. fn kill_worker_tree(worker: ProcRef) -> bool { + kill_worker_tree_signal(worker, libc::SIGTERM) +} + +fn kill_worker_tree_signal(worker: ProcRef, signal: libc::c_int) -> bool { if !same_process(worker) { return false; } @@ -2624,14 +4102,14 @@ fn kill_worker_tree(worker: ProcRef) -> bool { && pgid != my && proc_identity(child.pid) == Some((worker.pid, child.start_time)) { - libc::kill(-pgid, libc::SIGTERM); + libc::kill(-pgid, signal); } if proc_identity(child.pid) == Some((worker.pid, child.start_time)) { - libc::kill(child.pid, libc::SIGTERM); + libc::kill(child.pid, signal); } } if same_process(worker) { - libc::kill(worker.pid, libc::SIGTERM); + libc::kill(worker.pid, signal); return true; } } @@ -2697,10 +4175,7 @@ fn worker_candidate_still_matches( let Ok(cmd) = std::fs::read(format!("/proc/{}/cmdline", proc.pid)) else { return false; }; - let parts: Vec<&[u8]> = cmd - .split(|b| *b == 0) - .filter(|p| !p.is_empty()) - .collect(); + let parts: Vec<&[u8]> = cmd.split(|b| *b == 0).filter(|p| !p.is_empty()).collect(); parts.len() == 3 && parts[1] == b"--worker" && parts[2] == id.as_bytes() } @@ -2723,11 +4198,7 @@ fn reap_orphan_workers() -> usize { return 0; }; for e in rd.flatten() { - let Some(pid) = e - .file_name() - .to_str() - .and_then(|s| s.parse::().ok()) - else { + let Some(pid) = e.file_name().to_str().and_then(|s| s.parse::().ok()) else { continue; }; if pid == me { @@ -2782,6 +4253,14 @@ fn reap_orphan_workers() -> usize { continue; } }; + let _generation_lock = match state::try_acquire_session_generation_lock(&id) { + Ok(Some(lock)) => lock, + Ok(None) => continue, + Err(err) => { + eprintln!("skip stale-worker generation check for {id}: {err:#}"); + continue; + } + }; // A current worker holds this for its whole lifetime. If busy, the // process may be in the tiny claim window before state.json changes; // never infer staleness from the previous exited snapshot. If acquired, @@ -2848,6 +4327,114 @@ fn reap_orphan_workers() -> usize { reaped } +// A worker can itself be SIGKILLed or OOM-killed, bypassing RunGuard::drop. +// Its per-run token survives in state.json and is inherited by every Codex/MCP +// descendant, so a later manager can still close that ownership boundary. Both +// locks are held across classification, census, signalling, and the state write: +// a concurrent resume can neither be mistaken for the abandoned generation nor +// start while its predecessor is being cleaned. +fn reap_abandoned_generations() -> usize { + // Read raw persisted states. `load_sessions()` intentionally reconciles a + // dead worker to an in-memory `exited` row, which would hide the exact + // running/stopping snapshot that tells us crash cleanup is required here. + let entries = match fs::read_dir(state::jobs_dir()) { + Ok(entries) => entries, + Err(err) => { + eprintln!("skip abandoned-generation recovery: {err:#}"); + return 0; + } + }; + let sessions: Vec = entries + .flatten() + .filter_map(|entry| entry.file_name().to_str().map(str::to_string)) + .filter(|id| state::validate_session_id(id).is_ok()) + .filter_map(|id| state::read_state_optional(&id).ok().flatten()) + .collect(); + let mut recovered = 0; + for snapshot in sessions { + let suspicious = matches!( + snapshot.status.as_str(), + STATUS_STARTING | STATUS_RUNNING | STATUS_STOPPING + ) || (snapshot.status == STATUS_FAILED && snapshot.worker_token.is_some()); + if !suspicious || !snapshot.worker_lock_protocol { + continue; + } + let Some(token) = snapshot.worker_token.as_deref() else { + continue; + }; + let _init_lock = match state::try_acquire_session_init_lock(&snapshot.id) { + Ok(Some(lock)) => lock, + Ok(None) => continue, + Err(err) => { + eprintln!("skip abandoned generation {}: {err:#}", snapshot.id); + continue; + } + }; + let _generation_lock = match state::try_acquire_session_generation_lock(&snapshot.id) { + Ok(Some(lock)) => lock, + Ok(None) => continue, + Err(err) => { + eprintln!("skip abandoned generation {}: {err:#}", snapshot.id); + continue; + } + }; + let _worker_lock = match state::try_acquire_worker_lock(&snapshot.id) { + Ok(Some(lock)) => lock, + Ok(None) => continue, + Err(err) => { + eprintln!("skip abandoned generation {}: {err:#}", snapshot.id); + continue; + } + }; + let Ok(Some(mut current)) = state::read_state_optional(&snapshot.id) else { + continue; + }; + if current.worker_token.as_deref() != Some(token) + || !current.worker_lock_protocol + || !matches!( + current.status.as_str(), + STATUS_STARTING | STATUS_RUNNING | STATUS_STOPPING | STATUS_FAILED + ) + { + continue; + } + + let report = process_tree::terminate_generation_by_token( + token, + Duration::from_secs(2), + Duration::from_secs(2), + ); + current.worker_pid = None; + current.child_pid = None; + current.updated_at = state::now_secs(); + if report.is_clean() { + if report.term_signals + report.kill_signals > 0 { + recovered += 1; + } + current.status = STATUS_FAILED.to_string(); + current.worker_token = None; + current.last_error = Some(if snapshot.status == STATUS_FAILED { + format!( + "abandoned process generation recovered; previous error: {}", + snapshot.last_error.as_deref().unwrap_or("unknown failure") + ) + } else { + "worker died unexpectedly; abandoned process generation cleaned".to_string() + }); + } else { + current.status = STATUS_FAILED.to_string(); + current.last_error = Some(format!( + "abandoned process cleanup incomplete: {} survivor(s), census verified={}", + report.survivors, report.verified + )); + } + if let Err(err) = state::write_state(¤t) { + eprintln!("record abandoned generation {}: {err:#}", snapshot.id); + } + } + recovered +} + fn confirm_exit(app: &mut App) -> bool { let confirmed = app .exit_confirm @@ -2894,10 +4481,40 @@ fn render(app: &mut App) -> Result<()> { let (cols, rows) = terminal::size().unwrap_or((100, 30)); let mut frame = vec![String::new(); rows as usize]; - draw_header(&mut frame, cols, app); - draw_sessions(&mut frame, app, cols, rows); - draw_input(&mut frame, app, cols, rows); - draw_hint(&mut frame, app, cols, rows); + if cols < 44 || rows < 12 { + app.rows.clear(); + app.update_click = None; + put( + &mut frame, + 0, + styled_line(|buffer| { + let _ = queue!( + buffer, + SetForegroundColor(C_ACCENT), + Print(fit_cols("Codex Rail", cols as usize)) + ); + }), + ); + put( + &mut frame, + rows.saturating_div(2), + styled_line(|buffer| { + let _ = queue!( + buffer, + SetForegroundColor(C_NEEDS), + Print(fit_cols( + "terminal too small — resize to at least 44x12", + cols as usize + )) + ); + }), + ); + } else { + draw_header(&mut frame, cols, app); + draw_sessions(&mut frame, app, cols, rows); + draw_input(&mut frame, app, cols, rows); + draw_hint(&mut frame, app, cols, rows); + } let mut stdout = io::stdout(); let mut wrote = false; @@ -3011,6 +4628,7 @@ fn draw_sessions(frame: &mut [String], app: &mut App, cols: u16, rows: u16) { let max_rows = (last_list_y + 1).saturating_sub(start_y) as usize; if app.sessions.is_empty() { + app.hovered_row = None; // Rest the empty-state line at the vertical middle of the list area, not // its top edge — a calm centre is where the eye lands. let mid = start_y + (max_rows as u16) / 2; @@ -3022,7 +4640,7 @@ fn draw_sessions(frame: &mut [String], app: &mut App, cols: u16, rows: u16) { b, SetForegroundColor(C_DIM), Print(fit( - " No sessions yet — press e, type your first message, then Enter.", + " No sessions yet — type your first message, then Enter (Ctrl+N for blank).", cw )) ); @@ -3086,12 +4704,18 @@ fn draw_sessions(frame: &mut [String], app: &mut App, cols: u16, rows: u16) { DisplayItem::Header(b, count) => put(frame, y, section_header_line(*b, *count)), DisplayItem::Row(index) => { let selected = *index == app.selected; + let hovered = app.hovered_row == Some(y); let s = &app.sessions[*index]; let bucket = bucket_of(&app.activity, s); let preview = app .preview .get(&s.id) .cloned() + .or_else(|| { + (s.status == STATUS_FAILED) + .then(|| s.last_error.clone()) + .flatten() + }) .unwrap_or_else(|| home_tilde(&s.cwd)); // Autopilot badge: an active main leads its preview with "⟳ N/cap"; // a paused one shows why it handed back (so the row says "your turn"). @@ -3105,11 +4729,21 @@ fn draw_sessions(frame: &mut [String], app: &mut App, cols: u16, rows: u16) { ), None => preview, }; - put(frame, y, session_row_line(s, bucket, &preview, selected, cw, title_w)); + put( + frame, + y, + session_row_line(s, bucket, &preview, selected, hovered, cw, title_w), + ); app.rows.push((y, *index)); } } } + if app + .hovered_row + .is_some_and(|hovered| !app.rows.iter().any(|(row, _)| *row == hovered)) + { + app.hovered_row = None; + } // The selected session's working dir used to sit on its own full-width line // here — it overpowered the list. It now rides faintly on the composer's // bottom border (see draw_input), where it's available but out of the way. @@ -3148,20 +4782,25 @@ fn session_columns(sessions: &[SessionState], cw: usize) -> usize { // One session row, drawn in coloured segments: // ▌ {glyph} {title} {latest codex message} {age} // The selected row gets a terracotta left bar (▌) and a warm background across -// the full content width. The middle is codex's latest line (Claude Code's -// agents panel does the same); a session with no codex message yet (new or -// stopped) falls back to its path so rows stay distinguishable. The segments -// sum to exactly `cw`, so age lands at the right edge. Display-column widths -// keep CJK aligned. +// the full content width. A hovered row gets a quieter background and thin bar, +// but is NOT selected; moving the pointer cannot scroll, attach, or steal the +// keyboard cursor. The middle is codex's latest line (Claude Code's agents panel +// does the same); a session with no codex message yet (new or stopped) falls +// back to its path so rows stay distinguishable. The segments sum to exactly +// `cw`, so age lands at the right edge. Display-column widths keep CJK aligned. fn session_row_line( session: &SessionState, bucket: Bucket, preview: &str, selected: bool, + hovered: bool, cw: usize, title_w: usize, ) -> String { - let mut age: String = format_age(last_activity_secs(session)).chars().take(4).collect(); + let mut age: String = format_age(last_activity_secs(session)) + .chars() + .take(4) + .collect(); while age.chars().count() < 4 { age.insert(0, ' '); // right-align in a fixed 4-wide column } @@ -3176,7 +4815,7 @@ fn session_row_line( // Imported codex-history rows (adopted, not started by rail) render dimmer so // they read as "resumable history"; one whose rollout was just written shows // amber, a hint it may be a codex running elsewhere (attaching warns first). - let title_color = if selected { + let title_color = if selected || hovered { C_SELTITLE } else if adopted_maybe_live(session) { C_NEEDS @@ -3205,9 +4844,13 @@ fn session_row_line( styled_line(|b| { if selected { let _ = queue!(b, SetBackgroundColor(C_SEL_BG)); + } else if hovered { + let _ = queue!(b, SetBackgroundColor(C_HOVER_BG)); } if selected { let _ = queue!(b, SetForegroundColor(C_ACCENT), Print("▌"), Print(" ")); + } else if hovered { + let _ = queue!(b, SetForegroundColor(C_ACCENT_DIM), Print("▏"), Print(" ")); } else { let _ = queue!(b, Print(" ")); } @@ -3221,7 +4864,7 @@ fn session_row_line( SetForegroundColor(C_DIM), Print(preview_s), Print(" "), - SetForegroundColor(if selected { C_DIM } else { C_FAINT }), + SetForegroundColor(if selected || hovered { C_DIM } else { C_FAINT }), Print(age) ); }) @@ -3240,7 +4883,10 @@ fn draw_input(frame: &mut [String], app: &App, cols: u16, rows: u16) { // the box into a command menu — matching commands pop up just above it (like // Claude Code), and the highlighted one runs on Enter. It overlays the bottom // of the list (a transient overlay; the list redraws when the palette closes). - if matches!(app.mode, Mode::New) && app.input.starts_with('/') { + if matches!(app.mode, Mode::New) + && app.input.starts_with('/') + && !slash_matches(&app.input).is_empty() + { let ms = slash_matches(&app.input); let sel = app.slash_sel.min(ms.len().saturating_sub(1)); for (i, c) in ms.iter().enumerate() { @@ -3275,7 +4921,9 @@ fn draw_input(frame: &mut [String], app: &App, cols: u16, rows: u16) { // title ("╭─ new session ─╮"); otherwise a plain faint border. Transient // status and confirm messages live on the bottom hint line now, not here — // a quit/stop prompt has no business appearing inside the text box. - let slashing = matches!(app.mode, Mode::New) && app.input.starts_with('/'); + let slashing = matches!(app.mode, Mode::New) + && app.input.starts_with('/') + && !slash_matches(&app.input).is_empty(); let label = match app.mode { Mode::New if slashing => Some("command"), Mode::New => Some("new session"), @@ -3319,13 +4967,14 @@ fn draw_input(frame: &mut [String], app: &App, cols: u16, rows: u16) { ); if composing { let text_avail = avail.saturating_sub(1); // one column for the caret - // Show the tail when the input outgrows the box so the end you're - // typing at stays visible; otherwise show it whole (no padding, so - // the caret sits right after the text). - let visible = if display_width(&app.input) <= text_avail { - app.input.clone() + // Show the tail when the input outgrows the box so the end you're + // typing at stays visible; otherwise show it whole (no padding, so + // the caret sits right after the text). + let safe_input = composer_display_text(&app.input); + let visible = if display_width(&safe_input) <= text_avail { + safe_input } else { - fit_cols_tail(&app.input, text_avail) + fit_cols_tail(&safe_input, text_avail) }; let vis_w = display_width(&visible).min(text_avail); let pad = avail.saturating_sub(vis_w + 1); @@ -3344,7 +4993,7 @@ fn draw_input(frame: &mut [String], app: &App, cols: u16, rows: u16) { b, SetForegroundColor(C_DIM), Print(fit_cols( - "press e or type to message codex \u{00b7} type / for commands", + "type to message codex \u{00b7} Ctrl+N blank \u{00b7} / commands", avail )) ); @@ -3406,7 +5055,7 @@ fn draw_hint(frame: &mut [String], app: &App, cols: u16, rows: u16) { } else { let hint = match app.mode { Mode::Normal => { - "↑↓ move · Enter attach · e new · Space autopilot · / commands · Ctrl+R rename · Ctrl+X twice stop · Esc twice quit" + "↑↓ move · Enter attach · Ctrl+N blank · Ctrl+A autopilot · / commands · Ctrl+R rename · Ctrl+X twice stop · Esc twice quit" } Mode::New => "Enter start · Esc cancel · / for commands · empty = blank session", Mode::Rename => "Enter save · Esc cancel", @@ -3504,3 +5153,150 @@ impl Drop for TerminalSession { self.leave().ok(); } } + +#[cfg(test)] +mod tests { + use super::{ + composer_display_text, days_before, display_width, fit_cols, fit_cols_tail, + parse_import_spec, preview_line, sanitize_composer_text, sanitize_terminal_text, + scan_lifecycle, slash_matches, Activity, ImportSpec, Lifecycle, MAX_IMPORT_DAYS, + }; + use std::io::Write; + use std::time::{Duration, UNIX_EPOCH}; + + #[test] + fn import_parser_separates_days_exact_ids_and_invalid_arguments() { + let sid = "01900000-1234-7abc-8def-0123456789ab"; + assert_eq!(parse_import_spec("/import 15d"), Ok(ImportSpec::Days(15))); + assert_eq!(parse_import_spec(" /import 2D "), Ok(ImportSpec::Days(2))); + assert_eq!( + parse_import_spec(&format!("/import {sid}")), + Ok(ImportSpec::Session(sid.to_string())) + ); + for invalid in [ + "/import", + "/import 0d", + "/import 15", + "/import 15days", + "/import 2d extra", + "/import not-a-codex-id", + ] { + assert!(parse_import_spec(invalid).is_err(), "accepted {invalid:?}"); + } + assert!(parse_import_spec(&format!("/import {}d", MAX_IMPORT_DAYS + 1)).is_err()); + } + + #[test] + fn slash_matching_keeps_import_arguments_rail_side() { + assert_eq!(slash_matches("/import 15d").len(), 1); + assert_eq!(slash_matches("/im")[0].name, "/import"); + assert!(slash_matches("/important").is_empty()); + assert!(slash_matches("/review").is_empty()); + } + + #[test] + fn import_day_cutoff_is_checked_and_exact() { + let now = UNIX_EPOCH + Duration::from_secs(20 * 24 * 60 * 60); + assert_eq!( + days_before(now, 7), + Some(UNIX_EPOCH + Duration::from_secs(13 * 24 * 60 * 60)) + ); + assert_eq!(days_before(UNIX_EPOCH, u64::MAX), None); + } + + #[test] + fn untrusted_text_cannot_emit_terminal_or_bidi_controls() { + let hostile = "safe\u{1b}[2J\u{7}title\u{9b}31m\u{202e}txt"; + let safe = sanitize_terminal_text(hostile); + assert_eq!(safe, "safe[2Jtitle31mtxt"); + assert!(!fit_cols(hostile, 40).contains('\u{1b}')); + assert!(!fit_cols_tail(hostile, 8).contains('\u{9b}')); + } + + #[test] + fn sanitizing_controls_keeps_display_width_consistent() { + let hostile = "甲\u{1b}[2J乙"; + assert_eq!(display_width(hostile), display_width("甲[2J乙")); + assert_eq!(fit_cols(hostile, 8), "甲[2J乙 "); + } + + #[test] + fn previews_are_single_line_and_terminal_safe() { + assert_eq!( + preview_line("\n \u{1b}[2J hello\t world\nnext"), + "[2J hello world" + ); + } + + #[test] + fn composer_preserves_multiline_payload_but_renders_one_safe_line() { + let input = " first\r\n第二\n\tcode\u{1b}[2J"; + assert_eq!(sanitize_composer_text(input), " first\n第二\n\tcode[2J"); + assert_eq!(composer_display_text(input), " first↵第二↵⇥code[2J"); + } + + #[test] + fn common_unicode_clusters_have_terminal_cell_widths() { + assert_eq!(display_width("e\u{301}"), 1); + assert_eq!(display_width("❤️"), 2); + assert_eq!(display_width("👩‍💻"), 2); + assert_eq!(display_width("🇨🇳"), 2); + assert_eq!(display_width("中文"), 4); + assert_eq!(display_width(&fit_cols("👩‍💻x", 3)), 3); + } + + #[test] + fn lifecycle_indexing_is_bounded_and_aborted_turns_become_waiting() { + let path = std::env::temp_dir().join(format!( + "rail-lifecycle-{}-{}", + std::process::id(), + crate::state::now_millis() + )); + let complete = b"{\"payload\":{\"type\":\"task_complete\"}}\n"; + let started = b"{\"payload\":{\"type\":\"task_started\"}}\n"; + let aborted = b"{\"payload\":{\"type\":\"turn_aborted\"}}\n"; + let mut file = std::fs::File::create(&path).unwrap(); + file.write_all(complete).unwrap(); + file.write_all(&vec![b'x'; 9 * 1024 * 1024]).unwrap(); + file.write_all(b"\n").unwrap(); + file.write_all(started).unwrap(); + file.flush().unwrap(); + + let mut lifecycle = Lifecycle { + path: String::new(), + offset: 0, + last: None, + completed_turns: 0, + partial_record: Vec::new(), + discarding_oversized_record: false, + caught_up: false, + }; + assert_eq!( + scan_lifecycle(&mut lifecycle, path.to_str().unwrap()), + Activity::Active + ); + assert!(!lifecycle.caught_up); + for _ in 0..3 { + scan_lifecycle(&mut lifecycle, path.to_str().unwrap()); + if lifecycle.caught_up { + break; + } + } + assert!(lifecycle.caught_up); + assert_eq!(lifecycle.last, Some(Activity::Active)); + assert_eq!(lifecycle.completed_turns, 1); + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + file.write_all(aborted).unwrap(); + file.flush().unwrap(); + assert_eq!( + scan_lifecycle(&mut lifecycle, path.to_str().unwrap()), + Activity::Waiting + ); + assert_eq!(lifecycle.completed_turns, 1); + let _ = std::fs::remove_file(path); + } +} diff --git a/src/update.rs b/src/update.rs index 48aefd6..67ab941 100644 --- a/src/update.rs +++ b/src/update.rs @@ -6,16 +6,53 @@ //! same HTTPS_PROXY the rest of the tooling uses) so rail stays dependency-free. use anyhow::{anyhow, bail, Context, Result}; -use std::path::Path; +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Mutex, MutexGuard, TryLockError}; +use std::time::{SystemTime, UNIX_EPOCH}; const REPO: &str = "saofund/codex-rail"; +const MIN_EXECUTABLE_BYTES: usize = 4096; +const MAX_CURL_BYTES: usize = 64 * 1024 * 1024; +const BUILD_MARKER_PREFIX: &str = "CODEX_RAIL_BUILD_SHA="; +const BUILD_MARKER_SUFFIX: &str = "\n"; +static UPDATE_THREAD_LOCK: Mutex<()> = Mutex::new(()); +static UPDATE_TMP_COUNTER: AtomicU64 = AtomicU64::new(0); + +// Keep an exact, cheaply inspectable build identity in every release asset. The +// updater verifies this marker against main before it replaces the current +// binary, so a still-publishing/stale rolling release is rejected. +#[used] +static RAIL_BUILD_SHA_MARKER: &str = + concat!("CODEX_RAIL_BUILD_SHA=", env!("RAIL_GIT_SHA_FULL"), "\n"); + +struct UpdateGuard { + _thread: MutexGuard<'static, ()>, + _file: File, +} + +struct TempCleanup(PathBuf); + +impl Drop for TempCleanup { + fn drop(&mut self) { + let _ = fs::remove_file(&self.0); + } +} /// The git commit this binary was built from (see build.rs). pub fn build_sha() -> &'static str { env!("RAIL_GIT_SHA") } +pub fn build_sha_full() -> &'static str { + env!("RAIL_GIT_SHA_FULL") +} + /// The release-asset name for the running platform, e.g. `rail-x86_64-linux`. /// The CI release workflow names its uploads to match. pub fn asset_name() -> String { @@ -23,8 +60,23 @@ pub fn asset_name() -> String { } fn curl(url: &str, timeout: &str) -> Result> { + let max_bytes = MAX_CURL_BYTES.to_string(); let out = Command::new("curl") - .args(["-fsSL", "--max-time", timeout, "-H", "User-Agent: rail-update", url]) + .args([ + "--disable", + "-fsSL", + "--proto", + "=https", + "--proto-redir", + "=https", + "--max-filesize", + &max_bytes, + "--max-time", + timeout, + "-H", + "User-Agent: rail-update", + url, + ]) .output() .context("run curl (is it installed?)")?; if !out.status.success() { @@ -34,6 +86,12 @@ fn curl(url: &str, timeout: &str) -> Result> { String::from_utf8_lossy(&out.stderr).trim() ); } + if out.stdout.len() > MAX_CURL_BYTES { + bail!( + "curl response exceeded the {} MiB safety limit", + MAX_CURL_BYTES / 1024 / 1024 + ); + } Ok(out.stdout) } @@ -42,22 +100,34 @@ fn api_json(path: &str) -> Result { serde_json::from_slice(&body).context("parse GitHub API response") } -/// The latest commit sha on `main`, short. The repo has no version tags, so the -/// commit is the version signal. None on any failure (offline, rate-limited) so -/// callers can stay silent. -pub fn latest_main_sha() -> Option { - let v = api_json("commits/main").ok()?; - v.get("sha") +fn validate_git_sha(sha: &str) -> Result<()> { + if sha.len() < 7 || sha.len() > 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) { + bail!("GitHub returned an invalid commit sha"); + } + Ok(()) +} + +fn latest_main_sha_full() -> Result { + let v = api_json("commits/main").context("fetch latest main commit")?; + let sha = v + .get("sha") .and_then(|s| s.as_str()) - .map(|s| s.chars().take(7).collect()) + .context("latest main response has no commit sha")?; + validate_git_sha(sha)?; + Ok(sha.to_ascii_lowercase()) +} + +fn short_sha(sha: &str) -> Result { + validate_git_sha(sha)?; + Ok(sha.chars().take(7).collect()) } /// If a newer build than this one is available, the newer short sha; else None. pub fn newer_available() -> Option { - let latest = latest_main_sha()?; - let mine = build_sha(); - if mine != "unknown" && latest != mine { - Some(latest) + let latest_full = latest_main_sha_full().ok()?; + let mine = build_sha_full().to_ascii_lowercase(); + if mine != "unknown" && latest_full != mine { + short_sha(&latest_full).ok() } else { None } @@ -68,49 +138,410 @@ pub fn newer_available() -> Option { /// live process keeps the old inode, the next launch picks up the new one). /// Errors clearly if no Release/asset exists yet (CI publishes them on push). pub fn apply() -> Result { - let rel = api_json("releases/latest") - .context("fetch latest release (has CI published one yet?)")?; + let exe = current_executable_for_update()?; + let _guard = acquire_update_lock(&exe)?; + let expected_sha = latest_main_sha_full()?; + let rel = + api_json("releases/latest").context("fetch latest release (has CI published one yet?)")?; let tag = rel .get("tag_name") .and_then(|t| t.as_str()) - .unwrap_or("latest") - .to_string(); + .context("latest release has no tag")?; + if tag != "latest" { + bail!("latest GitHub release is not rail's rolling 'latest' release"); + } let want = asset_name(); - let url = rel + let url = release_asset_url(&rel, &want)?; + let bytes = curl(&url, "180").context("download the new binary")?; + install_download(&exe, &bytes, &expected_sha)?; + short_sha(&expected_sha) +} + +fn current_executable_for_update() -> Result { + let exe = std::env::current_exe().context("locate the running executable")?; + let name = exe.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if name.ends_with(" (deleted)") || name.starts_with(".nfs") { + bail!("this process already runs a replaced binary; restart rail before updating again"); + } + let meta = fs::symlink_metadata(&exe) + .with_context(|| format!("inspect running executable {}", exe.display()))?; + if !meta.is_file() { + bail!( + "running executable is not a regular file: {}", + exe.display() + ); + } + Ok(exe) +} + +fn update_lock_path(exe: &Path) -> PathBuf { + exe.with_extension("update.lock") +} + +fn acquire_update_lock(exe: &Path) -> Result { + let thread = match UPDATE_THREAD_LOCK.try_lock() { + Ok(guard) => guard, + Err(TryLockError::WouldBlock) => bail!("an update is already in progress"), + Err(TryLockError::Poisoned(err)) => err.into_inner(), + }; + let path = update_lock_path(exe); + let file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + .with_context(|| format!("open update lock {}", path.display()))?; + if !file.metadata()?.is_file() { + bail!("update lock is not a regular file: {}", path.display()); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .with_context(|| format!("restrict update lock {}", path.display()))?; + loop { + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + break; + } + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::Interrupted { + continue; + } + let raw = err.raw_os_error(); + if raw == Some(libc::EWOULDBLOCK) || raw == Some(libc::EAGAIN) { + bail!("an update is already in progress"); + } + return Err(err).with_context(|| format!("lock update file {}", path.display())); + } + Ok(UpdateGuard { + _thread: thread, + _file: file, + }) +} + +fn release_asset_url(release: &serde_json::Value, want: &str) -> Result { + let url = release .get("assets") .and_then(|a| a.as_array()) .into_iter() .flatten() - .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(want.as_str())) + .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(want)) .and_then(|a| a.get("browser_download_url")) .and_then(|u| u.as_str()) - .ok_or_else(|| anyhow!("the latest release has no binary named {want} for this platform"))?; + .ok_or_else(|| { + anyhow!("the latest release has no binary named {want} for this platform") + })?; + let prefix = format!("https://github.com/{REPO}/releases/download/"); + if !url.starts_with(&prefix) { + bail!("release asset URL is outside the expected GitHub repository"); + } + Ok(url.to_string()) +} - let exe = std::env::current_exe().context("locate the running executable")?; - let tmp = exe.with_extension("update-download"); - let dl = Command::new("curl") - .args(["-fsSL", "--max-time", "180", "-o"]) - .arg(&tmp) - .arg(url) - .output() - .context("download the new binary")?; - if !dl.status.success() { - let _ = std::fs::remove_file(&tmp); - bail!("download failed: {}", String::from_utf8_lossy(&dl.stderr).trim()); +fn build_marker(sha: &str) -> String { + format!("{BUILD_MARKER_PREFIX}{sha}{BUILD_MARKER_SUFFIX}") +} + +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && haystack + .windows(needle.len()) + .any(|window| window == needle) +} + +fn validate_download(bytes: &[u8], expected_sha: &str) -> Result<()> { + validate_git_sha(expected_sha)?; + if bytes.len() < MIN_EXECUTABLE_BYTES { + bail!("downloaded release is empty or implausibly small"); } - make_executable(&tmp)?; - std::fs::rename(&tmp, &exe) - .with_context(|| format!("replace {}", exe.display()))?; - Ok(tag) + validate_native_executable(bytes)?; + let marker = build_marker(expected_sha); + if !contains_bytes(bytes, marker.as_bytes()) { + bail!( + "release asset is stale or mismatched: expected build {}", + expected_sha + ); + } + Ok(()) } -#[cfg(unix)] -fn make_executable(p: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(p, std::fs::Permissions::from_mode(0o755)) - .with_context(|| format!("chmod {}", p.display())) +#[cfg(target_os = "linux")] +fn validate_native_executable(bytes: &[u8]) -> Result<()> { + if bytes.get(..4) != Some(b"\x7fELF") + || bytes.get(4) != Some(&2) + || bytes.get(5) != Some(&1) + || bytes.get(6) != Some(&1) + { + bail!("downloaded release is not a 64-bit little-endian ELF executable"); + } + let kind = u16::from_le_bytes([bytes[16], bytes[17]]); + if !matches!(kind, 2 | 3) { + bail!("downloaded ELF has an invalid executable type"); + } + let machine = u16::from_le_bytes([bytes[18], bytes[19]]); + let expected = if cfg!(target_arch = "x86_64") { + 62 + } else if cfg!(target_arch = "aarch64") { + 183 + } else { + bail!("self-update is unsupported on this Linux architecture"); + }; + if machine != expected { + bail!("downloaded ELF is for the wrong CPU architecture"); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn validate_native_executable(bytes: &[u8]) -> Result<()> { + if bytes.get(..4) != Some(&[0xcf, 0xfa, 0xed, 0xfe]) { + bail!("downloaded release is not a 64-bit Mach-O executable"); + } + let cpu = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + let expected = if cfg!(target_arch = "aarch64") { + 0x0100_000c + } else if cfg!(target_arch = "x86_64") { + 0x0100_0007 + } else { + bail!("self-update is unsupported on this macOS architecture"); + }; + if cpu != expected { + bail!("downloaded Mach-O is for the wrong CPU architecture"); + } + Ok(()) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn validate_native_executable(_bytes: &[u8]) -> Result<()> { + bail!("self-update is supported only on Linux and macOS") } -#[cfg(not(unix))] -fn make_executable(_p: &Path) -> Result<()> { + +fn create_unique_update_file(exe: &Path) -> Result<(PathBuf, File)> { + let parent = exe + .parent() + .context("running executable has no parent directory")?; + let base = exe.file_name().and_then(|n| n.to_str()).unwrap_or("rail"); + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + for _ in 0..64 { + let n = UPDATE_TMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = parent.join(format!( + ".{base}.update-tmp-{}-{stamp:x}-{n:x}", + std::process::id() + )); + match OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + { + Ok(file) => return Ok((path, file)), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(err).with_context(|| format!("create update file {}", path.display())) + } + } + } + bail!("could not allocate a unique update file") +} + +fn install_download(exe: &Path, bytes: &[u8], expected_sha: &str) -> Result<()> { + // Validate completely before creating or renaming anything next to the live + // executable. Every failure before the final rename leaves the old binary + // byte-for-byte intact. + validate_download(bytes, expected_sha)?; + let (tmp, mut file) = create_unique_update_file(exe)?; + let cleanup = TempCleanup(tmp.clone()); + file.write_all(bytes) + .with_context(|| format!("write update file {}", tmp.display()))?; + file.set_permissions(fs::Permissions::from_mode(0o755)) + .with_context(|| format!("chmod update file {}", tmp.display()))?; + file.sync_all() + .with_context(|| format!("sync update file {}", tmp.display()))?; + let meta = file.metadata()?; + if !meta.is_file() || meta.len() != bytes.len() as u64 || meta.permissions().mode() & 0o111 == 0 + { + bail!("staged update is not a complete executable file"); + } + drop(file); + let parent = exe + .parent() + .context("running executable has no parent directory")?; + let parent_dir = File::open(parent) + .with_context(|| format!("open update directory {}", parent.display()))?; + fs::rename(&tmp, exe).with_context(|| format!("replace {}", exe.display()))?; + parent_dir + .sync_all() + .with_context(|| format!("sync update directory {}", parent.display()))?; + drop(cleanup); // its path no longer exists after the successful rename Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + + fn test_dir(name: &str) -> std::path::PathBuf { + let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = + std::env::temp_dir().join(format!("rail-update-{name}-{}-{n}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn fake_executable(sha: &str) -> Vec { + let mut bytes = vec![0_u8; MIN_EXECUTABLE_BYTES]; + if cfg!(target_os = "linux") { + bytes[..4].copy_from_slice(b"\x7fELF"); + bytes[4] = 2; // ELFCLASS64 + bytes[5] = 1; // little endian + bytes[6] = 1; // ELF version + bytes[16..18].copy_from_slice(&3_u16.to_le_bytes()); // ET_DYN + let machine = if cfg!(target_arch = "x86_64") { + 62_u16 + } else if cfg!(target_arch = "aarch64") { + 183_u16 + } else { + 0_u16 + }; + bytes[18..20].copy_from_slice(&machine.to_le_bytes()); + } else if cfg!(target_os = "macos") { + bytes[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]); // MH_MAGIC_64 + let cpu = if cfg!(target_arch = "aarch64") { + 0x0100_000c_u32 + } else { + 0x0100_0007_u32 + }; + bytes[4..8].copy_from_slice(&cpu.to_le_bytes()); + } + let marker = build_marker(sha); + bytes[128..128 + marker.len()].copy_from_slice(marker.as_bytes()); + bytes + } + + #[test] + fn download_validation_rejects_empty_wrong_format_and_stale_sha() { + assert!(validate_download(&[], "1234567").is_err()); + assert!(validate_download(&vec![b'x'; MIN_EXECUTABLE_BYTES], "1234567").is_err()); + assert!(validate_download(&fake_executable("7654321"), "1234567").is_err()); + assert!(validate_download(&fake_executable("12345670"), "1234567").is_err()); + assert!(validate_download(&fake_executable("1234567"), "1234567").is_ok()); + } + + #[test] + fn failed_install_preserves_the_original_binary() { + let dir = test_dir("preserve"); + let exe = dir.join("rail"); + fs::write(&exe, b"ORIGINAL").unwrap(); + fs::set_permissions(&exe, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!(install_download(&exe, b"", "1234567").is_err()); + assert_eq!(fs::read(&exe).unwrap(), b"ORIGINAL"); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn successful_install_is_atomic_private_and_executable() { + let dir = test_dir("install"); + let exe = dir.join("rail"); + fs::write(&exe, b"ORIGINAL").unwrap(); + let bytes = fake_executable("1234567"); + + install_download(&exe, &bytes, "1234567").unwrap(); + assert_eq!(fs::read(&exe).unwrap(), bytes); + assert_eq!( + fs::metadata(&exe).unwrap().permissions().mode() & 0o777, + 0o755 + ); + assert_eq!( + fs::read_dir(&dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains("update-tmp")) + .count(), + 0 + ); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn update_lock_rejects_a_second_owner_and_releases_cleanly() { + const CHILD_EXE: &str = "CODEX_RAIL_TEST_UPDATE_LOCK_EXE"; + const CHILD_EXPECT: &str = "CODEX_RAIL_TEST_UPDATE_LOCK_EXPECT"; + if let Some(exe) = std::env::var_os(CHILD_EXE) { + let result = acquire_update_lock(Path::new(&exe)); + match std::env::var(CHILD_EXPECT).as_deref() { + Ok("busy") => assert!(result.is_err()), + Ok("free") => assert!(result.is_ok()), + other => panic!("unexpected child lock mode: {other:?}"), + } + return; + } + + let dir = test_dir("lock"); + let exe = dir.join("rail"); + fs::write(&exe, b"ORIGINAL").unwrap(); + let lock = update_lock_path(&exe); + fs::write(&lock, b"").unwrap(); + fs::set_permissions(&lock, fs::Permissions::from_mode(0o666)).unwrap(); + + let first = acquire_update_lock(&exe).unwrap(); + assert_eq!( + fs::metadata(&lock).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert!(acquire_update_lock(&exe).is_err()); + + let harness = std::env::current_exe().unwrap(); + let child = |expect: &str| { + std::process::Command::new(&harness) + .args([ + "--exact", + "update::tests::update_lock_rejects_a_second_owner_and_releases_cleanly", + ]) + .env(CHILD_EXE, &exe) + .env(CHILD_EXPECT, expect) + .status() + .unwrap() + }; + assert!(child("busy").success()); + + drop(first); + assert!(acquire_update_lock(&exe).is_ok()); + assert!(child("free").success()); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn release_asset_must_be_the_expected_https_repo_asset() { + let rel = serde_json::json!({ + "assets": [{ + "name": asset_name(), + "browser_download_url": format!( + "https://github.com/{REPO}/releases/download/latest/{}", + asset_name() + ) + }] + }); + assert!(release_asset_url(&rel, &asset_name()).is_ok()); + + let evil = serde_json::json!({ + "assets": [{ + "name": asset_name(), + "browser_download_url": "https://example.invalid/rail" + }] + }); + assert!(release_asset_url(&evil, &asset_name()).is_err()); + } +} diff --git a/src/worker.rs b/src/worker.rs index 67019ea..6020c4b 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,3 +1,5 @@ +use crate::distill; +use crate::process_tree::{self, CleanupReport, RunGuard, RUN_TOKEN_ENV}; use crate::protocol::{self, ClientFrame}; use crate::state::{ self, SessionState, STATUS_EXITED, STATUS_FAILED, STATUS_RUNNING, STATUS_STOPPING, @@ -7,17 +9,124 @@ use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; use std::collections::HashSet; use std::fs::{self, OpenOptions}; use std::io::{self, BufRead, Read, Seek, SeekFrom, Write}; -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::net::Shutdown; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::process; -use std::sync::mpsc::{self, Sender}; +use std::sync::mpsc::{self, SyncSender}; use std::thread; use std::time::{Duration, Instant}; const INITIAL_ROWS: u16 = 24; const INITIAL_COLS: u16 = 80; const TAIL_BYTES: usize = 96 * 1024; +const MAX_LOG_BYTES: u64 = 32 * 1024 * 1024; +const RETAIN_LOG_BYTES: u64 = 4 * 1024 * 1024; +const MAX_ACCEPTS_PER_TICK: usize = 8; +const CLIENT_IO_TIMEOUT: Duration = Duration::from_millis(500); +const BRACKETED_PASTE_ENABLE: &[u8] = b"\x1b[?2004h"; +const BRACKETED_PASTE_DISABLE: &[u8] = b"\x1b[?2004l"; +const SCREEN_CLEAR: &[u8] = b"\x1b[2J"; +const SCREEN_CLEAR_SCROLLBACK: &[u8] = b"\x1b[3J"; +const ALT_SCREEN_ENTER: &[u8] = b"\x1b[?1049h"; +const ALT_SCREEN_LEAVE: &[u8] = b"\x1b[?1049l"; +const ALT_SCREEN_1047_ENTER: &[u8] = b"\x1b[?1047h"; +const ALT_SCREEN_1047_LEAVE: &[u8] = b"\x1b[?1047l"; +const CODEX_COMPOSER_GLYPH: &[u8] = "›".as_bytes(); +const COMPOSER_QUIET_TIME: Duration = Duration::from_millis(150); +const CONTROL_QUEUE_CAPACITY: usize = 32; +const PTY_QUEUE_CAPACITY: usize = 64; + +fn initial_prompt_ready_timeout() -> Duration { + Duration::from_secs( + std::env::var("CODEX_RAIL_PROMPT_READY_TIMEOUT_SECS") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(60), + ) +} + +#[derive(Default)] +struct TuiReadiness { + pending: Vec, + paste_enabled: bool, + composer_seen: bool, + last_output_at: Option, +} + +impl TuiReadiness { + fn observe(&mut self, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + // The composer is usable only after the whole screen has settled, not + // merely 150ms after the first prompt-looking glyph. A loading status + // or dialog can redraw after `›`; every later PTY byte therefore + // restarts the quiet window while keeping the candidate latched. + self.last_output_at = Some(Instant::now()); + self.pending.extend_from_slice(bytes); + let patterns = [ + BRACKETED_PASTE_ENABLE, + BRACKETED_PASTE_DISABLE, + SCREEN_CLEAR, + SCREEN_CLEAR_SCROLLBACK, + ALT_SCREEN_ENTER, + ALT_SCREEN_LEAVE, + ALT_SCREEN_1047_ENTER, + ALT_SCREEN_1047_LEAVE, + CODEX_COMPOSER_GLYPH, + ]; + let mut cursor = 0; + while cursor < self.pending.len() { + let rest = &self.pending[cursor..]; + if rest.starts_with(BRACKETED_PASTE_ENABLE) { + self.paste_enabled = true; + self.composer_seen = false; + cursor += BRACKETED_PASTE_ENABLE.len(); + } else if rest.starts_with(BRACKETED_PASTE_DISABLE) { + self.paste_enabled = false; + self.composer_seen = false; + cursor += BRACKETED_PASTE_DISABLE.len(); + } else if let Some(reset) = [ + SCREEN_CLEAR, + SCREEN_CLEAR_SCROLLBACK, + ALT_SCREEN_ENTER, + ALT_SCREEN_LEAVE, + ALT_SCREEN_1047_ENTER, + ALT_SCREEN_1047_LEAVE, + ] + .into_iter() + .find(|sequence| rest.starts_with(sequence)) + { + self.composer_seen = false; + cursor += reset.len(); + } else if rest.starts_with(CODEX_COMPOSER_GLYPH) { + if self.paste_enabled { + self.composer_seen = true; + } + cursor += CODEX_COMPOSER_GLYPH.len(); + } else if patterns.iter().any(|pattern| pattern.starts_with(rest)) { + break; // a control sequence split across PTY reads + } else { + cursor += 1; + } + } + self.pending.drain(..cursor); + if self.pending.len() > 32 { + let keep_from = self.pending.len() - 32; + self.pending.drain(..keep_from); + } + } + + fn ready(&self) -> bool { + self.paste_enabled + && self.composer_seen + && self + .last_output_at + .is_some_and(|at| at.elapsed() >= COMPOSER_QUIET_TIME) + } +} pub fn run_worker(id: &str) -> Result<()> { state::validate_session_id(id)?; @@ -26,6 +135,7 @@ pub fn run_worker(id: &str) -> Result<()> { } state::ensure_base_dirs()?; + let subreaper_enabled = process_tree::enable_subreaper(); // Hold one kernel-backed lock for the worker's entire lifetime. Two manager // processes can race to resume the same stopped/imported session; without // this guard they would start two `codex resume` writers and the later worker @@ -35,8 +145,11 @@ pub fn run_worker(id: &str) -> Result<()> { // outcome: do not mark the winning worker's session failed. return Ok(()); }; + let Some(_socket_lock) = state::try_acquire_socket_lock(id)? else { + return Ok(()); + }; - match run_worker_inner(id) { + match run_worker_inner(id, subreaper_enabled) { Ok(()) => Ok(()), Err(err) => { mark_failed_if_owner(id, &format!("{err:#}")); @@ -45,9 +158,106 @@ pub fn run_worker(id: &str) -> Result<()> { } } -fn run_worker_inner(id: &str) -> Result<()> { +fn run_worker_inner(id: &str, subreaper_enabled: bool) -> Result<()> { let mut session = state::read_state(id)?; - let state_before_claim = session.clone(); + let _distill_run_lock = if session.distill_version.is_some() { + Some(distill::worker_run_lock().context("claim distillation lifetime lock")?) + } else { + None + }; + if session.codex_session_id.is_none() { + if let Some((codex_id, rollout)) = state::claimed_rollout_for_session(id) { + session.codex_session_id = Some(codex_id); + session.codex_rollout_path = Some(rollout); + persist_state(&session).context("recover atomically claimed Codex rollout")?; + } + } + if session.initial_prompt_injecting { + let report = session + .worker_token + .as_deref() + .map(|token| { + process_tree::terminate_generation_by_token( + token, + Duration::from_secs(2), + Duration::from_secs(2), + ) + }) + .unwrap_or(CleanupReport { + survivors: 1, + verified: false, + ..CleanupReport::default() + }); + session.status = STATUS_FAILED.to_string(); + session.worker_pid = None; + if report.is_clean() { + session.child_pid = None; + session.worker_token = None; + } + session.initial_prompt = None; + session.initial_prompt_injecting = false; + session.updated_at = state::now_secs(); + session.last_error = Some(if report.is_clean() { + "initial prompt delivery was interrupted and may have been submitted; the abandoned process generation was cleaned, so inspect the transcript before resending" + .to_string() + } else { + format!( + "initial prompt delivery was interrupted and cleanup could not verify the abandoned generation ({} survivor(s)); refusing to start another Codex", + report.survivors + ) + }); + persist_state(&session)?; + if report.is_clean() { + return Ok(()); + } + anyhow::bail!("interrupted initial prompt generation cleanup was not verified"); + } + if session.codex_session_id.is_some() && session.initial_prompt.is_some() { + let Some(rollout) = session.codex_rollout_path.as_deref() else { + session.status = STATUS_FAILED.to_string(); + session.last_error = Some( + "pending initial prompt belongs to a resumed Codex session but its rollout path is unknown; refusing an unverified resend" + .to_string(), + ); + session.updated_at = state::now_secs(); + persist_state(&session)?; + return Ok(()); + }; + let rollout_matches_session = state::rollout_head(Path::new(rollout)) + .map(|(_, rollout_id)| Some(rollout_id) == session.codex_session_id) + .unwrap_or(false); + if !rollout_matches_session { + session.status = STATUS_FAILED.to_string(); + session.last_error = Some( + "pending initial prompt rollout does not match the persisted Codex session id; refusing an unverified resend" + .to_string(), + ); + session.updated_at = state::now_secs(); + persist_state(&session)?; + return Ok(()); + } + match state::rollout_has_genuine_user_message(Path::new(rollout)) { + Ok(true) => { + // A human or older worker already submitted a genuine turn. + // Clear the stale pending copy rather than duplicate it. + session.initial_prompt = None; + persist_state(&session)?; + } + Ok(false) => { + // Definitely never submitted: keep it pending and inject only + // after the resumed TUI's real composer is ready. + } + Err(err) => { + session.status = STATUS_FAILED.to_string(); + session.last_error = Some(format!( + "cannot safely decide whether the pending initial prompt was already submitted: {err:#}" + )); + session.updated_at = state::now_secs(); + persist_state(&session)?; + return Ok(()); + } + } + } let socket_path = state::socket_path(id); if socket_path.exists() { // Compatibility with workers started by an older rail build (which do @@ -66,19 +276,6 @@ fn run_worker_inner(id: &str) -> Result<()> { } } - // No live legacy listener and we own worker.lock: claim runtime state now so - // every subsequent setup failure is attributable and can be persisted. - session.socket = socket_path.to_string_lossy().to_string(); - session.worker_pid = Some(process::id()); - session.child_pid = None; - session.worker_lock_protocol = true; - session.worker_token = Some(state::new_session_id()); - session.status = crate::state::STATUS_STARTING.to_string(); - session.exit_code = None; - session.last_error = None; - session.updated_at = state::now_secs(); - persist_state(&session)?; - if socket_path.exists() { let meta = fs::symlink_metadata(&socket_path) .with_context(|| format!("inspect stale socket {}", socket_path.display()))?; @@ -98,21 +295,12 @@ fn run_worker_inner(id: &str) -> Result<()> { if err.raw_os_error() == Some(libc::EADDRINUSE) && UnixStream::connect(&socket_path).is_ok() => { - // A legacy worker can bind in the narrow probe→bind window. Undo our - // provisional claim only if nobody else has already overwritten it, - // then treat that legacy listener as the idempotent winner. - if state::read_state(id) - .ok() - .and_then(|s| s.worker_pid) - == Some(process::id()) - { - state::write_state(&state_before_claim).ok(); - } + // The socket bind is the final atomic ownership boundary. We have + // not claimed state yet, so losing this race cannot clobber the + // listener's pid/token even if a data-dir flock was unreliable. return Ok(()); } - Err(err) => { - return Err(err).with_context(|| format!("bind {}", socket_path.display())) - } + Err(err) => return Err(err).with_context(|| format!("bind {}", socket_path.display())), }; state::restrict_file_to_owner(&socket_path)?; let socket_identity = fs::symlink_metadata(&socket_path) @@ -122,6 +310,19 @@ fn run_worker_inner(id: &str) -> Result<()> { .set_nonblocking(true) .context("set listener nonblocking")?; + // Only the canonical socket winner may claim runtime state. + session.socket = socket_path.to_string_lossy().to_string(); + session.worker_pid = Some(process::id()); + session.child_pid = None; + session.worker_lock_protocol = true; + let run_token = state::new_session_id(); + session.worker_token = Some(run_token.clone()); + session.status = crate::state::STATUS_STARTING.to_string(); + session.exit_code = None; + session.last_error = None; + session.updated_at = state::now_secs(); + persist_state(&session)?; + let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { @@ -135,32 +336,29 @@ fn run_worker_inner(id: &str) -> Result<()> { let mut cmd = CommandBuilder::new(&session.codex); cmd.cwd(Path::new(&session.cwd)); cmd.env("TERM", "xterm-256color"); + // Every process in this worker generation inherits a unique ownership + // marker. MCP servers routinely create their own process groups/sessions, + // so PGID alone is not a complete cleanup boundary. + cmd.env(RUN_TOKEN_ENV, &run_token); // Extra flags (e.g. a distill session's `-C

-s workspace-write` plus a // trust override) go BEFORE the prompt/resume args so codex parses them as // options. Empty for ordinary sessions. for a in &session.codex_args { cmd.arg(a); } - // Consume the first-message prompt exactly once: take() clears it from the - // in-memory session so the write_state below persists None and a later - // resume/restart won't replay it. - let initial_prompt = session.initial_prompt.take(); + // Prompts are deliberately NOT argv: `/proc//cmdline` is commonly + // world-readable and distill/pilot prompts can contain private transcript + // context. Fresh-session prompts are injected through the owned PTY once + // the TUI has started; resume already has its conversation and never replays + // a leftover first prompt. + let mut initial_prompt = session.initial_prompt.clone(); let is_resume = match &session.codex_session_id { Some(codex_session_id) => { cmd.arg("resume"); cmd.arg(codex_session_id); true } - None => { - // Fresh session: hand codex the first message as a positional - // prompt so it starts the first turn on spawn (interactive TUI). - // codex writes its rollout as soon as that turn begins, which is - // what lets the manager capture the path and read accurate status. - if let Some(prompt) = &initial_prompt { - cmd.arg(prompt); - } - false - } + None => false, }; // Snapshot codex's existing rollout files BEFORE spawning, so the watcher @@ -176,68 +374,151 @@ fn run_worker_inner(id: &str) -> Result<()> { Some(before.into_iter().collect()) }; + let (tx, rx) = mpsc::sync_channel(CONTROL_QUEUE_CAPACITY); + let (pty_tx, pty_rx) = mpsc::sync_channel(PTY_QUEUE_CAPACITY); + let mut run_guard = RunGuard::new(run_token, subreaper_enabled); let mut child = pair .slave .spawn_command(cmd) .with_context(|| format!("spawn {}", session.codex))?; let child_pid = child.process_id(); + run_guard.track_root(child_pid); drop(pair.slave); + // Install the waiter immediately after spawn. Any later fallible setup is + // protected by run_guard's Drop cleanup, while this thread reaps the PTY + // root and reports its exit to the worker loop. + { + let tx = tx.clone(); + thread::spawn(move || { + let outcome = match child.wait() { + Ok(status) => ChildOutcome { + exit_code: Some(i32::try_from(status.exit_code()).unwrap_or(1)), + success: status.success(), + wait_error: None, + }, + Err(err) => ChildOutcome { + exit_code: None, + success: false, + wait_error: Some(err.to_string()), + }, + }; + tx.send(WorkerEvent::ChildExit(outcome)).ok(); + }); + } + session.child_pid = child_pid; session.status = STATUS_RUNNING.to_string(); session.updated_at = state::now_secs(); persist_state(&session)?; let pty_reader = pair.master.try_clone_reader().context("clone pty reader")?; - let mut pty_writer = pair.master.take_writer().context("take pty writer")?; + let mut pty_writer = Some(pair.master.take_writer().context("take pty writer")?); let log_path = state::log_path(id); let mut log = OpenOptions::new() .create(true) + .read(true) .append(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) .open(&log_path) .context("open output log")?; - state::restrict_file_to_owner(&log_path)?; - - let (tx, rx) = mpsc::channel(); - spawn_pty_reader(pty_reader, tx.clone()); - { - let tx = tx.clone(); - thread::spawn(move || { - let _ = child.wait(); - tx.send(WorkerEvent::ChildExit).ok(); - }); + if !log.metadata()?.is_file() { + anyhow::bail!("output log is not a regular file: {}", log_path.display()); } + log.set_permissions(fs::Permissions::from_mode(0o600))?; + let mut log_bytes = log.metadata().map(|meta| meta.len()).unwrap_or(0); + + spawn_pty_reader(pty_reader, pty_tx, tx.clone()); if let Some(before) = codex_sessions_before { - spawn_session_id_watcher(tx.clone(), before, session.cwd.clone()); + spawn_session_id_watcher( + tx.clone(), + before, + session.cwd.clone(), + session.id.clone(), + child_pid, + initial_prompt + .as_deref() + .map(protocol::sanitize_submission_text), + ); } - let mut attached: Option<(u64, UnixStream)> = None; + let mut attached: Option = None; let mut next_client_id = 1_u64; - let mut stop_requested_at: Option = None; let mut last_output_persisted_at: Option = None; + let prompt_wait_started = Instant::now(); + let prompt_ready_timeout = initial_prompt_ready_timeout(); + let mut tui_readiness = TuiReadiness::default(); + let mut last_distill_completion_check = Instant::now(); + let mut rollout_lifecycle = RolloutLifecycle::default(); + // After a headless injection is acknowledged, do not accept another one + // until the rollout proves Codex started a successor turn. This closes the + // small DELIVERED -> task_started window where a second client could + // otherwise submit a duplicate reply against the same waiting marker. + let mut injected_before_started_turn: Option = None; const OUTPUT_PERSIST_INTERVAL: Duration = Duration::from_secs(2); loop { - accept_connections( + // Remember descendants while their PPID links are intact. Shutdown also + // performs a full environment-token census, so helpers that later setsid + // or reparent remain in this generation's ownership set. + run_guard.refresh_if_due(Duration::from_secs(2)); + let lifecycle_waiting = session + .codex_rollout_path + .as_deref() + .and_then(|path| rollout_lifecycle.scan(Path::new(path))); + if injected_before_started_turn + .is_some_and(|baseline| rollout_lifecycle.started_turns > baseline) + { + injected_before_started_turn = None; + } + let injection_ready = lifecycle_waiting == Some(true) + && injected_before_started_turn.is_none() + && !session.initial_prompt_injecting + && session.initial_prompt.is_none(); + if accept_connections( &listener, &tx, &mut attached, &mut next_client_id, &*pair.master, &session, - )?; + injection_ready, + )? { + return stop_run(&mut session, &mut run_guard, socket_identity); + } - match rx.recv_timeout(Duration::from_millis(80)) { - Ok(WorkerEvent::PtyOutput(bytes)) => { - log.write_all(&bytes).ok(); + let event = match rx.try_recv() { + Ok(event) => Some(event), + Err(mpsc::TryRecvError::Disconnected) => break, + Err(mpsc::TryRecvError::Empty) => { + match pty_rx.recv_timeout(Duration::from_millis(80)) { + Ok(bytes) => Some(WorkerEvent::PtyOutput(bytes)), + Err(mpsc::RecvTimeoutError::Timeout) => None, + Err(mpsc::RecvTimeoutError::Disconnected) => None, + } + } + }; + match event { + Some(WorkerEvent::PtyOutput(bytes)) => { + tui_readiness.observe(&bytes); + if log_bytes.saturating_add(bytes.len() as u64) > MAX_LOG_BYTES { + if compact_output_log(&mut log, RETAIN_LOG_BYTES).is_ok() { + log_bytes = log.metadata().map(|meta| meta.len()).unwrap_or(0); + } + } + if log.write_all(&bytes).is_ok() { + log_bytes = log_bytes.saturating_add(bytes.len() as u64); + } log.flush().ok(); - if let Some((_, client)) = attached.as_mut() { + if let Some(client) = attached.as_mut().filter(|client| !client.headless) { if client + .stream .write_all(&bytes) - .and_then(|_| client.flush()) + .and_then(|_| client.stream.flush()) .is_err() { - attached = None; + disconnect_attached(&mut attached); } } // Coarse busy/idle signal for the manager UI: throttled so a @@ -252,27 +533,70 @@ fn run_worker_inner(id: &str) -> Result<()> { last_output_persisted_at = Some(Instant::now()); } } - Ok(WorkerEvent::PtyEof) => {} - Ok(WorkerEvent::CodexSessionId { id, path }) => { + Some(WorkerEvent::PtyEof) => {} + Some(WorkerEvent::CodexSessionId { id, path }) => { session.codex_session_id = Some(id); session.codex_rollout_path = Some(path.to_string_lossy().to_string()); session.updated_at = state::now_secs(); - persist_state(&session).ok(); + persist_state(&session).context("persist claimed Codex rollout identity")?; } - Ok(WorkerEvent::ChildExit) => { - session.status = STATUS_EXITED.to_string(); - session.updated_at = state::now_secs(); - persist_state(&session).ok(); - remove_socket_if_same(Path::new(&session.socket), socket_identity); - return Ok(()); + Some(WorkerEvent::ChildExit(outcome)) => { + run_guard.note_root_exited(); + let report = run_guard.terminate(Duration::from_secs(1), Duration::from_secs(2)); + return finish_run( + &mut session, + report, + FinishCause::Natural(outcome), + socket_identity, + ); } - Ok(WorkerEvent::ClientInput(client_id, bytes)) => { + Some(WorkerEvent::ClientInput(client_id, bytes)) => { if is_current_client(&attached, client_id) { - pty_writer.write_all(&bytes).ok(); - pty_writer.flush().ok(); + let headless = attached + .as_ref() + .is_some_and(|client| client.id == client_id && client.headless); + let delivered = pty_writer + .as_mut() + .map(|writer| writer.write_all(&bytes).and_then(|_| writer.flush())) + .transpose() + .and_then(|result| { + result.ok_or_else(|| { + io::Error::new(io::ErrorKind::WouldBlock, "PTY writer is busy") + }) + }) + .is_ok(); + if headless && delivered { + injected_before_started_turn = Some(rollout_lifecycle.started_turns); + } + if let Some(client) = attached.as_mut().filter(|c| c.id == client_id) { + if client.headless { + let response: &[u8] = if delivered { + b"DELIVERED\n" + } else { + b"FAILED\n" + }; + if client + .stream + .write_all(response) + .and_then(|_| client.stream.flush()) + .is_err() + { + disconnect_attached(&mut attached); + } + } else if !delivered { + disconnect_attached(&mut attached); + } + } + // INJECT is a one-frame protocol. Closing immediately + // after the acknowledgement prevents a malicious or buggy + // peer from streaming a second logical submission through + // the already-authorized headless slot. + if headless { + disconnect_attached(&mut attached); + } } } - Ok(WorkerEvent::ClientResize(client_id, rows, cols)) => { + Some(WorkerEvent::ClientResize(client_id, rows, cols)) => { if is_current_client(&attached, client_id) { pair.master .resize(PtySize { @@ -284,28 +608,91 @@ fn run_worker_inner(id: &str) -> Result<()> { .ok(); } } - Ok(WorkerEvent::ClientDetach(client_id)) | Ok(WorkerEvent::ClientGone(client_id)) => { + Some(WorkerEvent::ClientDetach(client_id)) + | Some(WorkerEvent::ClientGone(client_id)) => { if is_current_client(&attached, client_id) { attached = None; } } - Ok(WorkerEvent::Stop) => { - begin_stop(&mut session, child_pid, &mut stop_requested_at); + Some(WorkerEvent::InitialPromptWritten { writer, error }) => { + pty_writer = Some(writer); + if let Some(error) = error { + anyhow::bail!("write initial prompt: {error}"); + } + session.initial_prompt = None; + session.initial_prompt_injecting = false; + persist_state(&session).context("record initial prompt delivery")?; } - Err(mpsc::RecvTimeoutError::Timeout) => {} - Err(mpsc::RecvTimeoutError::Disconnected) => break, + None => {} } - if stop_requested_at.is_none() - && state::take_worker_stop_request(&session).unwrap_or(false) - { - begin_stop(&mut session, child_pid, &mut stop_requested_at); + if state::take_worker_stop_request(&session).unwrap_or(false) { + return stop_run(&mut session, &mut run_guard, socket_identity); + } + + if tui_readiness.ready() && initial_prompt.is_some() { + session.initial_prompt_injecting = true; + persist_state(&session).context("record initial prompt delivery intent")?; + let prompt = initial_prompt.take().expect("checked above"); + let mut writer = pty_writer.take().context("initial prompt writer is busy")?; + let tx = tx.clone(); + thread::spawn(move || { + let error = write_initial_prompt(&mut *writer, &prompt) + .err() + .map(|err| err.to_string()); + tx.send(WorkerEvent::InitialPromptWritten { writer, error }) + .ok(); + }); } - if let Some(started) = stop_requested_at { - if started.elapsed() > Duration::from_secs(5) { - signal_child(child_pid, libc::SIGKILL); - stop_requested_at = Some(Instant::now()); + if initial_prompt.is_some() && prompt_wait_started.elapsed() >= prompt_ready_timeout { + anyhow::bail!( + "Codex composer did not become ready for the private initial prompt within {}s; attach to resolve any startup dialog, then retry", + prompt_ready_timeout.as_secs() + ); + } + + if session.distill_version.is_some() + && last_distill_completion_check.elapsed() >= Duration::from_secs(1) + { + last_distill_completion_check = Instant::now(); + match distill_run_completion(&session, lifecycle_waiting == Some(true)) { + DistillRunCompletion::Pending => {} + DistillRunCompletion::Complete => { + let Some(corpus) = session.distill_corpus_rel.clone() else { + return fail_run( + &mut session, + &mut run_guard, + socket_identity, + "validated distillation has no persisted private corpus path", + ); + }; + if let Err(err) = distill::cleanup_run_corpus(&corpus) { + return fail_run( + &mut session, + &mut run_guard, + socket_identity, + &format!( + "distillation output validated but private corpus cleanup failed: {err:#}" + ), + ); + } + if let Some(version) = session.distill_version { + distill::mark_output_validated(version) + .context("persist distillation validation marker")?; + } + session.distill_validated = true; + persist_state(&session).context("record validated distillation output")?; + return stop_run(&mut session, &mut run_guard, socket_identity); + } + DistillRunCompletion::Invalid(reason) => { + return fail_run( + &mut session, + &mut run_guard, + socket_identity, + &format!("distillation output failed coverage validation: {reason}"), + ); + } } } } @@ -313,19 +700,313 @@ fn run_worker_inner(id: &str) -> Result<()> { Ok(()) } -fn begin_stop( - session: &mut SessionState, - child_pid: Option, - stop_requested_at: &mut Option, -) { - if stop_requested_at.is_some() { - return; +fn write_initial_prompt(writer: &mut dyn Write, prompt: &str) -> io::Result<()> { + let wire = protocol::bracketed_submission(prompt); + writer.write_all(&wire)?; + writer.flush() +} + +enum DistillRunCompletion { + Pending, + Complete, + Invalid(String), +} + +fn distill_run_completion(session: &SessionState, rollout_waiting: bool) -> DistillRunCompletion { + let (Some(version), Some(_rollout)) = ( + session.distill_version, + session.codex_rollout_path.as_deref(), + ) else { + return DistillRunCompletion::Pending; + }; + if !rollout_waiting { + return DistillRunCompletion::Pending; + } + let Some(expected_turns) = session.distill_expected_user_turns else { + return DistillRunCompletion::Invalid( + "legacy distillation has no persisted coverage contract; output requires manual review" + .to_string(), + ); + }; + let output = state::distill_dir().join(format!("style-v{version:03}.md")); + match distill::validate_output_contract( + &output, + &session.distill_expected_markers, + expected_turns, + ) { + Ok(()) => DistillRunCompletion::Complete, + Err(err) => DistillRunCompletion::Invalid(format!("{err:#}")), + } +} + +#[derive(Default)] +struct RolloutLifecycle { + path: PathBuf, + offset: u64, + last_waiting: Option, + partial_record: Vec, + discarding_oversized_record: bool, + caught_up: bool, + started_turns: u64, +} + +impl RolloutLifecycle { + /// Incrementally consume the exact rollout from byte zero. Returning None + /// means fail-closed/busy: I/O failed, no lifecycle marker exists, or the + /// bounded scanner has not caught up yet. Unlike a fixed tail read, this + /// latches the last marker across arbitrarily large unrelated records. + fn scan(&mut self, path: &Path) -> Option { + if self.path != path { + self.path = path.to_path_buf(); + self.reset(); + } + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + .ok()?; + let meta = file.metadata().ok()?; + if !meta.is_file() { + return None; + } + let len = meta.len(); + if len < self.offset { + self.reset(); + } + if len > self.offset { + file.seek(SeekFrom::Start(self.offset)).ok()?; + const MAX_RECORD_BYTES: usize = 1024 * 1024; + const MAX_SCAN_BYTES_PER_TICK: usize = 8 * 1024 * 1024; + let mut scanned = 0usize; + let mut buf = [0_u8; 64 * 1024]; + while scanned < MAX_SCAN_BYTES_PER_TICK { + let want = buf.len().min(MAX_SCAN_BYTES_PER_TICK - scanned); + let read = file.read(&mut buf[..want]).ok()?; + if read == 0 { + break; + } + scanned += read; + self.offset = self.offset.saturating_add(read as u64); + for byte in &buf[..read] { + if self.discarding_oversized_record { + if *byte == b'\n' { + self.discarding_oversized_record = false; + } + continue; + } + if *byte == b'\n' { + self.observe_record(); + self.partial_record.clear(); + } else if self.partial_record.len() < MAX_RECORD_BYTES { + self.partial_record.push(*byte); + } else { + self.partial_record.clear(); + self.discarding_oversized_record = true; + } + } + } + } + self.caught_up = self.offset >= len + && self.partial_record.is_empty() + && !self.discarding_oversized_record; + if self.caught_up { + self.last_waiting + } else { + None + } + } + + fn reset(&mut self) { + self.offset = 0; + self.last_waiting = None; + self.partial_record.clear(); + self.discarding_oversized_record = false; + self.caught_up = false; + self.started_turns = 0; + } + + fn observe_record(&mut self) { + let Ok(value) = serde_json::from_slice::(&self.partial_record) else { + return; + }; + match value + .get("payload") + .and_then(|payload| payload.get("type")) + .and_then(|kind| kind.as_str()) + { + Some("task_started") => { + self.last_waiting = Some(false); + self.started_turns = self.started_turns.saturating_add(1); + } + Some("task_complete" | "turn_aborted" | "thread_rolled_back") => { + self.last_waiting = Some(true); + } + _ => {} + } } +} + +fn stop_run( + session: &mut SessionState, + run_guard: &mut RunGuard, + socket_identity: (u64, u64), +) -> Result<()> { session.status = STATUS_STOPPING.to_string(); session.updated_at = state::now_secs(); persist_state(session).ok(); - signal_child(child_pid, libc::SIGTERM); - *stop_requested_at = Some(Instant::now()); + let report = run_guard.terminate(Duration::from_secs(5), Duration::from_secs(2)); + finish_run(session, report, FinishCause::Stopped, socket_identity) +} + +fn fail_run( + session: &mut SessionState, + run_guard: &mut RunGuard, + socket_identity: (u64, u64), + reason: &str, +) -> Result<()> { + session.status = STATUS_STOPPING.to_string(); + session.updated_at = state::now_secs(); + persist_state(session).ok(); + let report = run_guard.terminate(Duration::from_secs(5), Duration::from_secs(2)); + session.status = STATUS_FAILED.to_string(); + session.updated_at = state::now_secs(); + session.worker_pid = None; + session.initial_prompt = None; + session.initial_prompt_injecting = false; + session.last_error = Some(if report.is_clean() { + reason.to_string() + } else { + format!( + "{reason}; process cleanup incomplete: {} survivor(s), census verified={}", + report.survivors, report.verified + ) + }); + if report.is_clean() { + session.child_pid = None; + session.worker_token = None; + } + let persisted = persist_state(session); + remove_socket_if_same(Path::new(&session.socket), socket_identity); + persisted?; + if report.is_clean() { + Ok(()) + } else { + anyhow::bail!("{reason}; generation cleanup was not verified") + } +} + +fn finish_run( + session: &mut SessionState, + report: CleanupReport, + cause: FinishCause, + socket_identity: (u64, u64), +) -> Result<()> { + let disposition = classify_finish(report, cause); + let prompt_delivery_uncertain = session.initial_prompt_injecting; + session.updated_at = state::now_secs(); + session.status = if prompt_delivery_uncertain { + STATUS_FAILED.to_string() + } else { + disposition.status.to_string() + }; + session.exit_code = disposition.exit_code; + session.last_error = if prompt_delivery_uncertain { + let warning = "initial prompt delivery was interrupted and may have been submitted"; + Some(match disposition.last_error { + Some(error) => format!("{warning}; {error}"), + None => warning.to_string(), + }) + } else { + disposition.last_error + }; + session.worker_pid = None; + session.initial_prompt = if prompt_delivery_uncertain { + None + } else { + session.initial_prompt.take() + }; + session.initial_prompt_injecting = false; + if report.is_clean() { + session.child_pid = None; + session.worker_token = None; + } + let persisted = persist_state(session); + remove_socket_if_same(Path::new(&session.socket), socket_identity); + persisted?; + if !disposition.cleanup_incomplete { + Ok(()) + } else { + anyhow::bail!( + "process cleanup incomplete after {} TERM and {} KILL signals: {} survivor(s)", + report.term_signals, + report.kill_signals, + report.survivors + ) + } +} + +#[derive(Debug)] +struct ChildOutcome { + exit_code: Option, + success: bool, + wait_error: Option, +} + +enum FinishCause { + Natural(ChildOutcome), + Stopped, +} + +struct FinishDisposition { + status: &'static str, + exit_code: Option, + last_error: Option, + cleanup_incomplete: bool, +} + +fn classify_finish(report: CleanupReport, cause: FinishCause) -> FinishDisposition { + let cleanup_incomplete = !report.is_clean(); + let (exit_code, child_error) = match cause { + FinishCause::Stopped => (None, None), + FinishCause::Natural(outcome) if outcome.success => (outcome.exit_code, None), + FinishCause::Natural(outcome) => { + let error = outcome + .wait_error + .unwrap_or_else(|| match outcome.exit_code { + Some(code) => format!("codex exited with status {code}"), + None => "codex exited unsuccessfully without a status code".to_string(), + }); + (outcome.exit_code, Some(error)) + } + }; + + let cleanup_error = if report.survivors > 0 { + Some(format!( + "process cleanup left {} generation-owned process{} alive", + report.survivors, + if report.survivors == 1 { "" } else { "es" } + )) + } else if cleanup_incomplete { + Some("process cleanup could not verify the owned generation boundary".to_string()) + } else { + None + }; + let last_error = match (child_error, cleanup_error) { + (Some(child), Some(cleanup)) => Some(format!("{child}; {cleanup}")), + (Some(error), None) | (None, Some(error)) => Some(error), + (None, None) => None, + }; + + FinishDisposition { + status: if last_error.is_some() { + STATUS_FAILED + } else { + STATUS_EXITED + }, + exit_code, + last_error, + cleanup_incomplete, + } } fn remove_socket_if_same(path: &Path, expected: (u64, u64)) { @@ -339,20 +1020,22 @@ fn remove_socket_if_same(path: &Path, expected: (u64, u64)) { fn accept_connections( listener: &UnixListener, - tx: &Sender, - attached: &mut Option<(u64, UnixStream)>, + tx: &SyncSender, + attached: &mut Option, next_client_id: &mut u64, master: &dyn MasterPty, session: &SessionState, -) -> Result<()> { - loop { + injection_ready: bool, +) -> Result { + for _ in 0..MAX_ACCEPTS_PER_TICK { let (mut stream, _) = match listener.accept() { Ok(pair) => pair, - Err(err) if err.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(err) if err.kind() == io::ErrorKind::WouldBlock => return Ok(false), Err(err) => return Err(err).context("accept client"), }; - stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + stream.set_read_timeout(Some(CLIENT_IO_TIMEOUT)).ok(); + stream.set_write_timeout(Some(CLIENT_IO_TIMEOUT)).ok(); let line = match protocol::read_line(&mut stream) { Ok(line) => line, Err(_) => continue, @@ -391,25 +1074,98 @@ fn accept_connections( let client_id = *next_client_id; *next_client_id += 1; - send_log_tail(&mut stream, &session.id).ok(); + if send_log_tail(&mut stream, &session.id).is_err() { + let _ = stream.shutdown(Shutdown::Both); + continue; + } let reader = stream.try_clone().context("clone client stream")?; spawn_client_reader(reader, client_id, tx.clone()); - *attached = Some((client_id, stream)); + *attached = Some(AttachedClient { + id: client_id, + stream, + headless: false, + }); + } + Some("INJECT") => { + if attached.is_some() || !injection_ready { + stream.write_all(b"BUSY\n").ok(); + stream.flush().ok(); + continue; + } + if stream + .write_all(b"READY\n") + .and_then(|_| stream.flush()) + .is_err() + { + continue; + } + let client_id = *next_client_id; + *next_client_id += 1; + // A headless peer must send its one input frame promptly. A + // client that disappears after READY cannot reserve the attach + // slot forever. + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + let reader = stream.try_clone().context("clone injector stream")?; + spawn_client_reader(reader, client_id, tx.clone()); + *attached = Some(AttachedClient { + id: client_id, + stream, + headless: true, + }); } Some("STOP") => { - tx.send(WorkerEvent::Stop).ok(); + stream.write_all(b"STOPPING\n").ok(); + stream.flush().ok(); + return Ok(true); } _ => {} } } + Ok(false) +} + +struct AttachedClient { + id: u64, + stream: UnixStream, + headless: bool, +} + +fn disconnect_attached(attached: &mut Option) { + if let Some(client) = attached.take() { + let _ = client.stream.shutdown(Shutdown::Both); + } +} + +fn compact_output_log(log: &mut fs::File, retain: u64) -> io::Result<()> { + let len = log.metadata()?.len(); + if len <= retain { + return Ok(()); + } + let start = len.saturating_sub(retain); + let mut reader = log.try_clone()?; + reader.seek(SeekFrom::Start(start))?; + let mut tail = Vec::with_capacity(retain as usize); + reader.read_to_end(&mut tail)?; + let tail = clean_tail_start(&tail, start > 0); + log.set_len(0)?; + log.write_all(b"\x1b[0m\r\n[rail] earlier output truncated\r\n")?; + log.write_all(tail)?; + log.flush() } fn send_log_tail(stream: &mut UnixStream, id: &str) -> Result<()> { let path = state::log_path(id); - let mut file = match fs::File::open(&path) { + let mut file = match OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + { Ok(file) => file, Err(_) => return Ok(()), }; + if !file.metadata()?.is_file() { + anyhow::bail!("output log is not a regular file: {}", path.display()); + } let len = file.metadata().map(|m| m.len()).unwrap_or(0); let start = len.saturating_sub(TAIL_BYTES as u64); if file.seek(SeekFrom::Start(start)).is_err() { @@ -442,23 +1198,27 @@ fn clean_tail_start(buf: &[u8], started_midfile: bool) -> &[u8] { } } -fn spawn_pty_reader(mut reader: Box, tx: Sender) { +fn spawn_pty_reader( + mut reader: Box, + output_tx: SyncSender>, + control_tx: SyncSender, +) { thread::spawn(move || { let mut buf = [0_u8; 8192]; loop { match reader.read(&mut buf) { Ok(0) => { - tx.send(WorkerEvent::PtyEof).ok(); + control_tx.send(WorkerEvent::PtyEof).ok(); break; } Ok(n) => { - if tx.send(WorkerEvent::PtyOutput(buf[..n].to_vec())).is_err() { + if output_tx.send(buf[..n].to_vec()).is_err() { break; } } Err(err) if err.kind() == io::ErrorKind::Interrupted => {} Err(_) => { - tx.send(WorkerEvent::PtyEof).ok(); + control_tx.send(WorkerEvent::PtyEof).ok(); break; } } @@ -466,7 +1226,7 @@ fn spawn_pty_reader(mut reader: Box, tx: Sender) { }); } -fn spawn_client_reader(mut stream: UnixStream, client_id: u64, tx: Sender) { +fn spawn_client_reader(mut stream: UnixStream, client_id: u64, tx: SyncSender) { thread::spawn(move || loop { match protocol::read_client_frame(&mut stream) { Ok(Some(ClientFrame::Input(bytes))) => { @@ -498,80 +1258,167 @@ fn spawn_client_reader(mut stream: UnixStream, client_id: u64, tx: Sender, client_id: u64) -> bool { +fn is_current_client(attached: &Option, client_id: u64) -> bool { attached .as_ref() - .map(|(current, _)| *current == client_id) + .map(|current| current.id == client_id) .unwrap_or(false) } -fn signal_child(pid: Option, signal: libc::c_int) { - if let Some(pid) = pid.and_then(state::checked_pid) { - unsafe { - // Signal the child's whole process GROUP, not just the pid. codex's npm - // launcher runs the real codex binary as a grandchild (and codex spawns - // sub-agent codex); killing only the direct pid leaks them, and leaked - // codex accumulate and lock codex's shared ~/.codex sqlite state. The PTY - // gave the child its own session/group, so -pgid can't hit us — but guard - // against ever signalling our own group just in case. - let pgid = libc::getpgid(pid); - if pgid > 1 && pgid != libc::getpgrp() { - libc::kill(-pgid, signal); - } - libc::kill(pid, signal); - } - } -} - // Correlates a freshly-spawned codex child with the rollout file it writes // under ~/.codex/sessions/, so a later worker restart can `codex resume // ` instead of losing the conversation. This format is undocumented and // reverse-engineered, so it's deliberately best-effort: on any mismatch we // just leave codex_session_id unset and fresh-spawn next time, same as today. -fn spawn_session_id_watcher(tx: Sender, seen: HashSet, cwd: String) { +fn spawn_session_id_watcher( + tx: SyncSender, + seen: HashSet, + cwd: String, + rail_id: String, + child_pid: Option, + expected_first_prompt: Option, +) { thread::spawn(move || { let root = state::codex_sessions_dir(); let mut seen = seen; - // Watch for the whole session, not a fixed 30s: a session started blank - // (no first message) writes no rollout until the user finally types, - // which can be minutes later — the old 30s giveup is exactly why those - // sessions ended up with no rollout path and a path-only row. The thread - // dies with the worker on codex exit, so this wide bound just caps a - // stuck case; a normal prompted session captures within a second or two. + // Prefer the file descriptor actually held by this Codex process. Cwd + // and creation time are not identities: two Rail sessions in one repo + // routinely create rollouts within the same second. let deadline = Instant::now() + Duration::from_secs(6 * 3600); + let started = Instant::now(); + let mut last_tree_scan = Instant::now() - Duration::from_secs(1); + let mut fallback_candidate: Option<(PathBuf, Instant)> = None; while Instant::now() < deadline { - thread::sleep(Duration::from_secs(1)); - let mut current = Vec::new(); - walk_jsonl(&root, 0, &mut current); - for path in current { - if seen.contains(&path) { - continue; - } - // A brand-new rollout appeared. Read its header to confirm codex - // wrote it for OUR cwd before claiming it — concurrently-starting - // sessions each create a rollout, and grabbing the wrong one would - // mislabel status/preview. If the header isn't written yet, leave - // it unseen and retry next tick rather than skip it forever. - let Some((rollout_cwd, _)) = state::rollout_head(&path) else { - continue; - }; - seen.insert(path.clone()); - if rollout_cwd != cwd { - continue; // belongs to another session - } - if let Some(session_id) = extract_codex_session_id(&path) { - tx.send(WorkerEvent::CodexSessionId { - id: session_id, - path, - }) - .ok(); + if let Some(path) = child_pid.and_then(|pid| rollout_open_by_process(pid, &root)) { + if claim_and_report_rollout(&tx, &rail_id, path) { return; } } + + // Portable fallback for a prompted session: require its exact first + // genuine user message and an atomic global path claim. Blank + // sessions never guess; on Linux their open fd is the proof. + if last_tree_scan.elapsed() >= Duration::from_secs(1) { + if let Some(expected) = expected_first_prompt.as_deref() { + last_tree_scan = Instant::now(); + let mut current = Vec::new(); + walk_jsonl(&root, 0, &mut current); + let mut matching = Vec::new(); + for path in current { + if seen.contains(&path) { + continue; + } + let Some((rollout_cwd, _)) = state::rollout_head(&path) else { + continue; + }; + if rollout_cwd != cwd { + seen.insert(path); + continue; + } + match state::rollout_first_user_message(&path) { + Some(first) if first == expected => { + matching.push(path); + } + Some(_) => { + seen.insert(path); + } + None => {} // header exists, first turn not flushed yet + } + } + if matching.len() == 1 { + let path = matching.pop().expect("checked one candidate"); + let stable = fallback_candidate.as_ref().is_some_and(|(prior, at)| { + prior == &path && at.elapsed() >= Duration::from_secs(1) + }); + if stable && claim_and_report_rollout(&tx, &rail_id, path.clone()) { + return; + } + if !fallback_candidate + .as_ref() + .is_some_and(|(prior, _)| prior == &path) + { + fallback_candidate = Some((path, Instant::now())); + } + } else { + // Two identical first prompts are not distinguishable by + // content. Wait for the process-fd proof; never swap them. + fallback_candidate = None; + } + } + } + + let delay = if started.elapsed() < Duration::from_secs(30) { + Duration::from_millis(200) + } else if started.elapsed() < Duration::from_secs(10 * 60) { + Duration::from_secs(2) + } else { + Duration::from_secs(15) + }; + thread::sleep(delay); } }); } +fn claim_and_report_rollout(tx: &SyncSender, rail_id: &str, path: PathBuf) -> bool { + if !state::try_claim_rollout(rail_id, &path).unwrap_or(false) { + return false; + } + let Some(session_id) = extract_codex_session_id(&path) else { + return false; + }; + tx.send(WorkerEvent::CodexSessionId { + id: session_id, + path, + }) + .is_ok() +} + +#[cfg(target_os = "linux")] +fn rollout_open_by_process(pid: u32, root: &Path) -> Option { + let fd_dir = PathBuf::from(format!("/proc/{pid}/fd")); + for entry in fs::read_dir(fd_dir).ok()?.flatten() { + let Ok(path) = fs::read_link(entry.path()) else { + continue; + }; + if path.starts_with(root) + && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") + && state::rollout_head(&path).is_some() + { + return Some(path); + } + } + None +} + +#[cfg(target_os = "macos")] +fn rollout_open_by_process(pid: u32, root: &Path) -> Option { + let output = std::process::Command::new("/usr/sbin/lsof") + .args(["-a", "-p", &pid.to_string(), "-Fn"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some(name) = line.strip_prefix('n') else { + continue; + }; + let path = PathBuf::from(name); + if path.starts_with(root) + && path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") + && state::rollout_head(&path).is_some() + { + return Some(path); + } + } + None +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn rollout_open_by_process(_pid: u32, _root: &Path) -> Option { + None +} + fn walk_jsonl(dir: &Path, depth: u32, out: &mut Vec) { if depth > 4 { return; @@ -659,28 +1506,161 @@ fn mark_failed_if_owner(id: &str, message: &str) { enum WorkerEvent { PtyOutput(Vec), PtyEof, - CodexSessionId { id: String, path: PathBuf }, - ChildExit, + CodexSessionId { + id: String, + path: PathBuf, + }, + ChildExit(ChildOutcome), ClientInput(u64, Vec), ClientResize(u64, u16, u16), ClientDetach(u64), ClientGone(u64), - Stop, + InitialPromptWritten { + writer: Box, + error: Option, + }, } #[cfg(test)] mod tests { - use super::clean_tail_start; + use super::{ + classify_finish, clean_tail_start, compact_output_log, write_initial_prompt, ChildOutcome, + FinishCause, RolloutLifecycle, TuiReadiness, + }; + use crate::process_tree::CleanupReport; + use crate::state::{STATUS_EXITED, STATUS_FAILED}; + use std::fs; + use std::fs::OpenOptions; + use std::io::Write; #[test] fn tail_replay_starts_on_a_clean_line() { // mid-file: drop the partial first line (through the first '\n') - assert_eq!(clean_tail_start(b"tial line\x1b[m\nclean\nrest", true), b"clean\nrest"); + assert_eq!( + clean_tail_start(b"tial line\x1b[m\nclean\nrest", true), + b"clean\nrest" + ); // whole-file read: keep everything, including the first line assert_eq!(clean_tail_start(b"first\nsecond", false), b"first\nsecond"); // mid-file but one giant line with no newline: replay whole, don't drop all - assert_eq!(clean_tail_start(b"no newline at all", true), b"no newline at all"); + assert_eq!( + clean_tail_start(b"no newline at all", true), + b"no newline at all" + ); // mid-file, newline only at the very end: nothing clean to show assert_eq!(clean_tail_start(b"partial\n", true), b""); } + + #[test] + fn natural_nonzero_exit_is_failed_but_an_active_stop_is_not() { + let clean = CleanupReport { + verified: true, + ..CleanupReport::default() + }; + let failed = classify_finish( + clean, + FinishCause::Natural(ChildOutcome { + exit_code: Some(7), + success: false, + wait_error: None, + }), + ); + assert_eq!(failed.status, STATUS_FAILED); + assert_eq!(failed.exit_code, Some(7)); + assert!(failed + .last_error + .as_deref() + .is_some_and(|error| error.contains("status 7"))); + + let stopped = classify_finish(clean, FinishCause::Stopped); + assert_eq!(stopped.status, STATUS_EXITED); + assert_eq!(stopped.exit_code, None); + assert_eq!(stopped.last_error, None); + } + + #[test] + fn initial_prompt_is_bracketed_and_cannot_synthesize_terminal_controls() { + let mut out = Vec::new(); + write_initial_prompt(&mut out, "first\nsecond\u{1b}[201~evil\u{202e}").unwrap(); + assert_eq!(out, b"\x1b[200~first\nsecond[201~evil\x1b[201~\r"); + } + + #[test] + fn prompt_readiness_requires_paste_mode_and_codex_composer_across_chunks() { + let mut readiness = TuiReadiness::default(); + readiness.observe(b"splash\x1b[?20"); + readiness.observe(b"04h loading"); + readiness.observe(b"still starting"); + readiness.observe(" draw › composer".as_bytes()); + std::thread::sleep(super::COMPOSER_QUIET_TIME + std::time::Duration::from_millis(20)); + assert!(readiness.ready()); + readiness.observe(b"\x1b[?2004l"); + assert!(!readiness.ready()); + + let mut dialog = TuiReadiness::default(); + dialog.observe("old › menu\x1b[?2004h Trust this folder? [y/N]".as_bytes()); + std::thread::sleep(super::COMPOSER_QUIET_TIME + std::time::Duration::from_millis(20)); + assert!(!dialog.ready()); + } + + #[test] + fn output_log_compaction_keeps_a_bounded_clean_tail() { + let path = std::env::temp_dir().join(format!( + "rail-output-log-{}-{}", + std::process::id(), + crate::state::now_millis() + )); + fs::write( + &path, + format!("old-prefix\n{}\nfinal-line\n", "x".repeat(8192)), + ) + .unwrap(); + let mut log = OpenOptions::new() + .read(true) + .append(true) + .open(&path) + .unwrap(); + compact_output_log(&mut log, 1024).unwrap(); + log.write_all(b"after\n").unwrap(); + drop(log); + let bytes = fs::read(&path).unwrap(); + assert!(bytes.len() < 1200); + assert!(bytes.starts_with(b"\x1b[0m\r\n[rail] earlier output truncated\r\n")); + assert!(bytes.ends_with(b"final-line\nafter\n")); + let _ = fs::remove_file(path); + } + + #[test] + fn lifecycle_waiting_survives_an_oversized_unrelated_tail() { + let path = std::env::temp_dir().join(format!( + "rail-worker-lifecycle-{}-{}", + std::process::id(), + crate::state::now_millis() + )); + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .unwrap(); + file.write_all(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"task_complete\"}}\n") + .unwrap(); + let noise = vec![b'x'; 1024 * 1024]; + for _ in 0..9 { + file.write_all(&noise).unwrap(); + } + file.write_all(b"\n").unwrap(); + file.flush().unwrap(); + + let mut lifecycle = RolloutLifecycle::default(); + assert_eq!(lifecycle.scan(&path), None); // bounded first tick, fail closed + assert_eq!(lifecycle.scan(&path), Some(true)); + + file.write_all(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"task_started\"}}\n") + .unwrap(); + file.flush().unwrap(); + assert_eq!(lifecycle.scan(&path), Some(false)); + assert_eq!(lifecycle.started_turns, 1); + drop(file); + let _ = fs::remove_file(path); + } } diff --git a/tests/cockpit.py b/tests/cockpit.py index 429d8be..3f0681b 100644 --- a/tests/cockpit.py +++ b/tests/cockpit.py @@ -8,13 +8,32 @@ flickering age column, a status that never latches, a repaint that leaves junk). python3 tests/cockpit.py [./target/release/rail] [--png OUTDIR] + python3 tests/cockpit.py [./target/release/rail] --import-only + python3 tests/cockpit.py [./target/release/rail] --mouse-only [--png OUTDIR] With no flags it runs the full feature audit and prints a PASS/FAIL table. The Cockpit class is also importable for ad-hoc driving: c = Cockpit(RAIL); c.boot(); c.new("hello"); c.png("after_new.png"); c.quit() """ -import fcntl, glob, json, os, pty, re, select, shutil, signal, struct, subprocess, sys, termios, threading, time +import fcntl +import copy +import glob +import json +import os +import pty +import re +import select +import shutil +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import termios +import threading +import time import pyte HERE = os.path.dirname(os.path.abspath(__file__)) @@ -23,6 +42,8 @@ else os.path.join(REPO, "target/release/rail") FAKE_STREAM = os.path.join(HERE, "fakecodex") # streams "tick" forever (a live turn) FAKE_SLEEP = os.path.join(HERE, "fakecodex_sleep") # quiet long-lived child +FAKE_DETACHED = os.path.join(HERE, "fakecodex_spawn_detached") +FAKE_PROMPT_TUI = os.path.join(HERE, "fakecodex_prompt_tui") COLS, ROWS = 110, 40 @@ -44,45 +65,62 @@ def _rgb(color, default): class Cockpit: - def __init__(self, rail=RAIL, root=None, codex=FAKE_SLEEP): + def __init__(self, rail=RAIL, root=None, codex=FAKE_SLEEP, cols=COLS, rows=ROWS): self.rail = os.path.abspath(rail) # workers spawn with cwd=/tmp; must be absolute # Root MUST be short: the worker binds $XDG_RUNTIME_DIR/codex-rail/.sock # and a Unix socket path over ~108 bytes (SUN_LEN) fails to bind. A long # root (e.g. under the job tmp dir) makes every worker fail with # "path must be shorter than SUN_LEN" — which reads as a fake rail bug. - self.root = root or ("/tmp/rc-" + str(os.getpid())) + # PID alone is not unique across concurrently-run container namespaces + # (both processes may be PID 2 while sharing /tmp). Reserve a genuinely + # unique, still-short path so parallel visual/import audits cannot delete + # each other's fixtures during boot/close. + self.root = root or tempfile.mkdtemp(prefix="rc-", dir="/tmp") self.codex = codex + self.cols = cols + self.nrows = rows self.data = self.root + "/data" self.run = self.root + "/run" self.home = self.root + "/home" + self.config = self.home + "/.config" + self.codex_home = self.home + "/.codex" self.jobs = self.data + "/codex-rail/jobs" self.p = None self.m = None - self.screen = pyte.Screen(COLS, ROWS) + self.screen = pyte.Screen(self.cols, self.nrows) self.stream = pyte.ByteStream(self.screen) self.raw = bytearray() self.lock = threading.Lock() self.stopped = threading.Event() self._zombies = [] + self._guardians = [] # ---- lifecycle ----------------------------------------------------------- def boot(self, settle=1.8, reset=True): if reset: shutil.rmtree(self.root, ignore_errors=True) - for d in (self.jobs, self.run, self.home): + for d in (self.jobs, self.run, self.home, self.config, self.codex_home): os.makedirs(d, exist_ok=True) env = os.environ.copy() - env.update({"XDG_DATA_HOME": self.data, "XDG_RUNTIME_DIR": self.run, "HOME": self.home, + # The cockpit is explicitly a graphical regression harness. Its parent + # may set NO_COLOR for non-interactive command output, but inheriting it + # would silently turn every colour/background assertion into a no-op. + env.pop("NO_COLOR", None) + env.update({"XDG_DATA_HOME": self.data, "XDG_RUNTIME_DIR": self.run, + "XDG_CONFIG_HOME": self.config, "CODEX_HOME": self.codex_home, + "HOME": self.home, "CODEX_RAIL_CODEX": self.codex, "TERM": "xterm-256color", + "COLORTERM": "truecolor", # 4s detach-hint progress bar -> fast in tests; raise it to watch it fill "CODEX_RAIL_HINT_MS": os.environ.get("COCKPIT_HINT_MS", "60"), # rescan ~/.codex for cwd-matching sessions fast so tests don't wait 20s "CODEX_RAIL_ADOPT_MS": "300", # don't hit GitHub for an update check during tests "CODEX_RAIL_NO_UPDATE_CHECK": "1", - "COLUMNS": str(COLS), "LINES": str(ROWS)}) + "COLUMNS": str(self.cols), "LINES": str(self.nrows)}) self.m, s = pty.openpty() - fcntl.ioctl(s, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) + fcntl.ioctl(s, termios.TIOCSWINSZ, + struct.pack("HHHH", self.nrows, self.cols, 0, 0)) self.p = subprocess.Popen([self.rail], stdin=s, stdout=s, stderr=s, env=env, preexec_fn=os.setsid, close_fds=True) os.close(s) @@ -116,6 +154,14 @@ def close(self): os.waitpid(z, 0) except Exception: pass + for guardian in self._guardians: + try: + guardian.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.kill(guardian.pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass shutil.rmtree(self.root, ignore_errors=True) def cpu_ticks(self): @@ -162,6 +208,17 @@ def key(self, payload, settle=0.35): os.write(self.m, payload) time.sleep(settle) + def resize(self, cols, rows, settle=0.6): + """Resize the real PTY and pyte's model together.""" + self.cols = cols + self.nrows = rows + with self.lock: + self.screen.resize(lines=rows, columns=cols) + fcntl.ioctl(self.m, termios.TIOCSWINSZ, + struct.pack("HHHH", rows, cols, 0, 0)) + os.kill(self.p.pid, signal.SIGWINCH) + time.sleep(settle) + def type(self, text, settle=0.3): self.key(text.encode(), settle) @@ -176,6 +233,16 @@ def text(self): def row_with(self, needle): return next((r for r in self.rows() if needle in r), None) + def row_has_background(self, needle): + """Whether any cell on the matching rendered row has a non-default bg.""" + with self.lock: + display = list(self.screen.display) + y = next((i for i, row in enumerate(display) if needle in row), None) + if y is None: + return False + return any(self.screen.buffer[y][x].bg not in (None, "default") + for x in range(self.cols)) + def selected_row(self): return next((r.strip() for r in self.rows() if r.lstrip().startswith("▌")), None) @@ -230,6 +297,22 @@ def wait_until(self, pred, timeout=15, poll=0.2): # ---- PNG snapshot (so the UI can actually be SEEN) ------------------------ def png(self, path, scale=1): from PIL import Image, ImageDraw, ImageFont + # A render is emitted as several PTY chunks. Snapshotting between them + # captures a valid pyte state but not a complete Rail frame, so wait for + # a short quiet gap first (well below Rail's 700ms refresh interval). + deadline = time.time() + 1.0 + stable_since = time.time() + with self.lock: + last_size = len(self.raw) + while time.time() < deadline: + time.sleep(0.02) + with self.lock: + size = len(self.raw) + if size != last_size: + last_size = size + stable_since = time.time() + elif time.time() - stable_since >= 0.12: + break cw, ch = 9, 19 mono = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", 15) try: @@ -237,14 +320,16 @@ def png(self, path, scale=1): except Exception: cjk = mono bg0 = (24, 22, 26) - img = Image.new("RGB", (COLS * cw, ROWS * ch), bg0) + img = Image.new("RGB", (self.cols * cw, self.nrows * ch), bg0) d = ImageDraw.Draw(img) with self.lock: - buf = self.screen.buffer - col = 0 - for y in range(ROWS): + # The drain thread keeps mutating pyte's buffer. Holding only a + # reference here can produce a torn PNG (half old frame, half new) + # after the lock is released, which defeats visual regression. + buf = copy.deepcopy(self.screen.buffer) + for y in range(self.nrows): x = 0 - while x < COLS: + while x < self.cols: cell = buf[y][x] chdata = cell.data or " " fg = _rgb(cell.fg, (210, 210, 218)) @@ -282,18 +367,28 @@ def seed(self, sid, title, **over): json.dump({"title": title, "title_pinned": over.get("title_pinned", False)}, open(f"{self.jobs}/{sid}/label.json", "w")) - def seed_running_worker(self, sid, title, codex=None): - """Spawn a REAL worker (fake codex) so status=running with live pids.""" + def seed_running_worker(self, sid, title, codex=None, **over): + """Spawn the production guardian -> worker chain with a fake Codex.""" codex = codex or FAKE_STREAM - self.seed(sid, title, status="starting", codex=codex) - env = os.environ.copy() - env.update({"XDG_DATA_HOME": self.data, "XDG_RUNTIME_DIR": self.run, "HOME": self.home, - "CODEX_RAIL_CODEX": codex}) - subprocess.Popen([self.rail, "--worker", sid], cwd="/tmp", env=env, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) + self.seed(sid, title, status="starting", codex=codex, **over) + self.start_guardian(sid, codex) self.wait_until(lambda: self._pid(sid, "child_pid") is not None, timeout=8) return sid + def start_guardian(self, sid, codex=None): + """Start a guardian for an already-persisted session (resume/crash tests).""" + codex = codex or self.codex + env = os.environ.copy() + env.update({"XDG_DATA_HOME": self.data, "XDG_RUNTIME_DIR": self.run, + "XDG_CONFIG_HOME": self.config, "CODEX_HOME": self.codex_home, + "HOME": self.home, + "CODEX_RAIL_CODEX": codex}) + guardian = subprocess.Popen([self.rail, "--guardian", sid], cwd="/tmp", env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True) + self._guardians.append(guardian) + return guardian + def seed_codex_history(self, n_sessions=2, msgs_each=4): """Write synthetic codex rollouts under HOME/.codex so distillation has real user messages to aggregate (event_msg.user_message lines).""" @@ -317,6 +412,30 @@ def seed_codex_history(self, n_sessions=2, msgs_each=4): open(path, "w", encoding="utf-8").write("\n".join(lines) + "\n") return total + def seed_import_rollout(self, sid, message, *, cwd=None, age_days=0): + """Create one import candidate with a controlled cwd, user turn and mtime. + + `message=None` deliberately creates a transcript with no genuine user + turn. A marker-like message can be supplied to exercise the production + marker filter. Import freshness is based on filesystem mtime, not the + date encoded in the fixture filename. + """ + cwd = os.getcwd() if cwd is None else cwd + d = os.path.join(self.codex_home, "sessions", "2099", "01", "01") + os.makedirs(d, exist_ok=True) + path = os.path.join(d, f"rollout-2099-01-01T00-00-00-{sid}.jsonl") + lines = [json.dumps({"type": "session_meta", "payload": { + "id": sid, "session_id": sid, "cwd": cwd, + "timestamp": "2099-01-01T00:00:00Z"}})] + if message is not None: + lines.append(json.dumps({"type": "event_msg", "payload": { + "type": "user_message", "message": message}})) + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + mtime = time.time() - age_days * 24 * 60 * 60 + os.utime(path, (mtime, mtime)) + return path + def seed_zombie(self, sid, title): z = os.fork() if z == 0: @@ -347,17 +466,17 @@ def dir_exists(self, sid): # ---- named feature actions ---------------------------------------------- def down(self): - self.key(b"s") + self.key(b"\x1b[B") def up(self): - self.key(b"w") + self.key(b"\x1b[A") def goto(self, title, tries=30): for _ in range(tries): sel = self.selected_row() or "" if title in sel: return True - self.key(b"s", 0.12) + self.key(b"\x1b[B", 0.12) return False def ctrl_x_twice(self): @@ -371,12 +490,379 @@ def rename(self, newname): self.key(b"\r", 0.6) def new(self, msg): - self.key(b"e", 0.4) if msg: - self.type(msg, 0.3) + self.type(msg, 0.3) # printable key enters composer verbatim + else: + self.key(b"\x0e", 0.4) # Ctrl+N creates a blank composer self.key(b"\r", 2.0) +def visual_audit(rail, pngdir): + """Sandbox-safe manager rendering audit: no worker, socket, or child process.""" + results = [] + + def check(name, ok, detail=""): + results.append((name, bool(ok), detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + + def snap(c, tag): + if pngdir: + c.png(os.path.join(pngdir, f"{tag}.png")) + + print(f"\n==== RAIL VISUAL-ONLY AUDIT ({rail}) ====") + c = Cockpit(rail, cols=20, rows=8).boot() + try: + tiny_20 = "terminal too small" in c.text().lower() and c.p.poll() is None + snap(c, "visual_20x8") + check("20x8: graceful too-small frame", tiny_20, repr(c.text())) + + c.resize(40, 12) + tiny_40 = "terminal too small" in c.text().lower() and c.p.poll() is None + snap(c, "visual_40x12") + check("40x12: graceful too-small frame", tiny_40, repr(c.text())) + + c.resize(110, 40) + mark = c.mark() + title_osc = b"\x1b]0;TITLE-ATTACK\x07" + preview_osc = b"\x1b]52;c;PREVIEW-ATTACK\x07" + rollout = os.path.join(c.codex_home, "sessions", "2026", "07", "13", + "rollout-2026-07-13T00-00-00-visual.jsonl") + os.makedirs(os.path.dirname(rollout), exist_ok=True) + with open(rollout, "w") as f: + f.write(json.dumps({"type": "session_meta", + "payload": {"id": "visual", "cwd": "/tmp"}}) + "\n") + f.write(json.dumps({"type": "event_msg", "payload": { + "type": "agent_message", + "message": "PREVIEW e\u0301 ❤️ 👩‍💻 🇨🇳 中文 " + + preview_osc.decode("ascii")}}) + "\n") + unicode_titles = ["U1 e\u0301", "U2 ❤️", "U3 👩‍💻", "U4 🇨🇳", "U5 中文"] + for index, title in enumerate(unicode_titles): + c.seed(f"visual-unicode-{index}", + title + (" " + title_osc.decode("ascii") if index == 4 else ""), + status="exited", + codex_rollout_path=(rollout if index == 4 else None), + codex_session_id=("visual" if index == 4 else None), + title_pinned=True) + rendered = c.wait_until( + lambda: all(c.row_with(f"U{index}") is not None for index in range(1, 6)) + and "中文" in c.text() and "PREVIEW" in c.text(), + timeout=8) + raw = c.raw_since(mark) + sanitized = title_osc not in raw and preview_osc not in raw + healthy = c.p.poll() is None and any("Codex Rail" in row for row in c.rows()) + snap(c, "visual_110x40_unicode") + check("resize 110x40: Unicode/CJK clusters render and manager remains healthy", + rendered and healthy, f"rendered={rendered} healthy={healthy}") + check("terminal safety: hostile title/preview controls never reach raw PTY", + sanitized, f"title_leaked={title_osc in raw} preview_leaked={preview_osc in raw}") + finally: + c.close() + + passed = sum(ok for _, ok, _ in results) + print(f"\n==== {passed}/{len(results)} visual-only checks PASS ====") + if pngdir: + print(f"PNG snapshots written to {pngdir}/") + return all(ok for _, ok, _ in results) + + +def _run_import_contract(rail, check, snap): + """Black-box PTY checks shared by `--import-only` and the full audit.""" + + # The empty-state onboarding must explain both ways to reach beyond the + # conservative seven-day automatic window. Check the rendered screen rather + # than implementation constants so this guards the actual discoverability. + c = Cockpit(rail).boot(settle=0.3) + try: + hinted = c.wait_until( + lambda: "/import 15d" in c.text() and "/import " in c.text(), + timeout=5) + check("import: startup hint advertises 15d and exact-session expansion", + hinted, repr(c.text())) + snap(c, "import_01_startup_hint") + finally: + c.close() + + # Seed all candidates BEFORE boot: this is specifically the initial automatic + # import contract, not a later incremental rescan. Filename dates are fixed; + # production must use rollout mtime for the seven-day boundary. + c = Cockpit(rail) + cwd = os.getcwd() + old_start_ms = int((time.time() - 90 * 24 * 60 * 60) * 1000) + + def import_sid(serial): + # Real Codex ids are UUIDv7: their first 48 bits encode start millis. + # Keep fixture creation older than every tested mtime so the UI age cell + # reflects that mtime instead of max(mtime, UUID start time). + stamp = f"{old_start_ms + serial * (1 << 16):012x}" + return f"{stamp[:8]}-{stamp[8:]}-7000-8000-{serial:012x}" + + recent_id = import_sid(1) + old_15d_id = import_sid(2) + exact_old_id = import_sid(3) + dismissed_id = import_sid(4) + other_cwd_id = import_sid(5) + no_turn_id = import_sid(6) + marker_only_id = import_sid(7) + exact_other_id = import_sid(8) + missing_id = import_sid(9) + ambiguous_id = import_sid(10) + + c.seed_import_rollout(recent_id, "IMPORT_RECENT genuine turn", age_days=6) + c.seed_import_rollout(old_15d_id, "IMPORT_OLD_TEN_DAYS", age_days=10) + c.seed_import_rollout(exact_old_id, "IMPORT_EXACT_OLD", age_days=30) + c.seed_import_rollout(dismissed_id, "IMPORT_DISMISSED_OLD", age_days=40) + c.seed_import_rollout(other_cwd_id, "IMPORT_OTHER_CWD", cwd="/tmp", age_days=1) + c.seed_import_rollout(no_turn_id, None, age_days=1) + c.seed_import_rollout(marker_only_id, "", age_days=1) + c.seed_import_rollout(exact_other_id, "IMPORT_EXACT_OTHER_CWD", cwd="/tmp", age_days=30) + ambiguous_path = c.seed_import_rollout( + ambiguous_id, "IMPORT_AMBIGUOUS", age_days=30) + ambiguous_copy = ambiguous_path.replace("/2099/01/01/", "/2099/01/02/") + os.makedirs(os.path.dirname(ambiguous_copy), exist_ok=True) + shutil.copy2(ambiguous_path, ambiguous_copy) + + dismiss_file = os.path.join(c.data, "codex-rail", ".adopt_dismissed") + os.makedirs(os.path.dirname(dismiss_file), exist_ok=True) + with open(dismiss_file, "w", encoding="utf-8") as f: + f.write(dismissed_id + "\n") + + c.boot(reset=False, settle=0.3) + try: + recent = c.wait_until(lambda: c.row_with("IMPORT_RECENT") is not None, timeout=8) + # Let at least one fast rescan complete before asserting every exclusion. + time.sleep(1.0) + excluded = { + "older_than_7d": c.row_with("IMPORT_OLD_TEN_DAYS") is None, + "exact_old": c.row_with("IMPORT_EXACT_OLD") is None, + "dismissed": c.row_with("IMPORT_DISMISSED_OLD") is None, + "other_cwd": c.row_with("IMPORT_OTHER_CWD") is None, + "no_user_turn": all(no_turn_id[:8] not in row for row in c.rows()), + "marker_only": all(marker_only_id[:8] not in row for row in c.rows()), + } + check("import: automatic scan is current-cwd + genuine-user + last-7d only", + recent and all(excluded.values()), + f"recent={recent} excluded={excluded}") + snap(c, "import_02_default_7d") + + c.type("/import 15d", 0.2) + c.key(b"\r", 0.4) + expanded = c.wait_until(lambda: c.row_with("IMPORT_OLD_TEN_DAYS") is not None, + timeout=8) + still_bounded = (c.row_with("IMPORT_EXACT_OLD") is None + and c.row_with("IMPORT_AMBIGUOUS") is None) + check("import: /import 15d expands the mtime window", + expanded and still_bounded, + f"row={c.row_with('IMPORT_OLD_TEN_DAYS')!r} bounded={still_bounded}") + + # Repeating a range import is idempotent: it must not duplicate rows or + # create a Rail-managed job for an in-memory adopted transcript. + c.type("/import 15d", 0.2) + c.key(b"\r", 0.4) + repeat_finished = c.wait_until( + lambda: "imported 0 additional chat(s)" in c.text(), timeout=8) + repeated = (repeat_finished + and sum("IMPORT_OLD_TEN_DAYS" in row for row in c.rows()) == 1) + check("import: repeating a day-window import is idempotent", repeated, + f"finished={repeat_finished} " + f"matching_rows={sum('IMPORT_OLD_TEN_DAYS' in row for row in c.rows())}") + + c.type(f"/import {exact_old_id}", 0.2) + c.key(b"\r", 0.4) + exact = c.wait_until(lambda: c.row_with("IMPORT_EXACT_OLD") is not None, timeout=8) + check("import: exact session id bypasses the age window", + exact, f"row={c.row_with('IMPORT_EXACT_OLD')!r}") + + c.type(f"/import {dismissed_id}", 0.2) + c.key(b"\r", 0.4) + restored = c.wait_until(lambda: c.row_with("IMPORT_DISMISSED_OLD") is not None, + timeout=8) + dismissed_after = (open(dismiss_file, encoding="utf-8").read() + if os.path.exists(dismiss_file) else "") + undismissed = dismissed_id not in dismissed_after.splitlines() + check("import: exact id restores a previously dismissed session", + restored and undismissed, + f"restored={restored} undismissed={undismissed}") + + jobs_before = set(os.listdir(c.jobs)) if os.path.isdir(c.jobs) else set() + c.type(f"/import {exact_other_id}", 0.2) + c.key(b"\r", 0.8) + exact_other_absent = c.row_with("IMPORT_EXACT_OTHER_CWD") is None + jobs_after_other = set(os.listdir(c.jobs)) if os.path.isdir(c.jobs) else set() + check("import: exact id still refuses a different cwd", + exact_other_absent and jobs_after_other == jobs_before and c.p.poll() is None, + f"absent={exact_other_absent} jobs_before={jobs_before} " + f"jobs_after={jobs_after_other} status={c.rows()[-1]!r}") + + c.type(f"/import {missing_id}", 0.2) + c.key(b"\r", 0.4) + missing_reported = c.wait_until(lambda: "was not found" in c.text(), timeout=5) + check("import: a missing exact id reports not-found without creating a job", + missing_reported and set(os.listdir(c.jobs)) == jobs_before, + f"reported={missing_reported} status={c.rows()[-1]!r}") + + c.type(f"/import {ambiguous_id}", 0.2) + c.key(b"\r", 0.4) + ambiguous_reported = c.wait_until(lambda: "is ambiguous" in c.text(), timeout=5) + check("import: duplicate rollout identity fails closed", + ambiguous_reported and c.row_with("IMPORT_AMBIGUOUS") is None + and set(os.listdir(c.jobs)) == jobs_before, + f"reported={ambiguous_reported} status={c.rows()[-1]!r}") + + # Invalid forms must be consumed by Rail and report usage. If any one is + # accidentally treated as a Codex prompt it creates a job and leaves the + # manager screen, both of which are asserted against here. + bad_usage = [] + invalid_commands = ("/import", "/import 0d", "/import 15", "/import 15days", + "/import 15d extra", "/import 3651d") + for command in invalid_commands: + mark = c.mark() + c.type(command, 0.15) + c.key(b"\r", 0.6) + wire = c.raw_since(mark).lower() + bad_usage.append(b"usage" in wire and b"/import" in wire) + if c.p.poll() is not None or "Codex Rail" not in c.text(): + break + jobs_after_bad = set(os.listdir(c.jobs)) if os.path.isdir(c.jobs) else set() + check("import: invalid arguments show rail-side usage and create no session", + len(bad_usage) == len(invalid_commands) and all(bad_usage) + and jobs_after_bad == jobs_before, + f"usage={bad_usage} jobs_before={jobs_before} jobs_after={jobs_after_bad}") + + c.type("/review", 0.25) + review_passthrough = ("Enter start" in c.text() + and "/review" in "\n".join(c.rows()[-7:]) + and "/distill" not in c.text()) + check("slash: unknown /review remains ordinary composer input", + review_passthrough, repr("\n".join(c.rows()[-8:]))) + c.key(b"\x1b", 0.2) + snap(c, "import_03_manual") + finally: + c.close() + + # Prefix filtering is a separate regression because `/di Enter` once fell + # through to ordinary prompt submission even while `/distill` was highlighted. + c = Cockpit(rail).boot() + try: + c.seed_codex_history(n_sessions=1, msgs_each=2) + c.key(b"/", 0.35) + palette = all(cmd in c.text() + for cmd in ("/distill", "/import", "/update", "/config", "/help")) + c.key(b"\x1b", 0.15) + c.type("/di", 0.25) + filtered = "/distill" in c.text() and "/update" not in c.text() + snap(c, "import_04_distill_prefix") + c.key(b"\r", 2.5) + corpora = glob.glob(os.path.join(c.home, ".config", "codex-rail", "distill", + "runs", "run-*", "corpus")) + ran = any(os.path.isdir(corpus) + and glob.glob(os.path.join(corpus, "corpus-*.md")) for corpus in corpora) + check("slash: /di Enter executes highlighted /distill rail-side", + palette and filtered and ran, + f"palette={palette} filtered={filtered} ran={ran}") + finally: + c.close() + + +def import_audit(rail, pngdir=None): + """Focused import/slash PTY regression, runnable with `--import-only`.""" + results = [] + + def check(name, ok, detail=""): + results.append((name, bool(ok), detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + + def snap(c, tag): + if pngdir: + c.png(os.path.join(pngdir, f"{tag}.png")) + + print(f"\n==== RAIL IMPORT AUDIT ({rail}) ====") + _run_import_contract(rail, check, snap) + passed = sum(ok for _, ok, _ in results) + print(f"\n==== {passed}/{len(results)} import checks PASS ====") + for name, ok, _ in results: + if not ok: + print(f" FAILED: {name}") + if pngdir: + print(f"PNG snapshots written to {pngdir}/") + return all(ok for _, ok, _ in results) + + +def _run_mouse_contract(rail, check, snap): + """Hover paints the physical row without stealing durable selection.""" + c = Cockpit(rail, rows=18).boot() + try: + for i in range(16): + c.seed(f"ms{i}", f"mouse-sess-{i}", status="exited") + loaded = c.wait_until( + lambda: sum("mouse-sess-" in row for row in c.rows()) >= 6, + timeout=6) + + def selected_title(): + match = re.search(r"mouse-sess-\d+", c.selected_row() or "") + return match.group(0) if match else None + + def visible_layout(): + return [(match.group(0), y) for y, row in enumerate(c.rows()) + if (match := re.search(r"mouse-sess-\d+", row))] + + # Force the overflowing list away from its initial viewport. This is the + # old failure mode: hover used to mutate selection and snap the viewport. + for _ in range(12): + c.key(b"\x1b[B", 0.05) + before_selection = selected_title() + before_view = visible_layout() + target_entry = next((entry for entry in before_view + if entry[0] != before_selection), None) + target, target_y = target_entry if target_entry is not None else (None, None) + initially_plain = target is not None and not c.row_has_background(target) + + if target_y is not None: + c.key(f"\x1b[<35;20;{target_y + 1}M".encode(), 0.4) # SGR Moved + hover_visible = target is not None and c.row_has_background(target) + selection_stable = selected_title() == before_selection + viewport_stable = visible_layout() == before_view + snap(c, "mouse_01_hover") + + # Moving onto a non-session line must remove the visual hover. + c.key(b"\x1b[<35;20;3M", 0.4) + hover_cleared = target is not None and not c.row_has_background(target) + selection_still_stable = selected_title() == before_selection + + # Wheel behavior remains selection-based and clears any stale hover. + wheel_y = (target_y + 1) if target_y is not None else 5 + c.key(f"\x1b[<65;20;{wheel_y}M".encode(), 0.4) + wheel_moved = selected_title() != before_selection + check("mouse: hover highlights without selecting or jumping; leave clears; wheel scrolls", + loaded and initially_plain and hover_visible and selection_stable + and viewport_stable and hover_cleared and selection_still_stable and wheel_moved, + f"loaded={loaded} target={target!r} initial={initially_plain} " + f"hover={hover_visible} selection={selection_stable} viewport={viewport_stable} " + f"cleared={hover_cleared} wheel={wheel_moved}") + finally: + c.close() + + +def mouse_audit(rail, pngdir=None): + """Focused sandbox-safe hover regression, runnable with `--mouse-only`.""" + results = [] + + def check(name, ok, detail=""): + results.append((name, bool(ok), detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + + def snap(c, tag): + if pngdir: + c.png(os.path.join(pngdir, f"{tag}.png")) + + print(f"\n==== RAIL MOUSE AUDIT ({rail}) ====") + _run_mouse_contract(rail, check, snap) + passed = sum(ok for _, ok, _ in results) + print(f"\n==== {passed}/{len(results)} mouse checks PASS ====") + if pngdir: + print(f"PNG snapshots written to {pngdir}/") + return all(ok for _, ok, _ in results) + + # ============================ FEATURE AUDIT ==================================== def audit(rail, pngdir=None): results = [] @@ -389,6 +875,22 @@ def snap(c, tag): if pngdir: c.png(f"{pngdir}/{tag}.png") + def recv_line(peer): + data = bytearray() + while not data.endswith(b"\n"): + chunk = peer.recv(4096) + if not chunk: + break + data.extend(chunk) + return bytes(data) + + def control_request(path, command, timeout=3): + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as peer: + peer.settimeout(timeout) + peer.connect(path) + peer.sendall(command) + return recv_line(peer) + print(f"\n==== RAIL COCKPIT AUDIT ({rail}) ====") # 1) boot: header; 0 sessions -> friendly hint; >=1 session -> all 3 sections @@ -409,21 +911,59 @@ def snap(c, tag): finally: c.close() - # 2) new session via `e` starts a real turn; title stored; auto-attach shows output - c = Cockpit(rail, codex=FAKE_STREAM).boot() + # 2) new session via printable composer input: the private prompt is not argv and is not blindly + # typed after a fixed sleep. The fake TUI consumes any early bytes, then + # explicitly enables bracketed paste and records the exact submission. + c = Cockpit(rail, codex=FAKE_PROMPT_TUI) + prompt_record = os.path.join(c.root, "prompt-record.json") + os.environ["CODEX_RAIL_PROMPT_RECORD"] = prompt_record + os.environ["CODEX_RAIL_PROMPT_MODE"] = "ready" + c.boot() try: c.new("investigate the thing") attached = c.wait_until(lambda: any("tick" in r for r in c.rows()), timeout=8) + recorded = c.wait_until( + lambda: os.path.exists(prompt_record) + and json.load(open(prompt_record)).get("payload") is not None + and json.load(open(prompt_record)).get("postcheck_done") is True, + timeout=8) + prompt_result = json.load(open(prompt_record)) if os.path.exists(prompt_record) else {} + prompt_ok = prompt_result.get("payload") == "investigate the thing" + framed = prompt_result.get("framed") is True + no_early_input = prompt_result.get("early_hex") == "" + expected_wire = b"\x1b[200~investigate the thing\x1b[201~\r".hex() + exact_wire = prompt_result.get("wire_hex") == expected_wire + submitted_once = prompt_result.get("extra_hex") == "" + argv_private = all("investigate the thing" not in arg + for arg in prompt_result.get("argv", [])) + + def prompt_state_cleared(): + try: + states = [json.load(open(f"{c.jobs}/{d}/state.json")) for d in os.listdir(c.jobs) + if os.path.exists(f"{c.jobs}/{d}/state.json")] + except Exception: + return False + return len(states) == 1 and states[0].get("initial_prompt") is None \ + and states[0].get("initial_prompt_injecting", False) is False + + state_cleared = c.wait_until(prompt_state_cleared, timeout=5) snap(c, "02_attached") c.key(b"\x1a", 1.0) # Ctrl-Z detach listed = c.row_with("investigate the thing") is not None labels = [json.load(open(f"{c.jobs}/{d}/label.json")).get("title") for d in os.listdir(c.jobs) if os.path.exists(f"{c.jobs}/{d}/label.json")] - check("new session: turn started (codex output seen on attach)", attached) + check("new session: private prompt waits for TUI readiness and is submitted once", + attached and recorded and prompt_ok and framed and exact_wire and submitted_once + and no_early_input and argv_private and state_cleared, + f"attached={attached} recorded={recorded} prompt_ok={prompt_ok} framed={framed} " + f"exact_wire={exact_wire} submitted_once={submitted_once} early={not no_early_input} " + f"argv_private={argv_private} state_cleared={state_cleared}") check("new session: detach repaints list", listed and any("Codex Rail" in r for r in c.rows())) check("new session: title saved to label.json", "investigate the thing" in labels, str(labels)) snap(c, "03_after_detach") finally: + os.environ.pop("CODEX_RAIL_PROMPT_RECORD", None) + os.environ.pop("CODEX_RAIL_PROMPT_MODE", None) c.close() # 3) THE AGE BUG: a streaming session's age must be STABLE, not bounce 0/1/2 @@ -524,17 +1064,41 @@ def secs(a): finally: c.close() - # 9) Space reserved: does not open the composer ("Enter start" = New-mode hint) + # 9) Navigation is arrow-only, Ctrl+N starts a blank composer, Ctrl+A owns + # autopilot, and every printable key (including the former w/s/d/e + # shortcuts, Shift, and a leading Space) enters the composer verbatim. c = Cockpit(rail).boot() try: - c.seed("sp-0", "SPACE_ROW", status="exited") + c.seed("key-0", "KEY_ROW_0", status="exited") + c.seed("key-1", "KEY_ROW_1", status="exited") time.sleep(0.8) - before = len(os.listdir(c.jobs)) - c.key(b" ", 0.4) - new_mode = "Enter start" in c.text() or "new session" in c.text() - after = len(os.listdir(c.jobs)) - check("space: reserved (no composer, no new session)", not new_mode and after == before, - f"new_mode={new_mode} dirs {before}->{after}") + first = c.selected_row() + c.down() + down = c.selected_row() + c.up() + up = c.selected_row() + nav_ok = first != down and first == up + + c.key(b"\x0e", 0.3) # Ctrl+N + blank_ok = "Enter start" in c.text() + c.key(b"\x1b", 0.2) + + printable_ok = True + seen = [] + for payload, visible in ((b"w", "w"), (b"s", "s"), (b"d", "d"), + (b"e", "e"), (b"W", "W"), (b" ", None)): + c.key(payload, 0.18) + in_composer = "Enter start" in c.text() + verbatim = visible is None or visible in "\n".join(c.rows()[-5:]) + printable_ok &= in_composer and verbatim + seen.append((payload.decode(), in_composer, verbatim)) + c.key(b"\x1b", 0.12) + + c.key(b"\x01", 0.4) # Ctrl+A, never composer input + ctrl_a_ok = "Enter start" not in c.text() + check("keys: arrows navigate; Ctrl+N blank; printable keys compose; Ctrl+A autopilot", + nav_ok and blank_ok and printable_ok and ctrl_a_ok, + f"nav={nav_ok} blank={blank_ok} printable={seen} ctrl_a={ctrl_a_ok}") finally: c.close() @@ -547,7 +1111,9 @@ def secs(a): exited = c.exited_within(2.5) detail = "exited cleanly" if not exited: - a = c.cpu_ticks(); time.sleep(1.0); b = c.cpu_ticks() + a = c.cpu_ticks() + time.sleep(1.0) + b = c.cpu_ticks() per = (b - a) if (a is not None and b is not None) else "?" detail = f"STILL ALIVE, cpu={per}/s (100=full core)" check("terminal death: manager exits, no 100% CPU orphan", exited, detail) @@ -572,7 +1138,8 @@ def secs(a): with open(flag, "w") as f: f.write("10\n") m1 = c.mark() - c.goto("hello world"); c.key(b"\r", 2.2) # attach again, now capped + c.goto("hello world") + c.key(b"\r", 2.2) # attach again, now capped capped = b"come back to rail" not in c.raw_since(m1) c.key(b"\x1a", 0.6) check("detach hint: bar + remaining-count shown, then stops after the cap", @@ -647,26 +1214,37 @@ def secs(a): try: c.seed_codex_history(n_sessions=2, msgs_each=4) c.key(b"\x04", 2.5) # Ctrl+D - corpus = os.path.join(c.home, ".config", "codex-rail", "distill", "corpus") - chunks = sorted(glob.glob(corpus + "/corpus-*.md")) if os.path.isdir(corpus) else [] - made = len(chunks) >= 1 - shown = "distill" in c.text() # a distill session is in the list - args_ok = False # ...and it carries the autonomous flags - for d in (os.listdir(c.jobs) if os.path.isdir(c.jobs) else []): + distill_dir = os.path.join(c.home, ".config", "codex-rail", "distill") + distill_states = [] + for entry in (os.listdir(c.jobs) if os.path.isdir(c.jobs) else []): try: - st = json.load(open(f"{c.jobs}/{d}/state.json")) - ca = st.get("codex_args", []) - if "distill" in st.get("title", "") and "workspace-write" in ca and "-C" in ca: - args_ok = True + state = json.load(open(os.path.join(c.jobs, entry, "state.json"))) + if state.get("distill_version") is not None: + distill_states.append(state) except Exception: pass - # rail pre-trusts the distill dir in codex's config so the TUI session + corpus_rel = distill_states[0].get("distill_corpus_rel") if distill_states else None + corpus = os.path.join(distill_dir, corpus_rel) if corpus_rel else "" + chunks = sorted(glob.glob(os.path.join(corpus, "corpus-*.md"))) \ + if corpus and os.path.isdir(corpus) else [] + made = len(chunks) >= 1 + shown = "distill" in c.text() # a distill session is in the list + args_ok = False # ...and it carries the autonomous flags + for st in distill_states: + ca = st.get("codex_args", []) + if "distill" in st.get("title", "") and "workspace-write" in ca and "-C" in ca: + args_ok = True + # rail pre-trusts this immutable run's corpus cwd in codex's config so the TUI session # doesn't stall on the first-run "trust this folder?" gate. cfg = os.path.join(c.home, ".codex", "config.toml") - distill_dir = os.path.join(c.home, ".config", "codex-rail", "distill") - trust_ok = os.path.exists(cfg) and f'[projects."{distill_dir}"]' in open(cfg).read() + trust_ok = bool(corpus) and os.path.exists(cfg) \ + and f'[projects."{corpus}"]' in open(cfg).read() + private_dirs = [os.path.dirname(distill_dir), distill_dir] + if corpus: + private_dirs.extend([os.path.dirname(os.path.dirname(corpus)), + os.path.dirname(corpus), corpus]) private = all((os.stat(d).st_mode & 0o777) == 0o700 - for d in (os.path.dirname(distill_dir), distill_dir, corpus)) \ + for d in private_dirs) \ and all((os.stat(p).st_mode & 0o777) == 0o600 for p in chunks) check("distill (Ctrl+D): corpus aggregated + autonomous session + dir pre-trusted", made and shown and args_ok and trust_ok and private, @@ -711,9 +1289,12 @@ def secs(a): try: ddir = os.path.join(c.home, ".config", "codex-rail", "distill") os.makedirs(ddir, exist_ok=True) - # a DONE distillation: idle session whose style file is already on disk - c.seed("dist-done", "[distill v7]", distill_version=7) + # a DONE distillation: cleanup and coverage have both been certified. + c.seed("dist-done", "[distill v7]", status="exited", distill_version=7, + distill_validated=True, worker_token=None, last_error=None) open(os.path.join(ddir, "style-v007.md"), "w").write("# style\n") + open(os.path.join(ddir, "style-v007.validated"), "w").write( + "validated-by-codex-rail\n") # a WORKING distillation: a running session whose rollout shows an # in-progress turn (task_started, no task_complete) -> Active -> Working. roll = os.path.join(c.home, ".codex", "sessions", "2026", "07", "06", @@ -832,47 +1413,13 @@ def secs(a): finally: c.close() - # 21) slash-command palette: typing '/' in the composer opens a command menu - # that filters as you type and runs the command rail-side (not sent to codex). - c = Cockpit(rail).boot() - try: - c.seed_codex_history(n_sessions=1, msgs_each=2) # so /distill has input - c.key(b"/", 0.4) # open the palette - palette = all(cmd in c.text() for cmd in ("/distill", "/update", "/config", "/help")) - c.key(b"di", 0.3) # filter -> only /distill - filtered = "/distill" in c.text() and "/update" not in c.text() - c.key(b"\r", 2.5) # Enter -> run /distill (like Ctrl+D) - corpus = os.path.join(c.home, ".config", "codex-rail", "distill", "corpus") - ran = os.path.isdir(corpus) and len(glob.glob(corpus + "/corpus-*.md")) >= 1 - check("slash palette: / opens + filters commands and runs one (/distill)", - palette and filtered and ran, f"palette={palette} filtered={filtered} ran={ran}") - snap(c, "21_slash") - finally: - c.close() + # 21) The focused import/slash contract is shared verbatim with + # `--import-only`, so the standalone gate and full audit cannot drift. + _run_import_contract(rail, check, snap) - # 22) mouse: moving the pointer must NOT change the selection (the hover-select - # bug jerked a scrolled list back to the top), and the wheel scrolls it. - c = Cockpit(rail).boot() - try: - for i in range(6): - c.seed(f"ms{i}", f"mouse-sess-{i}", status="exited") - c.wait_until(lambda: c.row_with("mouse-sess-0") is not None, timeout=6) - # Compare the selected SESSION, not the whole row (which contains a ticking - # age column that would otherwise look like a change). - def sel(): - m = re.search(r"mouse-sess-\d", c.selected_row() or "") - return m.group(0) if m else None - c.key(b"\x1b[B", 0.2); c.key(b"\x1b[B", 0.2) # Down twice - before = sel() - c.key(b"\x1b[<35;20;5M", 0.3) # SGR mouse MOVE over a different row - move_ok = sel() == before # selected session unchanged - c.key(b"\x1b[<65;20;10M", 0.3) # SGR wheel scroll-down - scroll_ok = sel() != before # selection moved to a different session - check("mouse: move doesn't hijack selection; wheel scrolls it", - move_ok and scroll_ok, - f"move_ok={move_ok} scroll_ok={scroll_ok} before={before!r}") - finally: - c.close() + # 22) The focused mouse contract is shared verbatim with `--mouse-only`, so + # the visual-hover gate and full audit cannot drift. + _run_mouse_contract(rail, check, snap) # 23) preview skips codex's synthetic "" marker and # shows the last REAL agent message instead. @@ -921,19 +1468,19 @@ def sel(): finally: os.environ.pop("CODEX_RAIL_FAKE_UPDATE", None) - # 25) Space toggles autopilot on the selected session: an "⟳ auto N/cap" badge - # and an "autopilot ON" status appear; Space again turns it off. + # 25) Ctrl+A toggles autopilot on the selected session: an "⟳ auto N/cap" badge + # and an "autopilot ON" status appear; Ctrl+A again turns it off. c = Cockpit(rail, codex=FAKE_SLEEP).boot() try: c.seed_running_worker("ap-1", "AUTOPILOT ME") c.wait_until(lambda: c.row_with("AUTOPILOT ME") is not None, timeout=8) - c.key(b" ", 0.5) # Space -> autopilot ON + c.key(b"\x01", 0.5) # Ctrl+A -> autopilot ON on_badge = any("auto 0/" in r for r in c.rows()) on_status = any("autopilot on" in r.lower() for r in c.rows()) - c.key(b" ", 0.5) # Space -> OFF + c.key(b"\x01", 0.5) # Ctrl+A -> OFF off_badge = not any("auto 0/" in r for r in c.rows()) off_status = any("autopilot off" in r.lower() for r in c.rows()) - check("autopilot: Space toggles it (⟳ badge + status on, then off)", + check("autopilot: Ctrl+A toggles it (⟳ badge + status on, then off)", on_badge and on_status and off_badge and off_status, f"on={on_badge}/{on_status} off={off_badge}/{off_status}") finally: @@ -956,16 +1503,21 @@ def sel(): os.remove(sock) # vanish the socket persisted["worker_pid"] = 4294967295 # unusable as a pid json.dump(persisted, open(state_path, "w"), indent=2) - c.key(b"\x18", 0.5); c.key(b"\x18", 1.2) # Ctrl+X twice -> stop + c.key(b"\x18", 0.5) + c.key(b"\x18", 1.2) # Ctrl+X twice -> stop def _alive(p): - try: os.kill(p, 0); return True - except OSError: return False + try: + os.kill(p, 0) + return True + except OSError: + return False killed = child is not None and not _alive(child) check("stop: socket-gone worker stops via generation-scoped control (no pid/proc)", killed, f"child={child} killed={killed}") finally: try: - if worker: os.kill(worker, signal.SIGKILL) + if worker: + os.kill(worker, signal.SIGKILL) except Exception: pass c.close() @@ -990,15 +1542,19 @@ def _alive(p): open(f"{byjobs}/{bsid}/state.json", "w")) json.dump({"title": "BYST", "title_pinned": False}, open(f"{byjobs}/{bsid}/label.json", "w")) byenv = os.environ.copy() - byenv.update({"XDG_DATA_HOME": bydata, "XDG_RUNTIME_DIR": byrun, "HOME": byhome, + byenv.update({"XDG_DATA_HOME": bydata, "XDG_RUNTIME_DIR": byrun, + "XDG_CONFIG_HOME": byhome + "/.config", "CODEX_HOME": byhome + "/.codex", + "HOME": byhome, "CODEX_RAIL_CODEX": FAKE_SLEEP}) - byw = subprocess.Popen([rail, "--worker", bsid], cwd="/tmp", env=byenv, + byw = subprocess.Popen([rail, "--guardian", bsid], cwd="/tmp", env=byenv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) - bychild = None + byworker = bychild = None try: for _ in range(40): try: - bychild = json.load(open(f"{byjobs}/{bsid}/state.json")).get("child_pid") + bystate = json.load(open(f"{byjobs}/{bsid}/state.json")) + byworker = bystate.get("worker_pid") + bychild = bystate.get("child_pid") if bychild: break except Exception: @@ -1014,11 +1570,20 @@ def _alive(p): finally: c.close() finally: - try: byw.kill() - except Exception: pass + try: + byw.kill() + except Exception: + pass + if byworker: + try: + os.kill(byworker, signal.SIGKILL) + except Exception: + pass if bychild: - try: os.kill(bychild, signal.SIGKILL) - except Exception: pass + try: + os.kill(bychild, signal.SIGKILL) + except Exception: + pass shutil.rmtree(byroot, ignore_errors=True) # 28) Two managers can race to resume the same session. Only one worker may @@ -1031,7 +1596,8 @@ def _alive(p): before = json.load(open(f"{c.jobs}/{sid}/state.json")) socket_before = os.stat(before["socket"]).st_ino env = os.environ.copy() - env.update({"XDG_DATA_HOME": c.data, "XDG_RUNTIME_DIR": c.run, "HOME": c.home, + env.update({"XDG_DATA_HOME": c.data, "XDG_RUNTIME_DIR": c.run, + "XDG_CONFIG_HOME": c.config, "CODEX_HOME": c.codex_home, "HOME": c.home, "CODEX_RAIL_CODEX": FAKE_SLEEP}) loser = subprocess.Popen([rail, "--worker", sid], cwd="/tmp", env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -1061,7 +1627,8 @@ def _alive(p): os.makedirs(os.path.dirname(socket_path), exist_ok=True) open(socket_path, "w").write("do not delete") env = os.environ.copy() - env.update({"XDG_DATA_HOME": c.data, "XDG_RUNTIME_DIR": c.run, "HOME": c.home, + env.update({"XDG_DATA_HOME": c.data, "XDG_RUNTIME_DIR": c.run, + "XDG_CONFIG_HOME": c.config, "CODEX_HOME": c.codex_home, "HOME": c.home, "CODEX_RAIL_CODEX": FAKE_SLEEP}) failed = subprocess.Popen([rail, "--worker", sid], cwd="/tmp", env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -1124,6 +1691,1002 @@ def _alive(p): c2.close() shutil.rmtree(shared, ignore_errors=True) + # 31) STOP owns the whole launched process tree, including a descendant that + # calls setsid(), moves to an independent PGID, and ignores SIGTERM. It is + # not enough for the recorded direct child to die while a tool/sub-agent + # survives in the background. + c = Cockpit(rail, codex=FAKE_DETACHED).boot() + detached_records = [] + pid_file = os.path.join(c.root, "detached-pids") + os.environ["CODEX_RAIL_DETACHED_PIDS"] = pid_file + def read_detached(): + try: + return [tuple(map(int, line.split())) for line in open(pid_file) + if len(line.split()) == 3] + except (FileNotFoundError, ValueError): + return [] + + try: + sid = "detached-tree" + c.seed_running_worker(sid, "DETACHED TREE", codex=FAKE_DETACHED) + spawned = c.wait_until(lambda: bool(read_detached()), timeout=8) + detached_records = read_detached() + child = c._pid(sid, "child_pid") + + def still_running(pid): + try: + stat = open(f"/proc/{pid}/stat").read() + return stat[stat.rfind(") ") + 2:].split()[0] != "Z" + except (FileNotFoundError, ProcessLookupError): + return False + + c.goto("DETACHED TREE") + c.ctrl_x_twice() + direct_gone = child is not None and c.wait_until( + lambda: not still_running(child), timeout=9) + descendants_gone = bool(detached_records) and c.wait_until( + lambda: all(not still_running(pid) for pid, _, _ in detached_records), + timeout=3) + check("stop: kills TERM-resistant descendants in independent process groups", + spawned and direct_gone and descendants_gone, + f"spawned={spawned} child={child} direct_gone={direct_gone} " + f"descendants={detached_records} descendants_gone={descendants_gone}") + finally: + os.environ.pop("CODEX_RAIL_DETACHED_PIDS", None) + # This cleanup is intentionally independent of rail: a failing regression + # must never leave its adversarial detached process on the host. + cleanup_records = set(detached_records + read_detached()) + for pid, pgid, session_id in cleanup_records: + try: + if pid == pgid == session_id: + os.killpg(pgid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + c.close() + + # 32) Every imported rollout gets a first-resume confirmation, even when its + # mtime is older than the old 180s "maybe live" heuristic. Age is not + # proof that no other terminal still owns the transcript. + c = Cockpit(rail) + cwd = os.getcwd() + sid = "019f0000-01d0-7000-8000-00000000cafe" + d = os.path.join(c.home, ".codex", "sessions", "2026", "07", "07") + os.makedirs(d, exist_ok=True) + roll = os.path.join(d, f"rollout-2026-07-07T00-00-11-{sid}.jsonl") + with open(roll, "w") as f: + f.write(json.dumps({"type": "session_meta", + "payload": {"id": sid, "cwd": cwd, + "timestamp": "2026-07-07T00:00:11"}}) + "\n") + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "user_message", + "message": "OLDADOPT first attach"}}) + "\n") + old = time.time() - 600 + os.utime(roll, (old, old)) + c.boot(reset=False) # startup performs a full scan, including old rollouts + try: + imported = c.wait_until(lambda: c.row_with("OLDADOPT") is not None, timeout=8) + c.goto("OLDADOPT") + c.key(b"\r", 0.8) + row = c.row_with("OLDADOPT") + status = c.rows()[-1] + warned = "take over" in status and row is not None + check("adopt: first attach confirms even when rollout mtime is old", + imported and warned, + f"imported={imported} warned={warned} rollout_age={int(time.time() - old)}s " + f"status={status!r} row={row!r}") + finally: + c.close() + + # 33) Session titles and rollout previews are untrusted terminal text. OSC + # sequences must be sanitized before Print reaches the user's terminal; + # checking raw PTY bytes catches attacks that a pyte screen would consume. + c = Cockpit(rail).boot() + try: + title_osc = b"\x1b]0;TC\x07" + preview_osc = b"\x1b]52;c;PC\x07" + roll = os.path.join(c.home, ".codex", "sessions", "2026", "07", "08", + "rollout-2026-07-08T00-00-01-control.jsonl") + os.makedirs(os.path.dirname(roll), exist_ok=True) + with open(roll, "w") as f: + f.write(json.dumps({"type": "session_meta", + "payload": {"id": "control", "cwd": "/tmp"}}) + "\n") + f.write(json.dumps({"type": "event_msg", "payload": { + "type": "agent_message", + "message": "PREVIEW_SAFE " + preview_osc.decode("ascii")}}) + "\n") + mark = c.mark() + c.seed("control", "TITLE_SAFE " + title_osc.decode("ascii"), status="exited", + codex_rollout_path=roll, codex_session_id="control", title_pinned=True) + rendered = c.wait_until( + lambda: c.row_with("TITLE_SAFE") is not None + and "PREVIEW_SAFE" in (c.row_with("TITLE_SAFE") or ""), + timeout=8) + raw = c.raw_since(mark) + title_leaked = title_osc in raw + preview_leaked = preview_osc in raw + check("terminal safety: title/preview control sequences never reach raw output", + rendered and not title_leaked and not preview_leaked, + f"rendered={rendered} title_leaked={title_leaked} preview_leaked={preview_leaked}") + finally: + c.close() + + # 34) Two managers may display one enabled autopilot, but only the manager + # holding its lifetime lease may drive or toggle it. The second window's + # Ctrl+A must be refused instead of racing a duplicate injection cycle. + shared = "/tmp/rc-autopilot-managers-" + str(os.getpid()) + c1 = c2 = None + try: + c1 = Cockpit(rail, root=shared, codex=FAKE_SLEEP).boot() + c2 = Cockpit(rail, root=shared, codex=FAKE_SLEEP).boot(reset=False) + sid = "autopilot-owner" + c1.seed_running_worker(sid, "ONE AUTOPILOT", codex=FAKE_SLEEP) + c1.wait_until(lambda: c1.row_with("ONE AUTOPILOT") is not None, timeout=8) + c2.wait_until(lambda: c2.row_with("ONE AUTOPILOT") is not None, timeout=8) + c1.goto("ONE AUTOPILOT") + c1.key(b"\x01", 0.8) + apath = os.path.join(c1.jobs, sid, "autopilot.json") + + def autopilot_enabled(): + try: + return json.load(open(apath)).get("enabled") is True + except Exception: + return False + + enabled = c1.wait_until(autopilot_enabled, timeout=5) + c2.goto("ONE AUTOPILOT") + c2.key(b"\x01", 0.8) + refused = any("another Rail window" in r for r in c2.rows()) + still_enabled = autopilot_enabled() + check("autopilot lease: a second manager cannot drive/toggle the same enabled autopilot", + enabled and refused and still_enabled, + f"enabled={enabled} refused={refused} still_enabled={still_enabled}") + finally: + if c2: + c2.close() + if c1: + c1.close() + shutil.rmtree(shared, ignore_errors=True) + + # 35) A distill worker is one-shot infrastructure. Once its style output is + # present and its rollout lifecycle is Waiting, the manager must stop the + # real worker and child automatically instead of leaking an idle Codex. + c = Cockpit(rail, codex=FAKE_SLEEP).boot() + try: + sid = "distill-autostop" + version = 41 + markers = ["a1b2c3d4", "e5f60718"] + expected_turns = 7 + corpus_rel = "runs/run-cockpit-41/corpus" + roll = os.path.join(c.home, ".codex", "sessions", "2026", "07", "09", + "rollout-2026-07-09T00-00-00-distill-autostop.jsonl") + os.makedirs(os.path.dirname(roll), exist_ok=True) + with open(roll, "w") as f: + f.write(json.dumps({"type": "session_meta", + "payload": {"id": "distill-autostop-codex", "cwd": "/tmp"}}) + "\n") + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "task_started"}}) + "\n") + c.seed_running_worker( + sid, "[distill v41]", codex=FAKE_SLEEP, distill_version=version, + codex_rollout_path=roll, codex_session_id="distill-autostop-codex", + distill_expected_markers=markers, + distill_expected_user_turns=expected_turns, + distill_corpus_rel=corpus_rel, distill_validated=False) + worker = c._pid(sid, "worker_pid") + child = c._pid(sid, "child_pid") + ddir = os.path.join(c.home, ".config", "codex-rail", "distill") + os.makedirs(ddir, exist_ok=True) + corpus = os.path.join(ddir, corpus_rel) + os.makedirs(corpus, exist_ok=True) + style = os.path.join(ddir, f"style-v{version:03}.md") + with open(style, "w") as f: + f.write("# complete\n\n## Coverage\n") + for marker in markers: + f.write(f"CHUNK_ID={marker}\n") + f.write(f"USER_TURNS_READ={expected_turns}\n") + # Only now transition the rollout from Active to Waiting. This prevents + # the worker from observing a partial style footer during fixture setup. + with open(roll, "a") as f: + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "task_complete", + "last_agent_message": "style written"}}) + "\n") + + def process_running(pid): + if not pid: + return False + try: + stat = open(f"/proc/{pid}/stat").read() + return stat[stat.rfind(") ") + 2:].split()[0] != "Z" + except (FileNotFoundError, ProcessLookupError): + return False + + stopped = c.wait_until( + lambda: not process_running(worker) and not process_running(child), + timeout=18) + try: + final_state = json.load(open(f"{c.jobs}/{sid}/state.json")) + except Exception: + final_state = {} + validated = os.path.join(ddir, f"style-v{version:03}.validated") + marker_ok = os.path.exists(validated) \ + and open(validated).read() == "validated-by-codex-rail\n" + check("distill lifecycle: exact Coverage validates, marks, cleans corpus, and stops", + worker is not None and child is not None and stopped + and final_state.get("status") == "exited" + and final_state.get("distill_validated") is True + and final_state.get("worker_pid") is None + and final_state.get("child_pid") is None + and final_state.get("worker_token") is None + and final_state.get("last_error") is None + and marker_ok and not os.path.exists(os.path.dirname(corpus)), + f"worker={worker} child={child} stopped={stopped} status={final_state.get('status')} " + f"validated={final_state.get('distill_validated')} marker={marker_ok} " + f"corpus_clean={not os.path.exists(os.path.dirname(corpus))} " + f"error={final_state.get('last_error')!r}") + finally: + c.close() + + # 36 / check 40) SIGKILL the worker itself, bypassing RunGuard::drop. Its + # guardian must reap both direct Codex and detached setsid descendants, + # clear the token only after certification, and leave a failed/resumable row. + shared = "/tmp/rc-abandoned-generation-" + str(os.getpid()) + pid_file = os.path.join(shared, "detached-pids") + os.environ["CODEX_RAIL_DETACHED_PIDS"] = pid_file + c1 = c2 = None + worker = child = None + detached_records = [] + + def read_abandoned_detached(): + try: + return [tuple(map(int, line.split())) for line in open(pid_file) + if len(line.split()) == 3] + except (FileNotFoundError, ValueError): + return [] + + def abandoned_process_running(pid): + if not pid: + return False + try: + stat = open(f"/proc/{pid}/stat").read() + return stat[stat.rfind(") ") + 2:].split()[0] != "Z" + except (FileNotFoundError, ProcessLookupError): + return False + + try: + c1 = Cockpit(rail, root=shared, codex=FAKE_DETACHED).boot() + sid = "abandoned-generation" + c1.seed_running_worker(sid, "ABANDONED GENERATION", codex=FAKE_DETACHED) + spawned = c1.wait_until(lambda: bool(read_abandoned_detached()), timeout=8) + detached_records = read_abandoned_detached() + before = json.load(open(f"{c1.jobs}/{sid}/state.json")) + worker = before.get("worker_pid") + child = before.get("child_pid") + token = before.get("worker_token") + if worker: + os.kill(worker, signal.SIGKILL) # worker only; deliberately bypass Drop + c1.wait_until(lambda: not abandoned_process_running(worker), timeout=4) + + c2 = Cockpit(rail, root=shared, codex=FAKE_DETACHED).boot(reset=False) + + def recovered_state(): + try: + state = json.load(open(f"{c1.jobs}/{sid}/state.json")) + except Exception: + return None + descendants = [child] + [pid for pid, _, _ in read_abandoned_detached()] + clean = all(not abandoned_process_running(pid) for pid in descendants) + error = state.get("last_error") or "" + cleanup_recorded = "guardian cleaned its process generation" in error + if state.get("status") == "failed" and cleanup_recorded and clean: + return state + return None + + recovered = c2.wait_until(lambda: recovered_state() is not None, timeout=12) + after = recovered_state() or {} + descendants = [child] + [pid for pid, _, _ in read_abandoned_detached()] + descendants_gone = all(not abandoned_process_running(pid) for pid in descendants) + check("guardian: worker SIGKILL reaps direct + detached generation and clears token", + spawned and worker is not None and child is not None and token + and recovered and descendants_gone + and after.get("worker_pid") is None and after.get("child_pid") is None, + f"spawned={spawned} worker={worker} child={child} token={bool(token)} " + f"recovered={recovered} descendants={descendants} gone={descendants_gone} " + f"status={after.get('status')} error={after.get('last_error')!r}") + finally: + os.environ.pop("CODEX_RAIL_DETACHED_PIDS", None) + detached_records = list(set(detached_records + read_abandoned_detached())) + for pid, pgid, session_id in detached_records: + try: + if pid == pgid == session_id: + os.killpg(pgid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + if child: + try: + os.kill(child, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + if c2: + c2.close() + if c1: + c1.close() + if worker: + try: + os.waitpid(worker, os.WNOHANG) + except (ChildProcessError, ProcessLookupError): + pass + shutil.rmtree(shared, ignore_errors=True) + + # 37 / check 41) Bracketed-paste mode alone is not prompt readiness. A fake + # TUI that never renders its composer must time out as failed, with zero + # bytes written to its stdin and no private prompt in argv. + c = Cockpit(rail, codex=FAKE_PROMPT_TUI) + prompt_record = os.path.join(c.root, "never-ready-prompt.json") + os.environ["CODEX_RAIL_PROMPT_RECORD"] = prompt_record + os.environ["CODEX_RAIL_PROMPT_MODE"] = "never" + os.environ["CODEX_RAIL_PROMPT_READY_TIMEOUT_SECS"] = "2" + c.boot() + try: + private_prompt = "NEVER_READY_PRIVATE_PROMPT" + c.new(private_prompt) + + def never_ready_state(): + try: + for entry in os.listdir(c.jobs): + path = os.path.join(c.jobs, entry, "state.json") + if os.path.exists(path): + return json.load(open(path)) + except Exception: + pass + return {} + + failed = c.wait_until( + lambda: never_ready_state().get("status") == "failed", timeout=12) + prompt_result = json.load(open(prompt_record)) if os.path.exists(prompt_record) else {} + no_blind_input = prompt_result.get("early_hex") == "" \ + and prompt_result.get("payload") is None + argv_private = all(private_prompt not in arg for arg in prompt_result.get("argv", [])) + state = never_ready_state() + check("initial prompt: never-ready TUI fails without argv or blind PTY injection", + failed and no_blind_input and argv_private, + f"failed={failed} no_blind_input={no_blind_input} argv_private={argv_private} " + f"status={state.get('status')} error={state.get('last_error')!r}") + finally: + os.environ.pop("CODEX_RAIL_PROMPT_RECORD", None) + os.environ.pop("CODEX_RAIL_PROMPT_MODE", None) + os.environ.pop("CODEX_RAIL_PROMPT_READY_TIMEOUT_SECS", None) + c.close() + + # 38 / check 42) Natural root exit is also a process-generation boundary. + # A successful direct fake Codex exits after spawning a TERM-resistant + # setsid child; the worker's ChildExit path must reap that descendant and + # persist a clean exited state without requiring an explicit STOP. + c = Cockpit(rail, codex=FAKE_DETACHED).boot() + pid_file = os.path.join(c.root, "natural-exit-detached-pids") + os.environ["CODEX_RAIL_DETACHED_PIDS"] = pid_file + os.environ["CODEX_RAIL_DETACHED_ROOT_MODE"] = "exit" + detached_records = [] + + def read_natural_detached(): + try: + return [tuple(map(int, line.split())) for line in open(pid_file) + if len(line.split()) == 3] + except (FileNotFoundError, ValueError): + return [] + + def natural_process_running(pid): + if not pid: + return False + try: + stat = open(f"/proc/{pid}/stat").read() + return stat[stat.rfind(") ") + 2:].split()[0] != "Z" + except (FileNotFoundError, ProcessLookupError): + return False + + try: + sid = "natural-exit-tree" + c.seed_running_worker(sid, "NATURAL EXIT TREE", codex=FAKE_DETACHED) + spawned = c.wait_until(lambda: bool(read_natural_detached()), timeout=8) + detached_records = read_natural_detached() + + def clean_exit_state(): + try: + state = json.load(open(f"{c.jobs}/{sid}/state.json")) + except Exception: + return None + gone = all(not natural_process_running(pid) + for pid, _, _ in read_natural_detached()) + if state.get("status") == "exited" and gone: + return state + return None + + cleaned = c.wait_until(lambda: clean_exit_state() is not None, timeout=10) + state = clean_exit_state() or {} + descendants_gone = bool(detached_records) and all( + not natural_process_running(pid) for pid, _, _ in detached_records) + check("natural exit: ChildExit cleanup reaps detached process generation", + spawned and cleaned and descendants_gone and state.get("last_error") is None, + f"spawned={spawned} cleaned={cleaned} descendants={detached_records} " + f"gone={descendants_gone} status={state.get('status')} error={state.get('last_error')!r}") + finally: + os.environ.pop("CODEX_RAIL_DETACHED_PIDS", None) + os.environ.pop("CODEX_RAIL_DETACHED_ROOT_MODE", None) + detached_records = list(set(detached_records + read_natural_detached())) + for pid, pgid, session_id in detached_records: + try: + if pid == pgid == session_id: + os.killpg(pgid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + c.close() + + # 39) The private first prompt is a logical submission, not argv. Preserve + # multiline/CJK/tab, strip terminal controls, and consume it once so a + # later guarded resume cannot replay an externally-visible side effect. + c = Cockpit(rail, codex=FAKE_PROMPT_TUI).boot() + prompt_record = os.path.join(c.root, "complex-initial-prompt.json") + os.environ["CODEX_RAIL_PROMPT_RECORD"] = prompt_record + os.environ["CODEX_RAIL_PROMPT_MODE"] = "ready" + try: + sid = "complex-initial" + private_prompt = "第一行\n\tsecond 👩‍💻\x1b[201~evil" + safe_prompt = "第一行\n\tsecond 👩‍💻[201~evil" + c.seed_running_worker(sid, "COMPLEX INITIAL", codex=FAKE_PROMPT_TUI, + initial_prompt=private_prompt) + first_done = c.wait_until( + lambda: os.path.exists(prompt_record) + and json.load(open(prompt_record)).get("postcheck_done") is True, + timeout=12) + first = json.load(open(prompt_record)) if os.path.exists(prompt_record) else {} + expected_wire = (b"\x1b[200~" + safe_prompt.encode("utf-8") + b"\x1b[201~\r").hex() + first_state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + once = (first_done and first.get("payload") == safe_prompt + and first.get("wire_hex") == expected_wire + and first.get("early_hex") == "" and first.get("extra_hex") == "" + and all(private_prompt not in arg for arg in first.get("argv", [])) + and first_state.get("initial_prompt") is None + and first_state.get("initial_prompt_injecting") is False) + + first_pid = first.get("pid") + first_guardian = c._guardians[-1] + stop_ack = control_request(first_state["socket"], b"STOP\n") == b"STOPPING\n" + stopped = c.wait_until( + lambda: json.load(open(os.path.join(c.jobs, sid, "state.json"))).get("status") + == "exited", timeout=10) + try: + first_guardian.wait(timeout=5) + except subprocess.TimeoutExpired: + pass + + resume_state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + resume_state["status"] = "starting" + resume_state["updated_at"] = int(time.time()) + with open(os.path.join(c.jobs, sid, "state.json"), "w") as f: + json.dump(resume_state, f, indent=2) + c.start_guardian(sid, FAKE_PROMPT_TUI) + resumed = c.wait_until( + lambda: os.path.exists(prompt_record) + and json.load(open(prompt_record)).get("pid") != first_pid + and json.load(open(prompt_record)).get("composer_emitted") is True, + timeout=10) + second = json.load(open(prompt_record)) if os.path.exists(prompt_record) else {} + no_replay = resumed and second.get("payload") is None and second.get("early_hex") == "" + second_state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + second_stop_ack = control_request(second_state["socket"], b"STOP\n") == b"STOPPING\n" + check("initial prompt: multiline/CJK/tab exact, controls stripped, argv private, resume once", + once and stop_ack and stopped and no_replay and second_stop_ack, + f"first={first_done}/{once} stop={stop_ack}/{stopped} resumed={resumed} " + f"no_replay={no_replay} second_stop={second_stop_ack}") + finally: + os.environ.pop("CODEX_RAIL_PROMPT_RECORD", None) + os.environ.pop("CODEX_RAIL_PROMPT_MODE", None) + c.close() + + # 40) Headless delivery has a strict two-phase protocol. Active/stale or + # human-attached sessions are BUSY; an idle Waiting session is READY, + # accepts exactly one framed bracketed submission, then says DELIVERED. + c = Cockpit(rail, codex=FAKE_PROMPT_TUI).boot() + prompt_record = os.path.join(c.root, "inject-record.json") + os.environ["CODEX_RAIL_PROMPT_RECORD"] = prompt_record + os.environ["CODEX_RAIL_PROMPT_MODE"] = "ready" + attach_peer = inject_peer = None + try: + sid = "inject-handshake" + rollout = os.path.join(c.codex_home, "sessions", "2026", "07", "10", + "rollout-2026-07-10T00-00-00-inject.jsonl") + os.makedirs(os.path.dirname(rollout), exist_ok=True) + with open(rollout, "w") as f: + f.write(json.dumps({"type": "session_meta", + "payload": {"id": "inject-codex", "cwd": "/tmp"}}) + "\n") + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "task_started"}}) + "\n") + c.seed_running_worker(sid, "INJECT HANDSHAKE", codex=FAKE_PROMPT_TUI, + codex_rollout_path=rollout, + codex_session_id="inject-codex") + state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + sock_path = state["socket"] + composer = c.wait_until( + lambda: os.path.exists(prompt_record) + and json.load(open(prompt_record)).get("composer_emitted") is True, + timeout=8) + active_busy = control_request(sock_path, b"INJECT\n") == b"BUSY\n" + + with open(rollout, "a") as f: + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "task_complete"}}) + "\n") + attach_peer = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + attach_peer.settimeout(3) + attach_peer.connect(sock_path) + attach_peer.sendall(b"ATTACH 24 80\n") + time.sleep(0.4) + attached_busy = control_request(sock_path, b"INJECT\n") == b"BUSY\n" + attach_zero = json.load(open(prompt_record)).get("payload") is None + attach_peer.close() + attach_peer = None + time.sleep(0.5) + + inject_peer = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + inject_peer.settimeout(3) + inject_peer.connect(sock_path) + inject_peer.sendall(b"INJECT\n") + ready = recv_line(inject_peer) == b"READY\n" + raw_message = "idle 第一行\n\tsecond 👩‍💻\x1b[201~evil" + safe_message = "idle 第一行\n\tsecond 👩‍💻[201~evil" + wire = b"\x1b[200~" + safe_message.encode("utf-8") + b"\x1b[201~\r" + inject_peer.sendall(b"\x00" + struct.pack("!I", len(wire)) + wire) + delivered = recv_line(inject_peer) == b"DELIVERED\n" + inject_peer.sendall(b"\x02") + inject_peer.close() + inject_peer = None + recorded = c.wait_until( + lambda: os.path.exists(prompt_record) + and json.load(open(prompt_record)).get("postcheck_done") is True, + timeout=8) + record = json.load(open(prompt_record)) if os.path.exists(prompt_record) else {} + exact = (recorded and record.get("payload") == safe_message + and record.get("wire_hex") == wire.hex() + and record.get("extra_hex") == "") + + with open(rollout, "a") as f: + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "task_started"}}) + "\n") + stale_busy = control_request(sock_path, b"INJECT\n") == b"BUSY\n" + unchanged = json.load(open(prompt_record)).get("wire_hex") == wire.hex() + check("INJECT: BUSY when active/attached, READY→DELIVERED idle exact wire, stale sends zero", + composer and active_busy and attached_busy and attach_zero and ready + and delivered and exact and stale_busy and unchanged, + f"composer={composer} active={active_busy} attached={attached_busy}/{attach_zero} " + f"ready={ready} delivered={delivered} exact={exact} stale={stale_busy}/{unchanged}") + control_request(sock_path, b"STOP\n") + finally: + if inject_peer: + inject_peer.close() + if attach_peer: + attach_peer.close() + os.environ.pop("CODEX_RAIL_PROMPT_RECORD", None) + os.environ.pop("CODEX_RAIL_PROMPT_MODE", None) + c.close() + + # 41) STOP acknowledgement is immediate but cleanup is separately certified. + # Freeze the worker immediately after ACK; the manager watchdog must wait + # 8s, verify identity, SIGKILL it, and let the guardian reap descendants. + c = Cockpit(rail, codex=FAKE_DETACHED).boot() + pid_file = os.path.join(c.root, "hung-stop-detached-pids") + os.environ["CODEX_RAIL_DETACHED_PIDS"] = pid_file + detached_records = [] + + def read_hung_detached(): + try: + return [tuple(map(int, line.split())) for line in open(pid_file) + if len(line.split()) == 3] + except (FileNotFoundError, ValueError): + return [] + + def hung_running(pid): + if not pid: + return False + try: + stat = open(f"/proc/{pid}/stat").read() + return stat[stat.rfind(") ") + 2:].split()[0] != "Z" + except (FileNotFoundError, ProcessLookupError): + return False + + try: + sid = "hung-stop" + c.seed_running_worker(sid, "HUNG STOP", codex=FAKE_DETACHED) + spawned = c.wait_until(lambda: bool(read_hung_detached()), timeout=8) + detached_records = read_hung_detached() + state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + worker, child = state.get("worker_pid"), state.get("child_pid") + stop_ack = control_request(state["socket"], b"STOP\n") == b"STOPPING\n" + if worker: + os.kill(worker, signal.SIGSTOP) + c.goto("HUNG STOP") + c.ctrl_x_twice() + accepted = c.wait_until( + lambda: "stop accepted; waiting for verified cleanup" in c.text(), timeout=4) + samples = [] + deadline = time.time() + 13 + while time.time() < deadline: + samples.append(c.text()) + if not hung_running(worker) and not hung_running(child) \ + and all(not hung_running(pid) for pid, _, _ in read_hung_detached()): + break + time.sleep(0.05) + escalated = any("escalated to verified SIGKILL" in sample for sample in samples) + gone = (not hung_running(worker) and not hung_running(child) + and all(not hung_running(pid) for pid, _, _ in read_hung_detached())) + final = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + certified = (final.get("worker_pid") is None and final.get("child_pid") is None + and final.get("worker_token") is None and final.get("status") == "failed") + check("STOP: exact ACK; SIGSTOP worker escalates at 8s; guardian certifies full cleanup", + spawned and stop_ack and accepted and escalated and gone and certified, + f"spawned={spawned} ack={stop_ack} accepted={accepted} escalated={escalated} " + f"gone={gone} certified={certified} error={final.get('last_error')!r}") + finally: + os.environ.pop("CODEX_RAIL_DETACHED_PIDS", None) + detached_records = list(set(detached_records + read_hung_detached())) + for pid, pgid, session_id in detached_records: + try: + if pid == pgid == session_id: + os.killpg(pgid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + try: + if 'worker' in locals() and worker: + os.kill(worker, signal.SIGCONT) + except (ProcessLookupError, PermissionError): + pass + c.close() + + # 42) A wrong Coverage footer is a hard failure: no validated marker, no + # Done state, and the immutable corpus is retained for diagnosis/retry. + c = Cockpit(rail, codex=FAKE_SLEEP).boot() + try: + sid, version = "distill-invalid", 42 + corpus_rel = "runs/run-cockpit-42/corpus" + rollout = os.path.join(c.codex_home, "sessions", "2026", "07", "11", + "rollout-2026-07-11T00-00-00-distill-invalid.jsonl") + os.makedirs(os.path.dirname(rollout), exist_ok=True) + with open(rollout, "w") as f: + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "task_started"}}) + "\n") + c.seed_running_worker( + sid, "[distill v42]", codex=FAKE_SLEEP, distill_version=version, + codex_rollout_path=rollout, codex_session_id="distill-invalid-codex", + distill_expected_markers=["expected01"], distill_expected_user_turns=3, + distill_corpus_rel=corpus_rel, distill_validated=False) + ddir = os.path.join(c.config, "codex-rail", "distill") + corpus = os.path.join(ddir, corpus_rel) + os.makedirs(corpus, exist_ok=True) + style = os.path.join(ddir, f"style-v{version:03}.md") + with open(style, "w") as f: + f.write("# invalid\n\n## Coverage\nCHUNK_ID=wrong000\nUSER_TURNS_READ=3\n") + with open(rollout, "a") as f: + f.write(json.dumps({"type": "event_msg", + "payload": {"type": "task_complete"}}) + "\n") + failed = c.wait_until( + lambda: json.load(open(os.path.join(c.jobs, sid, "state.json"))).get("status") + == "failed", timeout=12) + state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + marker = os.path.join(ddir, f"style-v{version:03}.validated") + rejected = (failed and state.get("distill_validated") is False + and not os.path.exists(marker) and os.path.isdir(corpus) + and (state.get("last_error") or "").startswith( + "distillation output failed coverage validation:")) + check("distill validation: wrong/missing/duplicate coverage fails closed and self-stops", + rejected and state.get("worker_token") is None + and state.get("worker_pid") is None and state.get("child_pid") is None, + f"failed={failed} rejected={rejected} error={state.get('last_error')!r}") + finally: + c.close() + + # 43) Concurrent prepares share one global lease/version allocator but use + # immutable per-run corpora. Both may succeed sequentially under the lock; + # they must never share a version claim or corpus directory. + c = Cockpit(rail) + for directory in (c.jobs, c.run, c.home, c.config, c.codex_home): + os.makedirs(directory, exist_ok=True) + try: + c.seed_codex_history(n_sessions=3, msgs_each=6) + env = os.environ.copy() + env.update({"XDG_DATA_HOME": c.data, "XDG_RUNTIME_DIR": c.run, + "XDG_CONFIG_HOME": c.config, "CODEX_HOME": c.codex_home, + "HOME": c.home, "CODEX_RAIL_DISTILL_CHUNK_BYTES": "900", + "CODEX_RAIL_DISTILL_MSG_CAP": "700"}) + prepares = [subprocess.Popen([rail, "--distill-prepare"], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True) for _ in range(2)] + outputs = [] + returncodes = [] + for prepare in prepares: + output, _ = prepare.communicate(timeout=45) + outputs.append(output) + returncodes.append(prepare.returncode) + versions, corpora = [], [] + for output in outputs: + version_match = re.search(r"next output: \S+/style-v(\d+)\.md", output) + corpus_match = re.search(r"available -> (\S+/runs/run-[^/]+/corpus)", output) + if version_match: + versions.append(int(version_match.group(1))) + if corpus_match: + corpora.append(corpus_match.group(1)) + isolated = (returncodes == [0, 0] and len(set(versions)) == 2 + and len(set(corpora)) == 2 and all(os.path.isdir(p) for p in corpora) + and all(glob.glob(os.path.join(p, "corpus-*.md")) for p in corpora)) + claims = glob.glob(os.path.join(c.config, "codex-rail", "distill", "claims", + "style-v*.claim")) + check("distill prepare: global lock/version claims and concurrent run-corpus isolation", + isolated and len(claims) >= 2, + f"rc={returncodes} versions={versions} corpora={corpora} claims={len(claims)}") + finally: + c.close() + + # 44) Tiny terminals fail gracefully, resize repaints, and common grapheme + # clusters/CJK stay renderable. CI persists all three frames as PNGs. + c = Cockpit(rail, cols=20, rows=8).boot() + try: + tiny_20 = "terminal too small" in c.text().lower() and c.p.poll() is None + snap(c, "44_tiny_20x8") + c.resize(40, 12) + tiny_40 = "terminal too small" in c.text().lower() and c.p.poll() is None + snap(c, "44_tiny_40x12") + c.resize(80, 20) + unicode_titles = ["U1 e\u0301", "U2 ❤️", "U3 👩‍💻", "U4 🇨🇳", "U5 中文"] + for index, title in enumerate(unicode_titles): + c.seed(f"unicode-layout-{index}", title, status="exited", title_pinned=True) + rendered = c.wait_until( + lambda: all(c.row_with(f"U{index}") is not None for index in range(1, 6)) + and "中文" in c.text(), timeout=6) + healthy = rendered and c.p.poll() is None and any("Codex Rail" in row for row in c.rows()) + snap(c, "44_resized_unicode") + check("visual: 20x8 + 40x12 graceful, resize recovers, Unicode/CJK render, PNG captured", + tiny_20 and tiny_40 and healthy, + f"20x8={tiny_20} 40x12={tiny_40} unicode={rendered} healthy={healthy}") + finally: + c.close() + + # 45) cleanup_pending survives a manager crash. A frozen pilot keeps its + # durable main link while the watchdog works; only certified guardian + # cleanup may remove the pilot and clear that link on restart. + c = Cockpit(rail, codex=FAKE_SLEEP) + for directory in (c.jobs, c.run, c.home, c.config, c.codex_home): + os.makedirs(directory, exist_ok=True) + worker = None + try: + main_id, pilot_id = "cleanup-main", "cleanup-pilot" + c.seed(main_id, "CLEANUP MAIN", status="exited") + c.seed(pilot_id, "↳ pilot · CLEANUP MAIN", status="starting", codex=FAKE_SLEEP) + c.start_guardian(pilot_id, FAKE_SLEEP) + started = c.wait_until(lambda: c._pid(pilot_id, "child_pid") is not None, timeout=8) + worker = c._pid(pilot_id, "worker_pid") + control_path = os.path.join(c.jobs, main_id, "autopilot.json") + with open(control_path, "w") as f: + json.dump({"enabled": False, "marker_version": 2, "pilot_id": pilot_id, + "replies": 0, "cap": 8, "phase": "Idle", + "main_marker": "", "pilot_marker": "", "pending_reply": "", + "last_reason": "restart cleanup", "cleanup_pending": True, + "phase_started_at": 0}, f, indent=2) + if worker: + os.kill(worker, signal.SIGSTOP) + c.boot(reset=False) + time.sleep(0.5) + during = json.load(open(control_path)) + preserved = (during.get("pilot_id") == pilot_id + and during.get("cleanup_pending") is True + and os.path.exists(os.path.join(c.jobs, pilot_id, "state.json"))) + + def cleanup_finished(): + try: + control = json.load(open(control_path)) + except Exception: + return False + return (control.get("pilot_id") is None + and control.get("cleanup_pending") is False + and not os.path.exists(os.path.join(c.jobs, pilot_id, "state.json"))) + + finished = c.wait_until(cleanup_finished, timeout=15) + check("autopilot restart: hung cleanup_pending pilot link preserved then certified/removed", + started and preserved and finished + and os.path.exists(os.path.join(c.jobs, main_id, "state.json")), + f"started={started} preserved={preserved} finished={finished} main_kept=" + f"{os.path.exists(os.path.join(c.jobs, main_id, 'state.json'))}") + finally: + try: + if worker: + os.kill(worker, signal.SIGCONT) + except (ProcessLookupError, PermissionError): + pass + c.close() + + # 46) Removing a main may not orphan its internal pilot. The first removal + # request retires the pilot; after verification, the second removes the + # main and its now-unused control file. + c = Cockpit(rail, codex=FAKE_SLEEP).boot() + try: + main_id, pilot_id = "remove-main", "remove-pilot" + c.seed(main_id, "REMOVE MAIN WITH PILOT", status="exited") + c.seed_running_worker(pilot_id, "↳ INTERNAL PILOT", codex=FAKE_SLEEP) + control_path = os.path.join(c.jobs, main_id, "autopilot.json") + with open(control_path, "w") as f: + json.dump({"enabled": True, "marker_version": 2, "pilot_id": pilot_id, + "replies": 0, "cap": 8, "phase": "Generating", + "main_marker": "v2:1", "pilot_marker": "", + "pending_reply": "", "last_reason": None, + "cleanup_pending": False, "phase_started_at": int(time.time())}, + f, indent=2) + c.wait_until(lambda: c.row_with("REMOVE MAIN WITH PILOT") is not None, timeout=6) + c.goto("REMOVE MAIN WITH PILOT") + c.ctrl_x_twice() + pilot_retired = c.wait_until( + lambda: not os.path.exists(os.path.join(c.jobs, pilot_id, "state.json")), timeout=12) + control = json.load(open(control_path)) if os.path.exists(control_path) else {} + link_cleared = control.get("pilot_id") is None and control.get("cleanup_pending") is False + c.goto("REMOVE MAIN WITH PILOT") + c.ctrl_x_twice() + main_removed = c.wait_until( + lambda: not os.path.exists(os.path.join(c.jobs, main_id, "state.json")), timeout=5) + check("autopilot ownership: main deletion retires linked pilot before removing main", + pilot_retired and link_cleared and main_removed and not os.path.exists(control_path), + f"pilot_retired={pilot_retired} link_cleared={link_cleared} " + f"main_removed={main_removed} control_exists={os.path.exists(control_path)}") + finally: + c.close() + + # 47) Crash-state phases are fail-closed. Injecting never resends a pending + # reply; a planned-but-missing pilot cannot become an orphan; an expired + # phase pauses at the configured timeout. + c = Cockpit(rail, codex=FAKE_PROMPT_TUI) + for directory in (c.jobs, c.run, c.home, c.config, c.codex_home): + os.makedirs(directory, exist_ok=True) + prompt_record = os.path.join(c.root, "autopilot-duplicate-record.json") + os.environ["CODEX_RAIL_PROMPT_RECORD"] = prompt_record + os.environ["CODEX_RAIL_PROMPT_MODE"] = "ready" + os.environ["CODEX_RAIL_AUTOPILOT_PHASE_TIMEOUT_SECS"] = "2" + try: + now = int(time.time()) + inject_id = "phase-injecting" + c.seed(inject_id, "PHASE INJECTING", status="starting", codex=FAKE_PROMPT_TUI) + c.start_guardian(inject_id, FAKE_PROMPT_TUI) + c.wait_until(lambda: c._pid(inject_id, "child_pid") is not None, timeout=8) + c.seed("phase-planned", "PHASE PLANNED", status="exited") + c.seed("phase-timeout", "PHASE TIMEOUT", status="exited") + + def write_control(sid, **over): + control = {"enabled": True, "marker_version": 2, "pilot_id": None, + "replies": 0, "cap": 8, "phase": "Idle", + "main_marker": "v2:1", "pilot_marker": "", + "pending_reply": "", "last_reason": None, + "cleanup_pending": False, "phase_started_at": now} + control.update(over) + with open(os.path.join(c.jobs, sid, "autopilot.json"), "w") as f: + json.dump(control, f, indent=2) + + write_control(inject_id, phase="Injecting", pending_reply="SEND EXACTLY ONCE") + write_control("phase-planned", phase="StartingPilot", pilot_id="planned-no-job") + write_control("phase-timeout", phase="Generating", phase_started_at=now - 30) + c.boot(reset=False) + + def phase_controls_settled(): + try: + values = [json.load(open(os.path.join(c.jobs, sid, "autopilot.json"))) + for sid in (inject_id, "phase-planned", "phase-timeout")] + except Exception: + return False + return all(not value.get("enabled") and value.get("phase") == "Idle" + for value in values) + + settled = c.wait_until(phase_controls_settled, timeout=8) + inject = json.load(open(os.path.join(c.jobs, inject_id, "autopilot.json"))) + planned = json.load(open(os.path.join(c.jobs, "phase-planned", "autopilot.json"))) + timed = json.load(open(os.path.join(c.jobs, "phase-timeout", "autopilot.json"))) + time.sleep(1.0) + record = json.load(open(prompt_record)) if os.path.exists(prompt_record) else {} + duplicate_suppressed = (record.get("payload") is None and record.get("early_hex") == "" + and "delivery was interrupted" in (inject.get("last_reason") or "") + and inject.get("pending_reply") == "") + no_orphan = (planned.get("pilot_id") is None + and not os.path.exists(os.path.join(c.jobs, "planned-no-job"))) + timed_out = "timed out" in (timed.get("last_reason") or "") + check("autopilot crash phases: duplicate suppressed, planned pilot no orphan, timeout pauses", + settled and duplicate_suppressed and no_orphan and timed_out, + f"settled={settled} duplicate={duplicate_suppressed} no_orphan={no_orphan} " + f"timeout={timed_out} reasons={[inject.get('last_reason'), planned.get('last_reason'), timed.get('last_reason')]}") + state = json.load(open(os.path.join(c.jobs, inject_id, "state.json"))) + control_request(state["socket"], b"STOP\n") + finally: + os.environ.pop("CODEX_RAIL_PROMPT_RECORD", None) + os.environ.pop("CODEX_RAIL_PROMPT_MODE", None) + os.environ.pop("CODEX_RAIL_AUTOPILOT_PHASE_TIMEOUT_SECS", None) + c.close() + + # 48) Same-cwd launches are correlated by persisted session identity, never + # prompt text or timing. Race different prompts, identical prompts, and + # an empty prompt; every guarded worker keeps its own rollout and wire. + c = Cockpit(rail, codex=FAKE_PROMPT_TUI).boot() + record_template = os.path.join(c.root, "multi-prompt-{pid}.json") + os.environ["CODEX_RAIL_PROMPT_RECORD"] = record_template + os.environ["CODEX_RAIL_PROMPT_MODE"] = "ready" + try: + cases = [ + ("multi-a", "resume-a", "different A"), + ("multi-b", "resume-b", "different B"), + ("multi-x1", "resume-x1", "same prompt"), + ("multi-x2", "resume-x2", "same prompt"), + ("multi-empty", "resume-empty", None), + ] + expected_rollouts = {} + for index, (sid, codex_id, prompt) in enumerate(cases): + rollout = os.path.join(c.codex_home, "sessions", "2026", "07", "12", + f"rollout-2026-07-12T00-00-0{index}-{codex_id}.jsonl") + os.makedirs(os.path.dirname(rollout), exist_ok=True) + with open(rollout, "w") as f: + f.write(json.dumps({"type": "session_meta", + "payload": {"id": codex_id, "cwd": "/tmp"}}) + "\n") + expected_rollouts[sid] = rollout + c.seed(sid, f"MULTI {sid}", status="starting", codex=FAKE_PROMPT_TUI, + initial_prompt=prompt, codex_session_id=codex_id, + codex_rollout_path=rollout) + for sid, _, _ in cases: + c.start_guardian(sid, FAKE_PROMPT_TUI) + + def records_ready(): + paths = glob.glob(os.path.join(c.root, "multi-prompt-*.json")) + if len(paths) != len(cases): + return False + records = [json.load(open(path)) for path in paths] + by_id = {} + for record in records: + argv = record.get("argv", []) + if "resume" in argv and argv.index("resume") + 1 < len(argv): + by_id[argv[argv.index("resume") + 1]] = record + return (len(by_id) == len(cases) + and all(by_id[codex_id].get("composer_emitted") for _, codex_id, _ in cases) + and all(by_id[codex_id].get("postcheck_done") is True + for _, codex_id, prompt in cases if prompt is not None)) + + ready = c.wait_until(records_ready, timeout=14) + records = [json.load(open(path)) + for path in glob.glob(os.path.join(c.root, "multi-prompt-*.json"))] + by_resume = {} + for record in records: + argv = record.get("argv", []) + if "resume" in argv: + position = argv.index("resume") + if position + 1 < len(argv): + by_resume[argv[position + 1]] = record + prompt_exact = True + state_exact = True + for sid, codex_id, prompt in cases: + record = by_resume.get(codex_id, {}) + prompt_exact &= record.get("payload") == prompt + state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + state_exact &= (state.get("codex_session_id") == codex_id + and state.get("codex_rollout_path") == expected_rollouts[sid] + and state.get("initial_prompt") is None) + unique_rollouts = len(set(expected_rollouts.values())) == len(cases) + check("same cwd concurrency: different/same/empty prompts keep unique rollout identity, no cross", + ready and len(by_resume) == len(cases) and prompt_exact + and state_exact and unique_rollouts, + f"ready={ready} records={len(records)} mapped={sorted(by_resume)} " + f"prompt_exact={prompt_exact} state_exact={state_exact} unique={unique_rollouts}") + for sid, _, _ in cases: + try: + state = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + control_request(state["socket"], b"STOP\n") + except Exception: + pass + finally: + os.environ.pop("CODEX_RAIL_PROMPT_RECORD", None) + os.environ.pop("CODEX_RAIL_PROMPT_MODE", None) + c.close() + # ---- summary npass = sum(1 for _, ok, _ in results if ok) print(f"\n==== {npass}/{len(results)} checks PASS ====") @@ -1139,5 +2702,12 @@ def _alive(p): pngdir = None if "--png" in sys.argv: pngdir = sys.argv[sys.argv.index("--png") + 1] - ok = audit(RAIL, pngdir) + if "--import-only" in sys.argv: + ok = import_audit(RAIL, pngdir) + elif "--mouse-only" in sys.argv: + ok = mouse_audit(RAIL, pngdir) + elif "--visual-only" in sys.argv: + ok = visual_audit(RAIL, pngdir) + else: + ok = audit(RAIL, pngdir) sys.exit(0 if ok else 1) diff --git a/tests/demo_capture.py b/tests/demo_capture.py new file mode 100755 index 0000000..d17c53e --- /dev/null +++ b/tests/demo_capture.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Regenerate README's deterministic Rail screenshot and short animated demo. + +The capture drives the real release binary through the same PTY/pyte path as +the graphical regression suite. It starts no workers and uses only synthetic +state/rollout fixtures, so the published demo is reproducible and contains no +local paths, session ids, or conversation data. + + python3 tests/demo_capture.py [./target/release/rail] [--out docs] +""" + +import argparse +import json +import os +import signal +import time + +from PIL import Image + +from cockpit import Cockpit, RAIL + + +def rollout(cockpit, sid, preview, active): + """Create the minimal lifecycle + preview rollout used by the real UI.""" + path = os.path.join(cockpit.root, f"{sid}.jsonl") + lifecycle = "task_started" if active else "task_complete" + records = [ + {"type": "event_msg", "payload": {"type": lifecycle}}, + {"type": "event_msg", "payload": { + "type": "agent_message", "message": preview}}, + ] + with open(path, "w", encoding="utf-8") as handle: + for record in records: + handle.write(json.dumps(record, ensure_ascii=False) + "\n") + return path + + +def title_row(cockpit, title): + return next(i for i, row in enumerate(cockpit.rows()) if title in row) + + +def move_pointer(cockpit, title): + # SGR mouse coordinates are one-based. 35 means no-button pointer motion. + row = title_row(cockpit, title) + cockpit.key(f"\x1b[<35;18;{row + 1}M".encode(), 0.35) + + +def capture(rail, outdir): + cockpit = Cockpit(rail, cols=100, rows=24).boot() + try: + now = int(time.time()) + cwd = "/work/codex-rail" + fixtures = [ + ("demo-needs-1", "Review crash cleanup", "All owned process generations are clean.", + "running", False, 301), + ("demo-needs-2", "Import recent Codex chats", "7-day scan complete · /import 15d for more.", + "running", False, 481), + ("demo-work-1", "Run visual regression", "Driving real PTY mouse events and snapshots…", + "running", True, 661), + ("demo-work-2", "Verify atomic updater", "Checking release SHA, asset name, and ELF format…", + "running", True, 841), + ("demo-stop-1", "Fix hover highlight", "Hover paints only; keyboard selection stays put.", + "exited", False, 1201), + ("demo-stop-2", "Draft release notes", "Guardian, import, and visual checks are green.", + "exited", False, 1801), + ] + for sid, title, preview, status, active, age in fixtures: + path = rollout(cockpit, sid, preview, active) + cockpit.seed( + sid, + title, + status=status, + cwd=cwd, + codex_rollout_path=path, + created_at=now - age, + updated_at=now - age, + last_output_at=now - age, + ) + + if not cockpit.wait_until( + lambda: all(cockpit.row_with(title) for _, title, *_ in fixtures), + timeout=8): + raise RuntimeError("demo sessions did not render") + + frame_dir = os.path.join(cockpit.root, "demo-frames") + frame_paths = [] + + required_rows = [title for _, title, *_ in fixtures] + + def snap(name, extra_rows=()): + path = os.path.join(frame_dir, f"{name}.png") + markers = required_rows + list(extra_rows) + deadline = time.time() + 3 + while time.time() < deadline: + if not all(cockpit.row_with(marker) for marker in markers): + time.sleep(0.05) + continue + # Rail repaints changed rows in one flush, but a PTY may split + # that flush into chunks. Freeze the manager and re-check the + # parsed screen before copying it so published media can never + # contain a half-cleared frame. + os.kill(cockpit.p.pid, signal.SIGSTOP) + try: + time.sleep(0.08) + if all(cockpit.row_with(marker) for marker in markers): + cockpit.png(path) + break + finally: + os.kill(cockpit.p.pid, signal.SIGCONT) + else: + raise RuntimeError(f"could not capture complete demo frame {name}") + frame_paths.append(path) + + snap("01-overview") + move_pointer(cockpit, "Run visual regression") + snap("02-hover-working") + move_pointer(cockpit, "Fix hover highlight") + snap("03-hover-stopped") + cockpit.key(b"\x1b[B", 0.35) + snap("04-keyboard-selection") + cockpit.type("/i", 0.45) + snap("05-import-palette", ("/import",)) + + os.makedirs(outdir, exist_ok=True) + png_path = os.path.join(outdir, "rail-demo.png") + gif_path = os.path.join(outdir, "rail-demo.gif") + with Image.open(frame_paths[1]) as source: + source.convert("RGB").save(png_path, optimize=True) + + frames = [] + for path in frame_paths: + with Image.open(path) as source: + frames.append(source.convert("P", palette=Image.Palette.ADAPTIVE, + colors=128)) + frames[0].save( + gif_path, + save_all=True, + append_images=frames[1:], + duration=[1300, 1100, 1100, 1100, 1700], + loop=0, + optimize=True, + disposal=2, + ) + return png_path, gif_path + finally: + cockpit.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("rail", nargs="?", default=RAIL) + parser.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "..", "docs")) + args = parser.parse_args() + png, gif = capture(os.path.abspath(args.rail), os.path.abspath(args.out)) + for path in (png, gif): + print(f"{path} {os.path.getsize(path)} bytes") + + +if __name__ == "__main__": + main() diff --git a/tests/distill_realcodex.py b/tests/distill_realcodex.py index 0c4b914..aaf019d 100644 --- a/tests/distill_realcodex.py +++ b/tests/distill_realcodex.py @@ -5,14 +5,20 @@ It builds a small synthetic CODEX_HOME with a recognizable user "voice", runs `rail --distill-prepare` (forcing tiny chunks so a handful of messages still spans several files), then runs a REAL `codex exec` with the exact prompt rail -would use — and checks the resulting style file echoes back EVERY chunk marker. -If codex had grep-sampled or stopped early, some markers would be missing. +would use. Success requires the same exact machine-readable Coverage contract +as the worker: each per-run marker exactly once and the exact USER-turn count. python3 tests/distill_realcodex.py [./target/release/rail] Slow (a real codex run) — not part of the fast cockpit/regress suites. """ -import json, os, re, subprocess, sys, time, shutil +import json +import os +import re +import shutil +import subprocess +import sys +import time REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) RAIL = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.join(REPO, "target/release/rail") @@ -71,20 +77,37 @@ def main(): prep = run([RAIL, "--distill-prepare"], env=env, timeout=60) print(prep.stdout.rstrip()) if prep.returncode != 0: - print("FAIL: --distill-prepare errored"); return 1 - markers = re.findall(r"id=([0-9a-f]{8})", prep.stdout) - workdir = re.search(r"in (\S+)/corpus", prep.stdout).group(1) - out_m = re.search(r"next output: \S+/(style-v\d+\.md)", prep.stdout) - out_file = out_m.group(1) - prompt_path = re.search(r"prompt: (\S+)", prep.stdout).group(1) + print("FAIL: --distill-prepare errored") + return 1 + markers = re.findall(r"^ corpus-\d+\.md id=([0-9a-f]{8})$", + prep.stdout, flags=re.MULTILINE) + corpus_m = re.search(r"available -> (\S+/runs/run-[^/]+/corpus)$", + prep.stdout, flags=re.MULTILINE) + out_m = re.search(r"^next output: (\S+/style-v\d+\.md)$", + prep.stdout, flags=re.MULTILINE) + prompt_m = re.search(r"^prompt: (\S+)$", prep.stdout, flags=re.MULTILINE) + turns_m = re.search(r"· (\d+) user turns ·", prep.stdout) + if not all((corpus_m, out_m, prompt_m, turns_m)): + print("FAIL: could not parse sealed --distill-prepare output") + return 1 + corpus = corpus_m.group(1) + out_path = out_m.group(1) + workdir = os.path.dirname(out_path) + prompt_path = prompt_m.group(1) + expected_turns = int(turns_m.group(1)) prompt = open(prompt_path, encoding="utf-8").read() n_chunks = len(markers) - print(f"\nprepared: {n_chunks} chunks, {n_msgs} synthetic msgs, workdir={workdir}") + print(f"\nprepared: {n_chunks} chunks, {n_msgs} synthetic msgs, " + f"expected_turns={expected_turns}, corpus={corpus}, workdir={workdir}") if n_chunks < 3: - print(f"FAIL: expected >=3 chunks to test multi-file reading, got {n_chunks}"); return 1 + print(f"FAIL: expected >=3 chunks to test multi-file reading, got {n_chunks}") + return 1 + if not os.path.isdir(corpus): + print(f"FAIL: immutable run corpus is missing: {corpus}") + return 1 # 2) REAL codex reads the whole corpus and writes the style file - print(f"\nrunning real codex exec (this takes a while) …") + print("\nrunning real codex exec (this takes a while) …") t0 = time.time() cx = run([CODEX, "exec", "-C", workdir, "--skip-git-repo-check", "-s", "workspace-write", @@ -97,19 +120,30 @@ def main(): print("---- codex tail ----\n" + tail + "\n--------------------") # 3) verify: the style file exists and echoes back EVERY chunk marker - out_path = os.path.join(workdir, out_file) if not os.path.exists(out_path): - print(f"FAIL: {out_path} was not written"); return 1 + print(f"FAIL: {out_path} was not written") + return 1 style = open(out_path, encoding="utf-8").read() - found = [m for m in markers if m in style] - missing = [m for m in markers if m not in style] + lines = style.splitlines() + coverage_indexes = [i for i, line in enumerate(lines) if line.strip() == "## Coverage"] + coverage = lines[coverage_indexes[-1] + 1:] if coverage_indexes else [] + found = [line.strip().removeprefix("CHUNK_ID=") for line in coverage + if line.strip().startswith("CHUNK_ID=")] + turn_claims = [line.strip().removeprefix("USER_TURNS_READ=") for line in coverage + if line.strip().startswith("USER_TURNS_READ=")] + exact_markers = (len(found) == len(markers) + and len(set(found)) == len(found) + and set(found) == set(markers)) + exact_turns = turn_claims == [str(expected_turns)] + missing = sorted(set(markers) - set(found)) has_sections = sum(k.lower() in style.lower() for k in ("voice", "tone", "feedback", "instruction", "coverage")) >= 3 print(f"\nstyle file: {len(style)} chars, sections_ok={has_sections}") - print(f"markers covered: {len(found)}/{n_chunks} missing={missing}") - ok = (len(missing) == 0) and has_sections - print("\n" + ("PASS — codex read the full corpus (all markers present)" - if ok else "FAIL — incomplete read or thin summary")) + print(f"coverage markers={found} missing={missing} exact={exact_markers}") + print(f"turn claims={turn_claims} expected={expected_turns} exact={exact_turns}") + ok = exact_markers and exact_turns and has_sections + print("\n" + ("PASS — exact full-corpus Coverage contract satisfied" + if ok else "FAIL — incomplete/duplicate/wrong Coverage or thin summary")) # leave ROOT for inspection on failure; clean on success if ok: shutil.rmtree(ROOT, ignore_errors=True) diff --git a/tests/fakecodex_prompt_tui b/tests/fakecodex_prompt_tui new file mode 100755 index 0000000..ce628a5 --- /dev/null +++ b/tests/fakecodex_prompt_tui @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Fake Codex TUI that exposes early/blind initial-prompt injection.""" + +import json +import os +import sys +import time +import tty + + +record_path = os.environ["CODEX_RAIL_PROMPT_RECORD"].replace("{pid}", str(os.getpid())) +mode = os.environ.get("CODEX_RAIL_PROMPT_MODE", "ready") +fd = sys.stdin.fileno() +tty.setraw(fd) +os.set_blocking(fd, False) + +record = { + "pid": os.getpid(), + "argv": sys.argv[1:], + "early_hex": "", + "payload": None, + "framed": False, + "ready_emitted": False, + "composer_emitted": False, + "wire_hex": "", + "extra_hex": "", + "postcheck_done": False, +} + + +def persist(): + tmp = f"{record_path}.{os.getpid()}.tmp" + with open(tmp, "w", encoding="utf-8") as out: + json.dump(record, out, ensure_ascii=False) + os.replace(tmp, record_path) + + +def read_available(): + chunks = [] + while True: + try: + chunk = os.read(fd, 65536) + except BlockingIOError: + break + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) + + +persist() +os.write(sys.stdout.fileno(), b"\x1b[?2004h") +record["ready_emitted"] = True +persist() + +# Enabling bracketed paste is not enough: Codex can do that before its composer +# exists. A blind fixed sleep loses this race, so consume and record every byte +# that reaches stdin before the UTF-8 composer marker appears. +early = bytearray() +composer_at = time.monotonic() + 0.70 +while time.monotonic() < composer_at: + early.extend(read_available()) + if early: + record["early_hex"] = bytes(early).hex() + persist() + time.sleep(0.02) + +if mode == "never": + # Stay alive with bracketed paste enabled but no composer. Rail owns the + # readiness timeout and must fail/stop this process itself. + while True: + early.extend(read_available()) + if early: + record["early_hex"] = bytes(early).hex() + persist() + time.sleep(0.02) + +os.write(sys.stdout.fileno(), "›".encode("utf-8")) +record["composer_emitted"] = True +persist() + +received = bytearray() +deadline = time.monotonic() + 8.0 +while time.monotonic() < deadline: + received.extend(read_available()) + if b"\x1b[201~\r" in received: + break + time.sleep(0.02) + +prefix = b"\x1b[200~" +suffix = b"\x1b[201~\r" +wire = bytes(received) +record["wire_hex"] = wire.hex() +record["framed"] = wire.startswith(prefix) and wire.endswith(suffix) +if record["framed"]: + record["payload"] = wire[len(prefix):-len(suffix)].decode("utf-8", errors="replace") +persist() + +if record["framed"]: + os.write(sys.stdout.fileno(), b"tick prompt accepted\r\n") + extra = bytearray() + postcheck_deadline = time.monotonic() + 1.0 + while time.monotonic() < postcheck_deadline: + extra.extend(read_available()) + time.sleep(0.02) + record["extra_hex"] = bytes(extra).hex() +record["postcheck_done"] = True +persist() +while True: + time.sleep(3600) diff --git a/tests/fakecodex_spawn_detached b/tests/fakecodex_spawn_detached new file mode 100755 index 0000000..4c0ed65 --- /dev/null +++ b/tests/fakecodex_spawn_detached @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Fake Codex with a TERM-resistant descendant in its own session/process group.""" + +import os +import signal +import sys +import time + + +pid_file = os.environ["CODEX_RAIL_DETACHED_PIDS"] +detached = os.fork() +if detached == 0: + os.setsid() + for sig in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): + signal.signal(sig, signal.SIG_IGN) + with open(pid_file, "a", encoding="utf-8") as out: + out.write(f"{os.getpid()} {os.getpgrp()} {os.getsid(0)}\n") + out.flush() + while True: + signal.pause() + +if os.environ.get("CODEX_RAIL_DETACHED_ROOT_MODE") == "exit": + # Ensure the adversarial child has published its identity before the direct + # Codex exits successfully and the worker takes its natural ChildExit path. + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline and not os.path.exists(pid_file): + time.sleep(0.01) + sys.exit(0) + +# Make rail exercise its SIGKILL grace path for the direct child as well. +for sig in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): + signal.signal(sig, signal.SIG_IGN) +while True: + signal.pause() diff --git a/tests/regress.py b/tests/regress.py index e1ce10f..55a124f 100644 --- a/tests/regress.py +++ b/tests/regress.py @@ -1,411 +1,34 @@ #!/usr/bin/env python3 -"""End-to-end regression tests for the rail manager. +"""Compatibility entry point for Rail's end-to-end regression suite. -These drive the REAL rail binary in a pty and read the REAL rendered screen -(via pyte) plus the on-disk state — no mocked internals. They exist because an -earlier round of "unit" checks passed while the shipped binary was still broken: -the bugs lived in the manager<->worker file interaction and in parsing real -codex rollouts, neither of which a fake could exercise. +The historical script duplicated a subset of cockpit.py and consequently +drifted to the removed e/w/s/Space shortcuts. Keep one executable source of +truth: callers of ``tests/regress.py`` now run the same sealed-interface audit +as CI, including guardian, prompt, process-tree, distill, and visual checks. - A rename survives a clobbering worker (title lives in manager-only label.json) - B message preview recovered from a real-format rollout (cwd+start correlation) - C `e` + text creates a session whose title is stored in label.json - D Space is reserved and never opens the composer - E rename on an idle/exited session sticks - F Working status latches through a long silent turn (incremental lifecycle scan) - G idle manager does zero full-screen clears (line-diff renderer, no flicker) - H attach then detach repaints the manager (no blank screen) - I Ctrl-X twice removes a stopped session from the list and disk - J a zombie worker is treated as stopped (not alive) and is removable - -Usage: python3 tests/regress.py ./target/release/rail [./tests/fakecodex] +Usage: python3 tests/regress.py [./target/release/rail] [--png OUTDIR] """ -import fcntl, json, os, pty, select, shutil, signal, struct, subprocess, sys, termios, threading, time -import pyte - -HERE = os.path.dirname(os.path.abspath(__file__)) -RAIL = sys.argv[1] if len(sys.argv) > 1 else "./target/release/rail" -FAKE = sys.argv[2] if len(sys.argv) > 2 else os.path.join(HERE, "fakecodex") -ROOT = "/tmp/rail-regress" -COLS, ROWS = 110, 40 - - -def sid_start_secs(session_id): - return int(session_id.replace("-", "")[:12], 16) // 1000 - - -def write_state(sp, st, old_schema=False): - s = dict(st) - if old_schema: # a worker build that predates these fields - for k in ("title_pinned", "initial_prompt"): - s.pop(k, None) - tmp = sp + ".tmp" - json.dump(s, open(tmp, "w"), indent=2) - os.replace(tmp, sp) - - -def base_state(sid, title, cwd, **over): - now = int(time.time()) - st = {"id": sid, "title": title, "cwd": cwd, "codex": FAKE, "status": "running", - "worker_pid": None, "child_pid": None, "socket": f"{ROOT}/run/{sid}.sock", - "created_at": now, "updated_at": now, "exit_code": None, "last_error": None, - "codex_session_id": None, "codex_rollout_path": None, "initial_prompt": None, - "title_pinned": False, "last_output_at": 0} - st.update(over) - return st - - -class Manager: - def __init__(self, data, run, codex_home=None): - env = os.environ.copy() - env.update({"XDG_DATA_HOME": data, "XDG_RUNTIME_DIR": run, "HOME": ROOT + "/home", - "CODEX_RAIL_CODEX": FAKE, "TERM": "xterm-256color", - "CODEX_RAIL_HINT_MS": "60", # 4s detach-hint progress bar -> fast in tests - "COLUMNS": str(COLS), "LINES": str(ROWS)}) - if codex_home: - env["CODEX_HOME"] = codex_home - os.makedirs(ROOT + "/home", exist_ok=True) - self.m, s = pty.openpty() - fcntl.ioctl(s, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) - self.p = subprocess.Popen([RAIL], stdin=s, stdout=s, stderr=s, env=env, - preexec_fn=os.setsid, close_fds=True) - os.close(s) - self.screen = pyte.Screen(COLS, ROWS) - self.stream = pyte.ByteStream(self.screen) - self.raw = bytearray() - self.lock = threading.Lock() - self.stopped = threading.Event() - threading.Thread(target=self._drain, daemon=True).start() - time.sleep(1.6) - - def _drain(self): - while not self.stopped.is_set(): - r, _, _ = select.select([self.m], [], [], 0.1) - if self.m in r: - try: - chunk = os.read(self.m, 65536) - except OSError: - return - with self.lock: - self.raw += chunk - self.stream.feed(chunk) - - def mark(self): - with self.lock: - return len(self.raw) - - def raw_since(self, mark): - with self.lock: - return bytes(self.raw[mark:]) - - def send(self, payload, settle=0.4): - os.write(self.m, payload) - time.sleep(settle) - - def rows(self): - with self.lock: - return [r.rstrip() for r in self.screen.display] - - def text(self): - return "\n".join(self.rows()) - - def row_with(self, needle): - return next((r for r in self.rows() if needle in r), None) - - def close(self): - self.stopped.set() - try: - os.killpg(os.getpgid(self.p.pid), signal.SIGKILL) - except Exception: - pass - -def kill_children(jobs): - if not os.path.isdir(jobs): - return - for d in os.listdir(jobs): - try: - st = json.load(open(jobs + "/" + d + "/state.json")) - for k in ("child_pid", "worker_pid"): - if st.get(k): - try: - os.kill(st[k], signal.SIGKILL) - except Exception: - pass - except Exception: - pass +import os +import sys +from cockpit import audit, visual_audit -def setup(sid, title, **over): - old_schema = over.pop("old_schema", False) - cwd = over.pop("cwd", "/tmp") - data, run = ROOT + "/data", ROOT + "/run" - jobs = data + "/codex-rail/jobs" - shutil.rmtree(ROOT, ignore_errors=True) - os.makedirs(jobs + "/" + sid, exist_ok=True) - os.makedirs(run, exist_ok=True) - sp = jobs + "/" + sid + "/state.json" - write_state(sp, base_state(sid, title, cwd, **over), old_schema=old_schema) - return data, run, jobs, sp - -# ---- A: rename survives a clobbering worker ---- -def test_a(): - print("\n== A: rename sticks against a clobbering worker ==") - data, run, jobs, sp = setup("clob", "hi", old_schema=True) - st = base_state("clob", "hi", "/tmp") - stop = threading.Event(); n = [0] - def clobber(): - while not stop.is_set(): - st["updated_at"] = int(time.time()) - write_state(sp, st, old_schema=True); n[0] += 1 - time.sleep(0.4) - threading.Thread(target=clobber, daemon=True).start() - mgr = Manager(data, run) - try: - mgr.send(b"\x12", 0.5); mgr.send(b"\x7f" * 24, 0.3) - mgr.send(b"hi2", 0.3); mgr.send(b"\r", 0.6) - time.sleep(4.0) - lp = jobs + "/clob/label.json" - label = json.load(open(lp)) if os.path.exists(lp) else None - disk = json.load(open(sp)) - ok = (mgr.row_with("hi2") is not None and label == {"title": "hi2", "title_pinned": True} - and n[0] > 3 and "title_pinned" not in disk) - print(f" clobber writes={n[0]} label={label} state.title={disk.get('title')!r}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - stop.set(); mgr.close() - - -# ---- B: preview recovered from a real-format rollout ---- -def test_b(): - print("\n== B: preview recovered from a real-format rollout ==") - fix = os.path.join(HERE, "fixtures", - "rollout-2026-07-02T13-51-07-019f2161-87b9-76e2-b7d3-64edc6cda7d1.jsonl") - start = sid_start_secs("019f2161-87b9-76e2-b7d3-64edc6cda7d1") - data, run, jobs, sp = setup("orphan", "review", - cwd="/tmp/rail-fixture-cwd", created_at=start - 20) - chome = ROOT + "/codexhome/sessions/2026/07/02" - os.makedirs(chome, exist_ok=True) - shutil.copy(fix, chome + "/" + os.path.basename(fix)) - mgr = Manager(data, run, codex_home=ROOT + "/codexhome") - try: - time.sleep(1.2) - row = mgr.row_with("review") - ok = row is not None and "PREVIEW_OK" in row - print(f" row={row.strip()!r}" if row else " row MISSING") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close() - - -# ---- C: `e` + text creates a session titled via label.json ---- -def test_c(): - print("\n== C: new session via `e` stores title in label.json ==") - data, run, jobs, _ = setup("seed", "seed-session", status="exited") - mgr = Manager(data, run) - try: - mgr.send(b"e", 0.4); mgr.send(b"my new task", 0.3) - mgr.send(b"\r", 1.2); mgr.send(b"\x1a", 1.0) # submit, then Ctrl+Z detach - titles = [] - for d in os.listdir(jobs): - lp = jobs + "/" + d + "/label.json" - if os.path.exists(lp): - titles.append(json.load(open(lp)).get("title")) - ok = "my new task" in titles - print(f" label titles={titles}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close(); kill_children(jobs) - - -# ---- D: Space is reserved ---- -def test_d(): - print("\n== D: Space reserved (no composer, no create) ==") - data, run, jobs, _ = setup("only", "only-session", status="exited") - mgr = Manager(data, run) - try: - mgr.send(b" ", 0.3); mgr.send(b" ", 0.3); mgr.send(b"\r", 0.5) - n = len(os.listdir(jobs)) - ok = n == 1 - print(f" session count={n}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close(); kill_children(jobs) - - -# ---- E: rename an idle/exited session ---- -def test_e(): - print("\n== E: rename on an idle/exited session sticks ==") - data, run, jobs, sp = setup("stat", "alpha", status="exited") - mgr = Manager(data, run) - try: - mgr.send(b"\x12", 0.5); mgr.send(b"\x7f" * 24, 0.3) - mgr.send("renamed-alpha".encode(), 0.3); mgr.send(b"\r", 0.6) - time.sleep(0.6) - lp = jobs + "/stat/label.json" - label = json.load(open(lp)) if os.path.exists(lp) else None - ok = mgr.row_with("renamed-alpha") is not None and label == {"title": "renamed-alpha", "title_pinned": True} - print(f" label={label}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close() - - -# ---- F: Working latches through a silent gap (the mtime bug) ---- -def test_f(): - print("\n== F: Working status latches through a silent gap ==") - sid = "019f25a9-adfd-7b43-a6f7-f3682b46ed54" - meta = {"type": "session_meta", "payload": {"session_id": sid, "id": sid, "cwd": "/tmp/cw"}} - started = {"type": "event_msg", "payload": {"type": "task_started", "turn_id": "t"}} - complete = {"type": "event_msg", "payload": {"type": "task_complete", "turn_id": "t", - "last_agent_message": "done"}} - start = sid_start_secs(sid) - data, run, jobs, sp = setup("w1", "worker", cwd="/tmp/cw", created_at=start, - codex_session_id=sid) - chome = ROOT + "/ch/sessions/2026/07/03" - os.makedirs(chome, exist_ok=True) - rp = chome + f"/rollout-2026-07-03T09-48-25-{sid}.jsonl" - open(rp, "w").write("".join(json.dumps(x) + "\n" for x in (meta, started))) - # Point the session at this rollout. - st = json.load(open(sp)); st["codex_rollout_path"] = rp; write_state(sp, st) - - # Count sessions in a named section. Empty sections are now hidden, so an - # absent section reads as None (not 0) — which is exactly how we assert the - # session LEFT Working and arrived in Needs input after task_complete. - def scount(mgr, name): - for r in mgr.rows(): - if name in r: - toks = r.replace("✻", " ").replace("●", " ").replace("○", " ").split() - for t in reversed(toks): - if t.isdigit(): - return int(t) - return None - return None - - mgr = Manager(data, run, codex_home=ROOT + "/ch") - try: - time.sleep(1.2); c0 = scount(mgr, "Working") - time.sleep(8.0); c8 = scount(mgr, "Working") # 8s of silence (> old 6s threshold) - with open(rp, "a") as f: f.write(json.dumps(complete) + "\n") - time.sleep(1.5) - wf = scount(mgr, "Working") # now empty -> hidden -> None - nf = scount(mgr, "Needs input") # session moved here -> 1 - ok = c0 == 1 and c8 == 1 and wf is None and nf == 1 - print(f" Working: t0={c0} t8s(silent)={c8} after_complete: Working={wf} NeedsInput={nf}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close() - - -# ---- G: idle renders emit no full-screen clears (flicker) ---- -def test_g(): - print("\n== G: idle emits no full-screen clears ==") - data, run, jobs, _ = setup("idle", "idle-session", status="exited") - mgr = Manager(data, run) - try: - time.sleep(1.0) - mark = mgr.mark() - time.sleep(4.0) # ~5 refresh cycles, no interaction - chunk = mgr.raw_since(mark) - clears = chunk.count(b"\x1b[2J") # Clear(ClearType::All) - ok = clears == 0 - print(f" idle bytes={len(chunk)} full_clears={clears}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close() - - -# ---- H: create -> attach -> detach repaints the list (diff-render round-trip) ---- -def test_h(): - print("\n== H: attach then detach repaints the manager (no blank screen) ==") - data, run, jobs, _ = setup("seed", "seed-session", status="exited") - mgr = Manager(data, run) - try: - # `e` + text creates a session, spawns a real worker (fakecodex), and - # auto-attaches. fakecodex prints "tick ..." so we can see the attach. - mgr.send(b"e", 0.4); mgr.send(b"tick-test", 0.3); mgr.send(b"\r", 2.5) - attached_ok = any("tick" in r for r in mgr.rows()) - # Ctrl+Z detaches back to the manager, which must fully repaint. - mgr.send(b"\x1a", 1.5) - rows = mgr.rows() - repainted = any("Codex Rail" in r for r in rows) and any("tick-test" in r for r in rows) - ok = attached_ok and repainted - print(f" attach showed codex output={attached_ok} list repainted after detach={repainted}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close(); kill_children(jobs) - - -# ---- I: Ctrl-X twice removes a stopped session from the list ---- -def test_i(): - print("\n== I: Ctrl-X twice removes a stopped session ==") - data, run, jobs, sp = setup("gone", "REMOVE_ME", status="exited") - mgr = Manager(data, run) - try: - listed_before = mgr.row_with("REMOVE_ME") is not None - mgr.send(b"\x18", 0.4) # first Ctrl-X: arm + confirm prompt - confirm = "remove this stopped" in mgr.text() - mgr.send(b"\x18", 0.8) # second Ctrl-X: remove - time.sleep(0.6) - gone_screen = mgr.row_with("REMOVE_ME") is None - dir_gone = not os.path.exists(jobs + "/gone") - ok = listed_before and confirm and gone_screen and dir_gone - print(f" listed_before={listed_before} confirm_prompt={confirm} " - f"gone_from_screen={gone_screen} dir_deleted={dir_gone}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close() +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO = os.path.dirname(HERE) -# ---- J: a zombie worker (defunct, unreaped) is treated as stopped, not alive ---- -def test_j(): - print("\n== J: zombie worker reconciles to Stopped and is removable ==") - zpid = os.fork() - if zpid == 0: - os._exit(0) # exits at once -> zombie (never reaped here) - time.sleep(0.2) - stt = open(f"/proc/{zpid}/stat").read() - zstate = stt[stt.rfind(") ") + 2:].strip()[0] # sanity: kernel state char is 'Z' - data, run, jobs, sp = setup("zomb", "ZOMBIE_ROW", - status="running", worker_pid=zpid, child_pid=99999999) - mgr = Manager(data, run) - try: - row = mgr.row_with("ZOMBIE_ROW") or "" - reconciled = "○" in row # ○ hollow = Stopped bucket (kill(pid,0) alone would say alive) - mgr.send(b"\x18", 0.4); mgr.send(b"\x18", 0.8); time.sleep(0.6) - removed = mgr.row_with("ZOMBIE_ROW") is None and not os.path.exists(jobs + "/zomb") - ok = zstate == "Z" and reconciled and removed - print(f" proc_state={zstate!r} reconciled_to_stopped={reconciled} removable={removed}") - print(" ", "PASS" if ok else "FAIL") - return ok - finally: - mgr.close() - try: - os.waitpid(zpid, 0) - except Exception: - pass +def main(): + rail = (os.path.abspath(sys.argv[1]) + if len(sys.argv) > 1 and not sys.argv[1].startswith("-") + else os.path.join(REPO, "target", "release", "rail")) + pngdir = None + if "--png" in sys.argv: + pngdir = sys.argv[sys.argv.index("--png") + 1] + runner = visual_audit if "--visual-only" in sys.argv else audit + return 0 if runner(rail, pngdir) else 1 if __name__ == "__main__": - results = {} - for name, fn in [("A", test_a), ("B", test_b), ("C", test_c), ("D", test_d), - ("E", test_e), ("F", test_f), ("G", test_g), ("H", test_h), - ("I", test_i), ("J", test_j)]: - try: - results[name] = fn() - except Exception as e: - print(f" EXCEPTION in {name}: {e}") - results[name] = False - shutil.rmtree(ROOT, ignore_errors=True) - print("\nSUMMARY: " + " | ".join(f"{k} {'PASS' if v else 'FAIL'}" for k, v in results.items())) - sys.exit(0 if all(results.values()) else 1) + raise SystemExit(main()) diff --git a/tests/requirements.txt b/tests/requirements.txt index 21ee303..9bc50be 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1,2 @@ pyte==0.8.2 +Pillow==10.4.0 From f1936ec8688b14877d7c3e281d80fead32ee9062 Mon Sep 17 00:00:00 2001 From: wuyitao Date: Tue, 14 Jul 2026 00:21:29 +0800 Subject: [PATCH 3/6] Fix cockpit gate and persist pre-bind worker setup failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production (src/worker.rs): - mark_failed_if_owner: reject only an EXPLICIT different owner. A None worker_pid means the failure happened before the final bind persisted our pid (e.g. a non-socket sits at the canonical socket path and run_worker bails pre-bind); at that point we still hold both worker.lock and socket.lock, so the error is ours to record and must not be swallowed into a stuck `starting` row. This is the one real production defect behind the red gate. Cockpit (tests/cockpit.py) — no production behavior changed: - keys #9: compare a stable row identity (the age cell ticks 0s->1s between reads) and wait on the composer repaint instead of fixed sleeps (fixes the nav flake and the uppercase-W race). - redesign #13: clear the transient "auto-imported N chat(s)..." footer with a harmless Left-arrow before asserting the Windows-style key hints. - distill #15: production runs the distill session FROM (and pre-trusts) the stable distill root; the corpus is a private per-run child. Assert cwd == -C == distill_dir trust and corpus-under-runs, not corpus trust. - never-ready: accept portable-pty 0.8.1's newline+VEOF ("0a04") writer-Drop cleanup sentinel; still forbid framed/private prompt bytes. - INJECT: drop the redundant post-DELIVERED detach frame. INJECT is a one-frame protocol and the worker closes the headless slot the instant it emits DELIVERED, so the extra frame raced that close and aborted the whole run with BrokenPipe (which had been hiding the two checks below). - STOP #58: poll for the guardian's async certification instead of reading the state file the instant the tree dies (the certified write lags process death). - distill-prepare #60: the run lock is non-blocking fail-closed (LOCK_EX| LOCK_NB), so a second overlapping prepare is refused, not queued. Assert the winners never share a version claim or corpus dir and each loser is exactly that fail-closed refusal, rather than expecting two concurrent successes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/worker.rs | 8 ++- tests/cockpit.py | 143 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 110 insertions(+), 41 deletions(-) diff --git a/src/worker.rs b/src/worker.rs index 6020c4b..bffb8e8 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1490,7 +1490,13 @@ fn persist_state(session: &SessionState) -> Result<()> { fn mark_failed_if_owner(id: &str, message: &str) { if let Ok(mut session) = state::read_state(id) { - if session.worker_pid != Some(process::id()) { + // Reject only an EXPLICIT different owner. A None worker_pid means the + // failure happened before the final bind persisted our pid (e.g. a + // non-socket sits at the canonical path, worker.rs bails pre-bind); at + // that point run_worker still holds both worker.lock and socket.lock, so + // recording the error is safe and it must not be silently swallowed into + // a stuck `starting` row. + if session.worker_pid.is_some_and(|pid| pid != process::id()) { return; } session.status = STATUS_FAILED.to_string(); diff --git a/tests/cockpit.py b/tests/cockpit.py index 3f0681b..68c03bb 100644 --- a/tests/cockpit.py +++ b/tests/cockpit.py @@ -1071,28 +1071,45 @@ def secs(a): try: c.seed("key-0", "KEY_ROW_0", status="exited") c.seed("key-1", "KEY_ROW_1", status="exited") - time.sleep(0.8) - first = c.selected_row() - c.down() - down = c.selected_row() - c.up() - up = c.selected_row() - nav_ok = first != down and first == up - c.key(b"\x0e", 0.3) # Ctrl+N - blank_ok = "Enter start" in c.text() - c.key(b"\x1b", 0.2) + # Compare a STABLE identity, not the whole selected-row string: the age + # cell ticks (0s -> 1s) between the down and up reads, so a full-string + # `first == up` compare flaked even though selection was correct. + def sel_id(): + r = c.selected_row() or "" + return next((k for k in ("KEY_ROW_0", "KEY_ROW_1") if k in r), None) + c.wait_until(lambda: c.row_with("KEY_ROW_0") and c.row_with("KEY_ROW_1"), timeout=6) + c.wait_until(lambda: sel_id() is not None, timeout=6) + first = sel_id() + c.down() + moved = c.wait_until(lambda: sel_id() not in (None, first), timeout=6) + after_down = sel_id() + c.up() + restored = c.wait_until(lambda: sel_id() == first, timeout=6) + nav_ok = bool(first) and moved and after_down != first and restored + + c.key(b"\x0e", 0.0) # Ctrl+N + blank_ok = c.wait_until(lambda: "Enter start" in c.text(), timeout=6) + c.key(b"\x1b", 0.0) + c.wait_until(lambda: "Enter start" not in c.text(), timeout=6) + + # Every printable (incl. the former w/s/d/e shortcuts, Shift, a leading + # Space) enters the composer verbatim. Drive it with waits, not fixed + # sleeps: on a slow runner the composer repaint lagged the read, so the + # uppercase-W in_composer probe fired before the box was up. printable_ok = True seen = [] for payload, visible in ((b"w", "w"), (b"s", "s"), (b"d", "d"), (b"e", "e"), (b"W", "W"), (b" ", None)): - c.key(payload, 0.18) - in_composer = "Enter start" in c.text() - verbatim = visible is None or visible in "\n".join(c.rows()[-5:]) - printable_ok &= in_composer and verbatim - seen.append((payload.decode(), in_composer, verbatim)) - c.key(b"\x1b", 0.12) + c.key(payload, 0.0) + in_composer = c.wait_until(lambda: "Enter start" in c.text(), timeout=6) + verbatim = visible is None or c.wait_until( + lambda v=visible: v in "\n".join(c.rows()[-5:]), timeout=6) + printable_ok &= bool(in_composer and verbatim) + seen.append((payload.decode(), bool(in_composer), bool(verbatim))) + c.key(b"\x1b", 0.0) # close the composer before the next byte + c.wait_until(lambda: "Enter start" not in c.text(), timeout=6) c.key(b"\x01", 0.4) # Ctrl+A, never composer input ctrl_a_ok = "Enter start" not in c.text() @@ -1171,14 +1188,17 @@ def secs(a): c = Cockpit(rail).boot() try: c.seed("hint-0", "HINT_ROW", status="exited") - time.sleep(0.8) - last = c.rows()[-1] - win_keys = ("↑↓" in last) and ("Ctrl+R" in last) and ("twice" in last) - c.key(b"\x1b", 0.5) # one Esc -> arm the quit confirm + c.wait_until(lambda: c.row_with("HINT_ROW") is not None, timeout=6) + # Boot leaves a one-shot "auto-imported N chat(s)…" note on the footer row. + # A harmless Left-arrow in normal mode forces a repaint (no select/compose) + # so the footer settles to the key hints before we assert on them. + c.key(b"\x1b[D", 0.0) + win_keys = c.wait_until( + lambda: all(k in c.rows()[-1] for k in ("↑↓", "Ctrl+R", "twice")), timeout=6) + c.key(b"\x1b", 0.0) # one Esc -> arm the quit confirm + on_status = c.wait_until(lambda: "Esc again to quit" in c.rows()[-1], timeout=6) rows_now = c.rows() - status = rows_now[-1] box_area = "\n".join(rows_now[-5:-1]) # the composer box, just above the status line - on_status = "Esc again to quit" in status clean_box = "Esc again" not in box_area check("redesign: Win-style hints + confirms on the status line, not in the box", win_keys and on_status and clean_box, @@ -1234,11 +1254,22 @@ def secs(a): ca = st.get("codex_args", []) if "distill" in st.get("title", "") and "workspace-write" in ca and "-C" in ca: args_ok = True - # rail pre-trusts this immutable run's corpus cwd in codex's config so the TUI session - # doesn't stall on the first-run "trust this folder?" gate. + # Production runs the distill session FROM the stable distill root (its + # cwd == the -C arg == distill_dir) and pre-trusts THAT in codex's config + # so the TUI session doesn't stall on the first-run "trust this folder?" + # gate. The corpus is a private per-run child (runs//corpus), not the + # cwd, so a stale/old session can't read a later run's rewritten corpus. cfg = os.path.join(c.home, ".codex", "config.toml") - trust_ok = bool(corpus) and os.path.exists(cfg) \ - and f'[projects."{corpus}"]' in open(cfg).read() + cwd_ok = False + for st in distill_states: + ca = st.get("codex_args", []) + c_flag = ca[ca.index("-C") + 1] if "-C" in ca and ca.index("-C") + 1 < len(ca) else None + if st.get("cwd") == distill_dir and c_flag == distill_dir: + cwd_ok = True + trust_ok = cwd_ok and os.path.exists(cfg) \ + and f'[projects."{distill_dir}"]' in open(cfg).read() + corpus_ok = bool(corpus) \ + and os.path.dirname(os.path.dirname(corpus)) == os.path.join(distill_dir, "runs") private_dirs = [os.path.dirname(distill_dir), distill_dir] if corpus: private_dirs.extend([os.path.dirname(os.path.dirname(corpus)), @@ -1246,9 +1277,10 @@ def secs(a): private = all((os.stat(d).st_mode & 0o777) == 0o700 for d in private_dirs) \ and all((os.stat(p).st_mode & 0o777) == 0o600 for p in chunks) - check("distill (Ctrl+D): corpus aggregated + autonomous session + dir pre-trusted", - made and shown and args_ok and trust_ok and private, - f"chunks={len(chunks)} shown={shown} args_ok={args_ok} trust_ok={trust_ok} private={private}") + check("distill (Ctrl+D): corpus aggregated + autonomous session + distill root pre-trusted", + made and shown and args_ok and trust_ok and corpus_ok and private, + f"chunks={len(chunks)} shown={shown} args_ok={args_ok} trust_ok={trust_ok} " + f"corpus_ok={corpus_ok} private={private}") snap(c, "15_distill") finally: c.close() @@ -2045,8 +2077,12 @@ def never_ready_state(): failed = c.wait_until( lambda: never_ready_state().get("status") == "failed", timeout=12) prompt_result = json.load(open(prompt_record)) if os.path.exists(prompt_record) else {} - no_blind_input = prompt_result.get("early_hex") == "" \ - and prompt_result.get("payload") is None + # portable-pty 0.8.1's Unix writer Drop emits newline+VEOF ("0a04") during + # timeout cleanup — that's dependency shutdown, not blind prompt injection. + # Accept empty or exactly that sentinel; still forbid framed/private bytes. + no_blind_input = prompt_result.get("early_hex") in ("", "0a04") \ + and prompt_result.get("payload") is None \ + and prompt_result.get("framed") is not True argv_private = all(private_prompt not in arg for arg in prompt_result.get("argv", [])) state = never_ready_state() check("initial prompt: never-ready TUI fails without argv or blind PTY injection", @@ -2239,7 +2275,9 @@ def clean_exit_state(): wire = b"\x1b[200~" + safe_message.encode("utf-8") + b"\x1b[201~\r" inject_peer.sendall(b"\x00" + struct.pack("!I", len(wire)) + wire) delivered = recv_line(inject_peer) == b"DELIVERED\n" - inject_peer.sendall(b"\x02") + # INJECT is a one-frame protocol: the worker closes this headless slot the + # instant it emits DELIVERED (worker.rs ~590). Sending a follow-up detach + # frame here races that close and raises BrokenPipe — just close. inject_peer.close() inject_peer = None recorded = c.wait_until( @@ -2320,13 +2358,24 @@ def hung_running(pid): escalated = any("escalated to verified SIGKILL" in sample for sample in samples) gone = (not hung_running(worker) and not hung_running(child) and all(not hung_running(pid) for pid, _, _ in read_hung_detached())) + # The guardian writes the certified terminal state (cleared pids/token + + # failed status) AFTER the tree dies, so poll for it rather than reading + # the file the instant `gone` flips — the write can lag process death. + def is_certified(): + try: + f = json.load(open(os.path.join(c.jobs, sid, "state.json"))) + except (FileNotFoundError, ValueError): + return False + return (f.get("worker_pid") is None and f.get("child_pid") is None + and f.get("worker_token") is None and f.get("status") == "failed") + certified = c.wait_until(is_certified, timeout=8) final = json.load(open(os.path.join(c.jobs, sid, "state.json"))) - certified = (final.get("worker_pid") is None and final.get("child_pid") is None - and final.get("worker_token") is None and final.get("status") == "failed") check("STOP: exact ACK; SIGSTOP worker escalates at 8s; guardian certifies full cleanup", spawned and stop_ack and accepted and escalated and gone and certified, f"spawned={spawned} ack={stop_ack} accepted={accepted} escalated={escalated} " - f"gone={gone} certified={certified} error={final.get('last_error')!r}") + f"gone={gone} certified={certified} status={final.get('status')!r} " + f"wpid={final.get('worker_pid')} cpid={final.get('child_pid')} " + f"token={final.get('worker_token') is not None} error={final.get('last_error')!r}") finally: os.environ.pop("CODEX_RAIL_DETACHED_PIDS", None) detached_records = list(set(detached_records + read_hung_detached())) @@ -2417,14 +2466,28 @@ def hung_running(pid): versions.append(int(version_match.group(1))) if corpus_match: corpora.append(corpus_match.group(1)) - isolated = (returncodes == [0, 0] and len(set(versions)) == 2 - and len(set(corpora)) == 2 and all(os.path.isdir(p) for p in corpora) - and all(glob.glob(os.path.join(p, "corpus-*.md")) for p in corpora)) + # The run lock is non-blocking (distill.rs acquire_run_lock: LOCK_EX|LOCK_NB): + # a distillation holds it for its whole prepare->codex->validate lifetime, so + # a second prepare that OVERLAPS the first is refused fail-closed rather than + # queued for minutes. Whichever prepares DO win must never share a version + # claim or corpus directory; any loser must be exactly that fail-closed + # refusal ("already preparing or running"), never a crash. + succeeded = [out for rc, out in zip(returncodes, outputs) if rc == 0] + losers = [out for rc, out in zip(returncodes, outputs) if rc != 0] claims = glob.glob(os.path.join(c.config, "codex-rail", "distill", "claims", "style-v*.claim")) + isolated = (len(succeeded) >= 1 + and len(versions) == len(corpora) == len(succeeded) + and len(set(versions)) == len(versions) + and len(set(corpora)) == len(corpora) + and all(os.path.isdir(p) for p in corpora) + and all(glob.glob(os.path.join(p, "corpus-*.md")) for p in corpora) + and all("already preparing or running" in out for out in losers) + and len(claims) == len(succeeded)) check("distill prepare: global lock/version claims and concurrent run-corpus isolation", - isolated and len(claims) >= 2, - f"rc={returncodes} versions={versions} corpora={corpora} claims={len(claims)}") + isolated, + f"rc={returncodes} versions={versions} corpora={corpora} " + f"claims={len(claims)} succeeded={len(succeeded)}") finally: c.close() From 852ba905c0a2ebcae1b926d3d2f8ac71c4be69f5 Mon Sep 17 00:00:00 2001 From: wuyitao Date: Wed, 15 Jul 2026 22:42:41 +0800 Subject: [PATCH 4/6] Fix self-update: compare against the release's own sha, not main HEAD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The updater asked GitHub for main's HEAD sha and (a) offered an update on ANY difference and (b) demanded the downloaded rolling-release binary's embedded marker equal main HEAD. But `releases/latest` is a rolling tag that LAGS main (CI builds after the push), so in practice: - a dev build ahead of the last publish nagged "update available" and, on click, tried to DOWNGRADE; - every install failed "release asset is stale or mismatched" because the release's marker never equalled the current main HEAD. Now resolve the rolling `latest` tag's own commit, ask GitHub's compare API for ahead/behind, and act only when the release is strictly `ahead`: no phantom note, no downgrade, and the marker is validated against the RELEASE's sha so a lagging-but-valid release installs cleanly. The decision is a pure function (classify_release) unit-tested across ahead/behind/diverged/identical/same-sha/ unknown/garbage-sha — including the exact "we are ahead of the last publish" case that used to fail. Verified against the live API: compare df71b6d...latest = behind -> NotNewer. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/update.rs | 165 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 151 insertions(+), 14 deletions(-) diff --git a/src/update.rs b/src/update.rs index 67ab941..58e7c7c 100644 --- a/src/update.rs +++ b/src/update.rs @@ -25,8 +25,10 @@ static UPDATE_THREAD_LOCK: Mutex<()> = Mutex::new(()); static UPDATE_TMP_COUNTER: AtomicU64 = AtomicU64::new(0); // Keep an exact, cheaply inspectable build identity in every release asset. The -// updater verifies this marker against main before it replaces the current -// binary, so a still-publishing/stale rolling release is rejected. +// updater verifies this marker against the RELEASE's own sha (the commit the +// rolling `latest` tag points at) before it replaces the current binary, so a +// half-uploaded/mismatched asset is rejected while a release that merely lags +// main still installs. #[used] static RAIL_BUILD_SHA_MARKER: &str = concat!("CODEX_RAIL_BUILD_SHA=", env!("RAIL_GIT_SHA_FULL"), "\n"); @@ -107,40 +109,121 @@ fn validate_git_sha(sha: &str) -> Result<()> { Ok(()) } -fn latest_main_sha_full() -> Result { - let v = api_json("commits/main").context("fetch latest main commit")?; +/// Resolve a git ref (branch, tag, or sha) to its full commit sha. +fn resolve_ref_sha_full(git_ref: &str) -> Result { + let v = api_json(&format!("commits/{git_ref}")) + .with_context(|| format!("resolve git ref {git_ref}"))?; let sha = v .get("sha") .and_then(|s| s.as_str()) - .context("latest main response has no commit sha")?; + .context("commit response has no sha")?; validate_git_sha(sha)?; Ok(sha.to_ascii_lowercase()) } +/// GitHub's ahead/behind status of the release relative to the running build. +fn compare_status_to_release(mine: &str, release_sha: &str) -> Result { + let v = api_json(&format!("compare/{mine}...{release_sha}")) + .context("compare the running build with the latest release")?; + v.get("status") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()) + .context("compare response has no status") +} + +/// How the rolling `latest` release relates to the running build. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ReleaseDecision { + /// The release is strictly ahead of us; the value is its full commit sha. + Newer(String), + /// Identical, we are ahead (a dev build past the last CI publish), or the + /// two lines diverged — nothing to install and nothing to advertise. + NotNewer, + /// No embedded sha (unversioned local build) or the comparison failed. + Unknown, +} + +/// Decide, from the running sha, the release sha, and GitHub's compare status, +/// whether the release is a genuine upgrade. Pure so every branch is unit-tested. +/// +/// This replaces the old "any sha difference == newer, and the download must +/// match `main` HEAD" logic, which had two real bugs: a rolling release that +/// merely LAGGED main failed every install (marker != main HEAD), and a local +/// build AHEAD of the last publish nagged to "update" (i.e. silently downgrade). +/// Requiring GitHub's `ahead` status fixes both. +fn classify_release(mine: &str, release_sha: &str, compare_status: &str) -> ReleaseDecision { + let mine = mine.trim().to_ascii_lowercase(); + if mine.is_empty() || mine == "unknown" || validate_git_sha(&mine).is_err() { + return ReleaseDecision::Unknown; + } + let release = release_sha.trim().to_ascii_lowercase(); + if validate_git_sha(&release).is_err() { + return ReleaseDecision::Unknown; + } + if release == mine { + return ReleaseDecision::NotNewer; + } + match compare_status { + // `status` is head-relative-to-base (base = our build): only "ahead" + // means the release contains commits we lack. "behind" (we are ahead — a + // dev build), "identical", and "diverged" must never offer or install. + "ahead" => ReleaseDecision::Newer(release), + _ => ReleaseDecision::NotNewer, + } +} + +/// Query GitHub for how the rolling `latest` release compares to this build. +fn release_decision() -> ReleaseDecision { + let mine = build_sha_full().to_ascii_lowercase(); + if mine == "unknown" { + return ReleaseDecision::Unknown; + } + let Ok(release) = resolve_ref_sha_full("latest") else { + return ReleaseDecision::Unknown; + }; + if release == mine { + return ReleaseDecision::NotNewer; + } + let Ok(status) = compare_status_to_release(&mine, &release) else { + return ReleaseDecision::Unknown; + }; + classify_release(&mine, &release, &status) +} + fn short_sha(sha: &str) -> Result { validate_git_sha(sha)?; Ok(sha.chars().take(7).collect()) } -/// If a newer build than this one is available, the newer short sha; else None. +/// If the rolling `latest` release is strictly newer than this build, its short +/// sha; else None. A local build EQUAL TO or AHEAD OF the last publish (the +/// common dev case) returns None — no phantom "update available" note. pub fn newer_available() -> Option { - let latest_full = latest_main_sha_full().ok()?; - let mine = build_sha_full().to_ascii_lowercase(); - if mine != "unknown" && latest_full != mine { - short_sha(&latest_full).ok() - } else { - None + match release_decision() { + ReleaseDecision::Newer(sha) => short_sha(&sha).ok(), + ReleaseDecision::NotNewer | ReleaseDecision::Unknown => None, } } /// Download the latest release binary for this platform and atomically replace /// the running executable (Linux/macOS let you rename over a running file; the /// live process keeps the old inode, the next launch picks up the new one). -/// Errors clearly if no Release/asset exists yet (CI publishes them on push). +/// Refuses to downgrade: installs only when the release is genuinely ahead, and +/// validates the download's embedded marker against the RELEASE's sha (not main +/// HEAD), so a rolling release that lags main still installs cleanly instead of +/// failing every time. pub fn apply() -> Result { let exe = current_executable_for_update()?; let _guard = acquire_update_lock(&exe)?; - let expected_sha = latest_main_sha_full()?; + let expected_sha = match release_decision() { + ReleaseDecision::Newer(sha) => sha, + ReleaseDecision::NotNewer => bail!( + "already up to date — the running build is current with (or newer than) the latest published release" + ), + ReleaseDecision::Unknown => bail!( + "cannot determine the latest release (no network reachable, or this is an unversioned local build)" + ), + }; let rel = api_json("releases/latest").context("fetch latest release (has CI published one yet?)")?; let tag = rel @@ -436,6 +519,60 @@ mod tests { assert!(validate_download(&fake_executable("1234567"), "1234567").is_ok()); } + #[test] + fn classify_release_only_upgrades_when_the_release_is_strictly_ahead() { + let mine = "0123456789abcdef0123456789abcdef01234567"; + let release = "89abcdef0123456789abcdef0123456789abcdef"; + + // The release has commits we lack -> a genuine upgrade. + assert_eq!( + classify_release(mine, release, "ahead"), + ReleaseDecision::Newer(release.to_string()) + ); + + // THE BUG THIS FIXES: our build is AHEAD of the last CI publish (the + // rolling release lags main). The old code saw "different sha" and both + // nagged and, on click, downgraded. Now every non-"ahead" status is inert. + assert_eq!( + classify_release(mine, release, "behind"), + ReleaseDecision::NotNewer + ); + assert_eq!( + classify_release(mine, release, "diverged"), + ReleaseDecision::NotNewer + ); + assert_eq!( + classify_release(mine, release, "identical"), + ReleaseDecision::NotNewer + ); + + // Same sha is up to date regardless of the (never-"ahead") status. + assert_eq!( + classify_release(mine, mine, "ahead"), + ReleaseDecision::NotNewer + ); + + // Case-insensitive sha match still counts as identical. + assert_eq!( + classify_release(mine, &mine.to_ascii_uppercase(), "ahead"), + ReleaseDecision::NotNewer + ); + + // An unversioned local build, or a garbage release sha, is never acted on. + assert_eq!( + classify_release("unknown", release, "ahead"), + ReleaseDecision::Unknown + ); + assert_eq!( + classify_release("", release, "ahead"), + ReleaseDecision::Unknown + ); + assert_eq!( + classify_release(mine, "not-a-real-sha", "ahead"), + ReleaseDecision::Unknown + ); + } + #[test] fn failed_install_preserves_the_original_binary() { let dir = test_dir("preserve"); From a2600ddec01179f6c285f5c635cdaf8eefdafac1 Mon Sep 17 00:00:00 2001 From: wuyitao Date: Wed, 15 Jul 2026 22:44:56 +0800 Subject: [PATCH 5/6] Fix build.rs embedding a stale git sha on non-main branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.rs only re-ran on changes to .git/refs/heads/main, so a commit on any feature branch (e.g. agent/deep-rail-hardening) never refreshed the embedded sha and `rail --version` — and the updater's own build_sha — reported an old commit. Watch .git/HEAD, .git/packed-refs, and the exact ref HEAD points at, so the sha stays correct on whatever branch is checked out. Co-Authored-By: Claude Opus 4.8 (1M context) --- build.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/build.rs b/build.rs index 6d77df5..9703364 100644 --- a/build.rs +++ b/build.rs @@ -22,7 +22,16 @@ fn main() { .unwrap_or_else(|| "unknown".to_string()); println!("cargo:rustc-env=RAIL_GIT_SHA={short_sha}"); println!("cargo:rustc-env=RAIL_GIT_SHA_FULL={full_sha}"); - // Rebuild when HEAD moves so the embedded sha stays current. + // Rebuild when the checked-out commit moves so the embedded sha stays + // current — on WHATEVER branch is checked out. The old code hard-coded + // refs/heads/main, so a commit on any other branch (e.g. a feature branch) + // left `rail --version` reporting a stale sha. Watch HEAD, packed-refs, and + // the specific ref HEAD points at. println!("cargo:rerun-if-changed=.git/HEAD"); - println!("cargo:rerun-if-changed=.git/refs/heads/main"); + println!("cargo:rerun-if-changed=.git/packed-refs"); + if let Ok(head) = std::fs::read_to_string(".git/HEAD") { + if let Some(reference) = head.strip_prefix("ref:").map(str::trim) { + println!("cargo:rerun-if-changed=.git/{reference}"); + } + } } From 98ef955c8566b7c938f0811dacf3253c9341bd7e Mon Sep 17 00:00:00 2001 From: wuyitao Date: Wed, 15 Jul 2026 23:11:37 +0800 Subject: [PATCH 6/6] Surface codex's own error on a startup failure; stop losing exit-time output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A too-old codex 400s the configured model and exits at startup, but the session only showed "codex exited with status N" — the real reason ("requires a newer version of Codex") was invisible, so a wrong-version codex read as an unexplained, repeatedly-triggered bug. finish_run now appends codex's last log line (ANSI/control stripped) to last_error on a quick failure. To make that reliable, drain the child's final PTY output into the log on ChildExit before finishing: rx (control) is polled before pty_rx, so ChildExit could arrive with the last chunk still queued — which both truncated the transcript tail and hid the reason. Bounded drain (50ms-quiet, 300ms cap). Tests: hint_from_log_bytes unit test (ANSI/OSC stripping, last readable line, no-junk); cockpit integration test with a fake codex that prints a version error and exits 1 -> asserts status=failed and the reason is surfaced ANSI-free. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/worker.rs | 144 +++++++++++++++++++++++++++++++++- tests/cockpit.py | 29 +++++++ tests/fakecodex_startup_error | 7 ++ 3 files changed, 176 insertions(+), 4 deletions(-) create mode 100755 tests/fakecodex_startup_error diff --git a/src/worker.rs b/src/worker.rs index bffb8e8..1b2a0cd 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -542,6 +542,27 @@ fn run_worker_inner(id: &str, subreaper_enabled: bool) -> Result<()> { } Some(WorkerEvent::ChildExit(outcome)) => { run_guard.note_root_exited(); + // Drain the child's final PTY output into the log before + // finishing. rx (control) is polled before pty_rx, so ChildExit + // can arrive with the last chunk — e.g. a startup error line — + // still queued or in flight. Losing it both truncated the + // transcript tail and hid the failure reason. Bounded: drain + // until 50ms of quiet, capped at 300ms (child exit is not a hot + // path), so a wedged reader can't stall teardown. + let drain_deadline = Instant::now() + Duration::from_millis(300); + loop { + let now = Instant::now(); + if now >= drain_deadline { + break; + } + match pty_rx.recv_timeout(Duration::from_millis(50).min(drain_deadline - now)) { + Ok(bytes) => { + let _ = log.write_all(&bytes); + } + Err(_) => break, + } + } + log.flush().ok(); let report = run_guard.terminate(Duration::from_secs(1), Duration::from_secs(2)); return finish_run( &mut session, @@ -895,12 +916,17 @@ fn fail_run( } } +// A codex that dies within this window of session creation failed at startup, +// so its last log line is its error message rather than a mid-run TUI redraw. +const STARTUP_HINT_SECS: u64 = 60; + fn finish_run( session: &mut SessionState, report: CleanupReport, cause: FinishCause, socket_identity: (u64, u64), ) -> Result<()> { + let child_failed = matches!(&cause, FinishCause::Natural(outcome) if !outcome.success); let disposition = classify_finish(report, cause); let prompt_delivery_uncertain = session.initial_prompt_injecting; session.updated_at = state::now_secs(); @@ -910,14 +936,26 @@ fn finish_run( disposition.status.to_string() }; session.exit_code = disposition.exit_code; + // On a quick startup failure a bare "codex exited with status N" hides the + // reason (a too-old codex 400s the model, a bad config, a trust prompt). + // Append codex's own last log line so the failure is legible in the list. + let mut base_error = disposition.last_error; + if child_failed && state::now_secs().saturating_sub(session.created_at) <= STARTUP_HINT_SECS { + if let Some(hint) = codex_failure_hint(&session.id) { + base_error = Some(match base_error { + Some(error) => format!("{error}: {hint}"), + None => format!("codex reported: {hint}"), + }); + } + } session.last_error = if prompt_delivery_uncertain { let warning = "initial prompt delivery was interrupted and may have been submitted"; - Some(match disposition.last_error { + Some(match base_error { Some(error) => format!("{warning}; {error}"), None => warning.to_string(), }) } else { - disposition.last_error + base_error }; session.worker_pid = None; session.initial_prompt = if prompt_delivery_uncertain { @@ -1198,6 +1236,78 @@ fn clean_tail_start(buf: &[u8], started_midfile: bool) -> &[u8] { } } +// Remove ANSI/VT escape sequences (CSI `ESC [ … final`, OSC `ESC ] … BEL/ST`, +// and any lone `ESC x`) so a TUI-colored line collapses to plain text. +fn strip_terminal_sequences(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes); + let mut out = String::with_capacity(text.len()); + let mut chars = text.chars(); + while let Some(c) = chars.next() { + if c != '\u{1b}' { + out.push(c); + continue; + } + match chars.next() { + // CSI: consume params/intermediates up to a final byte 0x40..=0x7e. + Some('[') => { + for n in chars.by_ref() { + if ('\u{40}'..='\u{7e}').contains(&n) { + break; + } + } + } + // OSC: consume up to BEL, or ST (`ESC \`). + Some(']') => { + while let Some(n) = chars.next() { + if n == '\u{7}' { + break; + } + if n == '\u{1b}' { + chars.next(); + break; + } + } + } + // Lone ESC or a two-char escape: drop the escape itself. + _ => {} + } + } + out +} + +// The last human-readable line of a captured PTY log, ANSI/control stripped. +// codex prints a plain error (e.g. "model X requires a newer version of Codex") +// before its TUI starts, so on a startup failure this surfaces WHY the session +// died instead of a bare exit status. Pure so the extraction is unit-tested. +fn hint_from_log_bytes(bytes: &[u8]) -> Option { + strip_terminal_sequences(bytes) + .lines() + .rev() + .map(|line| protocol::sanitize_submission_text(line).trim().to_string()) + .find(|line| line.chars().any(|c| c.is_alphabetic())) + .map(|line| line.chars().take(200).collect::()) + .filter(|line| !line.is_empty()) +} + +// Read the tail of a session's PTY log and return its last readable line. +fn codex_failure_hint(id: &str) -> Option { + let path = state::log_path(id); + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + .ok()?; + if !file.metadata().ok()?.is_file() { + return None; + } + let len = file.metadata().ok()?.len(); + let start = len.saturating_sub(TAIL_BYTES as u64); + file.seek(SeekFrom::Start(start)).ok()?; + let mut buf = Vec::new(); + file.read_to_end(&mut buf).ok()?; + hint_from_log_bytes(&buf) +} + fn spawn_pty_reader( mut reader: Box, output_tx: SyncSender>, @@ -1530,8 +1640,8 @@ enum WorkerEvent { #[cfg(test)] mod tests { use super::{ - classify_finish, clean_tail_start, compact_output_log, write_initial_prompt, ChildOutcome, - FinishCause, RolloutLifecycle, TuiReadiness, + classify_finish, clean_tail_start, compact_output_log, hint_from_log_bytes, + write_initial_prompt, ChildOutcome, FinishCause, RolloutLifecycle, TuiReadiness, }; use crate::process_tree::CleanupReport; use crate::state::{STATUS_EXITED, STATUS_FAILED}; @@ -1557,6 +1667,32 @@ mod tests { assert_eq!(clean_tail_start(b"partial\n", true), b""); } + #[test] + fn codex_failure_hint_surfaces_the_last_readable_line() { + // A colored startup error, cursor moves, and trailing blank lines: the + // reason must survive with ANSI/control bytes gone (so a version failure + // reads as its message, not "codex exited with status 1"). + let log = b"\x1b[2J\x1b[H\x1b[1;31mError: model gpt-5.6-sol requires a newer \ + version of Codex (>= 0.143.0)\x1b[0m\r\n\r\n"; + let hint = hint_from_log_bytes(log).expect("a readable error line"); + assert!( + hint.contains("requires a newer version of Codex"), + "got: {hint:?}" + ); + assert!(!hint.contains('\u{1b}'), "escape survived: {hint:?}"); + assert!(!hint.contains("[31m") && !hint.contains("[0m")); + + // An OSC title sequence is stripped; the real text is kept. + assert_eq!( + hint_from_log_bytes(b"\x1b]0;codex\x07ready to go\n").as_deref(), + Some("ready to go") + ); + + // Nothing but clears/whitespace yields no hint, so we never append junk. + assert_eq!(hint_from_log_bytes(b"\x1b[2J\x1b[H\r\n \t \r\n"), None); + assert_eq!(hint_from_log_bytes(b""), None); + } + #[test] fn natural_nonzero_exit_is_failed_but_an_active_stop_is_not() { let clean = CleanupReport { diff --git a/tests/cockpit.py b/tests/cockpit.py index 68c03bb..cc3b538 100644 --- a/tests/cockpit.py +++ b/tests/cockpit.py @@ -44,6 +44,7 @@ FAKE_SLEEP = os.path.join(HERE, "fakecodex_sleep") # quiet long-lived child FAKE_DETACHED = os.path.join(HERE, "fakecodex_spawn_detached") FAKE_PROMPT_TUI = os.path.join(HERE, "fakecodex_prompt_tui") +FAKE_STARTUP_ERROR = os.path.join(HERE, "fakecodex_startup_error") # prints a version error, exits 1 COLS, ROWS = 110, 40 @@ -1674,6 +1675,34 @@ def _alive(p): finally: c.close() + # 29b) A codex that dies at STARTUP (e.g. a too-old codex 400s the model) + # must surface codex's OWN error in the session's last_error, not just a + # bare "codex exited with status 1". This is the failure that made a + # wrong-version codex look like an unexplained, repeatedly-triggered bug. + c = Cockpit(rail, codex=FAKE_STARTUP_ERROR).boot() + try: + c.seed("boom-0", "BOOMSESSION", status="starting", codex=FAKE_STARTUP_ERROR) + c.start_guardian("boom-0", FAKE_STARTUP_ERROR) + + def boom_state(): + try: + return json.load(open(f"{c.jobs}/boom-0/state.json")) + except (FileNotFoundError, ValueError): + return {} + + failed = c.wait_until(lambda: boom_state().get("status") == "failed", timeout=10) + st = boom_state() + err = st.get("last_error") or "" + reason_visible = "requires a newer version of Codex" in err + # the surfaced reason must be plain text — no ANSI escape / SGR leftovers + ansi_free = "\x1b" not in err and "[31m" not in err and "[0m" not in err + check("codex startup failure surfaces codex's own error, not a bare exit code", + failed and reason_visible and ansi_free, + f"failed={failed} status={st.get('status')!r} reason={reason_visible} " + f"ansi_free={ansi_free} err={err!r}") + finally: + c.close() + # 30) Two full manager TUIs racing Enter on the same stopped row must converge # on one worker/one codex start. init.lock protects bootstrap state while # worker.lock protects the lifetime owner. diff --git a/tests/fakecodex_startup_error b/tests/fakecodex_startup_error new file mode 100755 index 0000000..ae3b5cc --- /dev/null +++ b/tests/fakecodex_startup_error @@ -0,0 +1,7 @@ +#!/bin/sh +# Stand-in for a codex that fails at startup (e.g. a too-old codex 400s the +# configured model). Print a colored error the way real codex does — cursor +# home + clear + SGR — then exit nonzero. Exercises the worker surfacing +# codex's OWN reason in the session's last_error instead of a bare exit code. +printf '\033[2J\033[H\033[1;31mError: model gpt-5.6-sol requires a newer version of Codex (>= 0.143.0)\033[0m\r\n' +exit 1