diff --git a/Cargo.lock b/Cargo.lock index 069c3c65..3f95298e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,12 +91,38 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + [[package]] name = "clap" version = "4.6.3" @@ -152,6 +178,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -161,6 +193,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -171,14 +212,40 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -231,6 +298,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", ] [[package]] @@ -245,6 +313,31 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -437,6 +530,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rustix" version = "1.1.4" @@ -511,6 +621,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -518,8 +639,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -538,6 +659,7 @@ dependencies = [ "sha2", "tempfile", "toml", + "tungstenite", ] [[package]] @@ -589,6 +711,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -628,6 +770,22 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 3e32ee82..e0220b25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ serde_json = "1" sha2 = "0.10" tempfile = "3" toml = "0.9" +tungstenite = "0.30" [dev-dependencies] libc = "0.2" diff --git a/crates/agent-spec/src/kdl_format.rs b/crates/agent-spec/src/kdl_format.rs index 6540668e..d4541af5 100644 --- a/crates/agent-spec/src/kdl_format.rs +++ b/crates/agent-spec/src/kdl_format.rs @@ -124,6 +124,23 @@ fn agent_node_to_raw(node: &DeclaredNode) -> anyhow::Result { "command" => raw.command = arg_string(child), "argv" => raw.argv = Some(argv(child)?), "ding" => raw.ding = true, + "deliver" => { + anyhow::ensure!( + raw.deliver.is_none(), + "agent declares `deliver` more than once" + ); + anyhow::ensure!( + child.type_name.is_none() + && child.children.is_empty() + && child.entries.len() == 1 + && child.entries[0].name.is_none(), + "agent `deliver` must contain exactly one positional string" + ); + raw.deliver = Some(Some( + arg_string(child) + .ok_or_else(|| anyhow::anyhow!("agent `deliver` value must be a string"))?, + )); + } "env" => {} "pty" => { if let Some(name) = arg_string(child) { diff --git a/crates/agent-spec/src/lib.rs b/crates/agent-spec/src/lib.rs index e9824715..cfe26b4e 100644 --- a/crates/agent-spec/src/lib.rs +++ b/crates/agent-spec/src/lib.rs @@ -43,6 +43,6 @@ pub use discovery::{ path_defaults, }; pub use spec::{ - AgentDesiredState, AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, - TaskLifecycle, parse_duration, validate_desired_state_reason, + AgentDesiredState, AgentSpec, DeliveryTransport, JobType, Resource, Restart, RestartMode, Task, + TaskKind, TaskLifecycle, parse_duration, validate_desired_state_reason, }; diff --git a/crates/agent-spec/src/spec.rs b/crates/agent-spec/src/spec.rs index a0cf9a29..d4b0afc8 100644 --- a/crates/agent-spec/src/spec.rs +++ b/crates/agent-spec/src/spec.rs @@ -5,9 +5,10 @@ //! stage's script; must NOT allocate a terminal, R09). st2 reads only the runner-normative subset: //! `identity`, presentation (`name`, `description`), `host`, `role` (metadata only), `type`, //! `workspace`, whole-agent desired state (plus legacy `retired`), `keep`, `supervisor`, -//! `restart{}`, task lifecycle, Resource bindings (declaration metadata), and the tasks. Everything render-only -//! (`harness`, `model`, `persona`, `permissions`, `transport`, `strategy`, `meta{}`) is baked into -//! the tasks/commands by the render layer and ignored here. +//! `restart{}`, `deliver`, task lifecycle, Resource bindings (declaration metadata), and the tasks. +//! Everything render-only (`harness`, `model`, `persona`, `permissions`, legacy `transport` +//! metadata, `strategy`, `meta{}`) is baked into the tasks/commands by the render layer and ignored +//! here. //! //! Three on-disk formats lower to this model: KDL (canonical, parsed by hand in `kdl_format`), and //! TOML/JSON (serde). Every spec is a `service` — `type = batch` is retired; evals run through the @@ -36,6 +37,32 @@ pub enum AgentDesiredState { Retired { reason: Option }, } +/// One provider-native message delivery transport declared by an agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeliveryTransport { + Mcp, + AppServer, +} + +impl DeliveryTransport { + pub fn as_str(self) -> &'static str { + match self { + Self::Mcp => "mcp", + Self::AppServer => "app-server", + } + } + + fn parse(value: &str) -> anyhow::Result { + match value { + "mcp" => Ok(Self::Mcp), + "app-server" => Ok(Self::AppServer), + _ => anyhow::bail!( + "unsupported `deliver` value '{value}' (expected `mcp` or `app-server`)" + ), + } + } +} + impl AgentDesiredState { pub fn as_str(&self) -> &'static str { match self { @@ -91,6 +118,8 @@ pub struct AgentSpec { pub keep: bool, /// Crash/restart policy (§4). `None` → the runner's default policy. pub restart: Option, + /// Provider-native delivery selected by `deliver`; `None` means legacy `ding` or no delivery. + pub delivery: Option, /// Named typed references used by the agent. st2 preserves these for readers but does not /// resolve them or assign launch, readiness, access, or lifecycle semantics. pub resources: Vec, @@ -327,6 +356,15 @@ impl AgentSpec { .any(|task| !task.derived && (task.command.is_some() || task.argv.is_some())) } + /// True when the declaration selected legacy screen delivery or one native transport. + pub fn has_delivery_transport(&self) -> bool { + self.delivery.is_some() + || self + .tasks + .iter() + .any(|task| task.derived && task.kind == TaskKind::Exec && task.name == "ding") + } + /// The restart policy in effect (declared, else the runner default). pub fn restart_policy(&self) -> Restart { self.restart.clone().unwrap_or_default() @@ -399,6 +437,9 @@ pub(crate) struct RawSpec { /// Compact catalog form: include the built-in `st2 ding` sidecar. #[serde(default)] pub ding: bool, + /// Compact catalog form: select one provider-native delivery transport. + #[serde(default, deserialize_with = "deserialize_explicit_optional")] + pub deliver: Option>, /// Compact catalog form: reconciliation policy for the generated agent PTY. pub lifecycle: Option, /// `pty "" {}` / `[pty.]` — interactive tasks. @@ -750,6 +791,7 @@ impl RawSpec { || self.command.is_some() || self.argv.is_some() || self.ding + || self.deliver.is_some() || !self.resource.0.is_empty() || !self.pty.is_empty() || !self.exec.is_empty() @@ -778,6 +820,15 @@ impl RawSpec { desired_state_value.as_deref(), desired_state_reason, )?; + let deliver = reject_explicit_null("deliver", self.deliver)?; + let delivery = deliver + .as_deref() + .map(DeliveryTransport::parse) + .transpose()?; + anyhow::ensure!( + !(self.ding && delivery.is_some()), + "agent '{identity}' declares both `ding` and `deliver`; choose one transport" + ); validate_launch( &identity, self.command.as_ref(), @@ -850,6 +901,7 @@ impl RawSpec { desired_state, keep: self.keep, restart: self.restart.map(RawRestart::lower), + delivery, resources, tasks, path, diff --git a/crates/agent-spec/tests/discovery.rs b/crates/agent-spec/tests/discovery.rs index af08b9a3..baa5e654 100644 --- a/crates/agent-spec/tests/discovery.rs +++ b/crates/agent-spec/tests/discovery.rs @@ -8,7 +8,7 @@ use std::fs; use std::path::Path; use std::time::Duration; -use agent_spec::spec::{TaskKind, TaskLifecycle}; +use agent_spec::spec::{DeliveryTransport, TaskKind, TaskLifecycle}; use agent_spec::{ AgentDesiredState, AgentSpec, JobType, Resource, Task, discover, discover_strict, }; @@ -139,10 +139,11 @@ fn lifecycle_fields_make_path_placed_files_agent_candidates() { } #[test] -fn explicit_json_null_lifecycle_fields_are_rejected_instead_of_granting_running_intent() { +fn explicit_json_null_fields_are_rejected_instead_of_granting_default_behavior() { for (name, lifecycle) in [ ("null-retired", r#""retired":null"#), ("null-state", r#""desired_state":null"#), + ("null-deliver", r#""deliver":null"#), ( "null-reason", r#""desired_state":"suspended","desired_state_reason":null"#, @@ -328,6 +329,88 @@ agent "cos" { ding.env.get("ST_AGENT").map(String::as_str), Some("Silber.cos") ); + assert!(spec.delivery.is_none()); + assert!(spec.has_delivery_transport()); +} + +#[test] +fn deliver_is_typed_without_lowering_to_the_legacy_ding_task() { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + "agents/h/claude/agent.kdl", + r#"agent "claude" { host "h"; command "claude"; deliver "mcp" }"#, + ); + write( + tmp.path(), + "agents/h/codex/agent.kdl", + r#"agent "codex" { host "h"; command "codex"; deliver "app-server" }"#, + ); + + let found = discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + let claude = find(&found.specs, "claude"); + let codex = find(&found.specs, "codex"); + assert_eq!(claude.delivery, Some(DeliveryTransport::Mcp)); + assert_eq!(codex.delivery, Some(DeliveryTransport::AppServer)); + assert_eq!(claude.delivery.unwrap().as_str(), "mcp"); + assert_eq!(codex.delivery.unwrap().as_str(), "app-server"); + for spec in [claude, codex] { + assert!(spec.has_delivery_transport()); + assert_eq!(spec.tasks.len(), 1); + assert!(spec.tasks.iter().all(|task| !task.derived)); + } +} + +#[test] +fn deliver_rejects_unknown_duplicate_mixed_and_malformed_declarations() { + for (name, declaration, expected) in [ + ( + "unknown", + r#"agent "worker" { command "true"; deliver "socket" }"#, + "unsupported `deliver` value 'socket'", + ), + ( + "duplicate", + r#"agent "worker" { command "true"; deliver "mcp"; deliver "app-server" }"#, + "declares `deliver` more than once", + ), + ( + "mixed", + r#"agent "worker" { command "true"; ding; deliver "mcp" }"#, + "declares both `ding` and `deliver`", + ), + ( + "missing", + r#"agent "worker" { command "true"; deliver }"#, + "must contain exactly one positional string", + ), + ( + "non-string", + r#"agent "worker" { command "true"; deliver #true }"#, + "value must be a string", + ), + ( + "property", + r#"agent "worker" { command "true"; deliver "mcp" mode="extra" }"#, + "must contain exactly one positional string", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + tmp.path(), + &format!("agents/h/{name}/agent.kdl"), + declaration, + ); + let found = discover(tmp.path()); + assert!(found.specs.is_empty(), "{name}: {:?}", found.specs); + assert_eq!(found.errors.len(), 1, "{name}: {:?}", found.errors); + assert!( + found.errors[0].message.contains(expected), + "{name}: expected {expected:?}, got {:?}", + found.errors[0] + ); + } } #[test] diff --git a/src/catalog_transaction.rs b/src/catalog_transaction.rs index cadc52b6..ff153459 100644 --- a/src/catalog_transaction.rs +++ b/src/catalog_transaction.rs @@ -629,6 +629,12 @@ fn normalize_agent(spec: &agent_spec::AgentSpec) -> Result Result { + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(state_dir.join("wrapper.log"))?; + Ok(Self { + file, + agent: agent.to_string(), + runtime_id: runtime_id.to_string(), + }) + } + + fn record(&mut self, stage: &str, detail: Value) -> Result<()> { + let unix_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before the Unix epoch")? + .as_millis(); + serde_json::to_writer( + &mut self.file, + &json!({ + "schema": WRAPPER_DIAGNOSTIC_SCHEMA, + "unixMs": unix_ms, + "agent": self.agent, + "runtimeId": self.runtime_id, + "stage": stage, + "detail": detail, + }), + )?; + self.file.write_all(b"\n")?; + self.file.flush()?; + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexRuntime { + schema: String, + agent: String, + runtime_id: String, + incarnation: String, +} + +impl CodexRuntime { + fn fresh(agent: String, runtime_id: String) -> Result { + Ok(Self { + schema: RUNTIME_SCHEMA.to_string(), + agent, + runtime_id, + incarnation: random_token()?, + }) + } + + pub fn agent(&self) -> &str { + &self.agent + } + + pub fn runtime_id(&self) -> &str { + &self.runtime_id + } + + pub fn incarnation(&self) -> &str { + &self.incarnation + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexThreadBinding { + schema: String, + agent: String, + runtime_id: String, + runtime_incarnation: String, + thread_id: String, +} + +impl CodexThreadBinding { + fn new(runtime: &CodexRuntime, thread_id: String) -> Self { + Self { + schema: BINDING_SCHEMA.to_string(), + agent: runtime.agent.clone(), + runtime_id: runtime.runtime_id.clone(), + runtime_incarnation: runtime.incarnation.clone(), + thread_id, + } + } + + pub fn thread_id(&self) -> &str { + &self.thread_id + } + + pub fn runtime_incarnation(&self) -> &str { + &self.runtime_incarnation + } +} + +/// The latest delivery-relevant state observed on the bound app-server control stream. +/// +/// `Active` is the only state that permits `turn/steer`: its turn ID came from the latest +/// unmatched `turn/started` event. Every other non-idle state is an explicit hold. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum CodexObservedState { + AwaitingStatus, + Idle, + Active { + #[serde(rename = "turnId")] + turn_id: String, + }, + Held { + reason: CodexHoldReason, + #[serde(rename = "turnId")] + turn_id: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CodexHoldReason { + ActiveWithoutTurn, + ConflictingTurn, + Review, + Compaction, + NotLoaded, + SystemError, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexControlState { + schema: String, + agent: String, + runtime_id: String, + runtime_incarnation: String, + thread_id: String, + subscribed: bool, + observed: CodexObservedState, +} + +#[derive(Debug, Clone)] +struct CodexDeliveryConfig { + catalog_root: PathBuf, + agent_dir: PathBuf, + inbox: PathBuf, + identity: String, + this_host: String, +} + +impl CodexDeliveryConfig { + fn resolve(catalog_root: &Path, identity: &str) -> Result { + let this_host = run::detect_host(); + let agent_dir = message::resolve_agent_dir(catalog_root, identity, &this_host)? + .with_context(|| { + format!( + "Codex native delivery agent '{identity}' is not declared in {}", + catalog_root.display() + ) + })?; + Ok(Self { + catalog_root: catalog_root.to_path_buf(), + inbox: message::inbox_dir(&agent_dir), + agent_dir, + identity: identity.to_string(), + this_host, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CodexDeliveryMethod { + Start, + Steer { turn_id: String }, +} + +#[derive(Debug, Clone)] +struct PendingCodexDelivery { + request_id: u64, + filename: String, + method: CodexDeliveryMethod, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum CodexDeliveryPhase { + Attempted, + Accepted, +} + +/// One durable FIFO delivery attempt. +/// +/// `Attempted` is written before transport. A replacement control connection reconciles that +/// ambiguous attempt against the resumed thread before it may send the client ID again. `Accepted` +/// is written only after the exact completed typed user-message event and remains until normal +/// message archive precedence removes the inbox entry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CodexDeliveryState { + schema: String, + agent: String, + runtime_id: String, + runtime_incarnation: String, + thread_id: String, + filename: String, + client_id: String, + phase: CodexDeliveryPhase, +} + +impl CodexDeliveryState { + fn attempted( + runtime: &CodexRuntime, + thread_id: String, + filename: String, + client_id: String, + ) -> Self { + Self { + schema: DELIVERY_STATE_SCHEMA.to_string(), + agent: runtime.agent.clone(), + runtime_id: runtime.runtime_id.clone(), + runtime_incarnation: runtime.incarnation.clone(), + thread_id, + filename, + client_id, + phase: CodexDeliveryPhase::Attempted, + } + } +} + +#[derive(Debug, Clone)] +struct RejectedCodexDelivery { + filename: String, + observed: CodexObservedState, +} + +struct CodexInboxDelivery { + config: CodexDeliveryConfig, + state_path: PathBuf, + runtime: CodexRuntime, + wake: Receiver<()>, + _watcher: Option, + next_refresh: Instant, + head: Option, + suppressed: bool, + state: Option, + pending: Option, + rejected: Option, + next_request_id: u64, +} + +impl CodexInboxDelivery { + fn new( + config: CodexDeliveryConfig, + state_path: PathBuf, + runtime: CodexRuntime, + ) -> Result { + fs::create_dir_all(&config.inbox).with_context(|| { + format!( + "creating Codex native delivery inbox {}", + config.inbox.display() + ) + })?; + let (wake_tx, wake) = mpsc::channel(); + let watcher = crate::watch::watch_recursive_mutations(&config.agent_dir, wake_tx); + let state = load_delivery_state(&state_path, &config.identity, runtime.runtime_id())?; + Ok(Self { + config, + state_path, + runtime, + wake, + _watcher: watcher, + next_refresh: Instant::now(), + head: None, + suppressed: false, + state, + pending: None, + rejected: None, + next_request_id: FIRST_DELIVERY_REQUEST_ID, + }) + } + + fn write_state(&mut self, state: CodexDeliveryState) -> Result<()> { + atomic_json(&self.state_path, &state)?; + self.state = Some(state); + Ok(()) + } + + fn clear_state(&mut self) -> Result<()> { + remove_state_file(&self.state_path)?; + self.state = None; + Ok(()) + } + + fn refresh_if_due(&mut self) -> Result<()> { + let mut due = Instant::now() >= self.next_refresh; + while self.wake.try_recv().is_ok() { + due = true; + } + if !due { + return Ok(()); + } + let unread = message::list_inbox(&self.config.inbox)?; + if self.state.as_ref().is_some_and(|state| { + unread + .iter() + .all(|message| message.filename != state.filename) + }) { + self.clear_state()?; + } + if self.rejected.as_ref().is_some_and(|rejected| { + unread + .iter() + .all(|message| message.filename != rejected.filename) + }) { + self.rejected = None; + } + self.head = unread.into_iter().next(); + self.suppressed = + status::read_state(&status::status_path(&self.config.agent_dir)) == status::State::Dnd; + self.next_refresh = Instant::now() + INBOX_REFRESH_FALLBACK; + Ok(()) + } + + fn maybe_request(&mut self, state: &CodexControlState) -> Result> { + self.refresh_if_due()?; + if self.pending.is_some() || !state.subscribed || self.suppressed { + return Ok(None); + } + if let Some(delivery_state) = self.state.as_ref() { + if delivery_state.thread_id == state.thread_id { + return Ok(None); + } + // A newly selected thread is a different delivery binding. An old binding's receipt + // must neither suppress nor acknowledge delivery to this thread. + self.clear_state()?; + } + let Some(head) = self.head.as_ref() else { + return Ok(None); + }; + if self.rejected.as_ref().is_some_and(|rejected| { + rejected.filename == head.filename && rejected.observed == state.observed + }) { + return Ok(None); + } + let method = match &state.observed { + CodexObservedState::Idle => CodexDeliveryMethod::Start, + CodexObservedState::Active { turn_id } => CodexDeliveryMethod::Steer { + turn_id: turn_id.clone(), + }, + CodexObservedState::AwaitingStatus | CodexObservedState::Held { .. } => { + return Ok(None); + } + }; + let request_id = self.next_request_id; + self.next_request_id = self + .next_request_id + .checked_add(1) + .context("Codex delivery request ID overflow")?; + let client_id = + stable_client_user_message_id(&self.config.identity, state.thread_id(), &head.filename); + let filename = head.filename.clone(); + let text = ding::poke_text( + &self.config.catalog_root, + &self.config.this_host, + &self.config.identity, + head, + ); + let request = + codex_delivery_request(request_id, state.thread_id(), &client_id, &text, &method); + self.write_state(CodexDeliveryState::attempted( + &self.runtime, + state.thread_id().to_string(), + filename.clone(), + client_id, + ))?; + self.pending = Some(PendingCodexDelivery { + request_id, + filename, + method, + }); + Ok(Some(request)) + } + + fn accept_response(&mut self, message: &Value, observed: &CodexObservedState) -> Result { + let Some(pending) = self.pending.as_ref() else { + return Ok(false); + }; + if message.get("method").is_some() + || message.get("id") != Some(&Value::from(pending.request_id)) + { + return Ok(false); + } + let pending = self + .pending + .take() + .context("Codex delivery is not pending")?; + if message.get("error").is_some() { + if !self + .state + .as_ref() + .is_some_and(|state| state.phase == CodexDeliveryPhase::Accepted) + { + self.clear_state()?; + } + self.rejected = Some(RejectedCodexDelivery { + filename: pending.filename, + observed: observed.clone(), + }); + return Ok(true); + } + match &pending.method { + CodexDeliveryMethod::Start => { + required_string(message, "/result/turn/id", "turn/start response")?; + } + CodexDeliveryMethod::Steer { turn_id } => { + let returned = required_string(message, "/result/turnId", "turn/steer response")?; + anyhow::ensure!( + returned == turn_id, + "Codex turn/steer response returned a different turn" + ); + } + } + self.rejected = None; + Ok(true) + } + + fn accept_typed_receipt(&mut self, message: &Value, state: &CodexControlState) -> Result { + if message.get("method").and_then(Value::as_str) != Some("item/completed") + || message.pointer("/params/item/type").and_then(Value::as_str) != Some("userMessage") + { + return Ok(false); + } + let Some(delivery_state) = self.state.as_ref() else { + return Ok(false); + }; + if message.pointer("/params/threadId").and_then(Value::as_str) != Some(state.thread_id()) + || delivery_state.thread_id != state.thread_id() + || delivery_state.runtime_incarnation != self.runtime.incarnation() + || state.runtime_incarnation != self.runtime.incarnation() + || message + .pointer("/params/item/clientId") + .and_then(Value::as_str) + != Some(delivery_state.client_id.as_str()) + { + return Ok(false); + } + if delivery_state.phase == CodexDeliveryPhase::Accepted { + return Ok(true); + } + let mut accepted = delivery_state.clone(); + accepted.phase = CodexDeliveryPhase::Accepted; + self.write_state(accepted)?; + Ok(true) + } + + /// Reconcile a pre-crash attempt against the typed history returned by `thread/resume` before + /// the same client ID can be sent again. + fn reconcile_resume(&mut self, message: &Value, state: &CodexControlState) -> Result<()> { + if message.get("error").is_some() { + return Ok(()); + } + let Some(delivery_state) = self.state.as_ref() else { + return Ok(()); + }; + if delivery_state.thread_id != state.thread_id() + || delivery_state.phase == CodexDeliveryPhase::Accepted + { + return Ok(()); + } + let turns = message + .pointer("/result/thread/turns") + .and_then(Value::as_array) + .context( + "Codex thread/resume response has no typed turn history for delivery recovery", + )?; + let accepted = turns.iter().any(|turn| { + turn.get("items") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("userMessage") + && item.get("clientId").and_then(Value::as_str) + == Some(delivery_state.client_id.as_str()) + }) + }) + }); + if accepted { + let mut state = delivery_state.clone(); + state.phase = CodexDeliveryPhase::Accepted; + self.write_state(state) + } else { + self.clear_state() + } + } +} + +fn load_delivery_state( + path: &Path, + identity: &str, + runtime_id: &str, +) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let state: CodexDeliveryState = serde_json::from_slice(&bytes) + .with_context(|| format!("reading Codex delivery state {}", path.display()))?; + anyhow::ensure!( + state.schema == DELIVERY_STATE_SCHEMA, + "Codex delivery state has unsupported schema '{}'", + state.schema + ); + anyhow::ensure!( + state.agent == identity && state.runtime_id == runtime_id, + "Codex delivery state belongs to a different runtime" + ); + anyhow::ensure!( + !state.runtime_incarnation.is_empty() + && !state.thread_id.is_empty() + && message::is_message_filename(&state.filename), + "Codex delivery state has an invalid runtime binding or filename" + ); + anyhow::ensure!( + state.client_id + == stable_client_user_message_id(identity, &state.thread_id, &state.filename), + "Codex delivery state client ID does not match its binding" + ); + Ok(Some(state)) +} + +fn remove_state_file(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => { + File::open(path.parent().context("state file has no parent")?)?.sync_all()?; + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn stable_client_user_message_id(recipient: &str, thread_id: &str, filename: &str) -> String { + let mut hash = Sha256::new(); + hash.update(b"st2.codex-client-user-message.v1"); + for value in [ + recipient.as_bytes(), + thread_id.as_bytes(), + filename.as_bytes(), + ] { + hash.update((value.len() as u64).to_be_bytes()); + hash.update(value); + } + format!("st2:{:x}", hash.finalize()) +} + +fn codex_delivery_request( + request_id: u64, + thread_id: &str, + client_id: &str, + text: &str, + method: &CodexDeliveryMethod, +) -> Value { + let mut params = json!({ + "threadId": thread_id, + "clientUserMessageId": client_id, + "input": [{ "type": "text", "text": text, "text_elements": [] }] + }); + let method_name = match method { + CodexDeliveryMethod::Start => "turn/start", + CodexDeliveryMethod::Steer { turn_id } => { + params["expectedTurnId"] = Value::String(turn_id.clone()); + "turn/steer" + } + }; + json!({ "method": method_name, "id": request_id, "params": params }) +} + +enum SubscriptionAcceptance { + Accepted { changed: bool }, + Deferred, +} + +impl CodexControlState { + fn new(runtime: &CodexRuntime, thread_id: String) -> Self { + Self { + schema: CONTROL_STATE_SCHEMA.to_string(), + agent: runtime.agent.clone(), + runtime_id: runtime.runtime_id.clone(), + runtime_incarnation: runtime.incarnation.clone(), + thread_id, + subscribed: false, + observed: CodexObservedState::AwaitingStatus, + } + } + + pub fn thread_id(&self) -> &str { + &self.thread_id + } + + pub fn observed(&self) -> &CodexObservedState { + &self.observed + } + + pub fn subscribed(&self) -> bool { + self.subscribed + } + + fn accept_subscription(&mut self, message: &Value) -> Result { + if let Some(error) = message.get("error") { + let code = error.get("code").and_then(Value::as_i64); + let detail = error.get("message").and_then(Value::as_str); + if code == Some(-32600) + && detail + .is_some_and(|detail| detail.starts_with("no rollout found for thread id ")) + { + return Ok(SubscriptionAcceptance::Deferred); + } + anyhow::bail!("Codex app-server rejected control thread/resume: {error}"); + } + anyhow::ensure!( + message.get("result").is_some(), + "Codex control thread/resume response has no result" + ); + let thread_id = required_string(message, "/result/thread/id", "thread/resume response")?; + anyhow::ensure!( + thread_id == self.thread_id, + "Codex control thread/resume returned a different thread" + ); + let status = required_string( + message, + "/result/thread/status/type", + "thread/resume response", + )?; + let before = (self.subscribed, self.observed.clone()); + self.subscribed = true; + self.observe_thread_status(status); + Ok(SubscriptionAcceptance::Accepted { + changed: (self.subscribed, self.observed.clone()) != before, + }) + } + + fn observe(&mut self, message: &Value) -> Result { + let Some(method) = message.get("method").and_then(Value::as_str) else { + return Ok(false); + }; + let before = self.observed.clone(); + match method { + "thread/started" => { + let thread_id = required_string(message, "/params/thread/id", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let status = required_string(message, "/params/thread/status/type", method)?; + self.observe_thread_status(status); + } + "thread/status/changed" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let status = required_string(message, "/params/status/type", method)?; + self.observe_thread_status(status); + } + "turn/started" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let turn_id = required_string(message, "/params/turn/id", method)?.to_string(); + self.observe_turn_started(turn_id); + } + "turn/completed" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let turn_id = required_string(message, "/params/turn/id", method)?; + self.observe_turn_completed(turn_id); + } + "item/started" | "item/completed" => { + let thread_id = required_string(message, "/params/threadId", method)?; + if thread_id != self.thread_id { + return Ok(false); + } + let item_type = required_string(message, "/params/item/type", method)?; + let reason = match item_type { + "enteredReviewMode" => CodexHoldReason::Review, + "contextCompaction" => CodexHoldReason::Compaction, + _ => return Ok(false), + }; + let turn_id = required_string(message, "/params/turnId", method)?; + self.observe_non_steerable(turn_id, reason); + } + _ => return Ok(false), + } + Ok(self.observed != before) + } + + fn observe_thread_status(&mut self, status: &str) { + self.observed = match status { + "idle" => CodexObservedState::Idle, + "active" => match &self.observed { + CodexObservedState::Active { .. } + | CodexObservedState::Held { + reason: + CodexHoldReason::Review + | CodexHoldReason::Compaction + | CodexHoldReason::ConflictingTurn, + .. + } => self.observed.clone(), + _ => CodexObservedState::Held { + reason: CodexHoldReason::ActiveWithoutTurn, + turn_id: None, + }, + }, + "notLoaded" => CodexObservedState::Held { + reason: CodexHoldReason::NotLoaded, + turn_id: None, + }, + "systemError" => CodexObservedState::Held { + reason: CodexHoldReason::SystemError, + turn_id: None, + }, + _ => CodexObservedState::Held { + reason: CodexHoldReason::SystemError, + turn_id: None, + }, + }; + } + + fn observe_turn_started(&mut self, turn_id: String) { + self.observed = match &self.observed { + CodexObservedState::Active { turn_id: current } if current == &turn_id => { + self.observed.clone() + } + CodexObservedState::Held { + reason: reason @ (CodexHoldReason::Review | CodexHoldReason::Compaction), + .. + } => CodexObservedState::Held { + reason: *reason, + turn_id: Some(turn_id), + }, + CodexObservedState::Active { .. } + | CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + .. + } => CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + turn_id: None, + }, + _ => CodexObservedState::Active { turn_id }, + }; + } + + fn observe_turn_completed(&mut self, turn_id: &str) { + self.observed = match &self.observed { + CodexObservedState::Idle => CodexObservedState::Idle, + CodexObservedState::Active { turn_id: current } if current == turn_id => { + CodexObservedState::Idle + } + CodexObservedState::Held { + reason: CodexHoldReason::Review | CodexHoldReason::Compaction, + .. + } => self.observed.clone(), + CodexObservedState::AwaitingStatus + | CodexObservedState::Held { + reason: CodexHoldReason::ActiveWithoutTurn, + .. + } => CodexObservedState::Idle, + CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + .. + } => self.observed.clone(), + _ => CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + turn_id: None, + }, + }; + } + + fn observe_non_steerable(&mut self, turn_id: &str, reason: CodexHoldReason) { + self.observed = match &self.observed { + CodexObservedState::Active { turn_id: current } if current == turn_id => { + CodexObservedState::Held { + reason, + turn_id: Some(turn_id.to_string()), + } + } + CodexObservedState::Held { + reason: current_reason, + .. + } if current_reason == &reason + && matches!( + reason, + CodexHoldReason::Review | CodexHoldReason::Compaction + ) => + { + self.observed.clone() + } + _ if matches!( + reason, + CodexHoldReason::Review | CodexHoldReason::Compaction + ) => + { + CodexObservedState::Held { + reason, + turn_id: Some(turn_id.to_string()), + } + } + _ => CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + turn_id: None, + }, + }; + } +} + +fn required_string<'a>(message: &'a Value, pointer: &str, method: &str) -> Result<&'a str> { + message + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .with_context(|| format!("{method} has no non-empty {pointer}")) +} + +/// Run one authored Codex argv behind a dedicated app server and initialized control connection. +pub fn run_controlled( + catalog_root: &Path, + identity: String, + runtime_id: String, + codex_argv: Vec, +) -> Result<()> { + anyhow::ensure!( + !codex_argv.is_empty(), + "Codex controlled launch argv is empty" + ); + ensure_supported_version(&codex_argv[0])?; + let delivery = CodexDeliveryConfig::resolve(catalog_root, &identity)?; + + let state_dir = state_dir(catalog_root, &identity); + secure_dir(&state_dir)?; + let _owner_lock = acquire_owner_lock(&state_dir)?; + let mut diagnostics = WrapperDiagnostics::open(&state_dir, &identity, &runtime_id)?; + diagnostics.record("ownerAcquired", json!({}))?; + + let result = run_controlled_owned( + catalog_root, + &state_dir, + identity, + runtime_id, + codex_argv, + delivery, + &mut diagnostics, + ); + match result { + Ok(()) => { + diagnostics.record("completed", json!({}))?; + Ok(()) + } + Err(error) => { + let error_text = format!("{error:#}"); + if let Err(diagnostic_error) = + diagnostics.record("failed", json!({ "error": error_text })) + { + return Err(error).context(format!( + "persisting Codex wrapper failure diagnostic: {diagnostic_error:#}" + )); + } + Err(error) + } + } +} + +fn run_controlled_owned( + catalog_root: &Path, + state_dir: &Path, + identity: String, + runtime_id: String, + codex_argv: Vec, + delivery: CodexDeliveryConfig, + diagnostics: &mut WrapperDiagnostics, +) -> Result<()> { + let binding_path = state_dir.join("binding.json"); + let resume_thread = load_resume_thread(&binding_path, &identity, &runtime_id)?; + + let socket_path = socket_path(catalog_root, &identity)?; + let socket_dir = socket_path + .parent() + .context("Codex app-server socket has no parent")?; + secure_dir(socket_dir)?; + match fs::symlink_metadata(&socket_path) { + Ok(metadata) => { + anyhow::ensure!( + metadata.file_type().is_socket(), + "Codex app-server path already exists and is not a socket: {}", + socket_path.display() + ); + match UnixStream::connect(&socket_path) { + Ok(_) => anyhow::bail!( + "Codex app-server socket {} is already live; refusing a second control owner", + socket_path.display() + ), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound + ) => + { + fs::remove_file(&socket_path).with_context(|| { + format!("removing stale Codex socket {}", socket_path.display()) + })?; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "checking existing Codex socket {} before launch", + socket_path.display() + ) + }); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("checking Codex socket path {}", socket_path.display())); + } + } + + // Publish a new incarnation only after this process holds the owner lock and has proved that no + // older daemon is live. A rejected second owner must not invalidate the first owner's binding. + let runtime = CodexRuntime::fresh(identity, runtime_id)?; + atomic_json(&state_dir.join("runtime.json"), &runtime)?; + diagnostics.record( + "runtimePublished", + json!({ + "runtimeIncarnation": runtime.incarnation(), + "resumeSelected": resume_thread.is_some(), + }), + )?; + + let log = OpenOptions::new() + .create(true) + .append(true) + .mode(0o600) + .open(state_dir.join("app-server.log"))?; + let endpoint = format!("unix://{}", socket_path.display()); + let mut server_args = controlled_app_server_args(&endpoint, &codex_argv[1..])?; + if resume_thread.is_some() && authored_bypasses_hook_trust(&codex_argv[1..])? { + let hook_cwd = controlled_hook_cwd(&codex_argv[1..])?; + if let Some(projection) = preflight_hook_trust( + &codex_argv[0], + &server_args, + &socket_path, + &hook_cwd, + &log, + diagnostics, + )? { + insert_app_server_config_override(&mut server_args, projection.override_value)?; + } + } + diagnostics.record("appServerStarting", json!({}))?; + let mut server = Command::new(&codex_argv[0]) + .args(server_args) + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .stderr(log) + .spawn() + .with_context(|| format!("starting {} app-server", codex_argv[0]))?; + let result = diagnostics + .record("appServerStarted", json!({ "pid": server.id() })) + .and_then(|_| { + run_connected( + &mut server, + &socket_path, + &runtime, + &codex_argv, + resume_thread.as_deref(), + delivery, + diagnostics, + ) + }); + terminate_child(&mut server); + let _ = fs::remove_file(&socket_path); + result +} + +fn run_connected( + server: &mut Child, + socket_path: &Path, + runtime: &CodexRuntime, + codex_argv: &[String], + resume_thread: Option<&str>, + delivery: CodexDeliveryConfig, + diagnostics: &mut WrapperDiagnostics, +) -> Result<()> { + let state_dir = state_dir(&delivery.catalog_root, &delivery.identity); + let endpoint = format!("unix://{}", socket_path.display()); + let tui_args = controlled_tui_args(&endpoint, &codex_argv[1..], resume_thread)?; + let expected_resume = + expected_resume_thread(&codex_argv[1..], resume_thread)?.map(str::to_owned); + diagnostics.record("waitingForControlSocket", json!({ "pid": server.id() }))?; + let control = connect_control(server, socket_path, STARTUP_TIMEOUT)?; + diagnostics.record("controlSocketConnected", json!({}))?; + let shutdown = control.try_clone()?; + let websocket = initialize_control(control)?; + diagnostics.record("controlInitialized", json!({}))?; + let (events_tx, events_rx) = mpsc::channel(); + let binding_path = state_dir.join("binding.json"); + let control_state_path = state_dir.join("control-state.json"); + let runtime_for_reader = runtime.clone(); + let (mut resume_ready_tx, resume_ready_rx) = if expected_resume.is_some() { + let (tx, rx) = mpsc::channel(); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + let event_thread = thread::spawn(move || { + let resume = expected_resume + .as_deref() + .zip(resume_ready_rx) + .map(|(thread_id, ready)| ControlResume { + thread_id, + ready, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }); + pump_control( + websocket, + &binding_path, + &control_state_path, + &runtime_for_reader, + resume, + Some(delivery), + events_tx, + ) + }); + + // A fresh initialized observer reads before this child can issue thread/start. A resumed + // observer waits on the gate below, then proves through thread/loaded/list that the TUI issued + // its own resume. Only after that typed observation may control send its redundant resume. + // Insert the remote endpoint as a global Codex option and preserve every authored argument + // after the provider executable. + let mut tui_command = Command::new(&codex_argv[0]); + tui_command.args(tui_args); + let mut tui = match tui_command + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn() + { + Ok(tui) => tui, + Err(error) => { + drop(resume_ready_tx); + let _ = shutdown.shutdown(Shutdown::Both); + let _ = event_thread.join(); + return Err(error) + .with_context(|| format!("starting controlled {} TUI", codex_argv[0])); + } + }; + let result = (|| -> Result<()> { + diagnostics.record("tuiStarted", json!({ "pid": tui.id() }))?; + if let Some(ready) = resume_ready_tx.take() { + ready + .send(()) + .context("starting Codex control resume after the TUI launched")?; + } + diagnostics.record("waitingForThreadBinding", json!({ "pid": tui.id() }))?; + wait_for_binding(&mut tui, &events_rx, STARTUP_TIMEOUT, diagnostics).and_then(|_| { + diagnostics.record("threadBound", json!({ "pid": tui.id() }))?; + monitor_bound_tui(&mut tui, &events_rx) + }) + })(); + if result.is_err() { + terminate_child(&mut tui); + } + drop(resume_ready_tx); + let _ = shutdown.shutdown(Shutdown::Both); + let _ = event_thread.join(); + result +} + +/// Start app-server with the authored global configuration inputs that its CLI supports. +/// +/// Project trust, strict parsing, and feature selection affect config and hook loading in the +/// server process. Passing them only to the remote TUI silently creates two different effective +/// configurations. TUI-only policy, model, workspace, authentication, and prompt arguments stay +/// on the TUI command. +fn controlled_app_server_args(endpoint: &str, authored_args: &[String]) -> Result> { + let boundary = interactive_root_prefix_end(authored_args)?; + let mut args = vec!["app-server".to_string()]; + let mut index = 0; + while index < boundary { + let argument = authored_args[index].as_str(); + if matches!(argument, "-c" | "--config" | "--enable" | "--disable") { + args.push(argument.to_string()); + args.push(authored_args[index + 1].clone()); + index += 2; + continue; + } + if argument == "--strict-config" + || argument.starts_with("--config=") + || argument.starts_with("--enable=") + || argument.starts_with("--disable=") + || (argument.starts_with("-c") && argument.len() > 2) + { + args.push(argument.to_string()); + index += 1; + continue; + } + if matches!( + argument, + "--oss" + | "--dangerously-bypass-approvals-and-sandbox" + | "--dangerously-bypass-hook-trust" + | "--search" + | "--no-alt-screen" + ) { + index += 1; + continue; + } + if matches!(argument, "-i" | "--image") + || argument.starts_with("-i=") + || argument.starts_with("--image=") + { + break; + } + let exact_value_option = matches!( + argument, + "--remote-auth-token-env" + | "-m" + | "--model" + | "--local-provider" + | "-p" + | "--profile" + | "-s" + | "--sandbox" + | "-C" + | "--cd" + | "--add-dir" + | "-a" + | "--ask-for-approval" + ); + index += if exact_value_option { 2 } else { 1 }; + } + args.extend(["--listen".to_string(), endpoint.to_string()]); + Ok(args) +} + +fn authored_bypasses_hook_trust(authored_args: &[String]) -> Result { + let boundary = interactive_root_prefix_end(authored_args)?; + Ok(authored_args[..boundary] + .iter() + .any(|argument| argument == "--dangerously-bypass-hook-trust")) +} + +/// Resolve the workspace whose non-managed hooks the remote TUI reviews before a resume. +/// +/// st2 starts the wrapper in the declared workspace. An explicit Codex `--cd`/`-C` overrides it, +/// and the last occurrence wins just as the provider CLI does. The path must already exist because +/// both project-layer discovery and remote resume require a real directory. +fn controlled_hook_cwd(authored_args: &[String]) -> Result { + let boundary = interactive_root_prefix_end(authored_args)?; + let mut selected = std::env::current_dir().context("reading controlled Codex workspace")?; + let mut index = 0; + while index < boundary { + let argument = authored_args[index].as_str(); + if matches!(argument, "-C" | "--cd") { + selected = PathBuf::from(&authored_args[index + 1]); + index += 2; + continue; + } + if let Some(value) = argument.strip_prefix("--cd=") { + selected = PathBuf::from(value); + } else if let Some(value) = argument.strip_prefix("-C") + && !value.is_empty() + { + selected = PathBuf::from(value); + } + index += if matches!( + argument, + "-c" | "--config" + | "--enable" + | "--disable" + | "--remote-auth-token-env" + | "-m" + | "--model" + | "--local-provider" + | "-p" + | "--profile" + | "-s" + | "--sandbox" + | "--add-dir" + | "-a" + | "--ask-for-approval" + ) { + 2 + } else { + 1 + }; + } + if selected.is_relative() { + selected = std::env::current_dir() + .context("reading controlled Codex workspace")? + .join(selected); + } + fs::canonicalize(&selected).with_context(|| { + format!( + "resolving controlled Codex workspace {}", + selected.display() + ) + }) +} + +#[derive(Debug)] +struct HookTrustProjection { + override_value: String, + count: usize, +} + +/// Codex 0.145/0.146 deliberately ignores the hook-trust bypass for startup review on every +/// persistent remote resume. Before the owning TUI starts, ask the same exact provider binary for +/// its typed hook keys and hashes, then project those hashes into the final app-server's session +/// flags. This implements the authored one-invocation bypass without writing persisted trust. +fn preflight_hook_trust( + codex: &str, + server_args: &[String], + socket_path: &Path, + cwd: &Path, + log: &File, + diagnostics: &mut WrapperDiagnostics, +) -> Result> { + diagnostics.record("hookTrustPreflightStarting", json!({}))?; + let mut server = Command::new(codex) + .args(server_args) + .stdin(Stdio::null()) + .stdout(log.try_clone()?) + .stderr(log.try_clone()?) + .spawn() + .with_context(|| format!("starting {codex} hook-trust preflight app-server"))?; + let result = diagnostics + .record("hookTrustPreflightStarted", json!({ "pid": server.id() })) + .and_then(|_| { + let control = connect_control(&mut server, socket_path, STARTUP_TIMEOUT)?; + let mut websocket = initialize_control(control)?; + query_hook_trust_projection(&mut websocket, cwd) + }); + terminate_child(&mut server); + let _ = fs::remove_file(socket_path); + let projection = result?; + diagnostics.record( + "hookTrustPreflightComplete", + json!({ "projectedHookCount": projection.as_ref().map_or(0, |value| value.count) }), + )?; + Ok(projection) +} + +fn query_hook_trust_projection( + websocket: &mut WebSocket, + cwd: &Path, +) -> Result> { + write_json_message( + websocket, + &json!({ + "method": "hooks/list", + "id": HOOK_TRUST_PREFLIGHT_REQUEST_ID, + "params": { "cwds": [cwd.to_string_lossy()] }, + }), + )?; + websocket + .get_ref() + .set_read_timeout(Some(STARTUP_TIMEOUT))?; + let response = loop { + let message = read_json_message(websocket)? + .context("Codex app-server closed during hook-trust preflight")?; + if message.get("id") == Some(&Value::from(HOOK_TRUST_PREFLIGHT_REQUEST_ID)) { + break message; + } + }; + if let Some(error) = response.get("error") { + anyhow::bail!("Codex app-server rejected hooks/list preflight: {error}"); + } + hook_trust_projection_from_response(&response, cwd) +} + +fn hook_trust_projection_from_response( + response: &Value, + cwd: &Path, +) -> Result> { + let data = response + .pointer("/result/data") + .and_then(Value::as_array) + .context("Codex hooks/list preflight response has no typed data")?; + anyhow::ensure!( + data.len() == 1, + "Codex hooks/list preflight returned {} cwd entries instead of one", + data.len() + ); + let entry = &data[0]; + anyhow::ensure!( + entry.get("cwd").and_then(Value::as_str) == Some(cwd.to_string_lossy().as_ref()), + "Codex hooks/list preflight returned a different cwd" + ); + let hooks = entry + .get("hooks") + .and_then(Value::as_array) + .context("Codex hooks/list preflight cwd entry has no typed hooks")?; + let mut projected = BTreeMap::new(); + for hook in hooks { + let status = hook + .get("trustStatus") + .and_then(Value::as_str) + .context("Codex hooks/list preflight hook has no trustStatus")?; + match status { + "trusted" | "managed" => continue, + "untrusted" | "modified" => {} + other => { + anyhow::bail!("Codex hooks/list preflight returned unknown trustStatus '{other}'") + } + } + anyhow::ensure!( + hook.get("isManaged").and_then(Value::as_bool) == Some(false), + "Codex hooks/list preflight returned a managed hook requiring trust" + ); + let key = hook + .get("key") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .context("Codex hooks/list preflight hook has no non-empty key")?; + let current_hash = hook + .get("currentHash") + .and_then(Value::as_str) + .filter(|value| value.starts_with("sha256:") && value.len() > "sha256:".len()) + .context("Codex hooks/list preflight hook has no typed currentHash")?; + if let Some(previous) = projected.insert(key.to_string(), current_hash.to_string()) { + anyhow::ensure!( + previous == current_hash, + "Codex hooks/list preflight returned conflicting hashes for one hook key" + ); + } + } + if projected.is_empty() { + return Ok(None); + } + + let mut state = toml::Table::new(); + for (key, current_hash) in projected { + let mut trust = toml::Table::new(); + trust.insert( + "trusted_hash".to_string(), + toml::Value::String(current_hash), + ); + state.insert(key, toml::Value::Table(trust)); + } + Ok(Some(HookTrustProjection { + count: state.len(), + override_value: format!("hooks.state={}", toml::Value::Table(state)), + })) +} + +fn insert_app_server_config_override( + server_args: &mut Vec, + override_value: String, +) -> Result<()> { + let listen = server_args + .iter() + .position(|argument| argument == "--listen") + .context("controlled Codex app-server argv has no --listen boundary")?; + server_args.splice(listen..listen, ["-c".to_string(), override_value]); + Ok(()) +} + +fn controlled_tui_args( + endpoint: &str, + authored_args: &[String], + resume_thread: Option<&str>, +) -> Result> { + let mut args = vec!["--remote".to_string(), endpoint.to_string()]; + let Some(thread_id) = resume_thread else { + args.extend_from_slice(authored_args); + return Ok(args); + }; + let Some(insertion) = resume_insertion_index(authored_args)? else { + args.extend_from_slice(authored_args); + return Ok(args); + }; + args.push("resume".to_string()); + // Codex models these flags on the `resume` command as well as the root command. Keep them + // before SESSION_ID so clap does not treat a following flag as the optional prompt. + args.extend_from_slice(&authored_args[..insertion]); + args.push(thread_id.to_string()); + args.extend_from_slice(&authored_args[insertion..]); + Ok(args) +} + +/// A saved binding constrains the watcher only when st2 inserted that resume selection. +/// +/// An authored `resume` or `fork` command owns its own selection. The watcher binds the first typed +/// event from that command instead of rejecting it because it differs from an older saved binding. +fn expected_resume_thread<'a>( + authored_args: &[String], + resume_thread: Option<&'a str>, +) -> Result> { + let Some(thread_id) = resume_thread else { + return Ok(None); + }; + Ok(resume_insertion_index(authored_args)? + .is_some() + .then_some(thread_id)) +} + +/// Find where a supported Codex interactive argv begins its prompt or subcommand. +/// +/// Automatic resume must insert `resume ` after global options and before the authored +/// prompt. Unknown options fail closed because guessing can turn an option value into a prompt or a +/// prompt into a session selector. `--image` is variadic, so automatic resume requires an explicit +/// `--` boundary when that option is present. +fn resume_insertion_index(authored_args: &[String]) -> Result> { + let insertion = interactive_root_prefix_end(authored_args)?; + if authored_args + .get(insertion) + .is_some_and(|argument| matches!(argument.as_str(), "resume" | "fork")) + { + Ok(None) + } else { + Ok(Some(insertion)) + } +} + +fn interactive_root_prefix_end(authored_args: &[String]) -> Result { + let delimiter = authored_args.iter().position(|arg| arg == "--"); + let mut index = 0; + while index < authored_args.len() { + let argument = authored_args[index].as_str(); + if argument == "--" { + return Ok(index); + } + if !argument.starts_with('-') || argument == "-" { + return Ok(index); + } + + if matches!( + argument, + "--strict-config" + | "--oss" + | "--dangerously-bypass-approvals-and-sandbox" + | "--dangerously-bypass-hook-trust" + | "--search" + | "--no-alt-screen" + ) { + index += 1; + continue; + } + anyhow::ensure!( + !matches!(argument, "-h" | "--help" | "-V" | "--version"), + "cannot automatically resume a Codex help or version invocation" + ); + + let exact_value_option = matches!( + argument, + "-c" | "--config" + | "--enable" + | "--disable" + | "--remote-auth-token-env" + | "-m" + | "--model" + | "--local-provider" + | "-p" + | "--profile" + | "-s" + | "--sandbox" + | "-C" + | "--cd" + | "--add-dir" + | "-a" + | "--ask-for-approval" + ); + if exact_value_option { + anyhow::ensure!( + index + 1 < authored_args.len(), + "Codex option '{argument}' has no value" + ); + index += 2; + continue; + } + if matches!(argument, "-i" | "--image") + || argument.starts_with("-i=") + || argument.starts_with("--image=") + { + let boundary = delimiter.context( + "automatic Codex resume with variadic --image requires an explicit `--` prompt boundary", + )?; + return Ok(boundary); + } + + let long_value = [ + "--config=", + "--enable=", + "--disable=", + "--remote-auth-token-env=", + "--model=", + "--local-provider=", + "--profile=", + "--sandbox=", + "--cd=", + "--add-dir=", + "--ask-for-approval=", + ] + .iter() + .any(|prefix| argument.starts_with(prefix)); + let short_value = ["-c", "-m", "-p", "-s", "-C", "-a"] + .iter() + .any(|prefix| argument.starts_with(prefix) && argument.len() > prefix.len()); + anyhow::ensure!( + long_value || short_value, + "cannot automatically resume through unknown Codex option '{}'", + diagnostic_option_name(argument) + ); + index += 1; + } + Ok(authored_args.len()) +} + +fn diagnostic_option_name(argument: &str) -> String { + if let Some((name, _)) = argument.split_once('=') { + return name.to_string(); + } + if argument.starts_with("--") { + return argument.to_string(); + } + argument.chars().take(2).collect() +} + +fn connect_control( + server: &mut Child, + socket_path: &Path, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + match UnixStream::connect(socket_path) { + Ok(stream) => return Ok(stream), + Err(error) if Instant::now() < deadline => { + if let Some(status) = server.try_wait()? { + anyhow::bail!("Codex app-server exited before control connected: {status}"); + } + if error.kind() != std::io::ErrorKind::NotFound + && error.kind() != std::io::ErrorKind::ConnectionRefused + { + return Err(error).with_context(|| { + format!("connecting Codex control socket {}", socket_path.display()) + }); + } + thread::sleep(Duration::from_millis(50)); + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "Codex control socket {} was not ready within {}s", + socket_path.display(), + timeout.as_secs() + ) + }); + } + } + } +} + +fn initialize_control(stream: UnixStream) -> Result> { + stream.set_read_timeout(Some(STARTUP_TIMEOUT))?; + let (mut websocket, response) = tungstenite::client("ws://localhost/", stream) + .map_err(|error| anyhow::anyhow!("Codex WebSocket handshake failed: {error}"))?; + anyhow::ensure!( + response.status().as_u16() == 101, + "Codex WebSocket handshake returned {}", + response.status() + ); + write_json_message( + &mut websocket, + &json!({ + "method": "initialize", + "id": 0, + "params": { + "clientInfo": { + "name": "st2", + "title": "st2", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": { "experimentalApi": true } + } + }), + )?; + + loop { + let message = read_json_message(&mut websocket)? + .context("Codex app-server closed the control connection during initialize")?; + if message.get("id") != Some(&Value::from(0)) { + continue; + } + if let Some(error) = message.get("error") { + anyhow::bail!("Codex app-server rejected initialize: {error}"); + } + anyhow::ensure!( + message.get("result").is_some(), + "Codex app-server initialize response has no result" + ); + break; + } + write_json_message( + &mut websocket, + &json!({ "method": "initialized", "params": {} }), + )?; + websocket.get_ref().set_read_timeout(None)?; + Ok(websocket) +} + +/// Wait until the owning TUI has loaded the preserved thread before this control connection +/// subscribes with its own `thread/resume` request. +/// +/// Process creation is not ownership evidence. If control resumes immediately after spawn, it can +/// win the cold resume and create the session before the TUI has attached, so a successful control +/// response would not prove that the TUI consumed its authored prompt. `thread/loaded/list` is a +/// typed observation of the TUI's progress and is available in every admitted Codex version. +fn wait_for_tui_loaded_thread( + websocket: &mut WebSocket, + expected_thread_id: &str, + timeout: Duration, +) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + write_json_message( + websocket, + &json!({ + "method": "thread/loaded/list", + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "params": {}, + }), + )?; + + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + anyhow::ensure!( + !remaining.is_zero(), + "controlled Codex TUI did not load preserved thread {expected_thread_id} before control resume" + ); + websocket + .get_ref() + .set_read_timeout(Some(remaining.min(CONTROL_POLL)))?; + let message = match poll_json_message(websocket)? { + ControlRead::Message(message) => message, + ControlRead::Timeout => continue, + ControlRead::Closed => anyhow::bail!( + "Codex app-server closed the control connection while waiting for the TUI to load preserved thread {expected_thread_id}" + ), + }; + if message.get("id") != Some(&Value::from(CONTROL_TUI_LOADED_REQUEST_ID)) { + continue; + } + if let Some(error) = message.get("error") { + anyhow::bail!("Codex app-server rejected thread/loaded/list: {error}"); + } + let loaded = message + .pointer("/result/data") + .and_then(Value::as_array) + .context("Codex thread/loaded/list response has no typed data")?; + let contains_expected = loaded.iter().try_fold(false, |found, thread_id| { + let thread_id = thread_id + .as_str() + .context("Codex thread/loaded/list returned a non-string thread id")?; + Ok::<_, anyhow::Error>(found || thread_id == expected_thread_id) + })?; + if contains_expected { + return Ok(()); + } + break; + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + anyhow::ensure!( + !remaining.is_zero(), + "controlled Codex TUI did not load preserved thread {expected_thread_id} before control resume" + ); + thread::sleep(remaining.min(CONTROL_POLL)); + } +} + +#[derive(Debug)] +enum ControlEvent { + TuiThreadLoaded(Sender<()>), + Bound, + Observed, + Closed, + Failed(String), +} + +struct ControlResume<'a> { + thread_id: &'a str, + ready: Receiver<()>, + tui_loaded_timeout: Duration, +} + +fn pump_control( + mut websocket: WebSocket, + binding_path: &Path, + control_state_path: &Path, + runtime: &CodexRuntime, + resume: Option>, + delivery: Option, + events: Sender, +) { + let result = (|| -> Result<()> { + let (expected_resume, resume_ready, tui_loaded_timeout) = match resume { + Some(resume) => ( + Some(resume.thread_id), + Some(resume.ready), + resume.tui_loaded_timeout, + ), + None => (None, None, TUI_LOADED_TIMEOUT), + }; + let mut control_state: Option = None; + let mut subscription_pending = false; + let delivery_state_path = control_state_path.with_file_name("delivery-state.json"); + let mut delivery = delivery + .map(|config| { + CodexInboxDelivery::new(config, delivery_state_path.clone(), runtime.clone()) + }) + .transpose()?; + websocket.get_ref().set_read_timeout(Some(CONTROL_POLL))?; + if let Some(thread_id) = expected_resume { + resume_ready + .context("saved Codex binding has no TUI-start gate")? + .recv() + .context("controlled Codex TUI ended before control resume")?; + wait_for_tui_loaded_thread(&mut websocket, thread_id, tui_loaded_timeout)?; + let (diagnostic_tx, diagnostic_rx) = mpsc::channel(); + events + .send(ControlEvent::TuiThreadLoaded(diagnostic_tx)) + .context("recording that the Codex TUI loaded the preserved thread")?; + diagnostic_rx + .recv() + .context("waiting for the Codex TUI-loaded diagnostic before control resume")?; + write_json_message( + &mut websocket, + &json!({ + "method": "thread/resume", + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "params": { "threadId": thread_id } + }), + )?; + subscription_pending = true; + } + loop { + let message = match poll_json_message(&mut websocket)? { + ControlRead::Message(message) => Some(message), + ControlRead::Timeout => None, + ControlRead::Closed => { + let _ = events.send(ControlEvent::Closed); + return Ok(()); + } + }; + let Some(message) = message else { + if let (Some(state), Some(delivery)) = (control_state.as_ref(), delivery.as_mut()) + && let Some(request) = delivery.maybe_request(state)? + { + write_json_message(&mut websocket, &request)?; + } + continue; + }; + if control_state.is_none() { + if let Some(thread_id) = expected_resume { + if message.get("method").is_some() + || message.get("id") != Some(&Value::from(CONTROL_SUBSCRIBE_REQUEST_ID)) + { + continue; + } + anyhow::ensure!( + subscription_pending, + "Codex control received an unexpected initial thread/resume response" + ); + subscription_pending = false; + let mut bound = CodexControlState::new(runtime, thread_id.to_string()); + match bound.accept_subscription(&message)? { + SubscriptionAcceptance::Accepted { .. } => { + if let Some(delivery) = delivery.as_mut() { + delivery.reconcile_resume(&message, &bound)?; + } + } + SubscriptionAcceptance::Deferred => anyhow::bail!( + "saved Codex resume binding has no persisted rollout for thread {thread_id}" + ), + } + atomic_json( + binding_path, + &CodexThreadBinding::new(runtime, thread_id.to_string()), + )?; + atomic_json(control_state_path, &bound)?; + control_state = Some(bound); + let _ = events.send(ControlEvent::Bound); + continue; + } + + let Some(thread_id) = binding_candidate(&message)? else { + continue; + }; + atomic_json( + binding_path, + &CodexThreadBinding::new(runtime, thread_id.to_string()), + )?; + let mut bound = CodexControlState::new(runtime, thread_id.to_string()); + // A fresh control client that observes the owning TUI's `thread/started` + // notification is already subscribed to that thread's broadcasts. Before its + // first turn there is no persisted rollout for a redundant `thread/resume`. + bound.subscribed = true; + atomic_json(control_state_path, &bound)?; + control_state = Some(bound); + let _ = events.send(ControlEvent::Bound); + } + + let state = control_state + .as_mut() + .context("Codex control state is unbound")?; + let delivery_response = match delivery.as_mut() { + Some(delivery) => { + delivery.accept_response(&message, &state.observed)? + || delivery.accept_typed_receipt(&message, state)? + } + None => false, + }; + let changed = if delivery_response { + false + } else if message.get("method").is_none() + && message.get("id") == Some(&Value::from(CONTROL_SUBSCRIBE_REQUEST_ID)) + { + anyhow::ensure!( + subscription_pending, + "Codex control received an unexpected thread/resume response" + ); + subscription_pending = false; + match state.accept_subscription(&message)? { + SubscriptionAcceptance::Accepted { changed } => { + if let Some(delivery) = delivery.as_mut() { + delivery.reconcile_resume(&message, state)?; + } + changed + } + SubscriptionAcceptance::Deferred => false, + } + } else { + state.observe(&message)? + }; + if changed { + atomic_json(control_state_path, state)?; + let _ = events.send(ControlEvent::Observed); + } + if !state.subscribed + && !subscription_pending + && subscription_candidate(&message, state.thread_id()) + { + write_json_message( + &mut websocket, + &json!({ + "method": "thread/resume", + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "params": { "threadId": state.thread_id } + }), + )?; + subscription_pending = true; + } + if let Some(delivery) = delivery.as_mut() + && let Some(request) = delivery.maybe_request(state)? + { + write_json_message(&mut websocket, &request)?; + } + } + })(); + if let Err(error) = result { + let _ = events.send(ControlEvent::Failed(format!("{error:#}"))); + } +} + +fn subscription_candidate(message: &Value, thread_id: &str) -> bool { + match message.get("method").and_then(Value::as_str) { + Some("thread/started") => { + message.pointer("/params/thread/id").and_then(Value::as_str) == Some(thread_id) + && matches!( + message + .pointer("/params/thread/status/type") + .and_then(Value::as_str), + Some("idle" | "active") + ) + } + Some("thread/status/changed") => { + message.pointer("/params/threadId").and_then(Value::as_str) == Some(thread_id) + && matches!( + message + .pointer("/params/status/type") + .and_then(Value::as_str), + Some("idle" | "active") + ) + } + _ => false, + } +} + +fn binding_candidate(message: &Value) -> Result> { + match message.get("method").and_then(Value::as_str) { + Some("thread/started") => { + let thread_id = required_string(message, "/params/thread/id", "thread/started")?; + Ok(Some(thread_id)) + } + _ => Ok(None), + } +} + +fn wait_for_binding( + tui: &mut Child, + events: &Receiver, + timeout: Duration, + diagnostics: &mut WrapperDiagnostics, +) -> Result<()> { + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = tui.try_wait()? { + anyhow::bail!("controlled Codex TUI exited before thread binding: {status}"); + } + let wait = deadline + .saturating_duration_since(Instant::now()) + .min(CONTROL_POLL); + if wait.is_zero() { + anyhow::bail!( + "controlled Codex TUI did not establish typed thread ownership within {}s", + timeout.as_secs() + ); + } + match events.recv_timeout(wait) { + Ok(ControlEvent::TuiThreadLoaded(acknowledge)) => { + diagnostics.record("tuiThreadLoaded", json!({ "pid": tui.id() }))?; + let _ = acknowledge.send(()); + } + Ok(ControlEvent::Bound) => return Ok(()), + Ok(ControlEvent::Observed) => {} + Ok(ControlEvent::Closed) => { + anyhow::bail!("Codex control connection closed before thread binding") + } + Ok(ControlEvent::Failed(error)) => { + anyhow::bail!("Codex control failed before thread binding: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("Codex control observer ended before thread binding") + } + } + } +} + +fn monitor_bound_tui(tui: &mut Child, events: &Receiver) -> Result<()> { + loop { + if let Some(status) = tui.try_wait()? { + return completed_tui(status); + } + match events.recv_timeout(CONTROL_POLL) { + Ok(ControlEvent::TuiThreadLoaded(acknowledge)) => { + let _ = acknowledge.send(()); + } + Ok(ControlEvent::Bound) => {} + Ok(ControlEvent::Observed) => {} + Ok(ControlEvent::Closed) => { + anyhow::bail!("Codex control connection closed while the TUI was live") + } + Ok(ControlEvent::Failed(error)) => { + anyhow::bail!("Codex control failed while the TUI was live: {error}") + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => { + anyhow::bail!("Codex control observer ended while the TUI was live") + } + } + } +} + +fn completed_tui(status: ExitStatus) -> Result<()> { + anyhow::ensure!( + status.success(), + "controlled Codex TUI exited with {status}" + ); + Ok(()) +} + +fn ensure_supported_version(codex: &str) -> Result<()> { + let output = Command::new(codex) + .arg("--version") + .output() + .with_context(|| format!("reading Codex version from {codex}"))?; + anyhow::ensure!( + output.status.success(), + "{codex} --version failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + let actual = String::from_utf8(output.stdout) + .context("Codex version output is not UTF-8")? + .trim() + .to_string(); + anyhow::ensure!( + SUPPORTED_CODEX_CLI_VERSIONS.contains(&actual.as_str()), + "unsupported Codex app-server protocol version '{actual}' (expected one of: {})", + SUPPORTED_CODEX_CLI_VERSIONS.join(", ") + ); + Ok(()) +} + +pub fn state_dir(catalog_root: &Path, identity: &str) -> PathBuf { + let base = std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state"))) + .unwrap_or_else(|| PathBuf::from("/tmp")); + state_dir_in(&base, catalog_root, identity) +} + +fn state_dir_in(base: &Path, catalog_root: &Path, identity: &str) -> PathBuf { + base.join("st2") + .join("codex") + .join(runtime_key(catalog_root, identity)) +} + +fn socket_path(catalog_root: &Path, identity: &str) -> Result { + let key = runtime_key(catalog_root, identity); + let preferred = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .map(|base| base.join("st2-codex").join(format!("{key}.sock"))); + if let Some(path) = preferred + && path.as_os_str().as_bytes().len() <= SOCKET_PATH_BUDGET + { + return Ok(path); + } + let path = PathBuf::from("/tmp") + .join(format!("st2-{}", unsafe { libc::geteuid() })) + .join("codex") + .join(format!("{key}.sock")); + anyhow::ensure!( + path.as_os_str().as_bytes().len() <= SOCKET_PATH_BUDGET, + "Codex app-server socket path is too long: {}", + path.display() + ); + Ok(path) +} + +fn runtime_key(catalog_root: &Path, identity: &str) -> String { + let mut hash = Sha256::new(); + for value in [catalog_root.as_os_str().as_bytes(), identity.as_bytes()] { + hash.update((value.len() as u64).to_be_bytes()); + hash.update(value); + } + let digest = format!("{:x}", hash.finalize()); + digest[..24].to_string() +} + +fn secure_dir(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +fn acquire_owner_lock(state_dir: &Path) -> Result { + let path = state_dir.join("owner.lock"); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .mode(0o600) + .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW) + .open(&path) + .with_context(|| format!("opening Codex runtime owner lock {}", path.display()))?; + // SAFETY: `file` owns this descriptor until the returned guard is dropped. `flock` does not + // access Rust memory, and closing the descriptor releases the process-scoped lock after crash. + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result != 0 { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("Codex runtime already has an owner at {}", path.display())); + } + Ok(file) +} + +fn atomic_json(path: &Path, value: &impl Serialize) -> Result<()> { + let parent = path.parent().context("state file has no parent")?; + secure_dir(parent)?; + let temp = parent.join(format!( + ".{}.{}.tmp", + path.file_name().unwrap().to_string_lossy(), + random_token()? + )); + let result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temp)?; + serde_json::to_writer_pretty(&mut file, value)?; + file.write_all(b"\n")?; + file.sync_all()?; + fs::rename(&temp, path)?; + File::open(parent)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temp); + } + result +} + +pub fn load_current_binding( + path: &Path, + runtime: &CodexRuntime, +) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let binding: CodexThreadBinding = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + binding.schema == BINDING_SCHEMA, + "unsupported Codex binding schema" + ); + anyhow::ensure!( + binding.agent == runtime.agent + && binding.runtime_id == runtime.runtime_id + && binding.runtime_incarnation == runtime.incarnation, + "Codex thread binding belongs to a different runtime incarnation" + ); + Ok(Some(binding)) +} + +pub fn load_current_control_state( + path: &Path, + runtime: &CodexRuntime, + binding: &CodexThreadBinding, +) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let state: CodexControlState = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + state.schema == CONTROL_STATE_SCHEMA, + "unsupported Codex control-state schema" + ); + anyhow::ensure!( + state.agent == runtime.agent + && state.runtime_id == runtime.runtime_id + && state.runtime_incarnation == runtime.incarnation + && state.thread_id == binding.thread_id, + "Codex control state belongs to a different runtime binding" + ); + Ok(Some(state)) +} + +fn load_resume_thread(path: &Path, agent: &str, runtime_id: &str) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let binding: CodexThreadBinding = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + binding.schema == BINDING_SCHEMA, + "unsupported Codex binding schema" + ); + anyhow::ensure!( + binding.agent == agent && binding.runtime_id == runtime_id, + "Codex resume binding belongs to a different agent runtime" + ); + anyhow::ensure!( + !binding.thread_id.is_empty(), + "Codex resume binding has an empty thread id" + ); + Ok(Some(binding.thread_id)) +} + +fn random_token() -> Result { + let mut bytes = [0_u8; 16]; + File::open("/dev/urandom")?.read_exact(&mut bytes)?; + Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn write_json_message(websocket: &mut WebSocket, value: &Value) -> Result<()> { + websocket.send(WebSocketMessage::Text(value.to_string().into()))?; + Ok(()) +} + +fn read_json_message(websocket: &mut WebSocket) -> Result> { + loop { + let message = match websocket.read() { + Ok(message) => message, + Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + return Ok(None); + } + Err(error) => return Err(error.into()), + }; + match message { + WebSocketMessage::Text(text) => { + let value = serde_json::from_str(&text) + .context("decoding Codex app-server WebSocket JSON")?; + return Ok(Some(value)); + } + WebSocketMessage::Close(_) => return Ok(None), + WebSocketMessage::Ping(_) | WebSocketMessage::Pong(_) => continue, + WebSocketMessage::Binary(_) | WebSocketMessage::Frame(_) => { + anyhow::bail!("Codex app-server sent a non-text WebSocket message") + } + } + } +} + +enum ControlRead { + Message(Value), + Timeout, + Closed, +} + +fn poll_json_message(websocket: &mut WebSocket) -> Result { + loop { + let message = match websocket.read() { + Ok(message) => message, + Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + return Ok(ControlRead::Closed); + } + Err(tungstenite::Error::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + return Ok(ControlRead::Timeout); + } + Err(error) => return Err(error.into()), + }; + match message { + WebSocketMessage::Text(text) => { + let value = serde_json::from_str(&text) + .context("decoding Codex app-server WebSocket JSON")?; + return Ok(ControlRead::Message(value)); + } + WebSocketMessage::Close(_) => return Ok(ControlRead::Closed), + WebSocketMessage::Ping(_) | WebSocketMessage::Pong(_) => continue, + WebSocketMessage::Binary(_) | WebSocketMessage::Frame(_) => { + anyhow::bail!("Codex app-server sent a non-text WebSocket message") + } + } + } +} + +fn terminate_child(child: &mut Child) { + match child.try_wait() { + Ok(Some(_)) => {} + _ => { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + #[test] + fn protocol_version_gate_accepts_only_the_exact_allowlist() { + let tmp = tempfile::tempdir().unwrap(); + let write_version = |name: &str, version: &str| { + let path = tmp.path().join(name); + fs::write(&path, format!("#!/bin/sh\nprintf '%s\\n' '{version}'\n")).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + for (name, version) in [ + ("codex-0145", "codex-cli 0.145.0"), + ("codex-0146", "codex-cli 0.146.0"), + ] { + ensure_supported_version(write_version(name, version).to_str().unwrap()).unwrap(); + } + let error = ensure_supported_version( + write_version("codex-0147", "codex-cli 0.147.0") + .to_str() + .unwrap(), + ) + .unwrap_err(); + assert!(error.to_string().contains("codex-cli 0.147.0")); + assert!( + error + .to_string() + .contains("codex-cli 0.145.0, codex-cli 0.146.0") + ); + } + + #[test] + fn tui_loaded_deadline_precedes_the_outer_binding_deadline() { + assert!(TUI_LOADED_TIMEOUT < STARTUP_TIMEOUT); + } + + fn delivery_config(root: &Path) -> CodexDeliveryConfig { + let agent_dir = root.join("agents/h/worker"); + CodexDeliveryConfig { + catalog_root: root.to_path_buf(), + inbox: message::inbox_dir(&agent_dir), + agent_dir, + identity: "h.worker".into(), + this_host: "h".into(), + } + } + + fn subscribed_state(observed: CodexObservedState) -> CodexControlState { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + state.subscribed = true; + state.observed = observed; + state + } + + fn inbox_delivery(root: &Path, config: CodexDeliveryConfig) -> CodexInboxDelivery { + CodexInboxDelivery::new( + config, + root.join("state/delivery-state.json"), + CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(), + ) + .unwrap() + } + + fn acknowledge_tui_thread_loaded(events: &Receiver) { + let ControlEvent::TuiThreadLoaded(acknowledge) = + events.recv_timeout(Duration::from_secs(2)).unwrap() + else { + panic!("control did not report the TUI-loaded gate"); + }; + acknowledge.send(()).unwrap(); + } + + #[test] + fn delivery_request_uses_typed_start_and_exact_turn_steer() { + let start = codex_delivery_request( + 2, + "thread-main", + "st2:client", + "notice", + &CodexDeliveryMethod::Start, + ); + assert_eq!(start["method"], "turn/start"); + assert_eq!(start["params"]["threadId"], "thread-main"); + assert_eq!(start["params"]["clientUserMessageId"], "st2:client"); + assert_eq!(start["params"]["input"][0]["type"], "text"); + assert_eq!(start["params"]["input"][0]["text"], "notice"); + assert!(start["params"].get("expectedTurnId").is_none()); + + let steer = codex_delivery_request( + 3, + "thread-main", + "st2:client", + "notice", + &CodexDeliveryMethod::Steer { + turn_id: "turn-current".into(), + }, + ); + assert_eq!(steer["method"], "turn/steer"); + assert_eq!(steer["params"]["expectedTurnId"], "turn-current"); + assert!(steer["params"].get("model").is_none()); + assert!(steer["params"].get("approvalPolicy").is_none()); + } + + #[test] + fn delivery_client_id_is_stable_and_binds_every_identity_component() { + let id = + stable_client_user_message_id("h.worker", "thread-main", "1786380000000-abc123.md"); + assert_eq!( + id, + stable_client_user_message_id("h.worker", "thread-main", "1786380000000-abc123.md") + ); + assert!(id.starts_with("st2:")); + assert_ne!( + id, + stable_client_user_message_id("h.other", "thread-main", "1786380000000-abc123.md") + ); + assert_ne!( + id, + stable_client_user_message_id("h.worker", "thread-other", "1786380000000-abc123.md") + ); + assert_ne!( + id, + stable_client_user_message_id("h.worker", "thread-main", "1786380000000-def456.md") + ); + } + + #[test] + fn review_compaction_and_dnd_hold_the_unread_fifo_head() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = + message::send_to_inbox(&config.inbox, "h.sender", Some("held"), None, &[], "body") + .unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); + for reason in [CodexHoldReason::Review, CodexHoldReason::Compaction] { + let state = subscribed_state(CodexObservedState::Held { + reason, + turn_id: Some("turn-current".into()), + }); + assert_eq!(delivery.maybe_request(&state).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + } + + status::set_state(&status::status_path(&config.agent_dir), status::State::Dnd).unwrap(); + delivery.next_refresh = Instant::now(); + assert_eq!( + delivery + .maybe_request(&subscribed_state(CodexObservedState::Idle)) + .unwrap(), + None + ); + assert_eq!(message::list_inbox(&config.inbox).unwrap().len(), 1); + } + + #[test] + fn a_rejected_exact_steer_has_no_fallback_and_remains_retryable_after_state_changes() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = + message::send_to_inbox(&config.inbox, "h.sender", Some("retry"), None, &[], "body") + .unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); + let active = subscribed_state(CodexObservedState::Active { + turn_id: "turn-current".into(), + }); + let steer = delivery.maybe_request(&active).unwrap().unwrap(); + assert_eq!(steer["method"], "turn/steer"); + assert_eq!(steer["params"]["expectedTurnId"], "turn-current"); + let request_id = steer["id"].clone(); + let client_id = steer["params"]["clientUserMessageId"].clone(); + + assert!( + !delivery + .accept_response( + &json!({ + "id": request_id, + "method": "item/commandExecution/requestApproval", + "params": {} + }), + active.observed(), + ) + .unwrap() + ); + assert!(delivery + .accept_response( + &json!({ "id": request_id, "error": { "code": -32600, "message": "stale turn" } }), + active.observed(), + ) + .unwrap()); + assert_eq!(delivery.maybe_request(&active).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + + let retry = delivery + .maybe_request(&subscribed_state(CodexObservedState::Idle)) + .unwrap() + .unwrap(); + assert_eq!(retry["method"], "turn/start"); + assert_eq!(retry["params"]["clientUserMessageId"], client_id); + assert!(config.inbox.join(&filename).is_file()); + } + + #[test] + fn a_success_response_is_only_an_attempt_and_does_not_archive_the_message() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("submitted"), + None, + &[], + "body", + ) + .unwrap(); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); + let idle = subscribed_state(CodexObservedState::Idle); + let request = delivery.maybe_request(&idle).unwrap().unwrap(); + assert_eq!( + delivery.state.as_ref().unwrap().phase, + CodexDeliveryPhase::Attempted, + "submission ownership is durable before transport" + ); + assert!( + delivery + .accept_response( + &json!({ "id": request["id"], "result": { "turn": { "id": "turn-new" } } }), + idle.observed(), + ) + .unwrap() + ); + assert_eq!( + delivery.state.as_ref().unwrap().phase, + CodexDeliveryPhase::Attempted, + "JSON success is not typed acceptance" + ); + assert_eq!(delivery.maybe_request(&idle).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + } + + #[test] + fn only_a_completed_matching_user_message_persists_acceptance() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("receipt"), + None, + &[], + "body", + ) + .unwrap(); + let state_path = tmp.path().join("state/delivery-state.json"); + let mut delivery = inbox_delivery(tmp.path(), config.clone()); + let mut idle = CodexControlState::new(&delivery.runtime, "thread-main".into()); + idle.subscribed = true; + idle.observed = CodexObservedState::Idle; + let request = delivery.maybe_request(&idle).unwrap().unwrap(); + let client_id = request["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .to_string(); + + assert!( + !delivery + .accept_typed_receipt( + &json!({ + "method": "item/started", + "params": { + "threadId": "thread-main", + "turnId": "turn-delivery", + "item": { "type": "userMessage", "clientId": client_id } + } + }), + &idle, + ) + .unwrap(), + "item/started is progress, not acceptance" + ); + assert!( + !delivery + .accept_typed_receipt( + &json!({ + "method": "item/completed", + "params": { + "threadId": "thread-other", + "turnId": "turn-delivery", + "item": { "type": "userMessage", "clientId": client_id } + } + }), + &idle, + ) + .unwrap(), + "another thread cannot acknowledge this delivery" + ); + assert!( + delivery + .accept_typed_receipt( + &json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-delivery", + "item": { "type": "userMessage", "clientId": client_id } + } + }), + &idle, + ) + .unwrap() + ); + assert_eq!( + load_delivery_state(&state_path, "h.worker", "h.worker") + .unwrap() + .unwrap() + .phase, + CodexDeliveryPhase::Accepted + ); + assert!(config.inbox.join(&filename).is_file()); + + drop(delivery); + let mut replacement = inbox_delivery(tmp.path(), config.clone()); + assert_eq!( + replacement.maybe_request(&idle).unwrap(), + None, + "a fresh runtime incarnation restores accepted duplicate control" + ); + + message::archive_msg( + &config.inbox, + &message::archive_dir(&config.agent_dir), + &filename, + ) + .unwrap(); + replacement.next_refresh = Instant::now(); + assert_eq!(replacement.maybe_request(&idle).unwrap(), None); + assert!( + !state_path.exists(), + "archive precedence clears the receipt" + ); + } + + #[test] + fn an_ambiguous_attempt_reconciles_resume_history_before_retry() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("reconcile"), + None, + &[], + "body", + ) + .unwrap(); + let idle = subscribed_state(CodexObservedState::Idle); + let mut first = inbox_delivery(tmp.path(), config.clone()); + let request = first.maybe_request(&idle).unwrap().unwrap(); + let client_id = request["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .to_string(); + drop(first); + + let mut recovered = inbox_delivery(tmp.path(), config.clone()); + assert_eq!(recovered.maybe_request(&idle).unwrap(), None); + recovered + .reconcile_resume( + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { + "id": "thread-main", + "turns": [{ + "id": "turn-delivery", + "items": [{ + "type": "userMessage", + "id": "item-delivery", + "clientId": client_id, + "content": [] + }] + }] + } + } + }), + &idle, + ) + .unwrap(); + assert_eq!( + recovered.state.as_ref().unwrap().phase, + CodexDeliveryPhase::Accepted + ); + assert_eq!(recovered.maybe_request(&idle).unwrap(), None); + assert!(config.inbox.join(&filename).is_file()); + + // An authoritative resumed history without the client ID proves that the pre-send record + // did not reach typed acceptance. Only then may the same stable ID be retried. + recovered.state.as_mut().unwrap().phase = CodexDeliveryPhase::Attempted; + atomic_json( + &tmp.path().join("state/delivery-state.json"), + recovered.state.as_ref().unwrap(), + ) + .unwrap(); + recovered + .reconcile_resume( + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { "thread": { "id": "thread-main", "turns": [] } } + }), + &idle, + ) + .unwrap(); + assert!(recovered.state.is_none()); + let retry = recovered.maybe_request(&idle).unwrap().unwrap(); + assert_eq!(retry["params"]["clientUserMessageId"], client_id); + } + + #[test] + fn malformed_delivery_state_fails_closed() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let state_path = tmp.path().join("state/delivery-state.json"); + atomic_json( + &state_path, + &json!({ + "schema": DELIVERY_STATE_SCHEMA, + "agent": "h.worker", + "runtimeId": "h.worker", + "runtimeIncarnation": "incarnation-test", + "threadId": "thread-main", + "filename": "1786380000000-abc123.md", + "clientId": "st2:tampered", + "phase": "attempted" + }), + ) + .unwrap(); + let error = match CodexInboxDelivery::new( + config, + state_path, + CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(), + ) { + Ok(_) => panic!("accepted malformed delivery state"), + Err(error) => error, + }; + assert!(error.to_string().contains("client ID does not match")); + } + + #[test] + fn subscribed_control_pump_delivers_a_typed_reference_to_the_real_fifo_head() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = + message::send_to_inbox(&config.inbox, "h.sender", Some("wired"), None, &[], "body") + .unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server_filename = filename.clone(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { "thread": { "id": "thread-main", "status": { "type": "idle" } } } + }), + ) + .unwrap(); + let delivery = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(delivery["id"], FIRST_DELIVERY_REQUEST_ID); + assert_eq!(delivery["method"], "turn/start"); + assert_eq!(delivery["params"]["threadId"], "thread-main"); + let head_id = server_filename + .trim_end_matches(".md") + .rsplit_once('-') + .unwrap() + .1; + assert!( + delivery["params"]["input"][0]["text"] + .as_str() + .unwrap() + .contains(head_id), + "the transport payload must identify the actionable FIFO head" + ); + assert_eq!( + delivery["params"]["clientUserMessageId"], + stable_client_user_message_id("h.worker", "thread-main", &server_filename) + ); + let client_id = delivery["params"]["clientUserMessageId"] + .as_str() + .unwrap() + .to_string(); + write_json_message( + &mut websocket, + &json!({ + "id": FIRST_DELIVERY_REQUEST_ID, + "result": { "turn": { "id": "turn-delivery" } } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-delivery", + "item": { + "type": "userMessage", + "id": "item-delivery", + "clientId": client_id, + "content": [] + } + } + }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + None, + Some(config), + tx, + ) + }); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + assert!(delivery_config(tmp.path()).inbox.join(filename).is_file()); + assert_eq!( + load_delivery_state( + &tmp.path().join("state/delivery-state.json"), + "h.worker", + "h.worker", + ) + .unwrap() + .unwrap() + .phase, + CodexDeliveryPhase::Accepted + ); + } + + #[test] + fn subscribed_control_pump_reconciles_an_ambiguous_attempt_without_replay() { + let tmp = tempfile::tempdir().unwrap(); + let config = delivery_config(tmp.path()); + let filename = message::send_to_inbox( + &config.inbox, + "h.sender", + Some("recover"), + None, + &[], + "body", + ) + .unwrap(); + let client_id = stable_client_user_message_id("h.worker", "thread-main", &filename); + let prior_runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let delivery_state_path = tmp.path().join("state/delivery-state.json"); + atomic_json( + &delivery_state_path, + &CodexDeliveryState::attempted( + &prior_runtime, + "thread-main".into(), + filename.clone(), + client_id.clone(), + ), + ) + .unwrap(); + + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server_client_id = client_id.clone(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + let loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(loaded["method"], "thread/loaded/list"); + assert_eq!(loaded["id"], CONTROL_TUI_LOADED_REQUEST_ID); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-main"] } + }), + ) + .unwrap(); + let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(subscribe["method"], "thread/resume"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { + "id": "thread-main", + "status": { "type": "idle" }, + "turns": [{ + "id": "turn-delivery", + "items": [{ + "type": "userMessage", + "id": "item-delivery", + "clientId": server_client_id, + "content": [] + }] + }] + } + } + }), + ) + .unwrap(); + assert!(matches!( + poll_json_message(&mut websocket).unwrap(), + ControlRead::Timeout + )); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); + resume_ready_tx.send(()).unwrap(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + Some(ControlResume { + thread_id: "thread-main", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), + Some(config), + tx, + ) + }); + acknowledge_tui_thread_loaded(&rx); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + + let recovered = load_delivery_state(&delivery_state_path, "h.worker", "h.worker") + .unwrap() + .unwrap(); + assert_eq!(recovered.phase, CodexDeliveryPhase::Accepted); + assert_eq!(recovered.client_id, client_id); + assert!(delivery_config(tmp.path()).inbox.join(filename).is_file()); + } + + #[test] + fn control_initializes_before_recording_the_first_thread_only() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + let initialize = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialize["method"], "initialize"); + assert_eq!(initialize["params"]["clientInfo"]["name"], "st2"); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + let initialized = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialized["method"], "initialized"); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { "thread": { "id": "thread-main", "status": { "type": "idle" } } } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + }), + ) + .unwrap(); + // JSON-RPC request IDs are per direction. A server request may reuse the client's + // subscription ID and must not be consumed as a client response. + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "method": "item/commandExecution/requestApproval", + "params": {} + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { "thread": { "id": "thread-review", "status": { "type": "idle" } } } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-main" } } + }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let state = tmp.path().join("state"); + let binding_path = state.join("binding.json"); + let control_state_path = state.join("control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + None, + None, + tx, + ) + }); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + + let binding = load_current_binding(&binding_path, &runtime) + .unwrap() + .unwrap(); + assert_eq!(binding.thread_id(), "thread-main"); + let state = + load_current_control_state(&state.join("control-state.json"), &runtime, &binding) + .unwrap() + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Active { + turn_id: "turn-main".into() + } + ); + assert!(state.subscribed()); + } + + #[test] + fn expected_resume_waits_for_tui_loaded_thread_and_binds_from_control_response() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let (pre_gate_checked_tx, pre_gate_checked_rx) = mpsc::channel(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + let initialize = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialize["method"], "initialize"); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + let initialized = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(initialized["method"], "initialized"); + assert!(matches!( + poll_json_message(&mut websocket).unwrap(), + ControlRead::Timeout + )); + pre_gate_checked_tx.send(()).unwrap(); + websocket + .get_mut() + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/started", + "params": { + "thread": { "id": "thread-unrelated", "status": { "type": "idle" } } + } + }), + ) + .unwrap(); + write_json_message( + &mut websocket, + &json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-unrelated", + "status": { "type": "active", "activeFlags": [] } + } + }), + ) + .unwrap(); + let first_loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(first_loaded["method"], "thread/loaded/list"); + assert_eq!(first_loaded["id"], CONTROL_TUI_LOADED_REQUEST_ID); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-unrelated"] } + }), + ) + .unwrap(); + let second_loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(second_loaded["method"], "thread/loaded/list"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-unrelated", "thread-prior"] } + }), + ) + .unwrap(); + let subscribe = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(subscribe["method"], "thread/resume"); + assert_eq!(subscribe["params"]["threadId"], "thread-prior"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "result": { + "thread": { "id": "thread-prior", "status": { "type": "idle" } } + } + }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + Some(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), + None, + tx, + ) + }); + pre_gate_checked_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap(); + resume_ready_tx.send(()).unwrap(); + acknowledge_tui_thread_loaded(&rx); + assert!(matches!( + rx.recv_timeout(Duration::from_secs(2)).unwrap(), + ControlEvent::Bound + )); + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + + let binding = load_current_binding(&binding_path, &runtime) + .unwrap() + .unwrap(); + assert_eq!(binding.thread_id(), "thread-prior"); + let state = load_current_control_state(&control_state_path, &runtime, &binding) + .unwrap() + .unwrap(); + assert!(state.subscribed()); + assert_eq!(state.observed(), &CodexObservedState::Idle); + } + + #[test] + fn tui_loaded_timeout_reports_the_specific_failure_before_outer_binding_timeout() { + let tmp = tempfile::tempdir().unwrap(); + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + let loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(loaded["method"], "thread/loaded/list"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": [] } + }), + ) + .unwrap(); + thread::sleep(Duration::from_millis(250)); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_path, + &control_state_path, + &runtime, + Some(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: Duration::from_millis(50), + }), + None, + tx, + ) + }); + resume_ready_tx.send(()).unwrap(); + let ControlEvent::Failed(error) = rx.recv_timeout(Duration::from_secs(2)).unwrap() else { + panic!("inner TUI-loaded deadline did not report its specific failure"); + }; + assert!( + error.contains( + "controlled Codex TUI did not load preserved thread thread-prior before control resume" + ), + "unexpected control failure: {error}" + ); + + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + server.join().unwrap(); + } + + #[test] + fn missing_saved_rollout_fails_without_rebinding_the_incarnation() { + let tmp = tempfile::tempdir().unwrap(); + let binding_path = tmp.path().join("state/binding.json"); + let control_state_path = tmp.path().join("state/control-state.json"); + let prior_runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let prior_binding = CodexThreadBinding::new(&prior_runtime, "thread-prior".into()); + atomic_json(&binding_path, &prior_binding).unwrap(); + + let socket = tmp.path().join("server.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut websocket = tungstenite::accept(stream).unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialize" + ); + write_json_message( + &mut websocket, + &json!({ "id": 0, "result": { "userAgent": "fake" } }), + ) + .unwrap(); + assert_eq!( + read_json_message(&mut websocket).unwrap().unwrap()["method"], + "initialized" + ); + let loaded = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(loaded["method"], "thread/loaded/list"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_TUI_LOADED_REQUEST_ID, + "result": { "data": ["thread-prior"] } + }), + ) + .unwrap(); + let resume = read_json_message(&mut websocket).unwrap().unwrap(); + assert_eq!(resume["method"], "thread/resume"); + assert_eq!(resume["params"]["threadId"], "thread-prior"); + write_json_message( + &mut websocket, + &json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "error": { + "code": -32600, + "message": "no rollout found for thread id thread-prior" + } + }), + ) + .unwrap(); + }); + + let stream = UnixStream::connect(&socket).unwrap(); + let shutdown = stream.try_clone().unwrap(); + let websocket = initialize_control(stream).unwrap(); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let (tx, rx) = mpsc::channel(); + let (resume_ready_tx, resume_ready_rx) = mpsc::channel(); + let runtime_for_pump = runtime.clone(); + let binding_for_pump = binding_path.clone(); + let control_state_for_pump = control_state_path.clone(); + let pump = thread::spawn(move || { + pump_control( + websocket, + &binding_for_pump, + &control_state_for_pump, + &runtime_for_pump, + Some(ControlResume { + thread_id: "thread-prior", + ready: resume_ready_rx, + tui_loaded_timeout: TUI_LOADED_TIMEOUT, + }), + None, + tx, + ) + }); + resume_ready_tx.send(()).unwrap(); + acknowledge_tui_thread_loaded(&rx); + let ControlEvent::Failed(error) = rx.recv_timeout(Duration::from_secs(2)).unwrap() else { + panic!("missing saved rollout did not fail closed"); + }; + assert!(error.contains("saved Codex resume binding has no persisted rollout")); + + server.join().unwrap(); + let _ = shutdown.shutdown(Shutdown::Both); + pump.join().unwrap(); + assert_eq!( + serde_json::from_slice::(&fs::read(&binding_path).unwrap()) + .unwrap(), + prior_binding + ); + assert!(!control_state_path.exists()); + } + + #[test] + fn a_binding_from_another_runtime_incarnation_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("binding.json"); + let prior = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let current = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + atomic_json( + &path, + &CodexThreadBinding::new(&prior, "thread-prior".into()), + ) + .unwrap(); + assert_eq!( + load_resume_thread(&path, "h.worker", "h.worker").unwrap(), + Some("thread-prior".into()), + "a validated prior binding may select resume but must not become current ownership" + ); + let error = load_current_binding(&path, ¤t).unwrap_err(); + assert!(error.to_string().contains("different runtime incarnation")); + } + + #[test] + fn watcher_holds_without_an_exact_turn_and_tracks_one_unmatched_lifecycle() { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + + assert!( + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-main", + "status": { "type": "active", "activeFlags": [] } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::ActiveWithoutTurn, + turn_id: None, + } + ); + + assert!( + state + .observe(&json!({ + "method": "turn/started", + "params": { + "threadId": "thread-main", + "turn": { "id": "turn-1" } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Active { + turn_id: "turn-1".into() + } + ); + + assert!( + !state + .observe(&json!({ + "method": "turn/started", + "params": { + "threadId": "thread-other", + "turn": { "id": "turn-other" } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Active { + turn_id: "turn-1".into() + } + ); + + assert!( + state + .observe(&json!({ + "method": "turn/completed", + "params": { + "threadId": "thread-main", + "turn": { "id": "turn-1" } + } + })) + .unwrap() + ); + assert_eq!(state.observed(), &CodexObservedState::Idle); + } + + #[test] + fn watcher_holds_review_compaction_and_conflicting_turns_until_safe() { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-1" } } + })) + .unwrap(); + state + .observe(&json!({ + "method": "item/started", + "params": { + "threadId": "thread-main", + "turnId": "turn-1", + "item": { "type": "enteredReviewMode" } + } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-1".into()), + } + ); + + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { + "threadId": "thread-main", + "status": { "type": "active", "activeFlags": [] } + } + })) + .unwrap(); + assert!(matches!( + state.observed(), + CodexObservedState::Held { + reason: CodexHoldReason::Review, + .. + } + )); + + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-2" } } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-2".into()), + } + ); + + // Codex can complete the preparatory review item after the reviewer turn starts. That + // duplicate review event keeps the typed hold bound to the newer turn. + assert!( + !state + .observe(&json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-1", + "item": { "type": "enteredReviewMode" } + } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-2".into()), + } + ); + + // The review hold also survives the stale turn completion. Only an idle thread releases + // it. + assert!( + !state + .observe(&json!({ + "method": "turn/completed", + "params": { "threadId": "thread-main", "turn": { "id": "turn-1" } } + })) + .unwrap() + ); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-2".into()), + } + ); + + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + })) + .unwrap(); + assert_eq!(state.observed(), &CodexObservedState::Idle); + + // A real review can start its reviewer turn before Codex reports the preparatory turn's + // typed review item. The typed non-steerable event refines that generic conflict. + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-late-1" } } + })) + .unwrap(); + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-late-2" } } + })) + .unwrap(); + assert!(matches!( + state.observed(), + CodexObservedState::Held { + reason: CodexHoldReason::ConflictingTurn, + .. + } + )); + state + .observe(&json!({ + "method": "item/started", + "params": { + "threadId": "thread-main", + "turnId": "turn-late-1", + "item": { "type": "enteredReviewMode" } + } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Review, + turn_id: Some("turn-late-1".into()), + } + ); + state + .observe(&json!({ + "method": "thread/status/changed", + "params": { "threadId": "thread-main", "status": { "type": "idle" } } + })) + .unwrap(); + + state + .observe(&json!({ + "method": "turn/started", + "params": { "threadId": "thread-main", "turn": { "id": "turn-3" } } + })) + .unwrap(); + state + .observe(&json!({ + "method": "item/completed", + "params": { + "threadId": "thread-main", + "turnId": "turn-3", + "item": { "type": "contextCompaction" } + } + })) + .unwrap(); + assert_eq!( + state.observed(), + &CodexObservedState::Held { + reason: CodexHoldReason::Compaction, + turn_id: Some("turn-3".into()), + } + ); + assert!( + !state + .observe(&json!({ + "method": "turn/completed", + "params": { "threadId": "thread-main", "turn": { "id": "turn-3" } } + })) + .unwrap() + ); + assert!(matches!( + state.observed(), + CodexObservedState::Held { + reason: CodexHoldReason::Compaction, + .. + } + )); + } + + #[test] + fn persisted_control_state_is_bound_to_the_exact_runtime_incarnation() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("control-state.json"); + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let binding = CodexThreadBinding::new(&runtime, "thread-main".into()); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + state.observed = CodexObservedState::Active { + turn_id: "turn-1".into(), + }; + atomic_json(&path, &state).unwrap(); + let persisted: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["observed"]["turnId"], "turn-1"); + assert!(persisted["observed"].get("turn_id").is_none()); + + assert_eq!( + load_current_control_state(&path, &runtime, &binding) + .unwrap() + .unwrap(), + state + ); + + let replacement = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let replacement_binding = CodexThreadBinding::new(&replacement, "thread-main".into()); + let error = + load_current_control_state(&path, &replacement, &replacement_binding).unwrap_err(); + assert!(error.to_string().contains("different runtime binding")); + } + + #[test] + fn subscription_waits_for_a_rollout_without_claiming_success() { + let runtime = CodexRuntime::fresh("h.worker".into(), "h.worker".into()).unwrap(); + let mut state = CodexControlState::new(&runtime, "thread-main".into()); + let acceptance = state + .accept_subscription(&json!({ + "id": CONTROL_SUBSCRIBE_REQUEST_ID, + "error": { + "code": -32600, + "message": "no rollout found for thread id thread-main" + } + })) + .unwrap(); + + assert!(matches!(acceptance, SubscriptionAcceptance::Deferred)); + assert!(!state.subscribed()); + assert_eq!(state.observed(), &CodexObservedState::AwaitingStatus); + } + + #[test] + fn app_server_receives_only_its_supported_global_configuration() { + let authored = vec![ + "-c".into(), + "projects={\"/workspace\"={trust_level=\"trusted\"}}".into(), + "--model".into(), + "gpt-test".into(), + "--enable".into(), + "one".into(), + "--disable=two".into(), + "--strict-config".into(), + "--dangerously-bypass-approvals-and-sandbox".into(), + "--dangerously-bypass-hook-trust".into(), + "boot".into(), + ]; + + assert_eq!( + controlled_app_server_args("unix:///server.sock", &authored).unwrap(), + [ + "app-server", + "-c", + "projects={\"/workspace\"={trust_level=\"trusted\"}}", + "--enable", + "one", + "--disable=two", + "--strict-config", + "--listen", + "unix:///server.sock", + ] + ); + } + + #[test] + fn remote_resume_projects_exact_hook_hashes_without_persisted_state() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = fs::canonicalize(tmp.path()).unwrap(); + let source = cwd.join(".codex/hooks.json"); + let untrusted_key = format!("{}:session_start:0:0", source.display()); + let modified_key = format!("{}:stop:1:0", source.display()); + let response = json!({ + "id": HOOK_TRUST_PREFLIGHT_REQUEST_ID, + "result": { + "data": [{ + "cwd": cwd, + "hooks": [ + { + "key": untrusted_key, + "currentHash": "sha256:one", + "trustStatus": "untrusted", + "isManaged": false, + "enabled": true + }, + { + "key": modified_key, + "currentHash": "sha256:two", + "trustStatus": "modified", + "isManaged": false, + "enabled": false + }, + { + "key": "already-trusted", + "currentHash": "sha256:three", + "trustStatus": "trusted", + "isManaged": false, + "enabled": true + }, + { + "key": "managed", + "currentHash": "sha256:four", + "trustStatus": "managed", + "isManaged": true, + "enabled": true + } + ] + }] + } + }); + + let projection = hook_trust_projection_from_response(&response, &cwd) + .unwrap() + .unwrap(); + assert_eq!(projection.count, 2); + let parsed: toml::Value = toml::from_str(&projection.override_value).unwrap(); + let state = parsed + .get("hooks") + .and_then(|hooks| hooks.get("state")) + .and_then(toml::Value::as_table) + .unwrap(); + assert_eq!( + state[&untrusted_key]["trusted_hash"].as_str(), + Some("sha256:one") + ); + assert_eq!( + state[&modified_key]["trusted_hash"].as_str(), + Some("sha256:two") + ); + assert!(!state.contains_key("already-trusted")); + assert!(!state.contains_key("managed")); + + let mut args = controlled_app_server_args( + "unix:///server.sock", + &["--dangerously-bypass-hook-trust".into(), "boot".into()], + ) + .unwrap(); + insert_app_server_config_override(&mut args, projection.override_value).unwrap(); + assert_eq!(args[args.len() - 4], "-c"); + assert!(args[args.len() - 3].starts_with("hooks.state=")); + assert_eq!(&args[args.len() - 2..], ["--listen", "unix:///server.sock"]); + } + + #[test] + fn hook_trust_projection_fails_closed_on_provider_shape_drift() { + let tmp = tempfile::tempdir().unwrap(); + let cwd = fs::canonicalize(tmp.path()).unwrap(); + let response = json!({ + "result": { + "data": [{ + "cwd": cwd, + "hooks": [{ + "key": "hook", + "currentHash": "not-a-provider-hash", + "trustStatus": "untrusted", + "isManaged": false + }] + }] + } + }); + let error = hook_trust_projection_from_response(&response, &cwd).unwrap_err(); + assert!(error.to_string().contains("typed currentHash")); + + let response = json!({ + "result": { + "data": [{ + "cwd": cwd, + "hooks": [{ + "key": "hook", + "currentHash": "sha256:value", + "trustStatus": "future-status", + "isManaged": false + }] + }] + } + }); + let error = hook_trust_projection_from_response(&response, &cwd).unwrap_err(); + assert!(error.to_string().contains("unknown trustStatus")); + } + + #[test] + fn hook_preflight_uses_the_explicit_controlled_workspace() { + let tmp = tempfile::tempdir().unwrap(); + let explicit = tmp.path().join("workspace"); + fs::create_dir(&explicit).unwrap(); + assert_eq!( + controlled_hook_cwd(&[ + "--dangerously-bypass-hook-trust".into(), + "--cd".into(), + explicit.display().to_string(), + "boot".into(), + ]) + .unwrap(), + fs::canonicalize(explicit).unwrap() + ); + assert!( + authored_bypasses_hook_trust(&[ + "--dangerously-bypass-hook-trust".into(), + "boot".into() + ]) + .unwrap() + ); + assert!( + !authored_bypasses_hook_trust(&["--".into(), "--dangerously-bypass-hook-trust".into()]) + .unwrap() + ); + } + + #[test] + fn app_server_configuration_extraction_fails_closed_at_ambiguous_boundaries() { + let missing = + controlled_app_server_args("unix:///server.sock", &["-c".into()]).unwrap_err(); + assert!(missing.to_string().contains("has no value")); + + let unknown = controlled_app_server_args( + "unix:///server.sock", + &["--future-option".into(), "value".into(), "boot".into()], + ) + .unwrap_err(); + assert!(unknown.to_string().contains("unknown Codex option")); + + let sensitive = controlled_app_server_args( + "unix:///server.sock", + &["--future-token=do-not-log-this".into(), "boot".into()], + ) + .unwrap_err(); + assert!(sensitive.to_string().contains("--future-token")); + assert!(!sensitive.to_string().contains("do-not-log-this")); + + assert_eq!( + controlled_app_server_args( + "unix:///server.sock", + &[ + "--config=projects.x.trust_level=\"trusted\"".into(), + "resume".into(), + "thread-explicit".into(), + ], + ) + .unwrap(), + [ + "app-server", + "--config=projects.x.trust_level=\"trusted\"", + "--listen", + "unix:///server.sock", + ] + ); + } + + #[test] + fn controlled_tui_resumes_a_prior_binding_without_overriding_authored_selection() { + let authored = vec!["--model".into(), "gpt-test".into(), "boot".into()]; + assert_eq!( + controlled_tui_args("unix:///server.sock", &authored, None).unwrap(), + [ + "--remote", + "unix:///server.sock", + "--model", + "gpt-test", + "boot" + ] + ); + assert_eq!( + controlled_tui_args("unix:///server.sock", &authored, Some("thread-prior")).unwrap(), + [ + "--remote", + "unix:///server.sock", + "resume", + "--model", + "gpt-test", + "thread-prior", + "boot" + ] + ); + assert_eq!( + controlled_tui_args( + "unix:///server.sock", + &["resume".into(), "thread-explicit".into()], + Some("thread-prior") + ) + .unwrap(), + [ + "--remote", + "unix:///server.sock", + "resume", + "thread-explicit" + ] + ); + assert_eq!( + expected_resume_thread( + &["resume".into(), "thread-explicit".into()], + Some("thread-prior") + ) + .unwrap(), + None + ); + + let fork = vec![ + "--dangerously-bypass-hook-trust".into(), + "fork".into(), + "thread-explicit".into(), + ]; + assert_eq!( + controlled_tui_args("unix:///server.sock", &fork, Some("thread-prior")).unwrap(), + [ + "--remote", + "unix:///server.sock", + "--dangerously-bypass-hook-trust", + "fork", + "thread-explicit" + ] + ); + assert_eq!( + expected_resume_thread(&fork, Some("thread-prior")).unwrap(), + None + ); + assert_eq!( + expected_resume_thread(&authored, Some("thread-prior")).unwrap(), + Some("thread-prior") + ); + } + + #[test] + fn controlled_tui_resume_fails_closed_at_ambiguous_option_boundaries() { + let unknown = controlled_tui_args( + "unix:///server.sock", + &["--future-option".into(), "value".into(), "prompt".into()], + Some("thread-prior"), + ) + .unwrap_err(); + assert!(unknown.to_string().contains("unknown Codex option")); + + let image = controlled_tui_args( + "unix:///server.sock", + &["--image".into(), "one.png".into(), "prompt".into()], + Some("thread-prior"), + ) + .unwrap_err(); + assert!(image.to_string().contains("explicit `--`")); + + assert_eq!( + controlled_tui_args( + "unix:///server.sock", + &[ + "--image".into(), + "one.png".into(), + "--".into(), + "prompt".into(), + ], + Some("thread-prior"), + ) + .unwrap(), + [ + "--remote", + "unix:///server.sock", + "resume", + "--image", + "one.png", + "thread-prior", + "--", + "prompt" + ] + ); + } + + #[test] + fn state_key_is_path_and_identity_specific_without_embedding_either() { + let base = Path::new("/state"); + let first = state_dir_in(base, Path::new("/catalog/a"), "h.worker"); + let second = state_dir_in(base, Path::new("/catalog/b"), "h.worker"); + assert_ne!(first, second); + assert!(first.starts_with("/state/st2/codex")); + assert!(!first.display().to_string().contains("worker")); + assert!(!first.display().to_string().contains("catalog/a")); + } + + #[test] + fn wrapper_diagnostics_keep_one_bounded_run_without_authored_input() { + let tmp = tempfile::tempdir().unwrap(); + let state = tmp.path().join("state"); + secure_dir(&state).unwrap(); + + { + let mut diagnostics = WrapperDiagnostics::open(&state, "h.worker", "h.worker").unwrap(); + diagnostics.record("ownerAcquired", json!({})).unwrap(); + diagnostics + .record("failed", json!({ "error": "control socket was not ready" })) + .unwrap(); + } + let path = state.join("wrapper.log"); + let first = fs::read_to_string(&path).unwrap(); + let entries = first + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0]["schema"], WRAPPER_DIAGNOSTIC_SCHEMA); + assert_eq!(entries[0]["agent"], "h.worker"); + assert_eq!(entries[1]["stage"], "failed"); + assert!(first.contains("control socket was not ready")); + assert!(!first.contains("prompt")); + + { + let mut replacement = WrapperDiagnostics::open(&state, "h.worker", "h.worker").unwrap(); + replacement.record("ownerAcquired", json!({})).unwrap(); + } + let replacement = fs::read_to_string(&path).unwrap(); + assert_eq!(replacement.lines().count(), 1); + assert!(!replacement.contains("control socket was not ready")); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + #[test] + fn runtime_owner_lock_is_nonblocking_and_released_on_close() { + let tmp = tempfile::tempdir().unwrap(); + let first = acquire_owner_lock(tmp.path()).unwrap(); + let error = acquire_owner_lock(tmp.path()).unwrap_err(); + assert!(error.to_string().contains("already has an owner")); + drop(first); + acquire_owner_lock(tmp.path()).unwrap(); + } +} diff --git a/src/eval_run.rs b/src/eval_run.rs index 9d1a3923..e3c0b9f0 100644 --- a/src/eval_run.rs +++ b/src/eval_run.rs @@ -16,7 +16,9 @@ use crate::eval_spec::{ }; use crate::expand::expand_catalog; use crate::flapping::FlappingCap; -use crate::reconcile::{TaskCompileContext, compile_generated_ding_tasks, reconcile}; +use crate::reconcile::{TaskCompileContext, compile_generated_tasks, reconcile}; +#[cfg(test)] +use crate::reconcile::compile_generated_ding_tasks; use crate::run::{Runner, SystemRunner, UpReport, detect_host, execute}; use agent_spec::spec::{AgentDesiredState, AgentSpec, JobType, Task, TaskKind, TaskLifecycle}; @@ -117,6 +119,7 @@ pub fn spec_to_agent_specs(agents: &[SpecAgent], host: &str, root: &Path) -> Vec desired_state: AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: Vec::new(), tasks, path: path.clone(), @@ -1093,7 +1096,7 @@ fn run_eval_inner( .collect::>(); (specs, runtime_tasks, participants, None) }; - compile_generated_ding_tasks(&mut specs, host, task_context)?; + compile_generated_tasks(&mut specs, host, task_context)?; let task_ids = runtime_tasks .iter() .map(|task| task.runtime_id.clone()) diff --git a/src/lib.rs b/src/lib.rs index 386cf746..31541c65 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod agents; pub mod catalog; pub mod catalog_lock; pub mod catalog_transaction; +pub mod codex_app_server; pub mod context; pub mod ding; pub mod eval_run; @@ -41,8 +42,8 @@ pub use agent_spec::{discovery, spec}; pub use agent_spec::discovery::{Discovered, SpecError, discover, discover_strict}; pub use agent_spec::spec::{ - AgentDesiredState, AgentSpec, JobType, Resource, Restart, RestartMode, Task, TaskKind, - TaskLifecycle, parse_duration, + AgentDesiredState, AgentSpec, DeliveryTransport, JobType, Resource, Restart, RestartMode, Task, + TaskKind, TaskLifecycle, parse_duration, }; pub use catalog_lock::CatalogLock; pub use exec_backend::ExecBackend; diff --git a/src/main.rs b/src/main.rs index c68324e2..e7a570c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -112,6 +112,19 @@ enum Command { #[arg(long, default_value_t = 1000)] interval: u64, }, + /// Internal controlled Codex launch. Generated only for `deliver "app-server"` tasks. + #[command(hide = true)] + CodexAppServer { + /// Exact agent bus identity that owns the controlled thread. + #[arg(long)] + identity: String, + /// Exact reconciled PTY task identity for this runtime. + #[arg(long)] + runtime_id: String, + /// Original structured Codex invocation, including its provider executable. + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + codex_argv: Vec, + }, /// Get or set an agent's presence status. No `--set` prints the status; no identity means yours /// (`$ST_AGENT`). Settable: offline | available | busy | away | dnd (`unknown` is derived). Status { @@ -779,6 +792,20 @@ fn main() -> Result<()> { host, interval, } => ding_cmd(session, identity, root, host, interval), + Command::CodexAppServer { + identity, + runtime_id, + codex_argv, + } => { + let catalog = catalog_arg(None)?; + let catalog = catalog.canonicalize().unwrap_or(catalog); + st2::codex_app_server::run_controlled( + &catalog, + identity, + runtime_id, + codex_argv, + ) + } Command::Status { identity, set, ctx } => status_cmd(identity, set, ctx), Command::Rename(args) => presentation_cmd(st2::agent_author::PresentationField::Name, args), Command::Describe(args) => { @@ -1433,6 +1460,12 @@ fn doctor_cmd(root: &Path, host: Option, require_supervisor: bool) -> Re ); continue; } + if !spec.has_delivery_transport() { + report_advisory( + &format!("{bus_id} delivery transport missing"), + "declare `ding` or `deliver`; agent receives no DING", + ); + } for task in &spec.tasks { let id = task .id @@ -1546,6 +1579,10 @@ fn report_check(problems: &mut usize, ok: bool, label: &str, detail: &str) { } } +fn report_advisory(label: &str, detail: &str) { + println!(" ⚠ {label} — {detail}"); +} + fn presentation_cmd( field: st2::agent_author::PresentationField, args: PresentationArgs, @@ -2504,7 +2541,7 @@ fn up_spec_fleet(spec_file: &Path, host: Option, once: bool, interval: u let task_context = st2::reconcile::TaskCompileContext::current(root.clone())?; st2::eval_run::prepare_spawn_env(task_context.st2_executable()); let mut specs = st2::eval_run::spec_to_agent_specs(&spec.agents, &this_host, &root); - st2::reconcile::compile_generated_ding_tasks(&mut specs, &this_host, &task_context)?; + st2::reconcile::compile_generated_tasks(&mut specs, &this_host, &task_context)?; let runner = SystemRunner::new(root.clone(), exec_state_dir(&this_host)); // One supervisor per (spec dir, host) — the same host-lock discipline as the catalog path. diff --git a/src/reconcile.rs b/src/reconcile.rs index 4688778a..d2d0b45a 100644 --- a/src/reconcile.rs +++ b/src/reconcile.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use agent_spec::spec::{AgentSpec, TaskKind, TaskLifecycle}; +use agent_spec::spec::{AgentSpec, DeliveryTransport, TaskKind, TaskLifecycle}; /// Immutable inputs captured once before generated tasks are compiled. #[derive(Debug, Clone, PartialEq, Eq)] @@ -73,6 +73,20 @@ impl TaskCompileContext { pub fn st2_executable(&self) -> &Path { &self.st2_executable } + + pub fn catalog_root(&self) -> &Path { + &self.catalog_root + } +} + +/// Compile every runner-owned launch marker into an exact invocation of this st2 binary. +pub fn compile_generated_tasks( + specs: &mut [AgentSpec], + this_host: &str, + context: &TaskCompileContext, +) -> Result<()> { + compile_generated_ding_tasks(specs, this_host, context)?; + compile_app_server_agent_tasks(specs, this_host, context) } /// Replace only runner-generated DING markers with exact direct argv. Authored tasks never carry @@ -122,6 +136,87 @@ pub fn compile_generated_ding_tasks( Ok(()) } +/// Route an explicitly selected Codex native transport through st2's controlled-launch wrapper. +/// +/// The wrapper owns the provider daemon and its control connection, so it can complete the +/// initialize handshake before the interactive client is allowed to create or resume a thread. +/// App-server delivery therefore requires structured argv: rewriting opaque shell source would be +/// unsound, and an already-remote launch would have two competing control owners. +pub fn compile_app_server_agent_tasks( + specs: &mut [AgentSpec], + this_host: &str, + context: &TaskCompileContext, +) -> Result<()> { + let st2_executable = context + .st2_executable + .to_str() + .context("running st2 executable path is not UTF-8")? + .to_owned(); + let catalog_root = context + .catalog_root + .to_str() + .context("catalog root is not UTF-8")? + .to_owned(); + + for spec in specs { + if spec.delivery != Some(DeliveryTransport::AppServer) { + continue; + } + let bus_id = spec.bus_id(this_host); + let mut candidates = spec + .tasks + .iter_mut() + .filter(|task| !task.derived && task.name == "agent"); + let task = candidates.next().with_context(|| { + format!( + "agent '{bus_id}' selects `deliver \"app-server\"` but has no canonical `agent` task" + ) + })?; + anyhow::ensure!( + candidates.next().is_none(), + "agent '{bus_id}' selects `deliver \"app-server\"` with more than one canonical `agent` task" + ); + anyhow::ensure!( + task.kind == TaskKind::Pty, + "agent '{bus_id}' selects `deliver \"app-server\"` for a non-PTY canonical task" + ); + let authored = task.argv.clone().with_context(|| { + format!( + "agent '{bus_id}' selects `deliver \"app-server\"`; its canonical task must use structured `argv`, not shell `command`" + ) + })?; + anyhow::ensure!( + !authored.is_empty(), + "agent '{bus_id}' selects `deliver \"app-server\"` with an empty canonical argv" + ); + anyhow::ensure!( + !authored + .iter() + .any(|arg| arg == "--remote" || arg.starts_with("--remote=")), + "agent '{bus_id}' selects `deliver \"app-server\"` but its canonical argv already declares `--remote`" + ); + let runtime_id = task + .id + .clone() + .unwrap_or_else(|| format!("{bus_id}.{}", task.name)); + let mut argv = vec![ + st2_executable.clone(), + "--catalog".to_string(), + catalog_root.clone(), + "codex-app-server".to_string(), + "--identity".to_string(), + bus_id, + "--runtime-id".to_string(), + runtime_id, + "--".to_string(), + ]; + argv.extend(authored); + task.command = None; + task.argv = Some(argv); + } + Ok(()) +} + /// ACTUAL state: one running/known task as st2 observes it (unioned across backends). #[derive(Debug, Clone, PartialEq, Eq)] pub struct Session { diff --git a/src/run.rs b/src/run.rs index 33d3d90c..3f596daf 100644 --- a/src/run.rs +++ b/src/run.rs @@ -32,7 +32,7 @@ use crate::flapping::FlappingCap; use crate::message; use crate::reconcile::{ PtyPresentation, ReconcilePlan, Session, TaskCompileContext, TaskLaunch, TaskTarget, - compile_generated_ding_tasks, + compile_generated_tasks, }; use crate::task_inventory::{ DesiredRuntime, ObservationBatch, ObservedState, RuntimeGeneration, RuntimeObservation, @@ -1419,7 +1419,7 @@ fn reconcile_pass( .filter(|spec| !materialized.failed_agents.contains(&spec.bus_id(this_host))) .cloned() .collect(); - if let Err(error) = compile_generated_ding_tasks(&mut eligible_specs, this_host, task_context) { + if let Err(error) = compile_generated_tasks(&mut eligible_specs, this_host, task_context) { report.skipped = true; report .errors @@ -1700,7 +1700,7 @@ where crate::reconcile::validate_task_identities(specs, this_host)?; let task_context = TaskCompileContext::current(catalog_root.to_path_buf())?; let mut compiled_specs = specs.to_vec(); - compile_generated_ding_tasks(&mut compiled_specs, this_host, &task_context)?; + compile_generated_tasks(&mut compiled_specs, this_host, &task_context)?; let sessions = runner .list_sessions() .map_err(|e| anyhow::anyhow!("list sessions: {e}"))?; @@ -2164,6 +2164,7 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2213,6 +2214,7 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Pty, @@ -2469,6 +2471,7 @@ mod tests { desired_state: crate::AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![], path: std::path::PathBuf::from("/x"), diff --git a/tests/catalog_diff.rs b/tests/catalog_diff.rs index d9de8858..5a3b4da8 100644 --- a/tests/catalog_diff.rs +++ b/tests/catalog_diff.rs @@ -158,6 +158,31 @@ fn desired_state_and_reason_have_distinct_secret_safe_semantic_addresses() { assert!(!rendered.contains(reason)); } +#[test] +fn declared_delivery_has_one_exact_semantic_address() { + let (_temp, catalog, prepared, root) = fixture(); + fs::write( + prepared.join("agents/host/worker/agent.kdl"), + r#"agent "worker" { + host "host" + deliver "app-server" + argv "tool" "arg" +} +"#, + ) + .unwrap(); + + let receipt = parsed(&diff(&catalog, &prepared, &root)); + let fields = agent_fields(&receipt); + assert_eq!( + fields + .iter() + .filter(|field| field.as_str() == "/agents/host/worker/delivery") + .count(), + 1 + ); +} + #[test] fn effective_task_id_and_cwd_defaults_normalize_to_explicit_values() { let (_temp, catalog, prepared, _root) = fixture(); diff --git a/tests/codex_app_server.rs b/tests/codex_app_server.rs new file mode 100644 index 00000000..b0f7d2f9 --- /dev/null +++ b/tests/codex_app_server.rs @@ -0,0 +1,103 @@ +use std::fs; +use std::path::Path; + +use st2::DeliveryTransport; +use st2::reconcile::{TaskCompileContext, compile_generated_tasks}; + +fn write(path: &Path, body: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, body).unwrap(); +} + +fn context(root: &Path) -> TaskCompileContext { + let executable = root.join("bin/st2"); + write(&executable, "test binary"); + TaskCompileContext::new(root.to_path_buf(), executable).unwrap() +} + +#[test] +fn app_server_selector_wraps_the_canonical_argv_with_exact_owner_inputs() { + let tmp = tempfile::tempdir().unwrap(); + let declaration = tmp.path().join("agents/h/worker/agent.kdl"); + write( + &declaration, + r#"agent "worker" { + host "h" + deliver "app-server" + argv "codex" "--model" "gpt-test" "boot" +} +"#, + ); + let mut found = st2::discover(tmp.path()); + assert!(found.errors.is_empty(), "{:?}", found.errors); + + compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap(); + + let spec = &found.specs[0]; + assert_eq!(spec.delivery, Some(DeliveryTransport::AppServer)); + let task = spec.tasks.iter().find(|task| task.name == "agent").unwrap(); + assert_eq!(task.command, None); + assert_eq!( + task.argv.as_deref(), + Some( + [ + tmp.path().join("bin/st2").display().to_string(), + "--catalog".into(), + tmp.path().display().to_string(), + "codex-app-server".into(), + "--identity".into(), + "h.worker".into(), + "--runtime-id".into(), + "h.worker".into(), + "--".into(), + "codex".into(), + "--model".into(), + "gpt-test".into(), + "boot".into(), + ] + .as_slice() + ) + ); +} + +#[test] +fn app_server_selector_rejects_shell_and_pre_remote_launches_without_mutating_them() { + for (name, launch, expected) in [ + ( + "shell", + "command \"exec codex\"", + "must use structured `argv`", + ), + ( + "remote", + "argv \"codex\" \"--remote\" \"unix:///other.sock\"", + "already declares `--remote`", + ), + ] { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join(format!("agents/h/{name}/agent.kdl")), + &format!("agent \"{name}\" {{ host \"h\"; deliver \"app-server\"; {launch} }}"), + ); + let mut found = st2::discover(tmp.path()); + assert!(found.errors.is_empty(), "{name}: {:?}", found.errors); + let before = found.specs.clone(); + let error = + compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap_err(); + assert!(error.to_string().contains(expected), "{error:#}"); + assert_eq!(found.specs, before, "{name} compile failure mutated source"); + } +} + +#[test] +fn mcp_selector_does_not_rewrite_the_authored_launch() { + let tmp = tempfile::tempdir().unwrap(); + write( + &tmp.path().join("agents/h/worker/agent.kdl"), + r#"agent "worker" { host "h"; deliver "mcp"; argv "claude" "boot" }"#, + ); + let mut found = st2::discover(tmp.path()); + let before = found.specs.clone(); + compile_generated_tasks(&mut found.specs, "h", &context(tmp.path())).unwrap(); + assert_eq!(found.specs, before); +} diff --git a/tests/doctor.rs b/tests/doctor.rs index 6ef50b28..edb46f83 100644 --- a/tests/doctor.rs +++ b/tests/doctor.rs @@ -361,3 +361,57 @@ fn suspended_declaration_distinguishes_live_dead_keep_and_dead_nonkeep() { assert!(!stdout.contains("h.idle presence"), "{stdout}"); } } + +#[test] +fn missing_delivery_is_advisory_while_an_invalid_delivery_is_a_catalog_problem() { + let tmp = tempfile::tempdir().unwrap(); + let catalog = tmp.path().join("catalog"); + let declaration = catalog.join("agents/h/worker/agent.kdl"); + let bin = tmp.path().join("bin"); + fs::create_dir_all(declaration.parent().unwrap()).unwrap(); + fs::create_dir_all(&bin).unwrap(); + fs::write( + &declaration, + r#"agent "worker" { host "h"; command "true" }"#, + ) + .unwrap(); + fs::write(declaration.parent().unwrap().join("status"), "available\n").unwrap(); + executable( + &bin.join("pty"), + "#!/bin/sh\nif [ \"$1\" = list ]; then printf '[{\"name\":\"h.worker\",\"status\":\"running\"}]\\n'; fi\n", + ); + + let missing = doctor(&catalog, &bin, &tmp.path().join("state")); + let stdout = String::from_utf8_lossy(&missing.stdout); + assert!( + missing.status.success(), + "stdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&missing.stderr) + ); + assert!( + stdout.contains( + "⚠ h.worker delivery transport missing — declare `ding` or `deliver`; agent receives no DING" + ), + "{stdout}" + ); + + fs::write( + &declaration, + r#"agent "worker" { host "h"; command "true"; deliver "mcp" }"#, + ) + .unwrap(); + let declared = doctor(&catalog, &bin, &tmp.path().join("state")); + let stdout = String::from_utf8_lossy(&declared.stdout); + assert!(declared.status.success(), "{stdout}"); + assert!(!stdout.contains("delivery transport missing"), "{stdout}"); + + fs::write( + &declaration, + r#"agent "worker" { host "h"; command "true"; deliver "mpc" }"#, + ) + .unwrap(); + let invalid = doctor(&catalog, &bin, &tmp.path().join("state")); + let stdout = String::from_utf8_lossy(&invalid.stdout); + assert!(!invalid.status.success(), "{stdout}"); + assert!(stdout.contains("unsupported `deliver` value 'mpc'"), "{stdout}"); +} diff --git a/tests/reconcile.rs b/tests/reconcile.rs index c0850f4c..a9f670ac 100644 --- a/tests/reconcile.rs +++ b/tests/reconcile.rs @@ -398,6 +398,7 @@ fn spec( }, keep: false, restart: None, + delivery: None, resources: Vec::new(), tasks, path: PathBuf::from(format!( diff --git a/tests/run.rs b/tests/run.rs index 67630946..c35f97f1 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -236,6 +236,7 @@ fn task_spec(identity: &str, host: Option<&str>, id: &str) -> AgentSpec { desired_state: AgentDesiredState::Running, keep: false, restart: None, + delivery: None, resources: vec![], tasks: vec![Task { kind: TaskKind::Exec,