diff --git a/AGENTS.md b/AGENTS.md index c563d59b7c..bb3939458b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -332,4 +332,6 @@ All components **must** be accessible for browser-use automation and WCAG compli Managed provider environment and recovery repair: [implementation plan](docs/plans/2026-10-05-managed-provider-environment.md). +Freshcodex completion: [implementation plan](docs/plans/2026-10-25-freshcodex-complete.md). Managed native runtime: [qualification and ownership](docs/development/codex-native-runtime.md). + When idle-waiting on subagents, always call `wait_agent` with `timeout_ms=3600000` (the 1-hour maximum). `wait_agent` wakes immediately on any subagent message or completion, so long timeouts add no latency — but every wait return (including a no-op timeout) costs a full model turn that re-sends the entire context. Never poll on shorter timeouts. Have workers `send_message` to `/root` at milestones so the parent wakes exactly when there is news. diff --git a/Cargo.lock b/Cargo.lock index 04bbb3ae4c..bf63f0f317 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1292,6 +1292,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tracing", ] [[package]] diff --git a/README.md b/README.md index 97ad3b1090..c9445d0104 100644 --- a/README.md +++ b/README.md @@ -123,11 +123,19 @@ The service is standalone and independent of Electron. ### Fresh agents -Freshclaude, Freshcodex, and Freshopencode share a chat interface with tool output, questions, and approval controls. Use `/model` or click the model name to choose a model and thinking level. Changes apply to your next message; the picker remembers recent choices for each project. +Freshclaude, Freshcodex, and Freshopencode share a chat interface with live tool output, attachments, questions, and approval controls. Use `/model` or click the model name to choose a model and thinking level. Freshcodex lists models offered by its running Codex installation and also accepts an exact custom model ID. Changes apply to your next message; the picker remembers recent choices for each project. **Default** removes Freshell’s thinking-level override; Codex may still use its own configuration or a choice retained from earlier turns. -In Freshclaude, “Allow and don't ask again” saves the rule Claude suggests, listed on the card before you click (project rules go to `.claude/settings.local.json` in the project), and when Claude finishes planning it shows the plan with choices to approve it, approve and auto-accept edits, approve and stop asking for permission, or keep planning. In Freshcodex, “Always allow” lasts until the pane reloads. +In Freshclaude, “Allow and don't ask again” saves the rule Claude suggests, listed on the card before you click (project rules go to `.claude/settings.local.json` in the project), and when Claude finishes planning it shows the plan with choices to approve it, approve and auto-accept edits, approve and stop asking for permission, or keep planning. Freshcodex shows the decisions offered by Codex for each request, including “Allow for this session” when available. -You can queue follow-up messages while an agent works. They run one at a time, and the queue stays available if the session disconnects or ends. Expand the queue to read or cancel individual messages. Codex permission settings control when it asks for approval; “Never ask” does not change the session’s file or network access limits. +Freshcodex settings separate **Build or Plan**, **File access**, and approval policy. “Never ask” does not expand file or network access. Incompatible model, effort, or provider settings produce a diagnostic instead of silently broadening access. + +Questions that await an answer pause the requested work. Async questions let Codex continue; replying sends an ordinary contextual message, and its status confirms delivery rather than completion of the requested work. MCP prompts offer explicit accept, decline, and cancel actions. Opening a linked page does not accept its prompt. Secret answers stay masked. + +You can queue follow-up messages while an agent works. They run one at a time, and the queue stays available if the session disconnects or ends. Expand the queue to read or cancel individual messages. + +Codex child conversations open read-only inside their parent. Available history can include live or saved output; ephemeral child history can be unavailable. Output labels distinguish retained, saved, interrupted, and truncated content, so a reload does not imply that every streamed byte was saved. + +Use `/undo` to roll back Codex conversation turns without changing files; Codex has no `/redo`. **Rewind code to here** restores a message’s code checkpoint separately. `!command` runs a shell command in the conversation’s working directory and sends its output to the agent. ### Session switching and recovery @@ -269,12 +277,13 @@ turning it off removes the server from Freshclaude and Kilroy agents started or resumed after the change, with no restart. Terminal panes do not follow the switch yet. -With fresh agents enabled, the CLI and MCP client can create Freshclaude or -Kilroy tabs, split them into panes, send prompts, and capture their conversation -as plain text. These operations also work without an open browser. Use -`agent: "claude"` or `agent: "kilroy"` with MCP `new-tab` and `split-pane`, or -`--agent claude` / `--agent kilroy` with the CLI. Both support resuming a saved -Claude conversation through `resume`. +With fresh agents enabled, the CLI and MCP client can create Freshclaude, +Kilroy, or Freshcodex tabs, split them into panes, send prompts, capture their +conversation as plain text, and wait for an output pattern. These operations +also work without an open browser. Use `agent: "claude"`, `agent: "kilroy"`, or +`agent: "codex"` with MCP `new-tab` and `split-pane`, or `--agent claude`, +`--agent kilroy`, or `--agent codex` with the CLI. Both support resuming a saved +conversation through `resume`; Freshcodex uses its canonical Codex session ID. ### Rust server scope diff --git a/crates/freshell-agent-runtime/Cargo.toml b/crates/freshell-agent-runtime/Cargo.toml index 25f21e6126..a9409c4fc2 100644 --- a/crates/freshell-agent-runtime/Cargo.toml +++ b/crates/freshell-agent-runtime/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } rusqlite = { version = "0.31", features = ["bundled"] } thiserror = "2" async-trait = "0.1" +tracing = "0.1" sha2 = "0.10" tokio = { version = "1", features = ["rt", "sync"] } diff --git a/crates/freshell-agent-runtime/src/host_actor.rs b/crates/freshell-agent-runtime/src/host_actor.rs index 7710abe798..d8236117fb 100644 --- a/crates/freshell-agent-runtime/src/host_actor.rs +++ b/crates/freshell-agent-runtime/src/host_actor.rs @@ -6,9 +6,11 @@ use async_trait::async_trait; pub use freshell_runtime_protocol::{ AgentEvent, AgentEventBatch as EventBatch, AgentJournalEvent as JournalEvent, FreshProvider, + NativeDispatchIdentity, }; use freshell_runtime_protocol::{ - CommandState, FreshAgentCapture, FreshAgentRollbackDirection, FreshAgentRollbackMode, + CommandState, FreshAgentCapture, FreshAgentNativeNameError, FreshAgentNativeNameOperation, + FreshAgentNativeNameReply, FreshAgentRollbackDirection, FreshAgentRollbackMode, FreshAgentTurnSettings, ProviderLaunchContext, ProviderModelSelection, ProviderSecretReference, ProviderSessionReference, RequestId, }; @@ -50,6 +52,10 @@ mod double_optional { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub naming_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, pub provider: FreshProvider, pub runtime_variant: String, pub cwd: String, @@ -76,9 +82,28 @@ pub struct FreshAgentProfile { } impl FreshAgentProfile { + pub fn effective_permission_mode(&self) -> Option<&str> { + self.permission_mode.as_deref().or_else(|| { + if self.provider != FreshProvider::Codex { + return None; + } + match &self.provider_launch_context.as_ref()?.preparation { + freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default, .. + } => approval_default + .as_ref() + .map(|default| default.policy.as_str()), + _ => None, + } + }) + } + /// Folds per-turn settings into the durable launch profile: every /// provided field replaces the stored one, absent fields keep it. pub fn apply_turn_settings(&mut self, settings: &FreshAgentTurnSettings) { + if let Some(value) = &settings.collaboration_mode { + self.collaboration_mode = Some(value.clone()); + } if let Some(value) = &settings.cwd { self.cwd = value.clone(); } @@ -86,7 +111,7 @@ impl FreshAgentProfile { self.model = Some(value.clone()); } if let Some(value) = &settings.effort { - self.effort = Some(value.clone()); + self.effort = value.clone(); } if let Some(value) = &settings.permission_mode { self.permission_mode = Some(value.clone()); @@ -112,6 +137,10 @@ impl FreshAgentProfile { pub fn validate(&self) -> Result<(), ActorError> { if self.runtime_variant.is_empty() + || self + .collaboration_mode + .as_deref() + .is_some_and(|mode| !matches!(mode, "default" | "plan")) || self.cwd.is_empty() || self.provider_store_id.is_empty() || self.native_session_id.as_deref().is_some_and(str::is_empty) @@ -168,6 +197,9 @@ pub struct TransportStart { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DispatchAck { pub provider_ack_id: Option, + /// Direct correlated provider acceptance, never an asynchronous Started + /// frame. The previous identity fences a recovery result to its caller. + pub native_identity: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -249,6 +281,20 @@ pub trait FreshAgentTransport: Send + Sync { ) -> Result { self.dispatch(request_id, text, profile).await } + /// Carries the caller's partial settings separately from durable launch + /// intent. Providers with native effective defaults must not restate an + /// unchanged launch profile as new overrides on every send. + async fn dispatch_with_turn_settings( + &self, + request_id: &RequestId, + text: &str, + profile: &FreshAgentProfile, + _settings: Option<&FreshAgentTurnSettings>, + logical_attempt_id: Option<&str>, + ) -> Result { + self.dispatch_with_checkpoint_identity(request_id, text, profile, logical_attempt_id) + .await + } async fn resolve_permission( &self, decision_id: &str, @@ -285,9 +331,31 @@ pub trait FreshAgentTransport: Send + Sync { async fn capture(&self, _max_bytes: usize) -> Result { Err("provider does not expose a hosted snapshot".into()) } + /// Optional authoritative in-memory source. No native RPC or runtime creation. + async fn retained_state(&self) -> Result, String> { + Ok(None) + } async fn snapshot(&self) -> Result { Err("provider does not expose a hosted snapshot".into()) } + async fn model_catalog(&self, _cwd: Option<&str>) -> Result { + Err("provider does not expose a model catalog".into()) + } + + async fn native_name( + &self, + _native_session_id: &str, + _codex_home: &str, + _operation: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "provider has no live native naming authority".into(), + }) + } + + async fn read_child(&self, _child_thread_id: &str) -> Result { + Err("provider does not expose child observation".into()) + } /// A registered zero-turn identity is transport-owned, not a durable native identity. /// Only transports that can prove their current local registration opt in. @@ -661,8 +729,8 @@ impl FreshAgentHostActor { other => Ok(other), }; } - if let Some(settings) = settings { - state.profile.apply_turn_settings(&settings); + if let Some(settings) = settings.as_ref() { + state.profile.apply_turn_settings(settings); state.profile.validate()?; } dispatch_profile = state.profile.clone(); @@ -687,14 +755,67 @@ impl FreshAgentHostActor { let result = self .transport - .dispatch_with_checkpoint_identity( + .dispatch_with_turn_settings( &request_id, &text, &dispatch_profile, + settings.as_ref(), logical_attempt_id.as_deref(), ) .await; let mut state = self.state.lock().await; + if let Ok(ack) = &result { + if let Some(identity) = &ack.native_identity { + if identity.native_session_id.is_empty() + || identity.native_session_id.chars().any(char::is_control) + || dispatch_profile.native_session_id.as_deref() + != Some(identity.previous_native_session_id.as_str()) + || state.profile.native_session_id != dispatch_profile.native_session_id + || state.profile.provider_store_id != dispatch_profile.provider_store_id + || state.profile.provider != dispatch_profile.provider + || identity + .pending_naming_handle + .as_ref() + .is_some_and(|handle| { + dispatch_profile.naming_handle.as_ref() != Some(handle) + }) + { + state + .commands + .get_mut(request_id.as_str()) + .expect("durable command") + .state = CommandState::Ambiguous; + push_event_bounded( + &mut state, + AgentEvent::CommandOutcome { + request_id, + state: CommandState::Ambiguous, + }, + self.event_byte_cap, + self.event_count_cap, + )?; + write_state(&self.state_dir, &state)?; + return Err(ActorError::AmbiguousDispatch); + } + let changed = state.profile.native_session_id.as_deref() + != Some(identity.native_session_id.as_str()); + state.profile.native_session_id = Some(identity.native_session_id.clone()); + state.profile.naming_handle = identity.pending_naming_handle.clone(); + if changed { + if let Some(reference) = state.profile.session_ref.as_mut() { + reference.session_id = identity.native_session_id.clone(); + } + push_event_bounded( + &mut state, + AgentEvent::Started { + native_session_id: identity.native_session_id.clone(), + }, + self.event_byte_cap, + self.event_count_cap, + )?; + } + } + } let command = state .commands .get_mut(request_id.as_str()) @@ -820,6 +941,7 @@ impl FreshAgentHostActor { return Err(ActorError::AmbiguousDispatch); } state.profile.native_session_id = Some(transition.child_session_id.clone()); + state.profile.naming_handle = None; if let Some(reference) = state.profile.session_ref.as_mut() { reference.session_id = transition.child_session_id.clone(); } @@ -1018,6 +1140,7 @@ impl FreshAgentHostActor { if let Some(native_session_id) = native_session_id { if state.profile.native_session_id.as_deref() != Some(&native_session_id) { state.profile.native_session_id = Some(native_session_id.clone()); + state.profile.naming_handle = None; if let Some(reference) = state.profile.session_ref.as_mut() { reference.session_id = native_session_id.clone(); } @@ -1113,7 +1236,124 @@ impl FreshAgentHostActor { Ok(snapshot) } - pub async fn record_event(&self, event: AgentEvent) -> Result { + pub async fn model_catalog(&self, cwd: Option<&str>) -> Result { + let before = self.profile().await; + if before.provider != FreshProvider::Codex + || before.native_session_id.is_none() + || !self.transport.is_live().await + { + return Err(ActorError::NativeIdentityMismatch); + } + let value = self + .transport + .model_catalog(cwd) + .await + .map_err(ActorError::Transport)?; + let after = self.profile().await; + if before.native_session_id != after.native_session_id + || before.provider_store_id != after.provider_store_id + || before.provider != after.provider + || before.cwd != after.cwd + || !self.transport.is_live().await + { + return Err(ActorError::NativeIdentityMismatch); + } + if serde_json::to_vec(&value) + .map_err(|e| ActorError::Transport(e.to_string()))? + .len() + > freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES - 4096 + { + return Err(ActorError::Transport( + "model catalog exceeds control reply frame limit".into(), + )); + } + Ok(value) + } + + pub async fn native_name( + &self, + native_session_id: &str, + codex_home: &str, + operation: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + let before = self.profile().await; + if before.provider != FreshProvider::Codex + || before.native_session_id.as_deref() != Some(native_session_id) + || codex_home.is_empty() + || !self.transport.is_live().await + { + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "native naming target is no longer current".into(), + }); + } + let reply = self + .transport + .native_name(native_session_id, codex_home, operation) + .await; + let after = self.profile().await; + if before.native_session_id != after.native_session_id + || before.provider_store_id != after.provider_store_id + || !self.transport.is_live().await + { + // A write may already have applied to the captured source. + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed); + } + if serde_json::to_vec(&reply).map_or(true, |bytes| { + bytes.len() > freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES - 4096 + }) { + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::InvalidResponse { + detail: "native naming reply exceeds control frame limit".into(), + }); + } + reply + } + + pub async fn read_child(&self, child_thread_id: &str) -> Result { + let before = self.profile().await; + if before.provider != FreshProvider::Codex + || before.native_session_id.is_none() + || !self.transport.is_live().await + { + return Err(ActorError::NativeIdentityMismatch); + } + let value = self + .transport + .read_child(child_thread_id) + .await + .map_err(ActorError::Transport)?; + let after = self.profile().await; + if before.native_session_id != after.native_session_id + || !self.transport.is_live().await + || value["parentThreadId"].as_str() != before.native_session_id.as_deref() + || value["childThreadId"].as_str() != Some(child_thread_id) + || (value.get("snapshot").is_some() + && (value["snapshot"]["threadId"].as_str() != Some(child_thread_id) + || value["snapshot"]["provider"] != "codex")) + { + return Err(ActorError::NativeIdentityMismatch); + } + if serde_json::to_vec(&value) + .map_err(|error| ActorError::Transport(error.to_string()))? + .len() + > freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES - 4096 + { + return Err(ActorError::Transport( + "child observation exceeds history reply frame limit".into(), + )); + } + Ok(value) + } + + pub async fn record_event(&self, mut event: AgentEvent) -> Result { + // The provider owns retained bodies. The bounded journal keeps only the + // invalidation edge; reads reconcile against the current source. + if let AgentEvent::Provider { payload } = &mut event { + if payload["event"]["type"] == "freshAgent.host.backpressure" { + if let Some(event) = payload["event"].as_object_mut() { + event.remove("retainedState"); + } + } + } let mut state = self.state.lock().await; let sequence = push_event_bounded(&mut state, event, self.event_byte_cap, self.event_count_cap)?; @@ -1190,11 +1430,69 @@ impl FreshAgentHostActor { Ok(sequence) } + async fn record_retained_permission(&self, decision_id: &str) -> Result<(), ActorError> { + // A reconnect/snapshot can expose a native control before the + // asynchronous event pump journals it. Reconcile only on the user + // command, from the existing live source; reads stay side-effect free. + let before = self.profile().await; + if before.provider != FreshProvider::Codex || !self.transport.is_live().await { + return Ok(()); + } + let Some(retained) = self + .transport + .retained_state() + .await + .map_err(ActorError::Transport)? + else { + return Ok(()); + }; + let after = self.profile().await; + if before.native_session_id.is_none() + || before.native_session_id != after.native_session_id + || before.provider_store_id != after.provider_store_id + || before.provider != after.provider + || !self.transport.is_live().await + || retained["snapshot"]["threadId"].as_str() != before.native_session_id.as_deref() + || retained["snapshot"]["provider"] != "codex" + || retained["snapshot"]["sessionType"] != "freshcodex" + { + return Ok(()); + } + let Some(payload) = retained["pendingControls"].as_array().and_then(|pending| { + pending.iter().find(|payload| { + payload["requestId"].as_str() == Some(decision_id) + && payload["sessionId"].as_str() == before.native_session_id.as_deref() + && matches!( + payload["type"].as_str(), + Some( + "freshAgent.permission.request" + | "freshAgent.question.request" + | "freshAgent.elicitation.request" + ) + ) + }) + }) else { + return Ok(()); + }; + self.record_permission(decision_id.to_owned(), payload.clone()) + .await?; + tracing::info!( + provider = "codex", + request_id = decision_id, + native_session_id = before.native_session_id.as_deref(), + "freshagent.host.control_admitted_from_retained" + ); + Ok(()) + } + pub async fn resolve_permission( &self, decision_id: &str, decision: Value, ) -> Result<(), ActorError> { + if !self.state.lock().await.decisions.contains_key(decision_id) { + self.record_retained_permission(decision_id).await?; + } let response_digest = digest(&serde_json::to_vec(&decision).map_err(persist)?); { let mut state = self.state.lock().await; @@ -1257,20 +1555,36 @@ impl FreshAgentHostActor { } pub async fn read_events(&self, after: u64, max_events: usize) -> EventBatch { - let state = self.state.lock().await; - let max_events = max_events.clamp(1, self.event_count_cap); - EventBatch { - retained_from: state.retained_from, - head: state.next_sequence.saturating_sub(1), - reset_required: after.saturating_add(1) < state.retained_from, - events: state - .events - .iter() - .filter(|event| event.sequence > after) - .take(max_events) - .cloned() - .collect(), + let mut batch = { + let state = self.state.lock().await; + let max_events = max_events.clamp(1, self.event_count_cap); + EventBatch { + retained_state: None, + retained_from: state.retained_from, + head: state.next_sequence.saturating_sub(1), + reset_required: after.saturating_add(1) < state.retained_from, + events: state + .events + .iter() + .filter(|event| event.sequence > after) + .filter(|event| !matches!(&event.event, AgentEvent::Started {native_session_id} + if state.profile.native_session_id.as_deref() != Some(native_session_id.as_str()))) + .take(max_events) + .cloned() + .collect(), + } + }; + if after == 0 || batch.reset_required || batch.events.iter().any(|entry| + matches!(&entry.event, AgentEvent::Provider { payload } if payload["event"]["type"] == "freshAgent.host.backpressure")) { + batch.retained_state = match self.transport.retained_state().await { + Ok(retained) => retained, + Err(error) => { + tracing::warn!(%error, "freshagent.host.retained_read_failed"); + None + } + }; } + batch } pub async fn interrupt(&self) -> Result<(), ActorError> { diff --git a/crates/freshell-agent-runtime/src/host_actor_tests.rs b/crates/freshell-agent-runtime/src/host_actor_tests.rs index f7f1ca0000..a24f96a9e2 100644 --- a/crates/freshell-agent-runtime/src/host_actor_tests.rs +++ b/crates/freshell-agent-runtime/src/host_actor_tests.rs @@ -125,6 +125,211 @@ impl FreshAgentTransport for SnapshotTransport { } Ok(self.value.clone()) } + async fn retained_state(&self) -> Result, String> { + let retained = self.value.get("retainedState").cloned(); + if retained.is_some() { + if let Some((entered, release)) = &self.pause { + entered.notify_one(); + release.notified().await; + } + } + Ok(retained) + } + async fn read_child(&self, _: &str) -> Result { + self.snapshot().await + } + async fn model_catalog(&self, _: Option<&str>) -> Result { + self.snapshot().await + } + async fn native_name( + &self, + native: &str, + _: &str, + _: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + assert_eq!( + Some(native), + self.initial_native.as_deref(), + "a stale target must never reach the transport" + ); + assert!( + self.live.load(Ordering::SeqCst), + "a dead transport must not receive metadata work" + ); + let value = self.snapshot().await.unwrap(); + FreshAgentNativeNameReply::Title(value["title"].as_str().map(str::to_string)) + } +} + +#[tokio::test] +async fn native_name_preserves_actor_state_and_rejects_stale_targets_and_source_loss() { + for scenario in [ + "live", + "wrong-native", + "dead-before", + "exit-during", + "replaced", + "oversized", + ] { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"title": if scenario=="oversized" { "x".repeat(freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES) } else { "Saved title".into() }}), + live: std::sync::atomic::AtomicBool::new(scenario != "dead-before"), + exit_during_read: scenario == "exit-during", + initial_native: Some("name-native".into()), + registered: None, + pause: (scenario == "replaced").then(|| (entered.clone(), release.clone())), + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "name-store", None), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let operation = tokio::spawn({ + let actor = actor.clone(); + async move { + actor + .native_name( + if scenario == "wrong-native" { + "stale-native" + } else { + "name-native" + }, + "/owned/codex", + &FreshAgentNativeNameOperation::Read, + ) + .await + } + }); + if scenario == "replaced" { + entered.notified().await; + // Controlled source replacement while the actual actor read is held. + actor.state.lock().await.profile.native_session_id = Some("replacement".into()); + release.notify_one(); + } + let reply = operation.await.unwrap(); + match scenario { + "live" => assert_eq!( + reply, + FreshAgentNativeNameReply::Title(Some("Saved title".into())) + ), + "wrong-native" | "dead-before" => assert!(matches!( + reply, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { .. }) + )), + "oversized" => assert!(matches!( + reply, + FreshAgentNativeNameReply::Failed( + FreshAgentNativeNameError::InvalidResponse { .. } + ) + )), + _ => assert_eq!( + reply, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed) + ), + } + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before, + "native naming is not a conversation command or writer transition" + ); + } +} + +#[tokio::test] +async fn model_catalog_preserves_actor_state_and_fences_liveness_size_and_replaced_native_source() { + for scenario in ["live", "exited", "oversized", "replaced"] { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!([{"id":"provider-current","displayName":if scenario=="oversized" {"x".repeat(freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)} else {"Provider Current".into()}}]), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: scenario == "exited", + initial_native: Some("catalog-native".into()), + registered: None, + pause: if scenario == "replaced" { + Some((entered.clone(), release.clone())) + } else { + None + }, + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "catalog-store", None), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let read = tokio::spawn({ + let actor = actor.clone(); + async move { actor.model_catalog(Some("/workspace")).await } + }); + if scenario == "replaced" { + entered.notified().await; + // Fixture-only replacement while the read is held; this does not + // grant native identity mutation or claim crash recovery coverage. + actor.state.lock().await.profile.native_session_id = Some("replacement-native".into()); + release.notify_one(); + } + let result = read.await.unwrap(); + assert_eq!(result.is_ok(), scenario == "live", "{scenario}: {result:?}"); + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before, + "a catalog is not a durable command or writer transition" + ); + } +} + +#[tokio::test] +async fn child_observation_preserves_actor_state_and_rejects_cross_parent_child_and_liveness() { + for scenario in [ + "saved", + "live", + "wrong-parent", + "wrong-child", + "exit-during-read", + "oversized", + ] { + let dir = tempfile::tempdir().unwrap(); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"parentThreadId":if scenario == "wrong-parent" {"foreign"} else {"parent-native"},"childThreadId":if scenario == "wrong-child" {"foreign"} else {"child-native"},"status":if scenario == "live" {"live"} else {"saved"},"snapshot":{"threadId":"child-native","provider":"codex","turns":if scenario == "oversized" {"x".repeat(freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES)} else {"Exact observed body".into()}}}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: scenario == "exit-during-read", + initial_native: Some("parent-native".into()), + registered: None, + pause: None, + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "child-store", None), + transport, + ) + .await + .unwrap(); + let before = fs::read(dir.path().join("fresh-agent-state.json")).unwrap(); + let result = actor.read_child("child-native").await; + assert_eq!( + result.is_ok(), + matches!(scenario, "saved" | "live"), + "{scenario}" + ); + assert_eq!( + fs::read(dir.path().join("fresh-agent-state.json")).unwrap(), + before + ); + assert_eq!( + actor.profile().await.native_session_id.as_deref(), + Some("parent-native") + ); + } } #[tokio::test] @@ -337,6 +542,9 @@ struct FakeTransport { dispatches: AtomicUsize, checkpoint_attempts: std::sync::Mutex>>, decisions: AtomicUsize, + retained: std::sync::Mutex>, + dispatch_identity: std::sync::Mutex>, + dispatch_gate: std::sync::Mutex, Arc)>>, stops: AtomicUsize, ambiguous_dispatch: bool, live: std::sync::atomic::AtomicBool, @@ -369,6 +577,7 @@ impl FreshAgentTransport for SerialTransport { tokio::time::sleep(std::time::Duration::from_millis(15)).await; self.in_flight.fetch_sub(1, Ordering::SeqCst); Ok(DispatchAck { + native_identity: None, provider_ack_id: None, }) } @@ -437,6 +646,9 @@ impl FakeTransport { dispatches: AtomicUsize::new(0), checkpoint_attempts: std::sync::Mutex::new(Vec::new()), decisions: AtomicUsize::new(0), + retained: std::sync::Mutex::new(None), + dispatch_identity: std::sync::Mutex::new(None), + dispatch_gate: std::sync::Mutex::new(None), stops: AtomicUsize::new(0), ambiguous_dispatch: false, live: std::sync::atomic::AtomicBool::new(true), @@ -452,6 +664,9 @@ impl FakeTransport { dispatches: AtomicUsize::new(0), checkpoint_attempts: std::sync::Mutex::new(Vec::new()), decisions: AtomicUsize::new(0), + retained: std::sync::Mutex::new(None), + dispatch_identity: std::sync::Mutex::new(None), + dispatch_gate: std::sync::Mutex::new(None), stops: AtomicUsize::new(0), ambiguous_dispatch: false, live: std::sync::atomic::AtomicBool::new(true), @@ -476,6 +691,11 @@ impl FreshAgentTransport for FakeTransport { _profile: &FreshAgentProfile, ) -> Result { self.dispatches.fetch_add(1, Ordering::SeqCst); + let gate = self.dispatch_gate.lock().unwrap().clone(); + if let Some((entered, release)) = gate { + entered.notify_one(); + release.notified().await; + } if self.ambiguous_dispatch { Err(DispatchFailure { message: "reply lost".into(), @@ -483,6 +703,7 @@ impl FreshAgentTransport for FakeTransport { }) } else { Ok(DispatchAck { + native_identity: self.dispatch_identity.lock().unwrap().clone(), provider_ack_id: Some("ack-1".into()), }) } @@ -514,6 +735,9 @@ impl FreshAgentTransport for FakeTransport { async fn is_live(&self) -> bool { self.live.load(Ordering::SeqCst) } + async fn retained_state(&self) -> Result, String> { + Ok(self.retained.lock().unwrap().clone()) + } async fn stop(self: Arc) -> Result<(), String> { self.stops.fetch_add(1, Ordering::SeqCst); Ok(()) @@ -525,6 +749,8 @@ impl FreshAgentTransport for FakeTransport { fn profile(provider: FreshProvider, store: &str, native: Option<&str>) -> FreshAgentProfile { FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, runtime_variant: provider.as_str().into(), provider, cwd: "/workspace".into(), @@ -830,6 +1056,123 @@ async fn transport_liveness_is_observed_without_transferring_ownership() { assert_eq!(transport.stops.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn accepted_dispatch_native_identity_requires_current_source_and_existing_pending_authority() +{ + for scenario in [ + "wrong-pending", + "initial-recovery", + "new-conversation", + "wrong-parent", + "empty-native", + "control-native", + "stale-native", + "stale-store", + "stale-provider", + "ambiguous", + ] { + let dir = tempfile::tempdir().unwrap(); + let transport = if scenario == "ambiguous" { + FakeTransport::ambiguous("native-old") + } else { + FakeTransport::new("native-old") + }; + let mut launch = profile(FreshProvider::Codex, "native-store", None); + launch.naming_handle = Some("pending-original".into()); + *transport.dispatch_identity.lock().unwrap() = Some(NativeDispatchIdentity { + previous_native_session_id: if scenario == "wrong-parent" { + "foreign-native" + } else { + "native-old" + } + .into(), + native_session_id: match scenario { + "empty-native" => "", + "control-native" => "native\nnew", + _ => "native-new", + } + .into(), + pending_naming_handle: match scenario { + "wrong-pending" => Some("foreign-pending".into()), + "initial-recovery" => launch.naming_handle.clone(), + _ => None, + }, + }); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + if scenario.starts_with("stale-") { + *transport.dispatch_gate.lock().unwrap() = Some((entered.clone(), release.clone())); + } + let actor = FreshAgentHostActor::open(dir.path(), launch, transport.clone()) + .await + .unwrap(); + let request = RequestId::parse("native-identity-request").unwrap(); + let work = tokio::spawn({ + let actor = actor.clone(); + let request = request.clone(); + async move { + actor + .dispatch(request, "controlled native input".into(), None) + .await + } + }); + if scenario.starts_with("stale-") { + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + let mut state = actor.state.lock().await; + match scenario { + "stale-native" => { + state.profile.native_session_id = Some("replacement-native".into()) + } + "stale-store" => state.profile.provider_store_id = "replacement-store".into(), + "stale-provider" => state.profile.provider = FreshProvider::Claude, + _ => unreachable!(), + } + drop(state); + release.notify_one(); + } + let result = work.await.unwrap(); + let current = actor.profile().await; + if matches!(scenario, "initial-recovery" | "new-conversation") { + assert_eq!(result, Ok(CommandState::ProviderAcked), "{scenario}"); + assert_eq!(current.native_session_id.as_deref(), Some("native-new")); + assert_eq!( + current.naming_handle, + if scenario == "initial-recovery" { + Some("pending-original".into()) + } else { + None + } + ); + } else { + assert!( + matches!(result, Err(ActorError::AmbiguousDispatch)), + "{scenario}: {result:?}" + ); + assert_ne!( + current.native_session_id.as_deref(), + Some("native-new"), + "{scenario}" + ); + assert_eq!( + current.naming_handle.as_deref(), + Some("pending-original"), + "{scenario}" + ); + assert_eq!( + actor + .dispatch(request, "controlled native input".into(), None) + .await, + Err(ActorError::AmbiguousDispatch), + "an uncertain native result cannot be replayed: {scenario}" + ); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 1, "{scenario}"); + } + actor.stop().await.unwrap(); + } +} + #[tokio::test] async fn pending_permission_persists_and_is_answered_exactly_once_after_gateway_restart() { let dir = tempfile::tempdir().unwrap(); @@ -873,6 +1216,137 @@ async fn pending_permission_persists_and_is_answered_exactly_once_after_gateway_ assert_eq!(transport.decisions.load(Ordering::SeqCst), 1); } +#[tokio::test] +async fn snapshot_control_can_be_answered_before_its_delayed_journal_event() { + for (scenario, event_type) in [ + ("current", "freshAgent.permission.request"), + ("current", "freshAgent.question.request"), + ("current", "freshAgent.elicitation.request"), + ("unknown", "freshAgent.permission.request"), + ("foreign-native", "freshAgent.permission.request"), + ("foreign-provider", "freshAgent.permission.request"), + ("foreign-type", "freshAgent.permission.request"), + ("foreign-parent", "freshAgent.permission.request"), + ("invalid-kind", "freshAgent.status"), + ("dead", "freshAgent.permission.request"), + ] { + let dir = tempfile::tempdir().unwrap(); + let transport = FakeTransport::new("parent-native"); + let payload = serde_json::json!({ + "type":event_type, "sessionId":if scenario=="foreign-parent" {"another-parent"} else {"parent-native"}, + "requestId":"child-request", "providerRequest":{"id":15,"threadId":"registered-child","turnId":"child-turn"}, + "native":{"availableDecisions":["accept","cancel"]} + }); + *transport.retained.lock().unwrap() = Some(serde_json::json!({ + "source":{"projectionId":"current-source","ownerEpoch":1,"ownerGeneration":2}, + "snapshot":{"provider":if scenario=="foreign-provider" {"claude"} else {"codex"},"sessionType":if scenario=="foreign-type" {"freshclaude"} else {"freshcodex"},"threadId":if scenario=="foreign-native" {"old-native"} else {"parent-native"},"pendingApprovals":[payload.clone()]}, + "pendingControls":[payload.clone()] + })); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "retained-control", None), + transport.clone(), + ) + .await + .unwrap(); + if scenario == "dead" { + transport.live.store(false, Ordering::SeqCst); + } + // The authoritative retained snapshot is visible while the event pump + // has not delivered PermissionRequested. A response must reconcile that + // exact current control, without turning snapshot reads into writes. + assert!(actor.state.lock().await.decisions.is_empty()); + let id = if scenario == "unknown" { + "invented-request" + } else { + "child-request" + }; + let decision = serde_json::json!({"nativeDecision":"accept"}); + let result = actor.resolve_permission(id, decision.clone()).await; + if scenario != "current" { + assert!(result.is_err(), "{scenario} must not admit a decision"); + assert!( + actor.state.lock().await.decisions.is_empty(), + "{scenario} must not persist an unproved control" + ); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 0, "{scenario}"); + continue; + } + result.expect("a current snapshot control must be immediately answerable"); + // The delayed event and duplicate user click must not send twice. + actor.record_permission(id.into(), payload).await.unwrap(); + actor.resolve_permission(id, decision).await.unwrap(); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 1); + assert_eq!(transport.starts.load(Ordering::SeqCst), 1); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + assert!(matches!( + actor + .resolve_permission(id, serde_json::json!({"nativeDecision":"cancel"})) + .await, + Err(ActorError::DecisionAlreadyResolved) + )); + } +} + +#[tokio::test] +async fn retained_control_admission_rejects_source_change_during_read() { + for changed in ["native", "store", "dead"] { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"retainedState":{ + "snapshot":{"provider":"codex","sessionType":"freshcodex","threadId":"native-held"}, + "pendingControls":[{"type":"freshAgent.permission.request","sessionId":"native-held","requestId":"held-request"}] + }}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: false, + initial_native: Some("native-held".into()), + registered: None, + pause: Some((entered.clone(), release.clone())), + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "held-store", None), + transport.clone(), + ) + .await + .unwrap(); + let response = tokio::spawn({ + let actor = actor.clone(); + async move { + actor + .resolve_permission( + "held-request", + serde_json::json!({"nativeDecision":"accept"}), + ) + .await + } + }); + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + match changed { + "native" => { + actor.state.lock().await.profile.native_session_id = + Some("replacement-native".into()) + } + "store" => { + actor.state.lock().await.profile.provider_store_id = "replacement-store".into() + } + "dead" => transport.live.store(false, Ordering::SeqCst), + _ => unreachable!(), + } + release.notify_one(); + assert_eq!( + response.await.unwrap(), + Err(ActorError::UnknownDecision), + "{changed}" + ); + assert!(actor.state.lock().await.decisions.is_empty(), "{changed}"); + } +} + #[tokio::test] async fn ambiguous_accepted_input_is_never_dispatched_twice() { let dir = tempfile::tempdir().unwrap(); @@ -1006,9 +1480,10 @@ async fn exact_native_identity_and_profile_are_reused_on_host_actor_recovery() { RequestId::parse("settings-update").unwrap(), "continue".into(), Some(FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("updated-model".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, }), @@ -1063,6 +1538,7 @@ async fn current_provider_preparation_reopens_the_same_fresh_conversation() { mcp_args: Vec::new(), }, FreshProvider::Codex => ProviderPreparation::Codex { + approval_default: None, tui_args: Vec::new(), sidecar_args: Vec::new(), }, @@ -1431,13 +1907,11 @@ async fn provider_native_fork_transitions_the_same_actor_without_a_second_writer let transport = Arc::new(ForkingTransport { forks: AtomicUsize::new(0), }); - let actor = FreshAgentHostActor::open( - dir.path(), - profile(FreshProvider::Codex, "same-store", Some("native-parent")), - transport.clone(), - ) - .await - .unwrap(); + let mut parent = profile(FreshProvider::Codex, "same-store", Some("native-parent")); + parent.naming_handle = Some("parent-only-pending-name".into()); + let actor = FreshAgentHostActor::open(dir.path(), parent, transport.clone()) + .await + .unwrap(); let request = RequestId::parse("fork-same-soul-1").unwrap(); let input = Some(serde_json::json!({"atTurnId":"turn-7"})); let transition = actor @@ -1446,6 +1920,10 @@ async fn provider_native_fork_transitions_the_same_actor_without_a_second_writer .unwrap(); assert_eq!(transition.child_session_id, "native-child"); assert!(transition.parent_retired_by_runtime); + assert!( + actor.profile().await.naming_handle.is_none(), + "an ordinary native fork must not reuse its parent's pending name on reopen" + ); assert_eq!( actor.profile().await.native_session_id.as_deref(), Some("native-child") @@ -1621,6 +2099,7 @@ impl FreshAgentTransport for ConfigureTransport { ) -> Result { self.sent_profiles.lock().unwrap().push(profile.clone()); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }) } @@ -1668,9 +2147,10 @@ impl FreshAgentTransport for ConfigureTransport { fn opus_high() -> FreshAgentTurnSettings { FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, } @@ -1959,3 +2439,106 @@ async fn configure_without_an_effort_keeps_the_profile_on_the_providers_effort() ); } } + +#[tokio::test] +async fn task6_covered_head_precedes_retained_await_so_a_final_event_remains_readable() { + let dir = tempfile::tempdir().unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"retainedState":{"snapshot":{"turns":[]}}}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: false, + initial_native: Some("native-held".into()), + registered: None, + pause: Some((entered.clone(), release.clone())), + }); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Codex, "held-source", None), + transport, + ) + .await + .unwrap(); + actor.record_event(AgentEvent::Provider{payload:serde_json::json!({"type":"freshAgent.event","event":{"type":"freshAgent.status","status":"running"}})}).await.unwrap(); + let read = tokio::spawn({ + let actor = actor.clone(); + async move { actor.read_events(0, 64).await } + }); + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + let final_sequence=actor.record_event(AgentEvent::Provider{payload:serde_json::json!({"type":"freshAgent.event","event":{"type":"freshAgent.turn.complete","at":200}})}).await.unwrap(); + release.notify_one(); + let first = read.await.unwrap(); + assert!( + first.head < final_sequence, + "the covered head must be captured before the held provider read" + ); + assert_eq!( + first.retained_state.as_ref().unwrap()["snapshot"]["turns"], + serde_json::json!([]) + ); + let next = actor.read_events(first.head, 64).await; + assert!(next.events.iter().any(|entry|entry.sequence==final_sequence && matches!(&entry.event,AgentEvent::Provider{payload} if payload["event"]["type"]=="freshAgent.turn.complete")),"advancing through the retained prefix must not skip the final concurrent event"); + actor.stop().await.unwrap(); +} + +#[tokio::test] +async fn task6_default_unsupported_provider_keeps_the_existing_event_wire_shape() { + let dir = tempfile::tempdir().unwrap(); + let transport = FakeTransport::new("legacy-native"); + let actor = FreshAgentHostActor::open( + dir.path(), + profile(FreshProvider::Claude, "legacy", None), + transport.clone(), + ) + .await + .unwrap(); + let batch = actor.read_events(0, 64).await; + assert!(batch.retained_state.is_none()); + assert!(serde_json::to_value(batch) + .unwrap() + .get("retainedState") + .is_none()); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + actor.stop().await.unwrap(); +} + +#[tokio::test] +async fn task6_large_retained_body_stays_out_of_the_small_durable_journal() { + let dir = tempfile::tempdir().unwrap(); + let body = "retained body ".repeat(200_000); + let retained = serde_json::json!({"snapshot":{"turns":[{"text":body}]}}); + let transport = Arc::new(SnapshotTransport { + value: serde_json::json!({"retainedState":retained}), + live: std::sync::atomic::AtomicBool::new(true), + exit_during_read: false, + initial_native: Some("body-native".into()), + registered: None, + pause: None, + }); + let actor = FreshAgentHostActor::open_with_bounds( + dir.path(), + profile(FreshProvider::Codex, "body-store", None), + transport, + 1024, + 8, + ) + .await + .unwrap(); + actor.record_event(AgentEvent::Provider{payload:serde_json::json!({"type":"freshAgent.event","event":{"type":"freshAgent.host.backpressure","retainedState":retained}})}).await.expect("a multi-MiB retained body must not consume the 1KiB event journal budget"); + let batch = actor.read_events(0, 8).await; + assert!( + batch.retained_state == Some(retained), + "the same body is reconstructed from the provider source on read" + ); + assert!( + std::fs::metadata(dir.path().join("fresh-agent-state.json")) + .unwrap() + .len() + < 64 * 1024, + "only the small recovery edge belongs in durable actor state" + ); + actor.stop().await.unwrap(); +} diff --git a/crates/freshell-agent-runtime/src/snapshot_projection.rs b/crates/freshell-agent-runtime/src/snapshot_projection.rs index a0e556c620..c385a56c97 100644 --- a/crates/freshell-agent-runtime/src/snapshot_projection.rs +++ b/crates/freshell-agent-runtime/src/snapshot_projection.rs @@ -73,6 +73,13 @@ fn visit(value: &mut Value, provider: &str, session_type: &str, known_pair: bool } fn project_capabilities(snapshot: &mut Map, provider: &str, known_pair: bool) { + if let Some(extension) = snapshot + .get_mut("extensions") + .and_then(|value| value.get_mut("codex")) + .and_then(Value::as_object_mut) + { + extension.remove("hostedNamingFacts"); + } let native = snapshot.get("capabilities").and_then(Value::as_object); let required_valid = native.is_some_and(|capabilities| { ["send", "interrupt", "approvals", "questions", "fork"] @@ -147,7 +154,7 @@ fn valid_scopes(value: &Value) -> bool { scopes.iter().all(|(name, value)| { matches!( name.as_str(), - "model" | "effort" | "sandbox" | "permissionMode" + "model" | "effort" | "sandbox" | "permissionMode" | "cwd" | "collaborationMode" ) && matches!( value.as_str(), Some("per-send" | "create-only" | "unsupported") diff --git a/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs b/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs index f033604619..27ee86d419 100644 --- a/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs +++ b/crates/freshell-agent-runtime/src/snapshot_projection_tests.rs @@ -22,6 +22,29 @@ fn advertised_capabilities() -> Value { }) } +#[test] +fn hosted_public_snapshots_keep_transcript_and_hide_internal_naming_facts() { + let snapshot = json!({"type":"freshAgent.session.snapshot","provider":"codex","sessionType":"freshcodex", + "capabilities":advertised_capabilities(),"turns":[{"role":"user","items":[{"kind":"text","text":"visible prompt"}]}], + "extensions":{"codex":{"usage":{"last":{"totalTokens":15}},"hostedNamingFacts":[{"kind":"activity","data":{"first_user_message":"internal naming input"}}]}}}); + let mut event = json!({"type":"freshAgent.event","event":snapshot.clone()}); + project_hosted_snapshot(&mut event, "codex", "freshcodex"); + assert!(event["event"]["extensions"]["codex"] + .get("hostedNamingFacts") + .is_none()); + assert_eq!(event["event"]["turns"], snapshot["turns"]); + assert_eq!( + event["event"]["extensions"]["codex"]["usage"], + snapshot["extensions"]["codex"]["usage"] + ); + let mut rest = snapshot; + crate::snapshot_projection::project_hosted_rest_snapshot(&mut rest, "codex", "freshcodex"); + assert!(rest["extensions"]["codex"] + .get("hostedNamingFacts") + .is_none()); + assert_eq!(rest["turns"], event["event"]["turns"]); +} + #[test] fn every_hosted_provider_and_snapshot_envelope_intersects_native_and_gateway_truth() { let providers = [ diff --git a/crates/freshell-claude-sidecar/exec-profile.mjs b/crates/freshell-claude-sidecar/exec-profile.mjs index 84591e95cd..d69a96707b 100644 --- a/crates/freshell-claude-sidecar/exec-profile.mjs +++ b/crates/freshell-claude-sidecar/exec-profile.mjs @@ -79,15 +79,17 @@ export function checkSdkExec(options) { executable(resolveProgram(options.command, options.cwd, environment)) } -export function managedSdkSpawn(stderr, environment = process.env) { - if (process.platform !== 'linux' || !usesHostedClaudeRuntime(environment)) return undefined +export function managedSdkSpawn(stderr, environment = process.env, ownProcess) { + const checkProfile = process.platform === 'linux' && usesHostedClaudeRuntime(environment) + if (!checkProfile && !ownProcess) return undefined return options => { - checkSdkExec(options) + if (checkProfile) checkSdkExec(options) const child = spawn(options.command, options.args, { cwd: options.cwd, env: options.env, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }) child.stderr.on('data', stderr) + ownProcess?.(child) return child } } diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 36b96e6f98..44a6de8d28 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -177,7 +177,7 @@ function emit(msg) { process.stdout.write(`${JSON.stringify(msg)}\n`) } -/** Set once the sidecar is exiting on its own (a refused boot, or an unrequested stream end). */ +/** Set once an exit path owns shutdown and the final exit code. */ let exiting = false /** How long an exiting sidecar waits for stdout and stderr to flush. */ const EXIT_FLUSH_BUDGET_MS = 2000 @@ -516,7 +516,10 @@ function handleCreate(req) { // the server's turn accounting reads it (sdk.session.state). env: withFreshellContext({ ...createClaudeSdkCleanEnv(process.env), CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' }, req.freshellContext), settingSources: ['user', 'project', 'local'], - spawnClaudeCodeProcess: managedSdkSpawn(sdkStderr), + spawnClaudeCodeProcess: managedSdkSpawn(sdkStderr, process.env, (child) => { + state.nativeProcess = child + state.nativeClosed = new Promise(resolve => child.once('close', resolve)) + }), stderr: sdkStderr, canUseTool: async (toolName, input, options) => { const s = sessions.get(sessionId) @@ -682,14 +685,34 @@ function handleInterrupt(req) { }) } -function shutdown() { +async function shutdown() { + if (exiting) return + exiting = true + const closed = [] for (const [sessionId, st] of sessions) { // Teardown: emit the card-clearing frames but NEVER resolve parked promises // (LB-04 — transport closing; a late resolve is an unhandled rejection). cancelPending(st, emit, sessionId, { resolveDeny: false }) + // close():void only starts SDK cleanup. Signal while we still own the + // CLI's pipes, and retain its reader until the real child has closed. + // Exiting Node first races native EPIPE against the SDK's exit signal. + if (st.nativeProcess) { + const child = st.nativeProcess + child.stdout.resume() + if (child.exitCode === null && child.signalCode === null) { + try { + if (!child.kill('SIGTERM')) log('warn', 'shutdown.signal_failed', { sessionId, cliPid: child.pid }) + } catch (error) { + log('warn', 'shutdown.signal_failed', { sessionId, cliPid: child.pid, error: errorText(error) }) + } + } + closed.push(st.nativeClosed) + } try { st.abort.abort(); st.query?.close?.() } catch { /* ignore */ } } sessions.clear() + await Promise.all(closed) + process.exit(0) } // ── stdin dispatch loop (newline-JSON) ─────────────────────────────────────── @@ -739,13 +762,13 @@ rl.on('line', (line) => { } break } - case 'shutdown': shutdown(); process.exit(0); break + case 'shutdown': void shutdown(); break default: log('warn', 'request.unknown_type', { requestType: String(req?.type) }) } }) // stdin closed (Rust reaped us): abort every query and exit so no claude CLI // grandchild is left mid-stream. -rl.on('close', () => { if (!exiting) { shutdown(); process.exit(0) } }) +rl.on('close', () => { if (!exiting) void shutdown() }) // stdout lost (Rust dropped our reader, e.g. a kill aborted its consumer before // it wrote the shutdown line): nobody hears us any more, the same request as // stdin EOF. Unhandled, the next emit's EPIPE kills node with exit 1 before @@ -757,10 +780,9 @@ rl.on('close', () => { if (!exiting) { shutdown(); process.exit(0) } }) process.stdout.on('error', (err) => { log('warn', 'stdout.closed', { code: err?.code ?? null, error: errorText(err) }) if (exiting) return - shutdown() - process.exit(0) + void shutdown() }) -process.on('SIGTERM', () => { shutdown(); process.exit(0) }) -process.on('SIGINT', () => { shutdown(); process.exit(0) }) +process.on('SIGTERM', () => { void shutdown() }) +process.on('SIGINT', () => { void shutdown() }) log('info', 'sidecar.ready', { pid: process.pid }) diff --git a/crates/freshell-codex/Cargo.toml b/crates/freshell-codex/Cargo.toml index 76dae41638..f483852202 100644 --- a/crates/freshell-codex/Cargo.toml +++ b/crates/freshell-codex/Cargo.toml @@ -31,6 +31,8 @@ publish.workspace = true # feature (verified to compile; wired live in the next step). Default-off keeps the shared # workspace build lean. default = [] +# Explicit connected preparation builds this probe; native lanes execute it offline. +native-policy-qualification = ["real-transport"] real-transport = ["dep:tokio-tungstenite", "dep:futures-util", "dep:libc", "dep:freshell-platform", "tokio/net", "tokio/io-util", "tokio/process"] [dependencies] diff --git a/crates/freshell-codex/src/app_server.rs b/crates/freshell-codex/src/app_server.rs index d7ff4ea8bb..2332430c4d 100644 --- a/crates/freshell-codex/src/app_server.rs +++ b/crates/freshell-codex/src/app_server.rs @@ -83,7 +83,10 @@ pub trait WsTransport: Send + Sync { #[derive(Clone, Debug, PartialEq)] pub enum CodexAppServerError { /// A JSON-RPC error envelope for the request (`CodexAppServerRpcError`, `client.ts:68-78`). - Rpc { method: String, error: RpcError }, + Rpc { + method: String, + error: Box, + }, /// The request timed out (`client.ts:784-787`). Timeout { method: String, timeout_ms: u64 }, /// The connection closed before the request completed (`client.ts:449,726`). @@ -130,6 +133,30 @@ impl std::fmt::Display for CodexAppServerError { impl std::error::Error for CodexAppServerError {} +impl CodexAppServerError { + /// Evidence-backed pre-admission refusals in the supported native protocol. + /// Internal errors, transport failures and lost replies can follow accepted + /// work, so callers must preserve their delivery receipt as ambiguous. + pub fn is_not_submitted(&self) -> bool { + match self { + Self::Rpc { method, error } => { + method == "initialize" + || method == "configRequirements/read" + || matches!(error.code, -32601 | -32602) + || (error.code == -32600 + && (error.message.starts_with("no active turn to ") + || error.message.starts_with("expected active turn id"))) + } + Self::Timeout { method, .. } + | Self::Closed { method } + | Self::Transport { method, .. } + | Self::InvalidResponse { method, .. } => { + matches!(method.as_str(), "initialize" | "configRequirements/read") + } + } + } +} + /// `thread/start` params (`CodexThreadStartInput`, `client.ts:80-83`; adapter create, /// `adapter.ts:825-830`). The client adds `experimentalRawEvents:false` + /// `persistExtendedHistory:true` (`client.ts:170-174`). @@ -154,6 +181,9 @@ pub struct StartThreadParams { pub struct StartedThread { pub thread_id: String, pub reasoning_effort: Option, + /// Effective settings stated by native start/resume. Absent fields are + /// deliberately distinguishable from an explicit null native default. + pub effective_settings: Value, } /// `thread/fork` params (the 0.147.0 `ThreadForkParams` generated type): `{threadId, @@ -187,6 +217,7 @@ pub struct StartTurnParams { pub effort: Option, pub sandbox_policy: Option, pub approval_policy: Option, + pub collaboration_mode: Option, } /// The `turn/start` result (`CodexTurnStartResult`, `protocol.ts:318-320`): the provider turn id. @@ -226,6 +257,18 @@ impl Drop for CodexAppServerClient { } impl CodexAppServerClient { + pub async fn is_initialized(&self) -> bool { + self.init.lock().await.is_some() + } + + /// Read visible model metadata from this connection's own provider context. + pub async fn list_models(&self, cursor: Option<&str>) -> Result { + let mut params = json!({"includeHidden":false,"limit":100}); + if let Some(cursor) = cursor { + params["cursor"] = json!(cursor); + } + self.request("model/list", params).await + } /// Wire a client to a transport with the default request timeout and the /// [`SNAPSHOT_READ_TIMEOUT_MS`] read budget. Returns the client plus the /// [`CodexNotification`] stream the background consumer feeds (the adapter's @@ -289,6 +332,7 @@ impl CodexAppServerClient { "clientInfo": { "name": "freshell", "version": "1.0.0" }, "capabilities": { "experimentalApi": true, + "extensions": {"openai/form": {}, "openai/elicitation": {"form": {}}, "openai/standard-form-input": {}}, "optOutNotificationMethods": ["thread/started"], }, }); @@ -317,11 +361,78 @@ impl CodexAppServerClient { self.codex_home.lock().await.clone() } + /// 0.164 may warn and fall back when managed requirements constrain the + /// requested policy. Check before submitting work; never turn that fallback + /// into a silent replacement of prepared untrusted intent. + async fn check_untrusted_requirements( + &self, + policy: Option<&str>, + ) -> Result<(), CodexAppServerError> { + if policy != Some("untrusted") { + return Ok(()); + } + let result = self.request("configRequirements/read", json!({})).await?; + let requirements = + result + .get("requirements") + .ok_or_else(|| CodexAppServerError::InvalidResponse { + method: "configRequirements/read".into(), + detail: + "missing native requirements while preserving approval_policy untrusted" + .into(), + })?; + let policies = requirements.get("allowedApprovalPolicies"); + if policies.is_some_and(|policies| { + !policies.is_null() + && !policies.as_array().is_some_and(|allowed| { + allowed + .iter() + .any(|policy| policy.as_str() == Some("untrusted")) + }) + }) { + tracing::warn!( + event = "codex.approval_policy_incompatible", + setting = "approval_policy", + requested = "untrusted", + stage = "requirements" + ); + return Err(CodexAppServerError::InvalidResponse { + method: "configRequirements/read".into(), detail: "Codex approval_policy untrusted is disallowed by native requirements; prepared approval intent was preserved and no work was submitted".into(), + }); + } + Ok(()) + } + + pub fn check_effective_approval_policy( + result: &Value, + requested: Option<&str>, + method: &str, + ) -> Result<(), CodexAppServerError> { + if requested == Some("untrusted") + && result.get("approvalPolicy").and_then(Value::as_str) != requested + { + tracing::warn!( + event = "codex.approval_policy_incompatible", + setting = "approval_policy", + requested = "untrusted", + method, + stage = "effective_response" + ); + return Err(CodexAppServerError::InvalidResponse { + method: method.into(), detail: "native response did not preserve requested approval_policy untrusted; refusing to submit work under a different policy".into(), + }); + } + Ok(()) + } + /// `thread/start` (`client.ts:168-186`) — the stable-from-create codex thread. pub async fn start_thread( &self, params: StartThreadParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_deref()) + .await?; + let requested_policy = params.approval_policy.clone(); let mut wire = Map::new(); insert_opt_str(&mut wire, "cwd", params.cwd); insert_opt_str(&mut wire, "model", params.model); @@ -337,6 +448,11 @@ impl CodexAppServerClient { wire.insert("persistExtendedHistory".to_string(), json!(true)); let result = self.request("thread/start", Value::Object(wire)).await?; + Self::check_effective_approval_policy( + &result, + requested_policy.as_deref(), + "thread/start", + )?; let thread_id = result .get("thread") .and_then(|t| t.get("id")) @@ -354,6 +470,7 @@ impl CodexAppServerClient { Ok(StartedThread { thread_id, reasoning_effort, + effective_settings: effective_thread_settings(&result), }) } @@ -368,6 +485,9 @@ impl CodexAppServerClient { thread_id: &str, params: StartThreadParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_deref()) + .await?; + let requested_policy = params.approval_policy.clone(); let mut wire = Map::new(); wire.insert("threadId".to_string(), json!(thread_id)); insert_opt_str(&mut wire, "cwd", params.cwd); @@ -384,6 +504,11 @@ impl CodexAppServerClient { let result = self .request_with_timeout("thread/resume", Value::Object(wire), self.read_timeout) .await?; + Self::check_effective_approval_policy( + &result, + requested_policy.as_deref(), + "thread/resume", + )?; let resumed_thread_id = result .get("thread") .and_then(|t| t.get("id")) @@ -401,6 +526,7 @@ impl CodexAppServerClient { Ok(StartedThread { thread_id: resumed_thread_id, reasoning_effort, + effective_settings: effective_thread_settings(&result), }) } @@ -413,6 +539,8 @@ impl CodexAppServerClient { &self, params: ThreadForkParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_deref()) + .await?; let mut wire = Map::new(); wire.insert("threadId".to_string(), json!(params.thread_id)); insert_opt_str(&mut wire, "lastTurnId", params.last_turn_id); @@ -472,6 +600,8 @@ impl CodexAppServerClient { &self, params: StartTurnParams, ) -> Result { + self.check_untrusted_requirements(params.approval_policy.as_ref().and_then(Value::as_str)) + .await?; let mut wire = Map::new(); wire.insert("threadId".to_string(), json!(params.thread_id)); wire.insert("input".to_string(), Value::Array(params.input)); @@ -485,6 +615,9 @@ impl CodexAppServerClient { insert_opt_str(&mut wire, "model", params.model); // DEV-0003: the reasoning effort crosses the wire EXACTLY as given (no clamp/remap). insert_opt_str(&mut wire, "effort", params.effort); + if let Some(mode) = params.collaboration_mode { + wire.insert("collaborationMode".to_string(), mode); + } let result = self.request("turn/start", Value::Object(wire)).await?; let turn_id = result @@ -500,7 +633,35 @@ impl CodexAppServerClient { Ok(StartedTurn { turn_id }) } - /// `turn/interrupt` (`client.ts:433-439`). + /// Active input belongs to the exact native turn the caller observed. + /// Native steer accepts no setting overrides and returns that same turn ID. + pub async fn steer_turn( + &self, + thread_id: &str, + expected_turn_id: &str, + input: Vec, + ) -> Result { + let result = self + .request( + "turn/steer", + json!({"threadId": thread_id, "expectedTurnId": expected_turn_id, "input": input}), + ) + .await?; + let turn_id = result + .get("turnId") + .and_then(Value::as_str) + .filter(|id| *id == expected_turn_id) + .ok_or_else(|| CodexAppServerError::InvalidResponse { + method: "turn/steer".into(), + detail: "missing or mismatched turnId".into(), + })?; + Ok(StartedTurn { + turn_id: turn_id.into(), + }) + } + + /// `turn/interrupt` (`client.ts:433-439`). A successful reply acknowledges + /// submission only; it is never terminal-turn evidence. pub async fn interrupt_turn( &self, thread_id: &str, @@ -717,7 +878,7 @@ impl CodexAppServerClient { Ok(Ok(Ok(result))) => Ok(result), Ok(Ok(Err(error))) => Err(CodexAppServerError::Rpc { method: method.to_string(), - error, + error: Box::new(error), }), // The sender was dropped without a value → the connection closed. Ok(Err(_)) => Err(CodexAppServerError::Closed { @@ -784,6 +945,23 @@ fn fail_all_pending(pending: &PendingMap, _reason: &str) { } } +fn effective_thread_settings(response: &Value) -> Value { + let mut settings = Map::new(); + for key in [ + "model", + "reasoningEffort", + "cwd", + "sandbox", + "approvalPolicy", + "collaborationMode", + ] { + if let Some(value) = response.get(key) { + settings.insert(key.into(), value.clone()); + } + } + Value::Object(settings) +} + fn insert_opt_str(map: &mut Map, key: &str, value: Option) { if let Some(value) = value { map.insert(key.to_string(), json!(value)); @@ -921,10 +1099,273 @@ pub fn new_channel_transport() -> (Arc, ChannelPeer) { mod tests { use super::*; + #[test] + fn only_native_pre_admission_errors_are_refusals() { + for message in [ + "no active turn to steer", + "no active turn to interrupt", + "expected active turn id `old` but found `new`", + ] { + assert!(CodexAppServerError::Rpc { + method: "turn/steer".into(), + error: Box::new(RpcError { + code: -32600, + message: message.into(), + data: None + }) + } + .is_not_submitted()); + } + for error in [ + CodexAppServerError::Rpc { + method: "turn/start".into(), + error: Box::new(RpcError { + code: -32603, + message: "internal failure".into(), + data: None, + }), + }, + CodexAppServerError::Timeout { + method: "turn/start".into(), + timeout_ms: 100, + }, + CodexAppServerError::Closed { + method: "turn/steer".into(), + }, + CodexAppServerError::Transport { + method: "turn/interrupt".into(), + message: "lost transport reply".into(), + }, + CodexAppServerError::InvalidResponse { + method: "turn/start".into(), + detail: "missing result".into(), + }, + ] { + assert!(!error.is_not_submitted(), "{error}"); + } + assert!(CodexAppServerError::Timeout { + method: "initialize".into(), + timeout_ms: 100 + } + .is_not_submitted()); + } + + #[cfg(feature = "native-policy-qualification")] + #[tokio::test] + async fn native_requirements_refuse_untrusted_before_work() { + struct RecordingTransport { + native: crate::transport::TungsteniteTransport, + frames: Arc>>, + } + impl WsTransport for RecordingTransport { + fn send(&self, text: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.frames.lock().unwrap().push(json!({"direction":"controller", "frame":serde_json::from_str::(&text).unwrap()})); + self.native.send(text).await + }) + } + fn recv(&self) -> BoxFuture<'_, Option> { + Box::pin(async move { + let text = self.native.recv().await?; + self.frames.lock().unwrap().push(json!({"direction":"native", "frame":serde_json::from_str::(&text).unwrap()})); + Some(text) + }) + } + fn close(&self) -> BoxFuture<'_, ()> { + self.native.close() + } + } + let endpoint = std::env::var("FRESHELL_CODEX_POLICY_PROBE_URL") + .expect("explicit owned native endpoint required"); + assert!(endpoint.starts_with("ws://127.0.0.1:")); + let receipt_path = std::env::var("FRESHELL_CODEX_POLICY_PROBE_RECEIPT") + .expect("explicit owned receipt required"); + let frames = Arc::new(StdMutex::new(Vec::::new())); + let transport = RecordingTransport { + native: crate::transport::TungsteniteTransport::connect(&endpoint) + .await + .unwrap(), + frames: frames.clone(), + }; + let (client, _notifications) = CodexAppServerClient::connect(Arc::new(transport)); + let mut refused = Vec::new(); + for route in ["create", "resume", "fork", "send"] { + let params = StartThreadParams { + approval_policy: Some("untrusted".into()), + ..Default::default() + }; + let parent = "019810de-1e5f-7db3-9c47-1c2a3b4c5d6e"; + let result = match route { + "create" => client.start_thread(params).await.map(|_| ()), + "resume" => client.resume_thread(parent, params).await.map(|_| ()), + "fork" => client + .fork_thread(ThreadForkParams { + thread_id: parent.into(), + approval_policy: Some("untrusted".into()), + ..Default::default() + }) + .await + .map(|_| ()), + _ => client + .start_turn(StartTurnParams { + thread_id: parent.into(), + approval_policy: Some(json!("untrusted")), + input: vec![json!({"type":"text","text":"must remain unsubmitted"})], + ..Default::default() + }) + .await + .map(|_| ()), + }; + let error = result.expect_err("native requirements must refuse before work"); + assert!( + matches!(&error, CodexAppServerError::InvalidResponse { method, .. } if method == "configRequirements/read"), + "{route}: {error}" + ); + assert!(error.to_string().contains("approval_policy untrusted")); + refused.push(route); + } + client.close().await; + let frames = frames.lock().unwrap(); + let requests: Vec<_> = frames + .iter() + .filter(|entry| entry["direction"] == "controller") + .map(|entry| &entry["frame"]) + .collect(); + assert_eq!( + requests + .iter() + .filter(|frame| frame["method"] == "configRequirements/read") + .count(), + 4 + ); + assert!(requests.iter().all(|frame| matches!( + frame["method"].as_str(), + Some("initialize" | "initialized" | "configRequirements/read") + ))); + let replies: Vec<_> = frames + .iter() + .filter(|entry| { + entry["direction"] == "native" + && entry["frame"]["result"]["requirements"]["allowedApprovalPolicies"] + .is_array() + }) + .collect(); + assert_eq!(replies.len(), 4); + assert!(replies + .iter() + .all( + |entry| entry["frame"]["result"]["requirements"]["allowedApprovalPolicies"] + == json!(["never"]) + )); + let receipt = json!({"event":"codex_native_policy_client_proof", "severity":"info", "refusedRoutes":refused, "frames":*frames, "workRequests":0}); + std::fs::write(&receipt_path, serde_json::to_vec(&receipt).unwrap()).unwrap(); + println!( + "{}", + json!({"event":"codex_native_policy_client_proof", "severity":"info", "refusedRoutes":refused, "workRequests":0}) + ); + } + fn started_thread_result() -> Value { json!({ "thread": { "id": "019810de-1e5f-7db3-9c47-1c2a3b4c5d6e" }, "reasoningEffort": "none" }) } + #[tokio::test] + async fn untrusted_requirements_refuse_before_native_work_on_every_typed_route() { + for route in ["create", "resume", "fork", "send"] { + let (transport, peer) = new_channel_transport(); + let (client, _notifications) = CodexAppServerClient::connect(transport); + let task = tokio::spawn(async move { + let params = StartThreadParams { + approval_policy: Some("untrusted".into()), + ..Default::default() + }; + match route { + "create" => client.start_thread(params).await.map(|_| ()), + "resume" => client.resume_thread("parent", params).await.map(|_| ()), + "fork" => client + .fork_thread(ThreadForkParams { + thread_id: "parent".into(), + approval_policy: Some("untrusted".into()), + ..Default::default() + }) + .await + .map(|_| ()), + _ => client + .start_turn(StartTurnParams { + thread_id: "parent".into(), + approval_policy: Some(json!("untrusted")), + input: vec![json!({"type":"text","text":"must remain unsubmitted"})], + ..Default::default() + }) + .await + .map(|_| ()), + } + }); + let (init, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond(&init, json!({"userAgent":"fixture", "codexHome":"/fixture", "platformFamily":"unix", "platformOs":"linux"})); + peer.expect_notification().await; + let (id, method, _) = peer.expect_request().await; + assert_eq!( + method, "configRequirements/read", + "{route} must check native requirements before work" + ); + peer.respond( + &id, + json!({"requirements":{"allowedApprovalPolicies":["never"]}}), + ); + let error = task + .await + .unwrap() + .expect_err("native requirement fallback must be refused"); + assert!(error.to_string().contains("approval_policy")); + assert!(error.to_string().contains("untrusted")); + } + } + + #[tokio::test] + async fn untrusted_native_effective_policy_mismatch_is_not_accepted() { + for route in ["create", "resume"] { + let (transport, peer) = new_channel_transport(); + let (client, _notifications) = CodexAppServerClient::connect(transport); + let task = tokio::spawn(async move { + let params = StartThreadParams { + approval_policy: Some("untrusted".into()), + ..Default::default() + }; + if route == "create" { + client.start_thread(params).await + } else { + client.resume_thread("parent", params).await + } + }); + let (init, _, _) = peer.expect_request().await; + peer.respond(&init, json!({"userAgent":"fixture", "codexHome":"/fixture", "platformFamily":"unix", "platformOs":"linux"})); + peer.expect_notification().await; + let (mut id, mut method, _) = peer.expect_request().await; + if method == "configRequirements/read" { + peer.respond(&id, json!({"requirements":null})); + (id, method, _) = peer.expect_request().await; + } + assert_eq!( + method, + if route == "create" { + "thread/start" + } else { + "thread/resume" + } + ); + peer.respond( + &id, + json!({"thread":{"id":"minted-or-resumed"}, "approvalPolicy":"never"}), + ); + assert!( + task.await.unwrap().is_err(), + "returned native effective policy must not silently replace untrusted intent" + ); + } + } + #[tokio::test] async fn initialize_handshake_sends_request_then_initialized_notification() { let (transport, peer) = new_channel_transport(); @@ -985,6 +1426,7 @@ mod tests { let turn_effort = effort.to_string(); let task = tokio::spawn(async move { c.start_turn(StartTurnParams { + collaboration_mode: None, thread_id: "thread-1".to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, diff --git a/crates/freshell-codex/src/catalog.rs b/crates/freshell-codex/src/catalog.rs new file mode 100644 index 0000000000..6ce6a76827 --- /dev/null +++ b/crates/freshell-codex/src/catalog.rs @@ -0,0 +1,404 @@ +//! Catalog reads use an existing initialized app-server. They never create a thread. +use crate::{CodexAppServerClient, CodexAppServerError}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{collections::HashSet, time::Duration}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexModel { + pub id: String, + pub display_name: String, + pub supported_effort_levels: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input_modalities: Option>, +} + +fn invalid(detail: &str) -> CodexAppServerError { + CodexAppServerError::InvalidResponse { + method: "model/list".into(), + detail: detail.into(), + } +} + +fn opaque(value: &Value) -> Option<&str> { + value.as_str().filter(|s| !s.trim().is_empty()) +} + +/// `id` is catalog metadata; `model` is the actual runnable provider model ID. +pub fn parse_page(page: &Value) -> Result<(Vec, Option), CodexAppServerError> { + let data = page + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| invalid("missing model data"))?; + let mut models = Vec::with_capacity(data.len()); + for row in data { + let id = row + .get("model") + .and_then(opaque) + .ok_or_else(|| invalid("missing runnable model ID"))?; + let display = row + .get("displayName") + .and_then(opaque) + .ok_or_else(|| invalid("missing model display name"))?; + let efforts = row + .get("supportedReasoningEfforts") + .and_then(Value::as_array) + .ok_or_else(|| invalid("invalid reasoning efforts"))?; + let mut levels = Vec::new(); + for effort in efforts { + let effort = effort + .get("reasoningEffort") + .and_then(opaque) + .ok_or_else(|| invalid("invalid reasoning effort"))?; + if !levels.iter().any(|level| level == effort) { + levels.push(effort.to_string()); + } + } + let default_effort = match row.get("defaultReasoningEffort") { + None | Some(Value::Null) => None, + Some(value) => Some( + opaque(value) + .ok_or_else(|| invalid("invalid default effort"))? + .to_string(), + ), + }; + let input_modalities = match row.get("inputModalities") { + None | Some(Value::Null) => None, + Some(value) => Some( + value + .as_array() + .ok_or_else(|| invalid("invalid input modalities"))? + .iter() + .map(|value| { + opaque(value) + .map(str::to_string) + .ok_or_else(|| invalid("invalid input modality")) + }) + .collect::, _>>()?, + ), + }; + models.push(CodexModel { + id: id.into(), + display_name: display.into(), + supported_effort_levels: levels, + default_effort, + input_modalities, + }); + } + let cursor = match page.get("nextCursor") { + None | Some(Value::Null) => None, + Some(value) => Some( + opaque(value) + .ok_or_else(|| invalid("invalid next cursor"))? + .to_string(), + ), + }; + Ok((models, cursor)) +} + +pub async fn read_models( + client: &CodexAppServerClient, +) -> Result, CodexAppServerError> { + tokio::time::timeout(Duration::from_secs(20), async { + let mut cursor = None; + let mut cursors = HashSet::new(); + let mut ids = HashSet::new(); + let mut models = Vec::new(); + loop { + let page = client.list_models(cursor.as_deref()).await?; + let (rows, next) = parse_page(&page)?; + for row in rows { + if ids.insert(row.id.clone()) { + models.push(row); + } + } + match next { + None => return Ok(models), + Some(next) if cursors.insert(next.clone()) => cursor = Some(next), + Some(_) => return Err(invalid("model pagination cursor repeated")), + } + } + }) + .await + .map_err(|_| CodexAppServerError::Timeout { + method: "model/list".into(), + timeout_ms: 20_000, + })? +} + +pub fn resolve_legacy_effort(model: &str, effort: &str, catalog: &[CodexModel]) -> String { + if effort == "max" + && catalog.iter().any(|row| { + row.id == model + && row.supported_effort_levels.iter().any(|s| s == "xhigh") + && !row.supported_effort_levels.iter().any(|s| s == "max") + }) + { + "xhigh".into() + } else { + effort.into() + } +} + +#[cfg(all(test, feature = "native-policy-qualification"))] +mod native_tests { + use super::*; + use crate::{ + app_server::{BoxFuture, WsTransport}, + CodexNotification, HistoryMode, StartThreadParams, StartTurnParams, ThreadForkParams, + }; + use serde_json::json; + use std::sync::{Arc, Mutex}; + + /// The existing owned release-contract runner supplies this live endpoint. + /// This feature is the established explicit native qualification lane. + #[tokio::test] + async fn native_catalog_and_opaque_effort_use_initialized_client() { + struct Recorded { + transport: crate::transport::TungsteniteTransport, + frames: Arc>>, + } + impl WsTransport for Recorded { + fn send(&self, text: String) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + self.frames.lock().unwrap().push(json!({"direction":"controller","frame":serde_json::from_str::(&text).unwrap()})); + self.transport.send(text).await + }) + } + fn recv(&self) -> BoxFuture<'_, Option> { + Box::pin(async move { + let text = self.transport.recv().await?; + self.frames.lock().unwrap().push(json!({"direction":"native","frame":serde_json::from_str::(&text).unwrap()})); + Some(text) + }) + } + fn close(&self) -> BoxFuture<'_, ()> { + self.transport.close() + } + } + let endpoint = std::env::var("FRESHELL_CODEX_CATALOG_PROBE_URL") + .expect("owned native endpoint required"); + assert!(endpoint.starts_with("ws://127.0.0.1:")); + let receipt = + std::env::var("FRESHELL_CODEX_CATALOG_PROBE_RECEIPT").expect("owned receipt required"); + let frames = Arc::new(Mutex::new(Vec::new())); + let transport = Recorded { + transport: crate::transport::TungsteniteTransport::connect(&endpoint) + .await + .unwrap(), + frames: frames.clone(), + }; + let (client, mut notifications) = CodexAppServerClient::connect(Arc::new(transport)); + client.initialize().await.unwrap(); + let models = read_models(&client).await.unwrap(); + assert!(!models.is_empty()); + assert!(models + .iter() + .all(|m| !m.id.is_empty() && !m.display_name.is_empty())); + assert!( + !models.iter().any(|model| model.id == "provider-current"), + "qualification requires an exact model omitted by discovery" + ); + let params = StartThreadParams { + model: Some(crate::normalize_freshcodex_model(Some("provider-current"))), + approval_policy: Some("never".into()), + sandbox: Some("read-only".into()), + history_mode: Some(HistoryMode::Paginated), + ..Default::default() + }; + let parent = client.start_thread(params.clone()).await.unwrap().thread_id; + async fn send_exact( + client: &CodexAppServerClient, + notifications: &mut tokio::sync::mpsc::UnboundedReceiver, + thread: &str, + model: &str, + effort: &str, + ) -> String { + let turn=client.start_turn(StartTurnParams {thread_id:thread.into(),input:vec![json!({"type":"text","text":"Controlled exact model and effort qualification"})],model:Some(crate::normalize_freshcodex_model(Some(model))),effort:crate::to_codex_reasoning_effort(Some(effort)).unwrap(),approval_policy:Some(json!("never")),..Default::default()}).await.unwrap(); + tokio::time::timeout(Duration::from_secs(30), async { + while let Some(notification) = notifications.recv().await { + if let CodexNotification::TurnCompleted(event) = notification { + if event.thread_id == thread + && event.turn_id.as_deref() == Some(&turn.turn_id) + { + assert_eq!(event.params["turn"]["status"], "completed"); + return; + } + } + } + panic!("native notifications closed before completion") + }) + .await + .unwrap(); + turn.turn_id + } + let mut completed = vec![ + send_exact( + &client, + &mut notifications, + &parent, + "provider-current", + "ultra", + ) + .await, + ]; + // A second turn checks that the configured native default cannot + // overwrite the exact explicit pair. Adapter configure itself is + // exercised through the full registered-state lifecycle test. + completed.push( + send_exact( + &client, + &mut notifications, + &parent, + "provider-current", + "ultra", + ) + .await, + ); + assert_eq!( + client + .resume_thread(&parent, params) + .await + .unwrap() + .thread_id, + parent + ); + completed.push( + send_exact( + &client, + &mut notifications, + &parent, + "provider-current", + "ultra", + ) + .await, + ); + let fork = client + .fork_thread(ThreadForkParams { + thread_id: parent.clone(), + model: Some("provider-current".into()), + approval_policy: Some("never".into()), + sandbox: Some("read-only".into()), + ..Default::default() + }) + .await + .unwrap(); + let child = fork["thread"]["id"].as_str().unwrap(); + completed.push( + send_exact( + &client, + &mut notifications, + child, + "provider-current", + "ultra", + ) + .await, + ); + // 0.164 accepts this opaque value even though discovery omits it. + // The owned release diagnostic proves its exact native/HTTP forwarding. + let future_turn = send_exact( + &client, + &mut notifications, + child, + "provider-current", + "future-unadvertised-native-effort", + ) + .await; + let advertised_model = models + .iter() + .find(|model| { + ["ultra", "xhigh"].iter().all(|effort| { + model + .supported_effort_levels + .iter() + .any(|supported| supported == effort) + }) + }) + .expect("installed catalog advertises ultra and xhigh"); + let advertised_thread = client + .start_thread(StartThreadParams { + model: Some(advertised_model.id.clone()), + approval_policy: Some("never".into()), + sandbox: Some("read-only".into()), + history_mode: Some(HistoryMode::Paginated), + ..Default::default() + }) + .await + .unwrap() + .thread_id; + let mut advertised_turns = Vec::new(); + for effort in ["ultra", "xhigh"] { + let turn_id = send_exact( + &client, + &mut notifications, + &advertised_thread, + &advertised_model.id, + effort, + ) + .await; + advertised_turns.push(json!({"turnId":turn_id,"effort":effort})); + } + let advertised_selection = + json!({"model":advertised_model,"threadId":advertised_thread,"turns":advertised_turns}); + client.close().await; + let frames = frames.lock().unwrap(); + let requests = frames + .iter() + .filter(|f| f["direction"] == "controller" && f["frame"].get("id").is_some()) + .map(|f| &f["frame"]) + .collect::>(); + assert_eq!( + requests + .iter() + .filter(|f| f["method"] == "initialize") + .count(), + 1 + ); + let first_work = requests + .iter() + .position(|frame| frame["method"] == "thread/start") + .unwrap(); + assert!( + requests[..first_work] + .iter() + .all(|frame| frame["method"] == "initialize" || frame["method"] == "model/list"), + "catalog discovery must create no native conversation" + ); + for method in ["thread/start", "thread/resume", "thread/fork"] { + let request = requests + .iter() + .find(|frame| frame["method"] == method) + .unwrap(); + assert_eq!(request["params"]["model"], "provider-current"); + } + let turns = requests + .iter() + .filter(|frame| frame["method"] == "turn/start") + .collect::>(); + assert_eq!(turns.len(), 7); + for request in &turns[..4] { + assert_eq!(request["params"]["model"], "provider-current"); + assert_eq!(request["params"]["effort"], "ultra"); + } + assert_eq!( + turns[4]["params"]["effort"], + "future-unadvertised-native-effort" + ); + for (request, effort) in turns[5..].iter().zip(["ultra", "xhigh"]) { + assert_eq!(request["params"]["model"], advertised_model.id); + assert_eq!(request["params"]["effort"], effort); + assert_eq!(request["params"]["approvalPolicy"], "never"); + } + std::fs::write( + receipt, + serde_json::to_vec( + &json!({"models":models,"frames":*frames,"advertisedSelection":advertised_selection,"nativeAcceptedFutureEffort":true,"futureEffortCompletedTurn":future_turn,"nativeExactLifecycleAccepted":true,"completedTurns":completed}), + ) + .unwrap(), + ) + .unwrap(); + } +} diff --git a/crates/freshell-codex/src/launch_lifecycle.rs b/crates/freshell-codex/src/launch_lifecycle.rs index ab0e5be2f9..25d7d99c0c 100644 --- a/crates/freshell-codex/src/launch_lifecycle.rs +++ b/crates/freshell-codex/src/launch_lifecycle.rs @@ -1214,13 +1214,7 @@ impl CodexLaunchRuntime for SpawnedCodexAppServerRuntime { #[cfg(target_os = "linux")] if self.sidecar_context.provider_exec_profile { - let additional = if program == "/usr/bin/setpriv" { - &["codex"][..] - } else { - &[] - }; - freshell_platform::provider_exec::check_command(cmd.as_std(), true, additional) - .map_err(|error| error.to_string())?; + crate::managed_launch::check_command(cmd.as_std())?; } let mut child = cmd .spawn() diff --git a/crates/freshell-codex/src/lib.rs b/crates/freshell-codex/src/lib.rs index bc4fbd1592..e420d4d2a3 100644 --- a/crates/freshell-codex/src/lib.rs +++ b/crates/freshell-codex/src/lib.rs @@ -28,16 +28,16 @@ //! production wiring behind the default-off `real-transport` feature (verified to compile; //! wired live in the next step, T2-over-rust). //! -//! ## DEV-0003 (REJECTED — `port/oracle/DEVIATIONS.md`) +//! ## Native selections //! -//! Codex reasoning-effort `none`/`minimal` are VALID codex efforts (`protocol.ts:26`) and are -//! **forwarded VERBATIM** on `turn/start` (`adapter.ts:130-131`). The proposed "clamp -//! none/minimal" fix was rejected with ZERO differ tolerance: any old-vs-new divergence in -//! codex effort handling is a port defect. The seam is [`model::to_codex_reasoning_effort`] -//! and [`app_server::CodexAppServerClient::start_turn`], both pinned by tests here and in -//! `tests/`. +//! The installed initialized client supplies the model catalog. Explicit model +//! and effort IDs remain opaque native values, including none/minimal. Native +//! validation decides whether custom values are usable; catalog omission never +//! substitutes a default. A legacy max alias applies only when the selected +//! installed model advertises xhigh without a distinct max. pub mod app_server; +pub mod catalog; pub mod durability; pub mod events; mod json_scan; @@ -49,6 +49,8 @@ pub mod remote_proxy_side_effects; #[cfg(feature = "real-transport")] pub mod launch_lifecycle; +#[cfg(all(feature = "real-transport", target_os = "linux"))] +pub mod managed_launch; #[cfg(feature = "real-transport")] pub mod remote_proxy; #[cfg(feature = "real-transport")] diff --git a/crates/freshell-codex/src/managed_launch.rs b/crates/freshell-codex/src/managed_launch.rs new file mode 100644 index 0000000000..53ff537efc --- /dev/null +++ b/crates/freshell-codex/src/managed_launch.rs @@ -0,0 +1,285 @@ +//! Final exec accounting for the one immutable managed Codex launcher. +use freshell_platform::provider_exec::{check_final_exec, process_profile, Profile}; +use serde::Deserialize; +use std::{collections::BTreeMap, io::Read, path::Path, process::Command}; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Identity { + version: String, + layout_file: String, + launch_wrapper: String, + node_executable: String, + npm_wrapper: String, + package_root: String, + helper_directory: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Layout { + version: String, + launch_wrapper: String, + node_executable: String, + npm_wrapper: String, + package_root: String, + helper_directory: String, + native_executable: String, +} + +fn identity() -> Result { + let resource: serde_json::Value = serde_json::from_str(include_str!( + "../../../docker/runtime/codex-native-sandbox.json" + )) + .map_err(|_| "Codex managed launch resource is invalid")?; + serde_json::from_value(resource["native"].clone()) + .map_err(|_| "Codex managed launch identity is incomplete".into()) +} + +/// Keep explicit custom executables; the managed default uses its owned launcher. +pub fn managed_program(program: &str) -> Result { + let identity = identity()?; + Ok(if program == "codex" || program == identity.npm_wrapper { + identity.launch_wrapper + } else { + program.to_owned() + }) +} + +fn read_layout(identity: &Identity) -> Result { + const LIMIT: u64 = 16 * 1024; + let file = std::fs::File::open(&identity.layout_file) + .map_err(|_| "Codex managed launch layout is missing; rebuild the pinned workload image")?; + let metadata = file + .metadata() + .map_err(|_| "Codex managed layout is unreadable")?; + if !metadata.is_file() || metadata.len() > LIMIT { + return Err("Codex managed layout exceeds its finite profile".into()); + } + let mut bytes = Vec::new(); + file.take(LIMIT + 1) + .read_to_end(&mut bytes) + .map_err(|_| "Codex managed layout is unreadable")?; + if bytes.len() as u64 > LIMIT { + return Err("Codex managed layout changed during its read".into()); + } + let layout: Layout = + serde_json::from_slice(&bytes).map_err(|_| "Codex managed layout is invalid")?; + if layout.version != identity.version + || layout.launch_wrapper != identity.launch_wrapper + || layout.node_executable != identity.node_executable + || layout.npm_wrapper != identity.npm_wrapper + || layout.package_root != identity.package_root + || layout.helper_directory != identity.helper_directory + || !Path::new(&layout.native_executable).is_absolute() + { + return Err("Codex managed layout is incompatible with this server".into()); + } + Ok(layout) +} + +fn managed_environment( + layout: &Layout, + environment: &BTreeMap, +) -> BTreeMap { + let mut child = environment.clone(); + let current = child.get("PATH").cloned().unwrap_or_default(); + let prefix = format!("{}:", layout.helper_directory); + if current != layout.helper_directory && !current.starts_with(&prefix) { + child.insert( + "PATH".into(), + if current.is_empty() { + layout.helper_directory.clone() + } else { + format!("{prefix}{current}") + }, + ); + } + for name in [ + "CODEX_MANAGED_BY_NPM", + "CODEX_MANAGED_BY_BUN", + "CODEX_MANAGED_BY_PNPM", + "CODEX_MANAGED_BY_VITE_PLUS", + "npm_config_user_agent", + "npm_execpath", + ] { + child.remove(name); + } + child.insert( + "CODEX_MANAGED_PACKAGE_ROOT".into(), + layout.package_root.clone(), + ); + child.insert("CODEX_MANAGED_BY_NPM".into(), "1".into()); + child +} + +fn check_managed_stages( + layout: &Layout, + args: &[String], + environment: &BTreeMap, + profile: Profile, +) -> Result<(), String> { + let environment = managed_environment(layout, environment); + let node_args = std::iter::once(layout.npm_wrapper.clone()) + .chain(args.iter().cloned()) + .collect::>(); + // The fixed Node executable reads the npm JS file as an argument; its + // shebang is not another exec. The npm child then execs native directly. + check_final_exec( + &layout.node_executable, + &node_args, + &environment, + &[], + profile, + ) + .map_err(|error| error.to_string())?; + check_final_exec(&layout.native_executable, args, &environment, &[], profile) + .map_err(|error| error.to_string()) +} + +/// Check the original context and the exact later Node/npm/native stages. +pub fn check_launch( + program: &str, + args: &[String], + environment: &BTreeMap, +) -> Result<(), String> { + let profile = process_profile().map_err(|error| error.to_string())?; + let boundary = (program == "/usr/bin/setpriv") + .then(|| args.iter().position(|arg| arg == "--")) + .flatten(); + let (target, provider_args) = if let Some(boundary) = boundary { + ( + args.get(boundary + 1) + .ok_or("Codex identity drop has no provider executable")? + .as_str(), + &args[boundary + 2..], + ) + } else { + (program, args) + }; + let additional = if boundary.is_some() { + vec![target] + } else { + vec![] + }; + check_final_exec(program, args, environment, &additional, profile) + .map_err(|error| error.to_string())?; + let identity = identity()?; + if target != identity.launch_wrapper { + return Ok(()); + } + check_managed_stages( + &read_layout(&identity)?, + provider_args, + environment, + profile, + ) +} + +pub fn check_command(command: &Command) -> Result<(), String> { + let mut environment = std::env::vars_os() + .map(|(name, value)| { + Ok(( + name.into_string() + .map_err(|_| "provider exec profile String")?, + value + .into_string() + .map_err(|_| "provider exec profile String")?, + )) + }) + .collect::, String>>()?; + for (name, value) in command.get_envs() { + let name = name.to_str().ok_or("provider exec profile String")?; + match value { + Some(value) => { + environment.insert( + name.into(), + value.to_str().ok_or("provider exec profile String")?.into(), + ); + } + None => { + environment.remove(name); + } + } + } + let args = command + .get_args() + .map(|arg| { + arg.to_str() + .map(str::to_owned) + .ok_or("provider exec profile String".to_owned()) + }) + .collect::, _>>()?; + check_launch( + command + .get_program() + .to_str() + .ok_or("provider exec profile Executable")?, + &args, + &environment, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn managed_helper_prefix_is_accounted_before_dispatch_at_actual_kernel_boundary() { + use std::os::unix::fs::PermissionsExt; + let owned = tempfile::tempdir().unwrap(); + let marker = owned.path().join("dispatched"); + let script = owned.path().join("entry.sh"); + std::fs::write( + &script, + format!("#!/bin/sh\nprintf dispatched > '{}'\n", marker.display()), + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let layout = Layout { + version: "fixture".into(), + launch_wrapper: script.to_str().unwrap().into(), + node_executable: "/bin/sh".into(), + npm_wrapper: script.to_str().unwrap().into(), + package_root: owned.path().to_str().unwrap().into(), + helper_directory: owned.path().join("helpers").to_str().unwrap().into(), + native_executable: "/usr/bin/true".into(), + }; + let profile = process_profile().unwrap(); + let environment = BTreeMap::from([( + "PATH".into(), + "x".repeat(profile.page_bytes * 32 - "PATH=".len() - 1), + )]); + check_final_exec( + "/bin/sh", + std::slice::from_ref(&layout.npm_wrapper), + &environment, + &[], + profile, + ) + .expect("initial environment must fit before the wrapper changes PATH"); + let checked = check_managed_stages(&layout, &[], &environment, profile); + assert!( + matches!(checked, Err(ref error) if error.contains("String")), + "final helper PATH must refuse before dispatch: {checked:?}" + ); + let expanded = format!("{}:{}", layout.helper_directory, environment["PATH"]); + let error = Command::new("/bin/sh") + .arg(&script) + .env_clear() + .env("PATH", expanded) + .spawn() + .expect_err("actual Linux kernel must reject the final oversized PATH"); + assert_eq!(error.raw_os_error(), Some(libc::E2BIG)); + assert!(!marker.exists()); + let small = BTreeMap::from([("PATH".into(), "/usr/bin:/bin".into())]); + check_managed_stages(&layout, &[], &small, profile).unwrap(); + assert!(Command::new("/bin/sh") + .arg(&script) + .env_clear() + .envs(small) + .status() + .unwrap() + .success()); + assert!(marker.is_file()); + } +} diff --git a/crates/freshell-codex/src/model.rs b/crates/freshell-codex/src/model.rs index 5f5def41f0..3571c4a4d6 100644 --- a/crates/freshell-codex/src/model.rs +++ b/crates/freshell-codex/src/model.rs @@ -1,371 +1,85 @@ -//! codex model / effort **normalization** — the codex slice of -//! `shared/fresh-agent-models.ts`, plus the wire-level effort mapping -//! `toCodexReasoningEffort` (`adapters/codex/adapter.ts:127-134`). -//! -//! Every freshcodex turn normalizes model+effort on the way in -//! (`adapter.ts:237-243`, and again in `send` `:961-963`). This crate is codex-only, so -//! the session type is always `freshcodex`; the ported functions specialise to that menu -//! but keep the reference's exact clamp semantics, in the reference's two stages: -//! -//! 1. **menu normalization** ([`normalize_freshcodex_model`] + [`normalize_freshcodex_effort`], -//! `fresh-agent-models.ts:101-152`): clamp the model to the freshcodex allowlist -//! (fallback `gpt-6-astra`); rewrite codex `xhigh → max`; then clamp the effort to the -//! (normalized) model's `thinkingEfforts`, else its `defaultEffort`, else the last menu -//! entry. -//! 2. **wire mapping** ([`to_codex_reasoning_effort`], `adapter.ts:127-134`): `max`/`xhigh` -//! → `xhigh`; **`none`/`minimal`/`low`/`medium`/`high` PASS THROUGH VERBATIM**; anything -//! else is an error. -//! -//! ## DEV-0003 (REJECTED — `port/oracle/DEVIATIONS.md`) -//! -//! `CodexReasoningEffortSchema = z.enum(['none','minimal','low','medium','high','xhigh'])` -//! (`protocol.ts:26`) models `none`/`minimal` as VALID codex efforts, governing BOTH the -//! outbound `turn/start.effort` (`protocol.ts:312`) and the inbound `reasoningEffort` echo -//! (`protocol.ts:233`). The proposed "clamp none/minimal" fix was **rejected**: the port -//! MUST reproduce the original's **verbatim forwarding** and must NOT clamp. The differ -//! grants NO tolerance — any old-vs-new divergence here is a port defect. [`to_codex_reasoning_effort`] -//! is the function that must not regress; [`FRESHCODEX_EFFORTS_VERBATIM`] enumerates the -//! five values that pass through unchanged. +//! Codex selections are opaque native values. Static defaults apply only to an +//! omitted model; the installed runtime validates explicit model and effort IDs. -/// `FRESHCODEX_DEFAULT_MODEL` (`fresh-agent-models.ts:15`). pub const FRESHCODEX_DEFAULT_MODEL: &str = "gpt-6-astra"; -/// `FRESHCODEX_DEFAULT_EFFORT` (`fresh-agent-models.ts:16`). pub const FRESHCODEX_DEFAULT_EFFORT: &str = "max"; - -/// The cheapest GPT model REACHABLE through freshcodex — the T2 `codex-gptmini.json` -/// baseline model. `normalizeFreshcodexModel` clamps to the freshcodex allowlist, so this -/// is the only non-flagship model in BOTH the allowlist and the real codex catalog -/// (`codex-gptmini.json` provenance / `modelReachabilityNote`). pub const CHEAPEST_T2_MODEL: &str = "gpt-5.3-codex-spark"; - -/// The five reasoning-effort values codex forwards VERBATIM on the wire (`adapter.ts:130`). -/// Pinned by DEV-0003: the port must not clamp/map any of these. `max`/`xhigh` are handled -/// separately (both map to `xhigh`, `adapter.ts:129`). pub const FRESHCODEX_EFFORTS_VERBATIM: &[&str] = &["none", "minimal", "low", "medium", "high"]; -/// One freshcodex model menu entry (`fresh-agent-models.ts:30-61`). -struct ModelOption { - value: &'static str, - thinking_efforts: &'static [&'static str], - default_effort: &'static str, -} - -/// The freshcodex menu (`FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex`, -/// `fresh-agent-models.ts:30-61`). -const FRESHCODEX_MODEL_OPTIONS: &[ModelOption] = &[ - ModelOption { - value: "gpt-6-astra", - thinking_efforts: &["low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.6-sol", - thinking_efforts: &["none", "low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.6-terra", - thinking_efforts: &["none", "low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.6-luna", - thinking_efforts: &["none", "low", "medium", "high", "xhigh", "max"], - default_effort: "max", - }, - ModelOption { - value: "gpt-5.3-codex-spark", - thinking_efforts: &["none", "minimal", "low", "medium", "high", "max"], - default_effort: "max", - }, -]; - -fn find_option(model: &str) -> Option<&'static ModelOption> { - FRESHCODEX_MODEL_OPTIONS.iter().find(|o| o.value == model) -} - -/// `normalizeFreshcodexModel(model)` (`fresh-agent-models.ts:101-104`): keep the model iff -/// it is in the freshcodex allowlist, else fall back to `gpt-6-astra`. pub fn normalize_freshcodex_model(model: Option<&str>) -> String { - match model.and_then(find_option) { - Some(option) => option.value.to_string(), - None => FRESHCODEX_DEFAULT_MODEL.to_string(), - } -} - -/// `resolveFreshAgentModelOption(freshcodex, model)` (`fresh-agent-models.ts:93-99`): the -/// matching menu entry, else the default (first) entry. Since [`normalize_freshcodex_model`] -/// always returns an allowlisted value this resolves precisely, but the fallback mirrors -/// the reference for defensiveness. -fn resolve_option(model: &str) -> Option<&'static ModelOption> { - find_option(model).or_else(|| FRESHCODEX_MODEL_OPTIONS.first()) + model + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(FRESHCODEX_DEFAULT_MODEL) + .to_string() } -/// `getFreshAgentThinkingOptions(freshcodex, 'codex', model)` (`fresh-agent-models.ts:121-129`): -/// the resolved (normalized) model's `thinkingEfforts`. -fn thinking_options(model: Option<&str>) -> &'static [&'static str] { - let normalized = normalize_freshcodex_model(model); - resolve_option(&normalized) - .map(|o| o.thinking_efforts) - .unwrap_or(&[]) -} - -/// `normalizeFreshAgentEffort(freshcodex, 'codex', model, effort)` — the MENU-level -/// normalization (`fresh-agent-models.ts:131-152`). Codex `xhigh → max` (`:142`), then -/// clamp to the model's `thinkingEfforts`, else the model's `defaultEffort` (if on the -/// menu), else the last menu entry. -/// -/// This is stage 1; the wire value is then produced by [`to_codex_reasoning_effort`] -/// (stage 2, which maps the resulting `max` back to `xhigh`). Note `none`/`minimal` survive -/// stage 1 unchanged **because freshcodex declares them in the model's `thinkingEfforts`** — -/// consistent with the protocol treating them as valid (DEV-0003). -pub fn normalize_freshcodex_effort(model: Option<&str>, effort: Option<&str>) -> Option { - let options = thinking_options(model); - - // codex `xhigh → max` (`:142` guards on `provider === 'codex'`). - let normalized_effort = match effort { - Some("xhigh") => Some("max"), - other => other, - }; - if let Some(e) = normalized_effort { - if options.contains(&e) { - return Some(e.to_string()); - } - } - - let normalized_model = normalize_freshcodex_model(model); - if let Some(opt) = resolve_option(&normalized_model) { - if options.contains(&opt.default_effort) { - return Some(opt.default_effort.to_string()); - } - } - options.last().map(|s| s.to_string()) +/// Omission leaves the native/configured effort default in control. A catalog +/// can describe a menu, but cannot prove a custom value invalid. +pub fn normalize_freshcodex_effort(_model: Option<&str>, effort: Option<&str>) -> Option { + effort + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) } -/// Raised when a reasoning-effort value cannot be mapped to the codex wire vocabulary — -/// the reference's `throw new Error('Freshcodex does not support reasoning effort "…"')` -/// (`adapter.ts:133`). #[derive(Clone, Debug, PartialEq, Eq)] pub struct CodexEffortError(pub String); - impl std::fmt::Display for CodexEffortError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Freshcodex does not support reasoning effort \"{}\". Choose none, minimal, low, medium, high, or max.", - self.0 - ) + write!(f, "Codex reasoning effort must be a nonempty native value") } } - impl std::error::Error for CodexEffortError {} -/// `toCodexReasoningEffort(value)` — the WIRE-level mapping (`adapter.ts:127-134`). -/// -/// - `None` → `None` (omit `effort` from `turn/start`). -/// - `max` / `xhigh` → `xhigh` (`:129`). -/// - **`none` / `minimal` / `low` / `medium` / `high` → VERBATIM (`:130-131`).** ← DEV-0003. -/// - anything else → [`CodexEffortError`] (`:133`). -/// -/// This function is the DEV-0003 seam: `none`/`minimal` MUST pass through unchanged. The -/// port must never clamp or remap them — the T2 codex differ grants no tolerance here. +/// The wire carries every nonempty value verbatim. The legacy max alias is +/// resolved separately, only against capabilities of the installed selected model. pub fn to_codex_reasoning_effort(value: Option<&str>) -> Result, CodexEffortError> { match value { None => Ok(None), - Some("max") | Some("xhigh") => Ok(Some("xhigh".to_string())), - Some(v) if FRESHCODEX_EFFORTS_VERBATIM.contains(&v) => Ok(Some(v.to_string())), - Some(other) => Err(CodexEffortError(other.to_string())), + Some(v) if !v.trim().is_empty() => Ok(Some(v.to_string())), + Some(v) => Err(CodexEffortError(v.to_string())), } } #[cfg(test)] mod tests { use super::*; - - // ── DEV-0003: none/minimal are forwarded VERBATIM (no clamp) ─────────────────────── - - #[test] - fn dev_0003_none_and_minimal_forward_verbatim() { - // The heart of DEV-0003: these five values must survive the wire mapping unchanged. - assert_eq!( - to_codex_reasoning_effort(Some("none")), - Ok(Some("none".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("minimal")), - Ok(Some("minimal".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("low")), - Ok(Some("low".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("medium")), - Ok(Some("medium".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("high")), - Ok(Some("high".to_string())) - ); - } - #[test] - fn dev_0003_full_pipeline_keeps_none_minimal_verbatim_for_spark() { - // The exact T2 model. Menu-normalize (`none`/`minimal` are on spark's menu, so they - // survive) THEN wire-map — still verbatim end-to-end. This is what the T2 differ grades. - let spark = Some(CHEAPEST_T2_MODEL); - for effort in ["none", "minimal", "low", "medium", "high"] { - let menu = normalize_freshcodex_effort(spark, Some(effort)); - assert_eq!( - menu.as_deref(), - Some(effort), - "menu stage must keep {effort} verbatim" - ); - let wire = to_codex_reasoning_effort(menu.as_deref()).expect("wire map"); - assert_eq!( - wire.as_deref(), - Some(effort), - "wire stage must keep {effort} verbatim" - ); + fn explicit_native_model_choices_are_never_replaced_by_static_defaults() { + for model in [ + "provider-current", + "gpt-6.1-sol", + CHEAPEST_T2_MODEL, + "gpt-5.4-mini", + ] { + assert_eq!(normalize_freshcodex_model(Some(model)), model); + for effort in [ + "ultra", "none", "minimal", "low", "medium", "high", "xhigh", "max", + ] { + assert_eq!( + normalize_freshcodex_effort(Some(model), Some(effort)).as_deref(), + Some(effort) + ); + assert_eq!( + to_codex_reasoning_effort(Some(effort)).unwrap().as_deref(), + Some(effort) + ); + } } } - - #[test] - fn max_and_xhigh_map_to_xhigh_on_the_wire() { - // `max`/`xhigh` → `xhigh` (`adapter.ts:129`) — NOT a DEV-0003 value; correctly mapped. - assert_eq!( - to_codex_reasoning_effort(Some("max")), - Ok(Some("xhigh".to_string())) - ); - assert_eq!( - to_codex_reasoning_effort(Some("xhigh")), - Ok(Some("xhigh".to_string())) - ); - } - #[test] - fn undefined_effort_stays_undefined() { - assert_eq!(to_codex_reasoning_effort(None), Ok(None)); - } - - #[test] - fn unsupported_effort_errors_like_the_reference() { - assert_eq!( - to_codex_reasoning_effort(Some("bogus")), - Err(CodexEffortError("bogus".to_string())) - ); - assert!(to_codex_reasoning_effort(Some("ultra")).is_err()); - } - - // ── model clamp (normalizeFreshcodexModel) ───────────────────────────────────────── - - #[test] - fn model_clamps_to_freshcodex_allowlist() { - // Allowlisted models pass through. - assert_eq!( - normalize_freshcodex_model(Some("gpt-6-astra")), - "gpt-6-astra" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.6-sol")), - "gpt-5.6-sol" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.6-terra")), - "gpt-5.6-terra" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.6-luna")), - "gpt-5.6-luna" - ); - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.3-codex-spark")), - "gpt-5.3-codex-spark" - ); - // Retired/unknown models fall back to the current freshcodex default. - assert_eq!( - normalize_freshcodex_model(Some("gpt-5.4-mini")), - FRESHCODEX_DEFAULT_MODEL - ); - // Unknown / missing → the freshcodex default. - assert_eq!( - normalize_freshcodex_model(Some("random")), - FRESHCODEX_DEFAULT_MODEL - ); + fn omitted_model_uses_default_and_omitted_effort_uses_native_default() { assert_eq!(normalize_freshcodex_model(None), FRESHCODEX_DEFAULT_MODEL); - } - - // ── effort menu normalization (normalizeFreshAgentEffort, codex slice) ────────────── - - #[test] - fn effort_menu_normalization_matches_reference() { - let spark = Some("gpt-5.3-codex-spark"); - // On-menu efforts kept. - assert_eq!( - normalize_freshcodex_effort(spark, Some("low")).as_deref(), - Some("low") - ); assert_eq!( - normalize_freshcodex_effort(spark, Some("none")).as_deref(), - Some("none") - ); - assert_eq!( - normalize_freshcodex_effort(spark, Some("max")).as_deref(), - Some("max") - ); - // codex xhigh → max at the menu stage (`:142`), and `max` is on spark's menu. - assert_eq!( - normalize_freshcodex_effort(spark, Some("xhigh")).as_deref(), - Some("max") - ); - // Absent effort → the model's defaultEffort (`max` for spark). - assert_eq!( - normalize_freshcodex_effort(spark, None).as_deref(), - Some("max") - ); - // Off-menu effort → clamped to the default (`max`). - assert_eq!( - normalize_freshcodex_effort(spark, Some("bogus")).as_deref(), - Some("max") - ); - } - - #[test] - fn effort_menu_for_gpt_56_models_keeps_current_reasoning_levels() { - let terra = Some("gpt-5.6-terra"); - assert_eq!( - normalize_freshcodex_effort(terra, Some("xhigh")).as_deref(), - Some("max") - ); - assert_eq!( - normalize_freshcodex_effort(terra, Some("max")).as_deref(), - Some("max") - ); - assert_eq!( - normalize_freshcodex_effort(terra, Some("none")).as_deref(), - Some("none") - ); - assert_eq!( - normalize_freshcodex_effort(terra, Some("minimal")).as_deref(), - Some("max") - ); - assert_eq!( - normalize_freshcodex_effort(terra, None).as_deref(), - Some("max") - ); - } - - #[test] - fn effort_for_unknown_model_uses_default_model_menu() { - // An unknown model normalizes to gpt-6-astra, whose menu includes `max`. - let unknown = Some("mystery-model"); - assert_eq!( - normalize_freshcodex_effort(unknown, Some("medium")).as_deref(), - Some("medium") + normalize_freshcodex_model(Some(" ")), + FRESHCODEX_DEFAULT_MODEL ); assert_eq!( - normalize_freshcodex_effort(unknown, None).as_deref(), - Some("max") + normalize_freshcodex_effort(Some("provider-current"), None), + None ); + assert_eq!(to_codex_reasoning_effort(None), Ok(None)); + assert!(to_codex_reasoning_effort(Some(" ")).is_err()); } } diff --git a/crates/freshell-codex/tests/app_server_drive.rs b/crates/freshell-codex/tests/app_server_drive.rs index 2adf584d63..7f24b7160e 100644 --- a/crates/freshell-codex/tests/app_server_drive.rs +++ b/crates/freshell-codex/tests/app_server_drive.rs @@ -32,6 +32,39 @@ async fn drive_handshake(peer: &freshell_codex::ChannelPeer) { assert_eq!(note, "initialized"); } +#[tokio::test] +async fn model_catalog_reads_all_visible_pages_on_the_initialized_client() { + let (transport, peer) = new_channel_transport(); + let (client, _notifs) = CodexAppServerClient::connect(transport); + let driver = tokio::spawn(async move { freshell_codex::catalog::read_models(&client).await }); + drive_handshake(&peer).await; + for (cursor, next, model, effort) in [ + (None, Some("opaque-next"), "gpt-6.1-sol", "xhigh"), + (Some("opaque-next"), None, "provider-current", "ultra"), + ] { + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "model/list"); + assert_eq!(params["includeHidden"], false); + assert_eq!( + params.get("cursor").and_then(serde_json::Value::as_str), + cursor + ); + peer.respond(&id,json!({"data":[{"id":"metadata-record","model":model,"displayName":format!("Native {model}"),"supportedReasoningEfforts":[{"reasoningEffort":effort}],"defaultReasoningEffort":effort,"inputModalities":["text","image"]}],"nextCursor":next})); + } + let rows = driver.await.unwrap().unwrap(); + drop(peer); + assert_eq!( + rows.iter().map(|r| r.id.as_str()).collect::>(), + vec!["gpt-6.1-sol", "provider-current"] + ); + assert_eq!(rows[1].supported_effort_levels, vec!["ultra"]); + assert_eq!(rows[1].default_effort.as_deref(), Some("ultra")); + assert_eq!( + rows[0].input_modalities, + Some(vec!["text".into(), "image".into()]) + ); +} + #[tokio::test] async fn full_drive_completed_turn_emits_the_positive_edge_with_verbatim_effort() { let (transport, peer) = new_channel_transport(); @@ -60,6 +93,7 @@ async fn full_drive_completed_turn_emits_the_positive_edge_with_verbatim_effort( .unwrap(); let turn = client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "freshell-t2-ok" })], cwd: Some("/work".to_string()), @@ -140,6 +174,7 @@ async fn full_drive_user_armed_interrupted_turn_does_not_chime() { .unwrap(); client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, @@ -212,6 +247,7 @@ async fn rpc_error_on_turn_start_surfaces_to_the_caller() { .expect("thread/start"); client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, diff --git a/crates/freshell-codex/tests/elicitation_capabilities.rs b/crates/freshell-codex/tests/elicitation_capabilities.rs new file mode 100644 index 0000000000..41188c2aad --- /dev/null +++ b/crates/freshell-codex/tests/elicitation_capabilities.rs @@ -0,0 +1,18 @@ +use freshell_codex::{new_channel_transport, CodexAppServerClient}; +use serde_json::json; + +#[tokio::test] +async fn task5_native_initialization_advertises_both_observed_openai_form_routes() { + let (transport, peer) = new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let initialize = tokio::spawn(async move { client.initialize().await.unwrap() }); + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "initialize"); + assert_eq!( + params["capabilities"]["extensions"], + json!({"openai/form":{},"openai/elicitation":{"form":{}},"openai/standard-form-input":{}}) + ); + peer.respond(&id, json!({})); + peer.expect_notification().await; + initialize.await.unwrap(); +} diff --git a/crates/freshell-codex/tests/interrupt_rpc.rs b/crates/freshell-codex/tests/interrupt_rpc.rs index bcb60c674d..9b969e5fe4 100644 --- a/crates/freshell-codex/tests/interrupt_rpc.rs +++ b/crates/freshell-codex/tests/interrupt_rpc.rs @@ -19,6 +19,37 @@ use freshell_codex::{ const THREAD_ID: &str = "019810de-1e5f-7db3-9c47-1c2a3b4c5d6e"; +#[tokio::test] +async fn active_input_uses_expected_id_steer_without_turn_settings_and_returns_the_same_turn() { + let (transport, peer) = new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let driver = tokio::spawn(async move { + client + .steer_turn( + THREAD_ID, + "turn-owned", + vec![json!({"type":"text","text":"follow up"})], + ) + .await + .unwrap() + }); + let (init, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond( + &init, + json!({"userAgent":"fixture","codexHome":"/h","platformFamily":"u","platformOs":"l"}), + ); + peer.expect_notification().await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/steer"); + assert_eq!( + params, + json!({"threadId":THREAD_ID,"expectedTurnId":"turn-owned","input":[{"type":"text","text":"follow up"}]}) + ); + peer.respond(&id, json!({"turnId":"turn-owned"})); + assert_eq!(driver.await.unwrap().turn_id, "turn-owned"); +} + #[tokio::test] async fn interrupt_turn_rpc_then_interrupted_completion_snapshots_without_chime() { let (transport, peer) = new_channel_transport(); @@ -34,6 +65,7 @@ async fn interrupt_turn_rpc_then_interrupted_completion_snapshots_without_chime( .expect("thread/start"); let turn = client .start_turn(StartTurnParams { + collaboration_mode: None, thread_id: THREAD_ID.to_string(), input: vec![json!({ "type": "text", "text": "hi" })], cwd: None, diff --git a/crates/freshell-freshagent/src/attention_ledger.rs b/crates/freshell-freshagent/src/attention_ledger.rs index ae98606afe..a9f6fe2c26 100644 --- a/crates/freshell-freshagent/src/attention_ledger.rs +++ b/crates/freshell-freshagent/src/attention_ledger.rs @@ -170,6 +170,21 @@ impl AttentionLedger { .collect() } + /// Identities joined by an actual materialization edge. A missing identity + /// has no candidates; unrelated retained conversations are never a fallback. + pub(crate) fn aliases_for(&self, id: &str) -> Vec { + let inner = self.inner.lock().expect("attention ledger lock"); + let Some(group) = inner.entries.get(id).map(|entry| entry.group) else { + return Vec::new(); + }; + inner + .entries + .values() + .filter(|entry| entry.group == group) + .map(|entry| entry.mark.session_id.clone()) + .collect() + } + fn trim(inner: &mut Inner, capacity: usize) { while inner.entries.len() > capacity { let oldest_group = inner diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index e558855555..8cd5b7cb23 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2016,10 +2016,11 @@ impl FreshClaudeState { // `sdk.session.init` (claude has no sandbox concept: always `None`), and // the launch is built from the same values (G15: the plugins too). let settings = crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: msg.model.clone(), sandbox: None, permission_mode: msg.permission_mode.clone(), - effort: msg.effort.clone(), + effort: msg.effort.clone().flatten(), cwd: msg.cwd.clone(), plugins: None, } @@ -5961,10 +5962,64 @@ async fn spawn_sidecar( mod provider_exec_tests { use super::*; + const CASE: &str = "FRESHELL_EXEC_PROFILE_FIXTURE_CHILD"; + const TEST: &str = "claude::provider_exec_tests::provider_exec_profile_checks_actual_managed_claude_sidecar_spawn"; + + fn process_observation(pid: u32) -> Result { + let start = crate::session_lease::proc_starttime(pid as i32) + .ok_or_else(|| format!("process {pid} has no live start time"))?; + let status = std::fs::read_to_string(format!("/proc/{pid}/status")) + .map_err(|error| error.to_string())?; + let parent = status + .lines() + .find_map(|line| line.strip_prefix("PPid:")?.trim().parse::().ok()) + .ok_or_else(|| format!("process {pid} has no parent observation"))?; + Ok(json!({"pid":pid,"start":start,"ppid":parent})) + } + + fn assert_joined_cases(outputs: Vec) { + assert_eq!(outputs.len(), 2); + for (case, output) in ["large", "small"].into_iter().zip(outputs) { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + eprintln!("[provider-exec-child stdout] {line}"); + } + assert!(stdout.lines().any(|line| line == "running 1 test")); + assert!(stdout + .lines() + .any(|line| line == format!("test {TEST} ... ok"))); + assert!(stdout.contains("test result: ok. 1 passed; 0 failed; 0 ignored;")); + let observations: Vec = String::from_utf8_lossy(&output.stderr) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|value| value["event"] == "provider_exec_fixture_closed") + .collect(); + assert_eq!(observations.len(), 1, "{observations:?}"); + let observation = &observations[0]; + assert_eq!(observation["test"], TEST); + assert_eq!(observation["case"], case); + let supervisor = &observation["supervisor"]; + let native = observation["native"].as_array().unwrap(); + assert_eq!(native.len(), usize::from(case == "small")); + for process in std::iter::once(supervisor).chain(native) { + let pid = u32::try_from(process["pid"].as_u64().unwrap()).unwrap(); + let start = process["start"].as_u64().unwrap(); + assert_eq!( + crate::session_lease::recorded_process_evidence(pid, Some(start)), + crate::session_lease::RecordedProcessEvidence::Gone + ); + } + for process in native { + assert_eq!(process["ppid"], supervisor["pid"]); + } + eprintln!("{observation}"); + } + } + #[tokio::test] async fn provider_exec_profile_checks_actual_managed_claude_sidecar_spawn() { - const CASE: &str = "FRESHELL_EXEC_PROFILE_FIXTURE_CHILD"; if let Ok(case) = std::env::var(CASE) { + let supervisor = process_observation(std::process::id()); let owned = tempfile::tempdir().unwrap(); let script = owned.path().join("synthetic-sidecar.mjs"); std::fs::write(&script, "setInterval(()=>{},1000)\n").unwrap(); @@ -5977,30 +6032,87 @@ mod provider_exec_tests { ) .unwrap(); let mut result = spawn_sidecar(None, None, "freshclaude").await; - if let Ok(SpawnedSidecar { child, .. }) = &mut result { - let pid = child.id().unwrap(); - child.kill().await.unwrap(); - child.wait().await.unwrap(); - eprintln!("synthetic owned sidecar pid {pid} stopped in fixture namespace"); - } + let mut native = Vec::new(); + let cleanup = if let Ok(SpawnedSidecar { child, .. }) = &mut result { + native.push( + child + .id() + .ok_or_else(|| "native child has no pid".to_string()) + .and_then(process_observation), + ); + Some((child.kill().await, child.wait().await)) + } else { + None + }; if case == "large" { assert!(matches!(result,Err(ref cause) if cause.contains("provider exec profile")),"managed fresh sidecar must reject oversized actual inherited+selected environment before spawn"); } else { assert!(result.is_ok(),"valid finite selected environment must still launch the actual sidecar command"); } + drop(result); + drop(owned); + if let Some((killed, waited)) = cleanup { + killed.unwrap(); + waited.unwrap(); + } + let native: Vec = native + .into_iter() + .collect::>() + .expect("observe actual spawned Claude identity"); + let observation = json!({"severity":"info","event":"provider_exec_fixture_closed", + "test":TEST,"case":case,"supervisor":supervisor.expect("observe child libtest identity"), + "native":native}); + use std::io::Write; + writeln!(std::io::stderr(), "{observation}").expect("report joined fixture identities"); return; } + assert_joined_cases(run_profile_cases().await); + } + + // The original exact test and the guard regression share this physical path. + async fn run_profile_cases() -> Vec { + let _process_fixture = crate::session_lease::process_fixture::lock().await; + let mut outputs = Vec::new(); for case in ["large", "small"] { - let output=std::process::Command::new(std::env::current_exe().unwrap()).env_clear() - .env("PATH","/usr/local/bin:/usr/bin:/bin").env(CASE,case) - .args(["--exact","claude::provider_exec_tests::provider_exec_profile_checks_actual_managed_claude_sidecar_spawn","--nocapture"]) - .output().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .env_clear() + .env("PATH", "/usr/local/bin:/usr/bin:/bin") + .env(CASE, case) + .args(["--exact", TEST, "--nocapture"]) + .output() + .unwrap(); assert!( output.status.success(), "actual sidecar fixture case {case} failed: {}", String::from_utf8_lossy(&output.stderr) ); + outputs.push(output); } + outputs + } + + #[tokio::test] + async fn provider_exec_parent_waits_for_process_fixture() { + let held = crate::session_lease::process_fixture::lock().await; + let mut parent = Box::pin(run_profile_cases()); + // Synchronous output either joins real children or waits at the parent guard. + let first_poll = futures_util::poll!(&mut parent); + let waited = first_poll.is_pending(); + drop(held); + let outputs = match first_poll { + std::task::Poll::Ready(outputs) => outputs, + std::task::Poll::Pending => parent.await, + }; + assert_joined_cases(outputs); + eprintln!( + "{}", + json!({"severity":"info","event":"provider_exec_parent_polled", + "test":TEST,"firstPollPending":waited}) + ); + assert!( + waited, + "actual Claude provider fixture spawned while the canonical process guard was held" + ); } } @@ -8092,6 +8204,7 @@ pub(crate) mod tests { let mut send = send_msg(&session_id, "carries an unsupported knob"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: None, effort: None, @@ -8264,15 +8377,16 @@ pub(crate) mod tests { state.set_identity_sink(fake.clone()); let mut create = dedup_create_msg("settings-send"); create.model = Some("opus".into()); - create.effort = Some("high".into()); + create.effort = Some(Some("high".into())); state.handle_create(create, None).await; let created = await_claude_created(&mut rx, "settings-send").await; let session_id = created["sessionId"].as_str().unwrap(); let mut send = send_msg(session_id, "Review this image"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("sonnet".into()), - effort: Some("max".into()), + effort: Some(Some("max".into())), permission_mode: Some("plan".into()), sandbox: None, }); @@ -8335,14 +8449,15 @@ pub(crate) mod tests { state.set_identity_sink(fake.clone()); let mut create = dedup_create_msg("partial-settings"); create.model = Some("opus".into()); - create.effort = Some("high".into()); + create.effort = Some(Some("high".into())); state.handle_create(create, None).await; let created = await_claude_created(&mut rx, "partial-settings").await; let session_id = created["sessionId"].as_str().unwrap(); let mut send = send_msg(session_id, "Keep this unsent"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, model: Some("sonnet".into()), - effort: Some("invalid".into()), + effort: Some(Some("invalid".into())), cwd: None, permission_mode: None, sandbox: None, @@ -8390,6 +8505,7 @@ pub(crate) mod tests { let mut send = send_msg(session_id, "Do not lose this prompt"); send.request_id = Some("retained-prompt".into()); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("unavailable".into()), effort: None, @@ -8438,6 +8554,7 @@ pub(crate) mod tests { fn dedup_create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -14981,7 +15098,7 @@ pub(crate) mod tests { msg.session_type = SessionType::Kilroy; msg.model = Some("opus-x".to_string()); msg.permission_mode = Some("plan".to_string()); - msg.effort = Some("high".to_string()); + msg.effort = Some(Some("high".to_string())); msg.cwd = Some(env.dir.to_string_lossy().to_string()); state.handle_create(msg, None).await; // Create publishes only after the native binding is durable. @@ -15164,6 +15281,7 @@ pub(crate) mod tests { "claude", DURABLE, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("opus-x".into()), sandbox: None, permission_mode: Some("plan".into()), @@ -16297,6 +16415,71 @@ rl.on('line', (line) => { // Even failed compaction settles its own command before rollback reopens. await_frame_of_inner_type(&mut rx, "freshAgent.result").await; await_ledger_busy(&st, &session_id, false).await; + // The simulator, like the CLI, flushes JSONL independently of result. + // Synchronize this fixture on its actual send identity and native point; + // a publicly advertised point without that backing is a product failure. + let compact_frames = env.request_frames_of_type("send", 1).await; + let compact_uuid = compact_frames[0]["uuid"] + .as_str() + .expect("compact send UUID") + .to_owned(); + let point = tokio::time::timeout(Duration::from_secs(15), async { + loop { + let public = st + .get_snapshot(SessionType::Freshclaude, &session_id) + .await + .ok(); + let advertised = public.as_ref().is_some_and(|snapshot| { + snapshot["turns"].as_array().is_some_and(|turns| { + turns.iter().any(|turn| turn["turnId"] == compact_uuid) + }) + }); + let saved = st + .effective_transcript(&session_id) + .await + .ok() + .filter(|saved| { + saved.source_path.is_some() + && saved + .text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .any(|line| line["type"] == "user" && line["uuid"] == compact_uuid) + }); + let point = saved.and_then(|saved| { + let exact = crate::claude_snapshot::resolve_resume_point( + &saved.text, + &session_id, + crate::claude_snapshot::ResumeTarget::ToTurn(compact_uuid.clone()), + ) + .ok()?; + let step = crate::claude_snapshot::resolve_resume_point( + &saved.text, + &session_id, + crate::claude_snapshot::ResumeTarget::Step, + ) + .ok()?; + assert_eq!(step.resume_at_uuid, exact.resume_at_uuid); + assert_eq!(step.prompt_text, exact.prompt_text); + Some(step) + }); + assert!( + !advertised || point.is_some(), + "public compact rollback point has no persisted native backing" + ); + if let Some(point) = point { + break point; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| { + panic!( + "compact send {compact_uuid} never became a persisted rollback point under {}", + env.dir.display() + ) + }); let (sink2, captured2) = capturing_sink(); st.handle_rollback( rollback_op(&session_id, "rb-compact-2", RollbackDirection::Undo), @@ -16308,6 +16491,15 @@ rl.on('line', (line) => { frames2[0]["event"]["type"], "freshAgent.rolledBack", "{frames2:?}" ); + assert_eq!(frames2[0]["event"]["removedPromptText"], point.prompt_text); + assert_eq!( + frames2[0]["event"]["removedTurnIds"], + json!(point + .removed_turns + .iter() + .filter_map(|turn| turn["turnId"].as_str()) + .collect::>()) + ); let replacement = frames2[0]["event"]["newSessionId"].as_str().unwrap(); assert_eq!( st.resolve_session_key(&session_id).await.as_deref(), @@ -17061,11 +17253,12 @@ rl.on('line', (line) => { cwd: None, request_id: Some("req-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }, } } @@ -17233,9 +17426,10 @@ rl.on('line', (line) => { let mut send = send_msg(&session_id, "carries a new model"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("opus[1m]".to_string()), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), permission_mode: None, sandbox: None, }); @@ -17277,9 +17471,10 @@ rl.on('line', (line) => { // exactly one metadata frame converges. let mut send = send_msg(&session_id, "carries a new model"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("opus[1m]".to_string()), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), permission_mode: None, sandbox: None, }); @@ -17303,9 +17498,10 @@ rl.on('line', (line) => { // no second configure), and the unchanged pair converges nothing. let mut resend = send_msg(&session_id, "same pair again"); resend.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some("opus[1m]".to_string()), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), permission_mode: None, sandbox: None, }); @@ -17346,6 +17542,7 @@ rl.on('line', (line) => { #[cfg(target_os = "linux")] #[tokio::test] async fn the_fenced_prior_probe_kills_a_reparented_descendant_from_the_recorded_identity() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let st = state(); let sid = format!("r37-reparent-{}", uuid::Uuid::new_v4()); // The condemned "sidecar" child holding a tagged "CLI grandchild". @@ -17461,6 +17658,7 @@ rl.on('line', (line) => { #[cfg(target_os = "linux")] #[tokio::test] async fn the_fenced_prior_probe_never_signals_a_reused_pid() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let st = state(); let sid = format!("r37-reuse-{}", uuid::Uuid::new_v4()); // A live "unrelated replacement" process holding the recorded pid. diff --git a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs index bfe9da1752..0fedef8f96 100644 --- a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -252,6 +252,7 @@ impl Drop for EndingSession { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index 8d759281d6..d2801fb894 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -126,6 +126,7 @@ impl Drop for ClaudeEnvReset { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -171,6 +172,7 @@ fn configure_msg(session_id: &str, permission_mode: &str) -> FreshAgentConfigure cwd: None, request_id: Some("live-frames-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: None, permission_mode: Some(permission_mode.to_string()), @@ -441,6 +443,7 @@ async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it( let mut session = LiveSession::start(Some("plan")).await; let mut send = send_msg(&session.pane, "[]"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: None, permission_mode: Some("acceptEdits".to_string()), diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs index b3d0e578a4..ea2bca8556 100644 --- a/crates/freshell-freshagent/src/claude/recovery.rs +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -259,7 +259,7 @@ pub(super) fn updated_settings( next.model = Some(model.clone()); } if let Some(effort) = &requested.effort { - next.effort = Some(effort.clone()); + next.effort = effort.clone(); } if let Some(permission_mode) = &requested.permission_mode { next.permission_mode = Some(permission_mode.clone()); diff --git a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs index dcec8c6777..87e5034cce 100644 --- a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs @@ -66,6 +66,7 @@ async fn a_configure_receipt_from_a_replaced_runtime_cannot_change_its_replaceme .configure_for_send( &id, &freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, model: Some("old-runtime-model".into()), cwd: None, effort: None, diff --git a/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs index a34f345631..b99ec947b8 100644 --- a/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs @@ -19,6 +19,7 @@ async fn a_configure_receipt_after_reset_saves_the_same_runtime_under_its_curren .configure_for_send( &old, &freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, model: Some("sonnet".into()), cwd: None, effort: None, @@ -91,6 +92,7 @@ async fn settings_persistence_failure_preserves_the_runtime_and_unsent_prompt_fo let observed = state.ownership_snapshot(PROVIDER, &session_id); let mut send = send_msg(&session_id, "keep this prompt until settings are saved"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, model: Some("sonnet".into()), cwd: None, effort: None, diff --git a/crates/freshell-freshagent/src/claude_edges.rs b/crates/freshell-freshagent/src/claude_edges.rs index f222750742..60ad989ad4 100644 --- a/crates/freshell-freshagent/src/claude_edges.rs +++ b/crates/freshell-freshagent/src/claude_edges.rs @@ -181,6 +181,7 @@ impl ClaudeEdgeHub { session_type: mark.session_type, last_turn_complete_at: mark.last_turn_complete_at, last_waiting_at: mark.last_waiting_at, + last_question_available_at: None, }) .collect() } diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index bec8840d33..f33408923c 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -38,6 +38,7 @@ impl ClaudeRestLane { .state .handle_create( FreshAgentCreate { + collaboration_mode: None, request_id: request.request_id, provider: Some(AgentProvider::Claude), session_type, @@ -69,6 +70,8 @@ impl ClaudeRestLane { session_id, session_ref, cleanup_token: cleanup_ownership_id, + effective_settings: None, + name_ref: None, }), ClaudeCreateOutcome::Failed { code, diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index c6a1a56453..f99b02e6b2 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -62,11 +62,10 @@ use freshell_codex::launch_lifecycle::{ use freshell_codex::launch_plan::{codex_sidecar_spawn_spec, CodexSidecarLaunchContext}; use freshell_codex::transport::{reap_owned_codex_sidecars, TungsteniteTransport}; use freshell_codex::{ - mint_ownership_id, normalize_codex_thread_status, normalize_freshcodex_effort, - normalize_freshcodex_model, read_rollout_history_mode, to_codex_reasoning_effort, - CodexAdapterEvent, CodexAppServerClient, CodexAppServerError, CodexNotification, CodexStatus, - CodexSubscription, HistoryMode, StartThreadParams, StartTurnParams, ThreadForkParams, - CODEX_SIDECAR_OWNERSHIP_ENV, + mint_ownership_id, normalize_codex_thread_status, read_rollout_history_mode, + to_codex_reasoning_effort, CodexAdapterEvent, CodexAppServerClient, CodexAppServerError, + CodexNotification, CodexStatus, CodexSubscription, HistoryMode, StartThreadParams, + StartTurnParams, ThreadForkParams, CODEX_SIDECAR_OWNERSHIP_ENV, }; use freshell_protocol::{ ErrorCode, ErrorMsg, FreshAgentAttach, FreshAgentCompact, FreshAgentConfigure, @@ -82,9 +81,75 @@ use crate::summary::{ }; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; +pub mod children; +pub mod settings; +use settings::{CodexEffectiveSettings, CodexWorkMode}; mod controls; +mod elicitation; mod metadata; pub(crate) mod native_history; +mod projection; +pub(crate) mod turn_state; +use turn_state::{CodexTurnState, DispatchToken, NativeSettlement, StopDisposition}; +type SharedCodexTurnState = Arc>; + +#[derive(Clone, Debug, PartialEq)] +pub enum CodexCreateOutcome { + Created { + session_id: String, + session_ref: Option, + cleanup_ownership_id: Option, + name_ref: Option, + }, + Failed { + code: String, + message: String, + retryable: Option, + owner: Option, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CodexSendOutcome { + Accepted { + session_id: String, + submitted_turn_id: String, + incarnation_id: String, + pending_naming_handle: Option, + }, + Refused { + code: String, + message: String, + retryable: Option, + owner: Option, + }, + Ambiguous { + session_id: String, + request_id: String, + incarnation_id: String, + code: String, + message: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CodexSettlementSource { + NativeNotification, + NativeThreadRead, + ConfirmedOwnedExit, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CodexTurnEvent { + Settled { + session_id: String, + turn_id: Option, + incarnation_id: String, + request_ids: Vec, + outcome: NativeSettlement, + source: CodexSettlementSource, + }, +} /// Unified agent names (Task 2): the ambient `CODEX_HOME` fallback for the /// durability-driven pending bind when the app-server's own initialize @@ -247,7 +312,9 @@ const STATUS_RANG_TURN_IDS_CAP: usize = 8; /// explicit: every lifecycle path must give the reducer, quiet-deadman, compact /// guard, and emission barrier for the same thread to the same task. struct CodexConsumerRuntime { + turn_state: SharedCodexTurnState, thread_id: String, + projection: Arc>, active_turn: Arc>>, /// The session's USER-interrupt marker: the interrupt control lane arms it, /// the consumer's subscription consumes it at the `interrupted` completion, @@ -299,6 +366,46 @@ struct CodexConsumerRuntime { event_emission_gate: Arc>, } +async fn wait_for_codex_stop_resolution(turns: &SharedCodexTurnState) { + loop { + let changed = turns.lock().expect("codex turn state").changed.clone(); + let notified = changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if !turns + .lock() + .expect("codex turn state") + .has_unresolved_stop() + { + return; + } + #[cfg(test)] + turns + .lock() + .expect("codex turn state") + .waiting_for_stop + .notify_one(); + notified.await; + } +} + +fn publish_codex_interrupt_refusal( + turns: &SharedCodexTurnState, + dispatch: DispatchToken, + turn_id: &str, + publish: impl FnOnce(), +) -> bool { + let mut state = turns.lock().expect("codex turn state"); + if !state.interrupt_refused(dispatch, turn_id) { + return false; + } + // Gate waiters may wake during the transition, but cannot acquire state + // and admit a newer Stop until its compatibility publication is complete. + // Production publication only writes the atomic attention marker. + publish(); + true +} + /// Seed the quiet window from `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS` (positive integer /// milliseconds; missing or unparseable falls back to [`DEFAULT_CODEX_QUIET_WINDOW_MS`]). fn codex_quiet_window_ms_from_env() -> u64 { @@ -308,6 +415,8 @@ fn codex_quiet_window_ms_from_env() -> u64 { ) } +type ChildReadAttempts = HashMap<(String, String, String), u8>; + /// Shared, cheaply-cloneable freshcodex WS state (mergeable into the server app + WsState). #[derive(Clone)] pub struct FreshCodexState { @@ -316,8 +425,12 @@ pub struct FreshCodexState { /// `freshell-ws` connection. `freshAgent.created` / `freshAgent.send.accepted` / /// `freshAgent.event` are pushed here so the oracle's capture socket records them. broadcast_tx: Arc>, + turn_events: tokio::sync::broadcast::Sender, /// threadId → live codex session (client + settings + owned sidecar). sessions: Arc>>, + child_reads: Arc>, + retained_changed: Arc>, + attention: Arc, /// The `settings.freshAgent.enabled` gate the WS `freshAgent.create` requires /// (default off; flipped true by `PATCH /api/settings`, as a real freshcodex user does). fresh_agent_enabled: Arc, @@ -429,6 +542,11 @@ pub struct FreshCodexState { /// set-once/shared model as [`Self::identity_sink`]. Wired by /// `freshell-server::main`; unwired = scoped naming fails unavailable. naming: crate::naming::NamingSink, + hosted_naming_events: bool, + naming_rollout_probe: Arc>, + sidecar_cleanup_probe: Arc>, + #[cfg(any(test, feature = "test-support"))] + sidecar_capture_observer: Arc>, /// Unified agent names: thread id → the pre-durable naming handle /// admitted at create. A `thread/start` id/path is PROSPECTIVE (the /// rollout hasn't materialized), so the handle stays pending and the @@ -462,6 +580,33 @@ pub struct FreshCodexState { pub type SnapshotPauseHook = std::sync::Arc futures_util::future::BoxFuture<'static, ()> + Send + Sync>; +/// A hosted runtime reads private native metadata as the provider identity. +/// The initialized root and exact thread ID remain the proof inputs. +pub type CodexNamingRolloutProbe = Arc< + dyn Fn( + String, + String, + ) + -> futures_util::future::BoxFuture<'static, Result, String>> + + Send + + Sync, +>; + +/// Hosted cleanup runs under the same identity as the exact owned sidecar. +/// A positive answer must confirm the recorded incarnation and whole tree gone. +pub type CodexSidecarCleanupProbe = Arc< + dyn Fn( + crate::session_lease::CondemnedRuntimeIdentity, + ) -> futures_util::future::BoxFuture<'static, Result> + + Send + + Sync, +>; + +/// Observe the exact one-time watcher capture without changing its evidence. +#[cfg(any(test, feature = "test-support"))] +pub type CodexSidecarCaptureObserver = + Arc; + /// The cached result of a completed codex `freshAgent.create`, keyed by `requestId` in /// [`FreshCodexState::create_dedup`]. Only `session_id` is needed: every other field of /// the `freshAgent.created` replay frame ([`FreshCodexState::handle_create`]'s replay @@ -475,6 +620,11 @@ struct CodexCreateRecord { /// One live freshcodex session: the app-server client, its owned sidecar, and the /// normalized create-time settings a later `send` re-uses. struct CodexSession { + collaboration_mode: Option, + native_turn_settings: Option, + native_thread_settings: Option, + turn_state: SharedCodexTurnState, + projection: Arc>, client: Arc, /// Normalized model (`normalizeFreshcodexModel`), reused verbatim on `send`. model: String, @@ -485,21 +635,11 @@ struct CodexSession { sandbox: Option, /// Raw create permissionMode (e.g. `never`) → the turn's `approvalPolicy`. permission_mode: Option, - /// Legacy `activeTurnByThread.get(sessionId)` mirror (adapter.ts:295,980,1009,1027): set - /// immediately after `turn/start` resolves (`handle_send`), cleared on a successful - /// `handle_interrupt` and whenever the notification consumer observes the turn/thread end - /// (`reduce_notification`). Lets `freshAgent.interrupt` target the in-flight turn. + /// Compatibility target for native/compact lifecycle consumers. Owed work + /// and Stop ownership live in `turn_state`; an interrupt ACK never clears it. active_turn: Arc>>, - /// The per-session USER-interrupt marker (opencode's `turn_aborted` - /// precedent): `handle_interrupt` arms it BEFORE issuing `turn/interrupt` - /// (and disarms it again when the RPC fails — the interrupt never landed), - /// the consumer's subscription consumes it at the `interrupted` - /// completion, and the consumer's `turn/started` fold CLEARS it (the - /// full opencode dispatch-clear discipline — a stale armed marker never - /// crosses a turn boundary). ONE `Arc` shared by this session record (the - /// interrupt lane) and the consumer's [`CodexSubscription`] (the guard), - /// so a user interrupt is silent while an automation/rollback-forced - /// `interrupted` still rings the unified attention edge. + /// Compatibility attention marker. Arm before native Stop; disarm only on + /// a proven pre-submission refusal or the matching terminal/new-turn fold. user_interrupt_pending: Arc, /// Delta-r1 F2: the compact-window busy truth. `thread/compact/start`'s answer /// carries no turn id, and the REAL 0.147.0 sequence returns the RPC BEFORE @@ -586,24 +726,11 @@ struct CodexSession { /// into the consumer ([`reduce_notification`]), the exit watcher /// ([`spawn_exit_watcher`]), and the snapshot path ([`FreshCodexState::get_snapshot`]). quiet_deadman: Arc>, - /// The lane's turn-in-flight AUTHORITY (the round-2 review finding): armed at - /// `start_turn` DISPATCH -- BEFORE awaiting the response -- so the - /// independently-running exit watcher and quiet deadman classify the turn as - /// in flight even while the response is still outstanding (a post-response - /// arm would miss a crash in that window). Mirrored at every `active_turn` - /// mutation, so it is a superset of `active_turn` between dispatch and the - /// crash/kill retirement points: SET alongside the consumer's - /// `turn/started` adoption and the snapshot's mid-flight seed, CLEARED at - /// every turn-end observation (the thread leaving running/starting, the - /// active turn's completion, `thread_closed`, a successful user - /// interrupt) and on a failed dispatch the sidecar survived (the - /// kill/crash arms retire the latch WITHOUT clearing `active_turn`, so a - /// post-kill/post-crash `active_turn` can outlive the latch; an - /// exit-induced dispatch failure instead leaves the latch armed for the - /// crash arm's swap-and-mint — a bare Err-arm retirement would land - /// before the crash arm and swallow the crash edge). The crash self-heal - /// arm reads and retires it (the crash IS the turn end it observes); a - /// requested kill retires it silently (the user initiated that turn end). + /// Compatibility in-flight/attention latch for existing crash, compact and + /// quiet-deadman consumers. Dispatch and matching native terminal evidence + /// mirror the identity-owned `turn_state`; interrupt acknowledgement does + /// not retire work. Existing lifecycle-loss attention dedup remains separate + /// from proving terminal delivery/owned-process settlement. turn_in_flight: Arc, /// The-usual SDD focused-r1 FR1-1: the per-session RETIRED-TURN slot. /// A terminal thread status retires the active-turn TRACKER while the @@ -655,6 +782,37 @@ struct CodexSession { provenance: Option, } +impl CodexSession { + fn effective_settings(&self) -> CodexEffectiveSettings { + let mut settings = CodexEffectiveSettings { + model: self.model.clone(), + effort: self.effort.clone(), + cwd: self.cwd.clone(), + sandbox: self.sandbox.clone(), + permission_mode: self.permission_mode.clone(), + collaboration_mode: self.collaboration_mode, + }; + // Historical lineage-only resumes have no recorded next-turn intent. + // Their native observations still supply working/display defaults; + // provenance-only refreshes keep the raw blank sentinel untouched. + if self.model.is_empty() { + if let Some(native) = &self.native_thread_settings { + settings.adopt_native(native); + } + } + settings + } + + fn apply_settings(&mut self, settings: &CodexEffectiveSettings) { + self.model = settings.model.clone(); + self.effort = settings.effort.clone(); + self.cwd = settings.cwd.clone(); + self.sandbox = settings.sandbox.clone(); + self.permission_mode = settings.permission_mode.clone(); + self.collaboration_mode = settings.collaboration_mode; + } +} + struct CodexCheckpointIdentityProbe { client: Arc, session_id: String, @@ -774,7 +932,11 @@ impl FreshCodexState { Self { sidecar_launch_context: CodexSidecarLaunchContext::default(), broadcast_tx, + turn_events: tokio::sync::broadcast::channel(256).0, sessions: Arc::new(TokioMutex::new(HashMap::new())), + child_reads: Arc::new(TokioMutex::new(HashMap::new())), + retained_changed: Arc::new(tokio::sync::watch::channel(0).0), + attention: Arc::new(crate::attention_ledger::AttentionLedger::default()), fresh_agent_enabled: Arc::new(AtomicBool::new(enabled)), settings: Arc::new(TokioMutex::new(settings)), auth_token, @@ -793,6 +955,11 @@ impl FreshCodexState { codex_quiet_window_ms: Arc::new(AtomicU64::new(codex_quiet_window_ms_from_env())), rollback_in_flight: crate::InFlightRegistry::new(), naming: crate::naming::NamingSink::default(), + hosted_naming_events: false, + naming_rollout_probe: Arc::new(std::sync::OnceLock::new()), + sidecar_cleanup_probe: Arc::new(std::sync::OnceLock::new()), + #[cfg(any(test, feature = "test-support"))] + sidecar_capture_observer: Arc::new(std::sync::OnceLock::new()), naming_handles: Arc::new(TokioMutex::new(HashMap::new())), snapshot_pause: Arc::new(StdMutex::new(None)), snapshot_pause_after_capture: Arc::new(StdMutex::new(None)), @@ -805,6 +972,60 @@ impl FreshCodexState { self } + pub fn with_hosted_naming_events(mut self) -> Self { + self.hosted_naming_events = true; + self + } + + pub fn set_naming_rollout_probe(&self, probe: CodexNamingRolloutProbe) { + let _ = self.naming_rollout_probe.set(probe); + } + + pub fn set_sidecar_cleanup_probe(&self, probe: CodexSidecarCleanupProbe) { + let _ = self.sidecar_cleanup_probe.set(probe); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_sidecar_capture_observer_for_tests(&self, observer: CodexSidecarCaptureObserver) { + assert!( + self.sidecar_capture_observer.set(observer).is_ok(), + "sidecar capture observer is already installed" + ); + } + + async fn report_hosted_naming_fact( + &self, + thread_id: &str, + fact: crate::naming::HostedCodexNamingFact, + ) { + if !self.hosted_naming_events { + return; + } + let Ok(fact) = serde_json::to_value(fact) else { + return; + }; + let projection = self + .sessions + .lock() + .await + .get(thread_id) + .map(|session| Arc::clone(&session.projection)); + let Some(projection) = projection else { + return; + }; + if !projection + .lock() + .expect("codex projection lock") + .retain_hosted_naming_fact(fact.clone()) + { + return; + } + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.into(), session_type: SESSION_TYPE.into(), session_id: thread_id.into(), + event: json!({"type":crate::naming::HOSTED_CODEX_NAMING_EVENT,"sessionId":thread_id,"fact":fact}), + })); + } + /// Wire the server-owned durable checkpoint-event path before this state is /// cloned into routers and session consumers. pub fn set_checkpoint_event_persister( @@ -1052,11 +1273,9 @@ impl FreshCodexState { .get(session_id) .cloned(); if let Some(condemned) = condemned { - let confirmed = crate::session_lease::kill_and_confirm_recorded_tree_dead( - &condemned, - CODEX_SIDECAR_OWNERSHIP_ENV, - ) - .await; + let confirmed = + confirm_codex_tree_dead(&condemned, self.sidecar_cleanup_probe.get().cloned()) + .await; if confirmed { let ownership = self .ownership_watch() @@ -1394,6 +1613,12 @@ impl FreshCodexState { return None; } let handle = handle.to_string(); + if self.hosted_naming_events { + self.naming_handles + .lock() + .await + .insert(thread_id.into(), handle.clone()); + } let sink = self.naming(); let projection = crate::naming::admit_pending_projection( &sink, @@ -1477,7 +1702,8 @@ impl FreshCodexState { None } - /// Unified agent names (Task 2): the durability-driven pending bind. A + /// Durability-driven naming: bind a pending handle, or admit a hosted + /// session's own name when it has no handle. A /// freshcodex thread is verified ONLY once its rollout exists under the /// initialized `codexHome` sessions tree (the first line a /// `session_meta` naming the thread — the same proof @@ -1488,17 +1714,44 @@ impl FreshCodexState { /// store answers the already-bound handle with a Read, and the stash is /// consumed on success). `codex_home` comes from the app-server's own /// initialize result when known. - pub(crate) async fn try_bind_pending_naming( + pub(crate) async fn try_materialize_naming( &self, thread_id: &str, codex_home: Option<&str>, ) -> Option { - let handle = self.naming_handles.lock().await.get(thread_id).cloned()?; + let handle = self.naming_handles.lock().await.get(thread_id).cloned(); + if handle.is_none() && !self.hosted_naming_events { + return None; + } + let (source_projection, cwd) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(thread_id)?; + (session.projection.clone(), session.cwd.clone()) + }; + if handle.is_none() + && source_projection + .lock() + .expect("codex projection lock") + .has_hosted_naming_proof() + { + return None; + } let home = codex_home .map(str::to_string) .or_else(codex_home_from_env)?; - let sessions_root = std::path::Path::new(&home).join("sessions"); - let Some(rollout) = locate_thread_rollout(&sessions_root, thread_id) else { + let rollout = if let Some(probe) = self.naming_rollout_probe.get() { + match probe(home.clone(), thread_id.to_string()).await { + Ok(path) => path, + Err(error) => { + tracing::warn!(provider = "codex", session_id = thread_id, %error, + "freshagent.codex.naming_proof_unavailable"); + None + } + } + } else { + locate_thread_rollout(&std::path::Path::new(&home).join("sessions"), thread_id) + }; + let Some(rollout) = rollout else { return None; // still prospective: the next turn-completed edge retries }; let target = freshell_protocol::session_names::SessionNameRef::Session { @@ -1515,6 +1768,40 @@ impl FreshCodexState { evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, persistence: freshell_protocol::native_location::NativePersistence::Verified, }; + if !self + .sessions + .lock() + .await + .get(thread_id) + .is_some_and(|session| Arc::ptr_eq(&session.projection, &source_projection)) + { + return None; + } + let Some(handle) = handle else { + // Established mint-new recovery has no pending name to transfer. + // The same verified-header boundary admits its own durable name, + // and also repairs an unnamed restored session without relabeling + // the identity transition or starting another provider. + self.report_hosted_naming_fact( + thread_id, + crate::naming::HostedCodexNamingFact::Acquisition { + target, + acquisition, + cwd, + }, + ) + .await; + let activity = source_projection + .lock() + .expect("codex projection lock") + .hosted_naming_activity(); + if let Some(activity) = activity { + if let Ok(activity) = serde_json::from_value(activity) { + self.report_hosted_naming_fact(thread_id, activity).await; + } + } + return None; + }; // Unified agent names (Task 4, T2-M5): the rollout-verified // pending→durable transition is an InitialMaterialization — the // shared classification-driven fold owns the policy, and the @@ -1524,6 +1811,20 @@ impl FreshCodexState { acquisition, pending: Some(handle), }; + if self.hosted_naming_events { + self.report_hosted_naming_fact( + thread_id, + crate::naming::HostedCodexNamingFact::Transition { + target: target.clone(), + transition: transition.clone(), + }, + ) + .await; + // This is the provider's verified materialization fact. Canonical + // acceptance remains in the gateway; the retained source keeps + // this fact available even if its final notification was dropped. + self.naming_handles.lock().await.remove(thread_id); + } match crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) .await { @@ -1665,6 +1966,7 @@ impl FreshCodexState { permission_mode: Option<&str>, effort: Option<&str>, cwd: Option<&str>, + collaboration_mode: Option, supersedes: Option<&str>, // D8 provenance stamps — `Some` from connection-scoped creates and // from the fork lane (the RESOLVED fork provenance: forking @@ -1693,6 +1995,7 @@ impl FreshCodexState { return Ok(()); }; let settings = crate::identity_sink::FreshAgentSettings { + collaboration_mode: collaboration_mode.map(|mode| mode.as_str().into()), model: if model.is_empty() { None } else { @@ -1819,7 +2122,241 @@ impl FreshCodexState { } } + async fn retain_native_exit(&self, session_id: &str, ownership_id: &str) { + let projection = self + .sessions + .lock() + .await + .get(session_id) + .filter(|session| session.sidecar_ownership_id == ownership_id) + .map(|session| session.projection.clone()); + if let Some(projection) = projection { + projection + .lock() + .expect("codex projection lock") + .retire_source(); + self.clear_source_controls(session_id, &projection).await; + } + } + + fn notify_retained(&self) { + self.retained_changed + .send_modify(|version| *version = version.wrapping_add(1)); + } + + /// A coalesced wakeup, never a history cursor or a provider RPC. + pub fn subscribe_retained_state(&self, _session_id: &str) -> tokio::sync::watch::Receiver { + self.retained_changed.subscribe() + } + + /// Current native facts, including unfinished event-owned output. Reading + /// this source cannot create/resume a runtime or wait for native history. + pub async fn retained_state(&self, session_id: &str) -> Result { + let resolved = { + let sessions = self.sessions.lock().await; + if sessions.contains_key(session_id) { + session_id.to_owned() + } else { + let mut aliases = self + .attention + .aliases_for(session_id) + .into_iter() + .filter(|id| sessions.contains_key(id)); + let resolved = aliases.next().ok_or(CodexSnapshotError::NotFound)?; + if aliases.next().is_some() { + return Err(CodexSnapshotError::NotFound); + } + resolved + } + }; + let session_id = resolved.as_str(); + let (projection, turns, exited, history_mode, quiet) = self + .sessions + .lock() + .await + .get(session_id) + .map(|s| { + ( + s.projection.clone(), + s.turn_state.clone(), + s.exited.clone(), + s.history_mode, + s.quiet_deadman.clone(), + ) + }) + .ok_or(CodexSnapshotError::NotFound)?; + let owner = self.ownership_snapshot(PROVIDER, session_id); + let stuck = quiet.lock().expect("quiet deadman lock").is_stuck(); + let mut snapshot = { + let p = projection.lock().expect("codex projection lock"); + let mut retained = p + .retained_snapshot(exited.load(Ordering::SeqCst)) + .map_err(|_| CodexSnapshotError::NotFound)?; + if let Some(status) = p.live_status() { + if matches!(status, "idle" | "running" | "stuck") { + retained.raw["thread"]["status"] = + json!({"type":if status == "idle" {"idle"} else {"active"}}); + } + } + let mut snapshot = build_codex_snapshot_json( + session_id, + &retained.raw, + false, + history_mode, + None, + stuck, + ) + .map_err(CodexSnapshotError::Protocol)?; + p.overlay(&mut snapshot, retained.metadata); + snapshot + }; + if turns.lock().expect("codex turn state").has_owed_work() && !exited.load(Ordering::SeqCst) + { + snapshot["status"] = json!(if stuck { "stuck" } else { "running" }); + snapshot["capabilities"]["send"] = json!(false); + snapshot["capabilities"]["interrupt"] = json!(true); + } + if exited.load(Ordering::SeqCst) { + snapshot["status"] = json!("exited"); + } + self.overlay_controls(session_id, &mut snapshot).await; + self.overlay_settings_snapshot(session_id, &mut snapshot) + .await; + let pending = self.retained_control_events(session_id).await; + let marks = self + .attention_marks() + .await + .into_iter() + .filter(|m| m.session_id == session_id) + .collect::>(); + let after = self.ownership_snapshot(PROVIDER, session_id); + if owner.epoch != after.epoch + || owner.generation != after.generation + || !self + .sessions + .lock() + .await + .get(session_id) + .is_some_and(|s| Arc::ptr_eq(&s.projection, &projection)) + { + return Err(CodexSnapshotError::HandoffInProgress { + generation: after.generation, + }); + } + Ok( + json!({"source":{"projectionId":snapshot["extensions"]["codex"]["projection"]["id"],"ownerEpoch":owner.epoch,"ownerGeneration":owner.generation},"snapshot":snapshot,"attentionMarks":marks,"pendingControls":pending}), + ) + } + + async fn catalog_binding( + &self, + context: &crate::model_capabilities::CodexCatalogContext, + ) -> Result<(String, Arc), crate::model_capabilities::CapabilityError> + { + use crate::model_capabilities::{runtime_pending, source_changed}; + let requested = context.native_reference()?; + let id = self + .ownership + .as_ref() + .map(|registry| registry.resolve_canonical(PROVIDER, requested)) + .unwrap_or_else(|| requested.to_string()); + let owner = self.ownership_snapshot(PROVIDER, &id); + if self.ownership.is_some() + && !matches!(&owner.state, freshell_ownership::OwnershipState::Live {owner,..} if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent) + { + return Err(runtime_pending()); + } + if context.observed_epoch.is_some_and(|v| v != owner.epoch) + || context + .observed_generation + .is_some_and(|v| v != owner.generation) + { + return Err(source_changed()); + } + let (key, client, exited) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(&id).ok_or_else(runtime_pending)?; + ( + json!([ + "codex", + id, + session.sidecar_ownership_id, + format!("{:p}", Arc::as_ptr(&session.client)), + format!("{:p}", Arc::as_ptr(&session.projection)), + session.cwd, + owner.epoch, + owner.generation, + format!("{:?}", owner.state) + ]) + .to_string(), + session.client.clone(), + session.exited.load(Ordering::SeqCst), + ) + }; + if exited || !client.is_initialized().await { + return Err(runtime_pending()); + } + Ok((key, client)) + } + + /// Registered-client discovery only: never spawn, resume, read saved history, + /// or search attention aliases to manufacture a catalog authority. + pub async fn model_catalog_source( + &self, + context: crate::model_capabilities::CodexCatalogContext, + ) -> Result< + crate::model_capabilities::CodexCatalogSource, + crate::model_capabilities::CapabilityError, + > { + let (key, _) = self.catalog_binding(&context).await?; + Ok(crate::model_capabilities::CodexCatalogSource { + key: key.clone(), + probe: Arc::new(LocalCodexCatalogProbe { + state: self.clone(), + context, + expected_source: key, + }), + }) + } + + pub async fn model_catalog( + &self, + context: crate::model_capabilities::CodexCatalogContext, + ) -> Result { + use crate::model_capabilities::{codex_probe_error, source_changed}; + let (key, client) = self.catalog_binding(&context).await?; + let models = freshell_codex::catalog::read_models(&client).await.map_err(|error| { + tracing::warn!(provider=PROVIDER,session_id=%context.session_id,cause=%error,"freshagent.codex.catalog_unavailable"); + codex_probe_error(error) + })?; + if self.catalog_binding(&context).await?.0 != key { + return Err(source_changed()); + } + Ok(serde_json::to_value(models).expect("native catalog rows serialize")) + } + fn broadcast(&self, msg: &ServerMessage) { + match msg { + ServerMessage::FreshAgentCreated(created) => { + self.attention.observe(&created.session_id, SESSION_TYPE) + } + ServerMessage::FreshAgentSessionMaterialized(materialized) => { + self.attention + .alias(&materialized.previous_session_id, &materialized.session_id); + let mut controls = self.controls.lock().expect("codex controls registry"); + if let Some(retained) = controls.get(&materialized.previous_session_id).cloned() { + controls.insert(materialized.session_id.clone(), retained); + } + } + ServerMessage::FreshAgentKilled(killed) if killed.success => { + self.attention.forget(&killed.session_id) + } + ServerMessage::FreshAgentRecoveryStopped(stopped) if stopped.success => { + self.attention.forget(&stopped.session_id) + } + _ => {} + } + self.notify_retained(); if let Ok(frame) = serde_json::to_string(msg) { let _ = self.broadcast_tx.send(frame); } @@ -1863,7 +2400,7 @@ impl FreshCodexState { &self, msg: FreshAgentCreate, provenance: Option, - ) { + ) -> CodexCreateOutcome { let request_id = msg.request_id.clone(); // Unified agent names (Task 2): the pane's pre-durable naming handle // (threaded to both create lanes; `None` = an unnamed pane). @@ -1890,12 +2427,20 @@ impl FreshCodexState { session_type: SESSION_TYPE.to_string(), session_ref: Some(SessionLocator { provider: PROVIDER.to_string(), - session_id: cached.session_id, + session_id: cached.session_id.clone(), }), - name_ref, + name_ref: name_ref.clone(), session_name, })); - return; + return CodexCreateOutcome::Created { + session_id: cached.session_id.clone(), + session_ref: Some(SessionLocator { + provider: PROVIDER.into(), + session_id: cached.session_id, + }), + cleanup_ownership_id: None, + name_ref, + }; } FreshAgentCreateOutcome::Proceed(guard) => guard, }; @@ -1953,24 +2498,42 @@ impl FreshCodexState { .unwrap_or_default(), None => crate::identity_sink::FreshAgentSettings::default(), }; - let cwd = msg.cwd.clone().or(rec.cwd); - let raw_model = msg.model.clone().or(rec.model); - let model = normalize_freshcodex_model(raw_model.as_deref()); - let raw_effort = msg.effort.clone().or(rec.effort); - let effort = normalize_freshcodex_effort(Some(&model), raw_effort.as_deref()); - let mut sandbox = msg.sandbox.map(sandbox_wire_value).or(rec.sandbox); - let mut permission_mode = msg.permission_mode.clone().or(rec.permission_mode); - if let Err(error) = normalize_codex_permission(&mut permission_mode, &mut sandbox) { - self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", error); - return; - } - - // Validate the effort maps to the codex wire vocabulary (adapter create calls - // toCodexReasoningEffort purely to reject unsupported efforts before spawning). - if let Err(err) = to_codex_reasoning_effort(effort.as_deref()) { - self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", &err.to_string()); - return; - } + let raw_model = msg + .model + .clone() + .or_else(|| { + msg.model_selection + .as_ref() + .and_then(|selection| selection.as_ref()) + .map(|selection| selection.model_id.clone()) + }) + .or(rec.model); + let base = CodexEffectiveSettings { + model: raw_model.unwrap_or_default(), + effort: msg.effort.clone().unwrap_or(rec.effort), + cwd: msg.cwd.clone().or(rec.cwd), + sandbox: msg.sandbox.map(sandbox_wire_value).or(rec.sandbox), + permission_mode: msg.permission_mode.clone().or(rec.permission_mode), + collaboration_mode: None, + }; + let patch = freshell_protocol::FreshAgentSendSettings { + collaboration_mode: msg.collaboration_mode.clone().or(rec.collaboration_mode), + ..Default::default() + }; + let effective = match CodexEffectiveSettings::resolve(&base, &patch) { + Ok(settings) => settings, + Err(error) => { + return self.fail_create(&request_id, error.code, &error.message); + } + }; + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; // FIX (CODEX-FIRST triage Finding 1): a `freshAgent.create` carrying // `resumeSessionId` must RESUME the existing thread -- mirroring the reference's @@ -1990,8 +2553,7 @@ impl FreshCodexState { tracing::warn!(target: "freshell_freshagent::codex", session_id = %resume_session_id, request_id = %request_id, "fresh_agent_create_refused: a live terminal PTY owns this session (Task 13b cross-kind live-guard)"); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } // Task 12 (D8 for fresh agents): claim the per-sessionRef lease BEFORE any // spawn -- exactly one in-flight resume (and one live rollout writer) per @@ -2024,8 +2586,7 @@ impl FreshCodexState { session_id = %resume_session_id, request_id = %request_id, code = err.code(), "fresh_agent_create_refused: the observed fence is half-sent (invalid)"); - self.fail_create(&request_id, err.code(), err.message()); - return; + return self.fail_create(&request_id, err.code(), err.message()); } }; let own_ticket = match self.begin_lane_claim_at( @@ -2096,21 +2657,20 @@ impl FreshCodexState { moved on while the live-runtime adopt committed — the \ create is refused (never a live writer left unowned)" ); - self.fail_create( + return self.fail_create( &request_id, "STALE_CLAIM", "ownership moved on while adopting the live session; refresh and retry", ); - return; } } // D8 (focused-ep1-r4 Finding 1): the adopt re-parks/re-stamps // the CURRENT connection's provenance — recovery plans mint // NEW tabIds, so the incumbent must not keep the old tab's // attribution. - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } Some(ticket) } @@ -2119,9 +2679,9 @@ impl FreshCodexState { // session adopts, spawn nothing. With no coordinator // there is no fence to consult. if self.has_live_session(&resume_session_id).await { - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } None } @@ -2129,9 +2689,9 @@ impl FreshCodexState { // A same-kind live runtime the coordinator names — // adopt it, spawn nothing (the COORDINATED map-hit: // the claim already verified the key's state). - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } crate::ownership_lane::LaneClaim::Refused(outcome) => { tracing::warn!(target: "freshell_freshagent::codex", @@ -2140,8 +2700,15 @@ impl FreshCodexState { "fresh_agent_create_refused: the ownership coordinator refused the \ claim (kata b8ke cross-kind authority)" ); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_with_owner( + &request_id, + "SESSION_RESERVED", + "Another resume for this session is in flight", + crate::ownership_lane::fresh_agent_owner_fields_from_outcome( + &self.ownership, + &outcome, + ), + ); } }; let mut lease_guard: Option = None; @@ -2165,13 +2732,12 @@ impl FreshCodexState { // Under-lock ADOPT (the V5 TOCTOU window). D8 // (focused-ep1-r4 Finding 1): re-parks/re-stamps the // CURRENT connection's provenance on the incumbent. - self.adopt_live_create(&request_id, &resume_session_id, provenance.clone()) + return self + .adopt_live_create(&request_id, &resume_session_id, provenance.clone()) .await; - return; } crate::session_lease::FreshSessionClaim::Held { .. } => { - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, @@ -2191,30 +2757,30 @@ impl FreshCodexState { } tracing::error!(target: "invariant", pid, session_id = %resume_session_id, "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } } } - self.handle_create_resume( - request_id, - resume_session_id, - cwd, - model, - effort, - sandbox, - permission_mode, - lease_guard, - own_ticket, - provenance, - naming_handle, - ) - .await; - return; - } - - // b8ke ext r22 F1: the fresh create is coordinator-owned from BEFORE - // spawn — the pane-scoped provisional claim (the createRequestId the + return self + .handle_create_resume( + request_id, + resume_session_id, + cwd, + model, + effort, + sandbox, + permission_mode, + collaboration_mode, + lease_guard, + own_ticket, + provenance, + naming_handle, + ) + .await; + } + + // b8ke ext r22 F1: the fresh create is coordinator-owned from BEFORE + // spawn — the pane-scoped provisional claim (the createRequestId the // caller already carries; the established pre-spawn-claim discipline // of the resume paths). The whole spawn window (sidecar boot + // thread/start) then has a Starting generation, a cancellation @@ -2256,9 +2822,9 @@ impl FreshCodexState { // an earlier create for this pane completed (the key is // Aliased{to: canonical}); the idempotent re-drive ADOPTS // the live session, never a second spawn. - self.adopt_live_create(&request_id, &claim_key, provenance.clone()) + return self + .adopt_live_create(&request_id, &claim_key, provenance.clone()) .await; - return; } crate::ownership_lane::LaneClaim::Refused(outcome) => { tracing::warn!(target: "freshell_freshagent::codex", @@ -2267,8 +2833,15 @@ impl FreshCodexState { "fresh_agent_create_refused: the ownership coordinator refused the \ fresh create's provisional claim (kata b8ke ext r22 F1)" ); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_with_owner( + &request_id, + "SESSION_RESERVED", + "Another resume for this session is in flight", + crate::ownership_lane::fresh_agent_owner_fields_from_outcome( + &self.ownership, + &outcome, + ), + ); } } @@ -2276,8 +2849,7 @@ impl FreshCodexState { let (client, notifs, ownership_id, child) = match self.spawn_sidecar(cwd.as_deref()).await { Ok(parts) => parts, Err(err) => { - self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); - return; + return self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); } }; @@ -2309,19 +2881,41 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), }) .await; - let thread_id = match started { - Ok(started) => started.thread_id, + let started = match started { + Ok(started) => started, Err(err) => { client.close().await; let mut child = child; let _ = child.start_kill(); reap_owned_codex_sidecars(&ownership_id); crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); - self.fail_create(&request_id, "CODEX_THREAD_START_FAILED", &err.to_string()); - return; + return self.fail_create( + &request_id, + "CODEX_THREAD_START_FAILED", + &err.to_string(), + ); } }; + let thread_id = started.thread_id.clone(); + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; + // b8ke ext r22 F1: the MINT-TIME REKEY — the SAME ticket's // in-flight record moves from the pane-scoped provisional key to // the canonical thread id in ONE atomic step (`rekey_starting`: @@ -2368,12 +2962,11 @@ impl FreshCodexState { let _ = child.start_kill(); reap_owned_codex_sidecars(&ownership_id); crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "the minted thread id was claimed by another owner during the create; torn down", ); - return; } } } @@ -2390,6 +2983,8 @@ impl FreshCodexState { cwd, sandbox, permission_mode, + collaboration_mode, + started.effective_settings.clone(), None, // Threads freshell starts are paginated (we SET the mode at start). Some(HistoryMode::Paginated), @@ -2399,7 +2994,7 @@ impl FreshCodexState { None, naming_handle, ) - .await; + .await } /// The resume branch of `handle_create` (FINDING 1 -- CODEX-FIRST triage): spawn a @@ -2434,6 +3029,7 @@ impl FreshCodexState { effort: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, mut lease_guard: Option, // kata b8ke Task 3: the coordinator claim ticket from // `handle_create`'s resume lane — every failure return below drops @@ -2444,7 +3040,7 @@ impl FreshCodexState { provenance: Option, // Unified agent names (Task 2): the pane's pre-durable naming handle. naming_handle: Option, - ) { + ) -> CodexCreateOutcome { let _ = &mut own_ticket; // moved to finish_create below on the success path // b8ke focused episode-2 round-2 F4: the claim exists — register the // watchdog machinery NOW, before the spawn + thread/resume awaits (up @@ -2474,12 +3070,11 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!("codex thread {resume_session_id} not found"), ); - return; } // Round 4 (focused-ep5-r3 Finding 1): the claim's dead-state @@ -2521,8 +3116,7 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); - return; + return self.fail_create(&request_id, "CODEX_APP_SERVER_START_FAILED", &err); } }; // Task 12: arm the TTL tree-kill path now that the child + its tag exist. @@ -2561,8 +3155,11 @@ impl FreshCodexState { if is_codex_thread_not_found(&err) { self.mark_thread_dead(&resume_session_id).await; } - self.fail_create(&request_id, "FRESH_AGENT_CREATE_FAILED", &err.to_string()); - return; + return self.fail_create( + &request_id, + "FRESH_AGENT_CREATE_FAILED", + &err.to_string(), + ); } }; // TERM-25: never silently proceed against the wrong thread. `thread/resume` @@ -2583,7 +3180,7 @@ impl FreshCodexState { returned = %started.thread_id, "freshagent.codex.wrong_thread_resume_rejected" ); - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!( @@ -2592,9 +3189,25 @@ impl FreshCodexState { started.thread_id, resume_session_id ), ); - return; } let thread_id = resume_session_id; + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; self.clear_dead_thread(&thread_id).await; // Retire-on-kill round 2/3 (focused-ep5-r1 Finding 2, -r2 Finding @@ -2626,14 +3239,13 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!( "codex thread {thread_id} closed while the resume was in flight; torn down" ), ); - return; } // Kata 1wxv Task 2 (r3): the durable rollout meta is the ONE source of @@ -2665,6 +3277,8 @@ impl FreshCodexState { cwd, sandbox, permission_mode, + collaboration_mode, + started.effective_settings.clone(), lease_guard, history_mode, own_ticket, @@ -2676,7 +3290,7 @@ impl FreshCodexState { Some((thread_id.as_str(), claim_dead_state)), naming_handle, ) - .await; + .await } /// Shared tail of `handle_create` and `handle_create_resume`: register the session @@ -2699,6 +3313,8 @@ impl FreshCodexState { cwd: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, + native_thread_settings: Value, mut lease_guard: Option, // Kata 1wxv Task 2: the thread's durable history mode — `Some(Paginated)` // for threads freshell started, the rollout-meta parse for resumes. @@ -2725,7 +3341,7 @@ impl FreshCodexState { // (None = an unnamed pane; the frame then carries the durable // session's own projection when a record exists). naming_handle: Option, - ) { + ) -> CodexCreateOutcome { let _ = &mut own_ticket; // consumed at the commit below on the success path // D8 (focused-ep1-r5 Finding 2): a HOLLOW `Some` (a partially // initialized client's hello — all fields absent) behaves like `None` @@ -2735,6 +3351,7 @@ impl FreshCodexState { // kata b8ke Task 3: the sidecar pid for the commit's owner identity // (captured before `child` moves into the exit watcher). let sidecar_pid = child.id(); + let cleanup_ownership_id = Some(ownership_id.clone()); // Unified agent names (Task 2): capture the app-server's initialized // `codexHome` BEFORE `client` moves into the session registration @@ -2769,9 +3386,9 @@ impl FreshCodexState { // re-parks/re-stamps the CURRENT connection's provenance on the // incumbent (this arm returns immediately, so moving `provenance` // in is safe). - self.adopt_live_create(&request_id, &thread_id, provenance) + return self + .adopt_live_create(&request_id, &thread_id, provenance) .await; - return; } // Legacy `activeTurnByThread` mirror for THIS session (adapter.ts:295) -- set on @@ -2809,10 +3426,16 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))); let (created_tx, created_rx) = oneshot::channel(); let consumer = self.spawn_consumer_after( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: thread_id.clone(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -2824,6 +3447,7 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, Some(created_rx), ); @@ -2836,7 +3460,7 @@ impl FreshCodexState { // b8ke focused round-2 R2-1: the condemned-prior tag (the pid was // captured above for the coordinator commit). let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, thread_id.clone(), @@ -2850,11 +3474,17 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), + Some(self.clone()), ); self.sessions.lock().await.insert( thread_id.clone(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(native_thread_settings), + turn_state: turn_state.clone(), client, model: model.clone(), effort: effort.clone(), @@ -2868,6 +3498,7 @@ impl FreshCodexState { history_mode, turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -2914,14 +3545,13 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", &format!( "codex thread {thread_id} closed while the resume was in flight; torn down" ), ); - return; } } @@ -2945,12 +3575,11 @@ impl FreshCodexState { if let Some((claim_id, _)) = claim { self.rollback_session_claim(claim_id).await; } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "session lease revoked during create; torn down", ); - return; } } @@ -2994,6 +3623,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, None, provenance.as_ref(), // Unified agent names (Task 4, T2-M5): the naming classification @@ -3025,12 +3655,11 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "the session's resume record could not be persisted; torn down", ); - return; } if let Err(outcome) = self.commit_lane_claim_at(&mut own_ticket, &thread_id, &thread_id, sidecar_pid) @@ -3049,12 +3678,11 @@ impl FreshCodexState { if let Some(mut g) = lease_guard.take() { g.fail(); } - self.fail_create( + return self.fail_create( &request_id, "FRESH_AGENT_CREATE_FAILED", "session ownership changed during create; torn down", ); - return; } // DIAG-01: fresh-agent session lifecycle -- provider/session_id/cwd, @@ -3116,7 +3744,7 @@ impl FreshCodexState { // bind, so the classification is not yet verified at upsert time). if claim.is_some() { if let Some(transition) = self - .try_bind_pending_naming(&thread_id, codex_home.as_deref()) + .try_materialize_naming(&thread_id, codex_home.as_deref()) .await { tracing::debug!( @@ -3139,9 +3767,9 @@ impl FreshCodexState { session_type: SESSION_TYPE.to_string(), session_ref: Some(SessionLocator { provider: PROVIDER.to_string(), - session_id: thread_id, + session_id: thread_id.clone(), }), - name_ref, + name_ref: name_ref.clone(), session_name, })); @@ -3150,6 +3778,27 @@ impl FreshCodexState { // notification already buffered on `notifs` is only delivered to the consumer // (and thus only broadcast) from this point on. let _ = created_tx.send(()); + self.publish_codex_settings( + &thread_id, + &CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }, + None, + ); + CodexCreateOutcome::Created { + session_id: thread_id.clone(), + session_ref: Some(SessionLocator { + provider: PROVIDER.into(), + session_id: thread_id, + }), + cleanup_ownership_id, + name_ref, + } } /// REVIEW FIX (item 3): legacy's `freshAgent.create.failed` sends `retryable: true` on @@ -3163,28 +3812,53 @@ impl FreshCodexState { /// so this was a real, user-visible gap: every Rust `fail_create` call previously sent /// no `retryable` field at all (serde omits `None`), silently hiding the retry button /// legacy always offers. - fn fail_create(&self, request_id: &str, code: &str, message: &str) { - self.broadcast(&ServerMessage::FreshAgentCreateFailed( - FreshAgentCreateFailed { - owner_kind: None, - owner_generation: None, - owner_epoch: None, - code: code.to_string(), - message: message.to_string(), - request_id: request_id.to_string(), - retryable: Some(true), - }, - )); + fn fail_create(&self, request_id: &str, code: &str, message: &str) -> CodexCreateOutcome { + self.fail_create_with_owner(request_id, code, message, None) + } + + fn fail_create_with_owner( + &self, + request_id: &str, + code: &str, + message: &str, + owner: Option, + ) -> CodexCreateOutcome { + let outcome = CodexCreateOutcome::Failed { + code: code.into(), + message: message.into(), + retryable: Some(true), + owner, + }; + if let CodexCreateOutcome::Failed { + code, + message, + retryable, + owner, + } = &outcome + { + self.broadcast(&ServerMessage::FreshAgentCreateFailed( + FreshAgentCreateFailed { + owner_kind: owner.as_ref().map(|owner| owner.owner_kind.into()), + owner_generation: owner.as_ref().map(|owner| owner.owner_generation), + owner_epoch: owner.as_ref().map(|owner| owner.owner_epoch), + code: code.clone(), + message: message.clone(), + request_id: request_id.into(), + retryable: *retryable, + }, + )); + } + outcome } /// The D8 loser answer (Task 12): reuses the existing create-failed frame with the /// fixed reservation code — NO new protocol fields. - fn fail_create_session_reserved(&self, request_id: &str) { + fn fail_create_session_reserved(&self, request_id: &str) -> CodexCreateOutcome { self.fail_create( request_id, "SESSION_RESERVED", "Another resume for this session is in flight", - ); + ) } /// The HAS-LIVE→ADOPT arm (Task 12, V1): answer a loser's create-with-resume with a @@ -3209,7 +3883,7 @@ impl FreshCodexState { request_id: &str, thread_id: &str, provenance: Option, - ) { + ) -> CodexCreateOutcome { if let Some(p) = provenance.filter(|p| p.is_meaningful()) { // Re-park + snapshot the incumbent's settings in one lock scope // (the refresh write below asserts the parked identity together @@ -3227,10 +3901,12 @@ impl FreshCodexState { s.cwd.clone(), s.sandbox.clone(), s.permission_mode.clone(), + s.collaboration_mode, ) }) }; - if let Some((model, effort, cwd, sandbox, permission_mode)) = parked { + if let Some((model, effort, cwd, sandbox, permission_mode, collaboration_mode)) = parked + { let _ = self .record_codex_binding( thread_id, @@ -3240,6 +3916,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, None, Some(&p), // Unified agent names: a refresh carries no naming fact. @@ -3286,9 +3963,18 @@ impl FreshCodexState { provider: PROVIDER.to_string(), session_id: thread_id.to_string(), }), - name_ref, + name_ref: name_ref.clone(), session_name, })); + CodexCreateOutcome::Created { + session_id: thread_id.into(), + session_ref: Some(SessionLocator { + provider: PROVIDER.into(), + session_id: thread_id.into(), + }), + cleanup_ownership_id: None, + name_ref, + } } // ── freshAgent.send (WS) ───────────────────────────────────────────────── @@ -3301,7 +3987,11 @@ impl FreshCodexState { /// `ensureRuntime` lazy-restart invariant, adapter.ts:935-946) -- the ONLY visible effect /// is added latency on this one send; there is no user-facing error frame for a /// self-healed crash. - pub async fn handle_send(&self, msg: FreshAgentSend) { + pub(crate) fn subscribe_turn_events(&self) -> tokio::sync::broadcast::Receiver { + self.turn_events.subscribe() + } + + pub async fn handle_send(&self, msg: FreshAgentSend) -> CodexSendOutcome { let request_id = msg.request_id.clone(); let checkpoint_identity_request = msg.checkpoint_intent.as_ref().and_then(|intent| { request_id @@ -3322,8 +4012,7 @@ impl FreshCodexState { tracing::warn!(target: "freshell_freshagent::codex", session_id = %msg.session_id, code = err.code(), "fresh_agent_send_refused: the observed fence is half-sent (invalid)"); - self.send_error(&request_id, err.code(), err.message()); - return; + return self.refuse_send(&request_id, err.code(), err.message()); } }; @@ -3336,22 +4025,29 @@ impl FreshCodexState { session_id = new_session_id; } Err(EnsureAliveError::NotFound) => { - self.send_error(&request_id, "SESSION_NOT_FOUND", "codex session not found"); - return; + return self.refuse_send( + &request_id, + "SESSION_NOT_FOUND", + "codex session not found", + ); } Err(EnsureAliveError::RespawnFailed(err)) => { - self.send_error(&request_id, "CODEX_RESPAWN_FAILED", &err); - return; + return self.refuse_send(&request_id, "CODEX_RESPAWN_FAILED", &err); } Err(EnsureAliveError::Reserved) => { // Task 13 (D8): another create/attach holds this sessionRef -- the // retryable non-lost error channel; the client re-drive converges. + let outcome = self.refuse_send( + &request_id, + "SESSION_RESERVED", + "Another resume for this session is in flight", + ); self.emit_fresh_agent_error( &session_id, "SESSION_RESERVED", "Another resume for this session is in flight", ); - return; + return outcome; } } @@ -3389,6 +4085,7 @@ impl FreshCodexState { guard.get(&session_id).map(|s| { ( s.client.clone(), + s.turn_state.clone(), s.active_turn.clone(), s.turn_in_flight.clone(), // FR1-1: the install-site void of the retired-turn slot. @@ -3401,11 +4098,13 @@ impl FreshCodexState { s.quiet_deadman.clone(), s.exited.clone(), s.checkpoint_probe_cancelled.clone(), + s.sidecar_ownership_id.clone(), ) }) }; let Some(( client, + turn_state, active_turn, turn_in_flight, retired_turn_id, @@ -3414,10 +4113,10 @@ impl FreshCodexState { quiet_deadman, exited, checkpoint_probe_cancelled, + incarnation_id, )) = looked_up else { - self.send_error(&request_id, "SESSION_NOT_FOUND", "codex session not found"); - return; + return self.refuse_send(&request_id, "SESSION_NOT_FOUND", "codex session not found"); }; // Kata 1wxv Task 2 (r2 serialization discipline): take the session's @@ -3430,7 +4129,104 @@ impl FreshCodexState { // this lock, the send simply waits, then proceeds and destroys redo // ("send waits, rollback wins, then destroys"). let _turn = turn_lock.lock().await; + wait_for_codex_stop_resolution(&turn_state).await; + + // Read defaults after acquiring the turn lock: a queued send inherits the + // last accepted settings, while this message's explicit choices win. + let stored = self + .sessions + .lock() + .await + .get(&session_id) + .map(CodexSession::effective_settings); + let Some(stored) = stored else { + return self.refuse_send(&request_id, "SESSION_NOT_FOUND", "codex session not found"); + }; + let mut patch = msg.settings.clone().unwrap_or_default(); + if patch.cwd.is_none() && stored.cwd.is_none() { + patch.cwd = cwd; + } + let effective = match CodexEffectiveSettings::resolve(&stored, &patch) { + Ok(settings) => settings, + Err(error) => { + return self.refuse_send(&request_id, error.code, &error.message); + } + }; + let model = effective.model.clone(); + let effort = effective.effort.clone(); + let turn_cwd = effective.cwd.clone(); + let sandbox = effective.sandbox.clone(); + let permission_mode = effective.permission_mode.clone(); + let wire_choice = if effort.as_deref() == Some("max") { + match freshell_codex::catalog::read_models(&client).await { + Ok(catalog) => Some(freshell_codex::catalog::resolve_legacy_effort( + &model, "max", &catalog, + )), + Err(error) => { + tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?request_id, cause=%error, "freshagent.codex.effort_catalog_unavailable"); + effort.clone() + } + } + } else { + effort.clone() + }; + let wire_effort = match to_codex_reasoning_effort(wire_choice.as_deref()) { + Ok(value) => value, + Err(error) => { + return self.refuse_send(&request_id, "INVALID_EFFORT", &error.to_string()); + } + }; + let native_settings = CodexEffectiveSettings { + effort: wire_effort.clone(), + ..effective.clone() + }; + + let params = StartTurnParams { + collaboration_mode: native_settings.native_collaboration_mode(), + thread_id: session_id.clone(), + input: codex_user_input(&msg.text, msg.images.as_deref()), + cwd: turn_cwd.clone(), + model: Some(model.clone()), + // Preserve exact native intent, with only the advertised legacy alias above. + effort: wire_effort, + sandbox_policy: sandbox.as_deref().map(sandbox_policy_value), + approval_policy: permission_mode.as_deref().map(|p| json!(p)), + }; + // The emission barrier keeps acceptance before terminal publication. + // Recheck Stop while atomically recording dispatch ownership: a Stop + // arriving after the first wait must either block issuance or latch on + // this exact new dispatch. No consumer lock is held across the wait. + let (_event_emission, expected_turn_id, dispatch) = loop { + let emission = event_emission_gate.lock().await; + let active = active_turn.lock().expect("active_turn mutex").clone(); + let selected = { + let mut turns = turn_state.lock().expect("codex turn state"); + if let Some(active) = active.as_deref() { + turns.observe_started(active); + } + if turns.has_unresolved_stop() { + None + } else { + if turns.has_owned_exit() { + return self.refuse_send(&request_id, "SESSION_RESERVED", "The owned Codex runtime exited while this input waited; refresh before sending again"); + } + let expected = turns.active_turn_id().map(str::to_owned); + if expected.is_none() && turns.has_unknown_delivery() { + return self.refuse_send(&request_id, "CODEX_DELIVERY_AMBIGUOUS", "Earlier input has unresolved native delivery; recover or inspect the conversation before sending again"); + } + Some(( + expected, + turns.begin_dispatch(request_id.as_deref().unwrap_or_default()), + )) + } + }; + if let Some((expected, dispatch)) = selected { + break (emission, expected, dispatch); + } + drop(emission); + wait_for_codex_stop_resolution(&turn_state).await; + }; // The checkpoint workspace barrier can park a send outside this // handler while ownership changes. Recheck the captured wire fence // after the turn lock so a delayed send cannot reach a replacement @@ -3453,52 +4249,17 @@ impl FreshCodexState { tracing::warn!(target: "freshell_freshagent::codex", session_id = %session_id, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); - self.send_error(&request_id, "SESSION_RESERVED", &message); - return; + turn_state + .lock() + .expect("codex turn state") + .reject(dispatch); + return self.refuse_send(&request_id, "SESSION_RESERVED", &message); } } } else { None }; - // Read defaults after acquiring the turn lock: a queued send inherits the - // last accepted settings, while this message's explicit choices win. - let stored = { - let sessions = self.sessions.lock().await; - sessions.get(&session_id).map(|s| { - ( - s.model.clone(), - s.effort.clone(), - s.cwd.clone(), - s.sandbox.clone(), - s.permission_mode.clone(), - ) - }) - }; - let Some((stored_model, stored_effort, stored_cwd, stored_sandbox, stored_permission)) = - stored - else { - self.send_error(&request_id, "SESSION_NOT_FOUND", "codex session not found"); - return; - }; - let settings = msg.settings.as_ref(); - let model = settings - .and_then(|s| s.model.clone()) - .unwrap_or(stored_model); - let effort = settings.and_then(|s| s.effort.clone()).or(stored_effort); - let turn_cwd = settings.and_then(|s| s.cwd.clone()).or(stored_cwd).or(cwd); - let mut sandbox = settings - .and_then(|s| s.sandbox) - .map(sandbox_wire_value) - .or(stored_sandbox); - let mut permission_mode = settings - .and_then(|s| s.permission_mode.clone()) - .or(stored_permission); - if let Err(error) = normalize_codex_permission(&mut permission_mode, &mut sandbox) { - self.send_error(&request_id, "INVALID_PERMISSION_MODE", error); - return; - } - // Decision 5 (codex/opencode/claude share this helper): any new // submission permanently destroys redo. AWAITED before the turn goes // out; a ledger write failure is logged but never blocks the send. @@ -3513,111 +4274,87 @@ impl FreshCodexState { tracing::warn!(error = %err, session = %session_id, "freshagent.codex.destroy_redo_on_submit_failed"); } - // Re-normalize model/effort on send (adapter.ts:961-963) — idempotent for stored values. - let model = normalize_freshcodex_model(Some(&model)); - let effort = normalize_freshcodex_effort(Some(&model), effort.as_deref()); - let wire_effort = match to_codex_reasoning_effort(effort.as_deref()) { - Ok(value) => value, - Err(err) => { - self.send_error(&request_id, "INVALID_EFFORT", &err.to_string()); - return; - } - }; - - let params = StartTurnParams { - thread_id: session_id.clone(), - input: codex_user_input(&msg.text, msg.images.as_deref()), - cwd: turn_cwd.clone(), - model: Some(model.clone()), - // DEV-0003: none/minimal/low/medium/high forwarded VERBATIM; max/xhigh → xhigh. - effort: wire_effort, - sandbox_policy: sandbox.as_deref().map(sandbox_policy_value), - approval_policy: permission_mode.as_deref().map(|p| json!(p)), - }; - - // The app-server can write a terminal notification immediately after its - // turn/start response. Hold the session-local emission gate from before that - // RPC through the accepted broadcast below, so the client first receives the - // request-correlated submittedTurnId and then the completion-derived frames. - let _event_emission = event_emission_gate.lock().await; - - // The turn-in-flight latch + quiet window arm at DISPATCH time -- BEFORE - // awaiting the response: the independently-running exit watcher and the - // quiet-deadman waiter must classify this turn as in flight even while - // the response is still outstanding (a crash in that window ends a real - // dispatched turn; a post-response arm would classify it idle and miss - // the crash edge, or arm the deadman after the sidecar is already - // gone). The response-failure arm below disarms both ONLY when the - // sidecar is not known-dead. + let contextual_answers = self.contextual_answer_targets(&session_id, &msg.text).await; turn_in_flight.store(true, Ordering::SeqCst); note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); - - let submitted_turn_id = match client.start_turn(params).await { + let result = if let Some(expected) = expected_turn_id.as_deref() { + client.steer_turn(&session_id, expected, params.input).await + } else { + client.start_turn(params).await + }; + let (submitted_turn_id, latched_stop) = match result { Ok(started) => { - // adapter.ts:980 -- track the active turn immediately (before any - // turn/started notification), so a fast-follow interrupt has a target. - *active_turn.lock().expect("active_turn mutex") = Some(started.turn_id.clone()); - // FR1-1 (stale-slot protection): the new turn's install VOIDS - // the retired-turn slot a PREVIOUS turn's idle retirement may - // have left — a late completion for that older turn must never - // retire THIS turn's crash latch. The emission gate (held above - // across the RPC) keeps the consumer from folding any - // completion in this window, so the two writes are serialized - // against the reducer. The slot guard is sequenced with (never - // nested inside) the tracker guard. + let mut turns = turn_state.lock().expect("codex turn state"); + let latched_stop = turns.accept(dispatch, &started.turn_id); + let active = turns.active_turn_id().map(str::to_owned); + let owed = turns.has_owed_work(); + drop(turns); + *active_turn.lock().expect("active_turn mutex") = active; + turn_in_flight.store(owed, Ordering::SeqCst); *retired_turn_id.lock().expect("retired_turn_id mutex") = None; - // E3R1 (durable already-rang set): the install deliberately - // does NOT touch the set. The pre-fix install VOID reopened - // the gate for a PRIOR turn's late completion after a - // lifecycle-loss mint — a double bell. A stale set entry is - // safe: the gate matches turn ids, so it can never gate THIS - // new turn's own completion. - // A turn is now in flight: refresh the quiet window off the - // response (the sidecar has provably accepted the turn; the - // deadline resets from this lane-visible proof). note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); - started.turn_id + (started.turn_id, latched_stop) } Err(err) => { - // The dispatch/response failed. Retire the latch + disarm the - // dispatch-time window ONLY when the sidecar is NOT known-dead: - // an exit-induced failure (the production crash shape — the - // sidecar dies mid-dispatch and the transport's read loop fails - // the pending request with a connection-closed error) must - // LEAVE the latch armed so the exit watcher's crash arm owns - // the turn-end classification. This arm has no awaits, so a - // bare retirement would land before the crash arm's swap - // (which sits behind disarm + the `exited` broadcast + an - // awaited kill-and-confirm) and swallow the crash edge with - // near-certainty. The crash arm is guaranteed to run for a - // dead child; if it already ran, its swap minted the edge and - // leaving the now-false latch untouched is harmless. A - // requested kill also retires the latch silently and - // unconditionally, so a close-induced failure can never - // out-live it. Ordinary rejections (a live sidecar's RPC - // error, a timeout) still retire promptly. - let exit_induced = exited.load(Ordering::SeqCst) - || matches!(err, CodexAppServerError::Closed { .. }); - if !exit_induced { - turn_in_flight.store(false, Ordering::SeqCst); + let (refused, fact, owed) = { + let mut turns = turn_state.lock().expect("codex turn state"); + let refused = err.is_not_submitted() && turns.reject(dispatch); + if !refused { + turns.mark_ambiguous(dispatch); + } + ( + refused, + turns + .facts() + .iter() + .find(|fact| fact.token == dispatch) + .cloned(), + turns.has_owed_work(), + ) + }; + if !exited.load(Ordering::SeqCst) { + turn_in_flight.store(owed, Ordering::SeqCst); note_codex_activity(self, &session_id, &turn_in_flight, &quiet_deadman); } - self.send_error(&request_id, "CODEX_TURN_START_FAILED", &err.to_string()); - return; + tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?request_id, incarnation_id=%incarnation_id, dispatch=?dispatch, expected_turn_id=?expected_turn_id, refused, error=%err, "freshagent.codex.send_failed"); + if let Some(turn_id) = fact + .as_ref() + .filter(|fact| fact.delivery == turn_state::Delivery::Accepted) + .and_then(|fact| fact.turn_id.clone()) + { + // Observed native admission outweighs a contradictory RPC reply. + (turn_id, StopDisposition::None) + } else if refused { + return self.refuse_send( + &request_id, + "CODEX_TURN_START_FAILED", + &err.to_string(), + ); + } else { + self.send_error(&request_id, "CODEX_DELIVERY_AMBIGUOUS", &err.to_string()); + return CodexSendOutcome::Ambiguous { + session_id, + request_id: request_id.unwrap_or_default(), + incarnation_id, + code: "CODEX_DELIVERY_AMBIGUOUS".into(), + message: err.to_string(), + }; + } } }; + self.acknowledge_contextual_answers(&session_id, contextual_answers) + .await; drop(ownership_guard); // Only persist choices once the provider has accepted them. Failed sends // keep the previous working settings for resume and retry. let mut settings_changed = false; if let Some(session) = self.sessions.lock().await.get_mut(&session_id) { - settings_changed = session.model != model || session.effort != effort; - session.model = model.clone(); - session.effort = effort.clone(); - session.cwd = turn_cwd.clone(); - session.sandbox = sandbox.clone(); - session.permission_mode = permission_mode.clone(); + settings_changed = session.effective_settings() != effective; + session.apply_settings(&effective); + if expected_turn_id.is_none() { + session.native_turn_settings = Some(native_settings); + } } // b8ke ext r29 F3: the post-send binding refresh carries the // operation's CLAIM pair (captured above, right after ensure-alive) @@ -3643,6 +4380,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), turn_cwd.as_deref(), + effective.collaboration_mode, None, // Send/settings mutation is not a new browser assertion — conn-less // (merge keeps prior stamps, ep4 writer rules). @@ -3674,14 +4412,7 @@ impl FreshCodexState { // a device that missed the configure still lands here). Keyed at the // client-addressed id, the same stamp the accepted frame below uses. if settings_changed { - self.broadcast(&crate::session_metadata::session_metadata_frame( - PROVIDER, - SESSION_TYPE, - &session_id, - Some(model.as_str()), - effort.as_deref(), - None, - )); + self.publish_codex_settings(&session_id, &effective, request_id.as_deref()); } // Unified agent names (Task 4): the shared accepted-input callback — @@ -3689,12 +4420,9 @@ impl FreshCodexState { // target is the stashed pre-durable handle (it redirects once the // rollout bind lands), else the durable thread itself. A failed feed // never blocks the turn. - let naming_target = self - .naming_handles - .lock() - .await - .get(&session_id) - .cloned() + let pending_naming_handle = self.naming_handles.lock().await.get(&session_id).cloned(); + let naming_target = pending_naming_handle + .clone() .map(|handle| freshell_protocol::session_names::SessionNameRef::Pending { id: handle }) .unwrap_or_else( || freshell_protocol::session_names::SessionNameRef::Session { @@ -3702,6 +4430,20 @@ impl FreshCodexState { session_id: session_id.clone(), }, ); + if !msg.text.trim().is_empty() { + self.report_hosted_naming_fact( + &session_id, + crate::naming::HostedCodexNamingFact::Activity(crate::naming::NameActivity { + target: naming_target.clone(), + mode: SESSION_TYPE.into(), + event_id: submitted_turn_id.to_string(), + reason: crate::naming::NameActivityReason::AcceptedUserMessage, + first_user_message: Some(msg.text.trim().to_string()), + cwd: turn_cwd.clone(), + }), + ) + .await; + } crate::naming::report_accepted_input( &self.naming(), &naming_target, @@ -3752,6 +4494,33 @@ impl FreshCodexState { self.broadcast(&accepted); } + if expected_turn_id.is_some() && msg.settings.is_some() { + broadcast_codex_event( + &session_id, + json!({"type":"freshAgent.notice", "level":"info", "code":"CODEX_SETTINGS_STAGED", "message":"Settings are saved for the next turn; active input keeps this turn's settings."}), + &self.broadcast_tx, + ); + } + let outcome = CodexSendOutcome::Accepted { + session_id: session_id.clone(), + submitted_turn_id: submitted_turn_id.clone(), + incarnation_id, + pending_naming_handle, + }; + // Release both serialization locks before Stop RPC work. The consumer + // must remain free to observe terminal evidence while Stop is pending. + drop(_event_emission); + drop(_turn); + if let StopDisposition::Interrupt { dispatch, turn_id } = latched_stop { + self.issue_codex_interrupt( + &session_id, + client.clone(), + turn_state.clone(), + dispatch, + turn_id, + ) + .await; + } if let Some((request_id, logical_attempt_id)) = checkpoint_identity_request { drop( self.spawn_checkpoint_identity_probe(CodexCheckpointIdentityProbe { @@ -3766,6 +4535,7 @@ impl FreshCodexState { }), ); } + outcome } fn spawn_checkpoint_identity_probe( @@ -3886,11 +4656,26 @@ impl FreshCodexState { }) } - fn send_error(&self, request_id: &Option, code: &str, message: &str) { - self.broadcast(&ServerMessage::Error(ErrorMsg { - provider_preparation_failure: None, - owner_kind: None, - owner_generation: None, + fn refuse_send( + &self, + request_id: &Option, + code: &str, + message: &str, + ) -> CodexSendOutcome { + self.send_error(request_id, code, message); + CodexSendOutcome::Refused { + code: code.into(), + message: message.into(), + retryable: None, + owner: None, + } + } + + fn send_error(&self, request_id: &Option, code: &str, message: &str) { + self.broadcast(&ServerMessage::Error(ErrorMsg { + provider_preparation_failure: None, + owner_kind: None, + owner_generation: None, owner_epoch: None, code: ErrorCode::InternalError, message: format!("{code}: {message}"), @@ -3913,8 +4698,8 @@ impl FreshCodexState { /// re-snapshot the durable binding row, and broadcast /// `freshAgent.session.metadata` so every device's model surfaces converge /// immediately. Explicit choices win over the stored values, exactly like - /// `handle_send`'s settings merge; the effort is wire-validated up front - /// so the record never holds a value the next turn could not send. + /// `handle_send`'s settings merge. Nonempty effort strings stay exact for + /// native validation on the next turn. /// /// No busy refusal: recording mid-turn is safe — the in-flight turn keeps /// the parameters it already started with, and the next turn picks up the @@ -3922,179 +4707,255 @@ impl FreshCodexState { /// unknown session surfaces on the pane's error banner. pub async fn handle_configure(&self, msg: FreshAgentConfigure) { let session_id = msg.session_id.clone(); - let stored = { - let sessions = self.sessions.lock().await; - sessions.get(&session_id).map(|s| { - ( - s.model.clone(), - s.effort.clone(), - s.sandbox.clone(), - s.permission_mode.clone(), - s.cwd.clone(), - ) - }) - }; - let Some((stored_model, stored_effort, stored_sandbox, stored_permission, stored_cwd)) = - stored - else { - self.emit_fresh_agent_error( + let target = self + .sessions + .lock() + .await + .get(&session_id) + .map(|session| (session.turn_lock.clone(), session.client.clone())); + let Some((turn_lock, client)) = target else { + self.configure_error( &session_id, + msg.request_id.as_deref(), "INVALID_SESSION_ID", "codex session not found", ); return; }; - - let settings = &msg.settings; - let mut model = settings - .model - .clone() - .unwrap_or_else(|| stored_model.clone()); - let effort = settings.effort.clone().or_else(|| stored_effort.clone()); - let mut sandbox = settings - .sandbox - .map(sandbox_wire_value) - .or_else(|| stored_sandbox.clone()); - let mut permission_mode = settings - .permission_mode - .clone() - .or_else(|| stored_permission.clone()); - if let Err(error) = normalize_codex_permission(&mut permission_mode, &mut sandbox) { - self.emit_fresh_agent_error(&session_id, "INVALID_PERMISSION_MODE", error); - return; - } - // Re-normalize on configure (the same idempotent pass send applies). - model = normalize_freshcodex_model(Some(&model)); - let effort = normalize_freshcodex_effort(Some(&model), effort.as_deref()); - if let Err(err) = to_codex_reasoning_effort(effort.as_deref()) { - self.emit_fresh_agent_error(&session_id, "INVALID_EFFORT", &err.to_string()); + // Keep the operation's retained owner fence across both the turn wait + // and durable write. Never sample a replacement owner's generation. + let (epoch, generation) = self + .ownership_stamps + .lock() + .expect("ownership stamps lock") + .get(&session_id) + .map(|stamp| (Some(stamp.epoch), Some(stamp.generation))) + .unwrap_or((None, None)); + let _turn = turn_lock.lock().await; + let stored = self + .sessions + .lock() + .await + .get(&session_id) + .filter(|session| { + Arc::ptr_eq(&client, &session.client) && !session.exited.load(Ordering::SeqCst) + }) + .map(CodexSession::effective_settings); + let Some(stored) = stored else { + self.configure_error( + &session_id, + msg.request_id.as_deref(), + "SESSION_RESERVED", + "The Codex runtime changed while settings waited; refresh before changing them.", + ); return; - } - - let settings_changed = stored_model != model - || stored_effort != effort - || stored_sandbox != sandbox - || stored_permission != permission_mode; - if !settings_changed { - // Idempotent configure: nothing to converge. + }; + let mut patch = msg.settings; + patch.cwd = patch.cwd.or(msg.cwd); + let effective = match CodexEffectiveSettings::resolve(&stored, &patch) { + Ok(settings) => settings, + Err(error) => { + self.configure_error( + &session_id, + msg.request_id.as_deref(), + error.code, + &error.message, + ); + return; + } + }; + if effective == stored { return; } - if let Some(session) = self.sessions.lock().await.get_mut(&session_id) { - session.model = model.clone(); - session.effort = effort.clone(); - session.sandbox = sandbox.clone(); - session.permission_mode = permission_mode.clone(); - } - let _ = self + if let Err(error) = self .record_codex_binding( &session_id, None, - &model, - sandbox.as_deref(), - permission_mode.as_deref(), - effort.as_deref(), - stored_cwd.as_deref(), - None, - // A settings record is not a new browser assertion — conn-less - // (the ledger merge keeps the row's prior stamps). - None, - // Unified agent names: a settings refresh carries no naming fact. + &effective.model, + effective.sandbox.as_deref(), + effective.permission_mode.as_deref(), + effective.effort.as_deref(), + effective.cwd.as_deref(), + effective.collaboration_mode, None, None, None, + epoch, + generation, false, ) - .await; + .await + { + tracing::warn!(provider=PROVIDER, session_id=%session_id, request_id=?msg.request_id, error=%error, "freshagent.codex.configure_persist_failed"); + self.configure_error( + &session_id, + msg.request_id.as_deref(), + "CODEX_SETTINGS_PERSIST_FAILED", + "Could not save Codex settings. The previous settings remain active; try again.", + ); + return; + } + let applied = { + let mut sessions = self.sessions.lock().await; + match sessions + .get_mut(&session_id) + .filter(|session| Arc::ptr_eq(&client, &session.client)) + { + Some(session) => { + session.apply_settings(&effective); + true + } + None => false, + } + }; + if !applied { + self.configure_error( + &session_id, + msg.request_id.as_deref(), + "SESSION_RESERVED", + "The Codex runtime changed while saving settings; refresh before changing them.", + ); + return; + } + self.publish_codex_settings(&session_id, &effective, msg.request_id.as_deref()); + } - // Broadcast keyed at the CLIENT-ADDRESSED id: the client's fold - // resolves the sessions-map entry by it. - self.broadcast(&crate::session_metadata::session_metadata_frame( - PROVIDER, - SESSION_TYPE, - &session_id, - Some(model.as_str()), - effort.as_deref(), - None, - )); + fn configure_error( + &self, + session_id: &str, + request_id: Option<&str>, + code: &str, + message: &str, + ) { + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({"type":"freshAgent.error","sessionId":session_id,"requestId":request_id,"code":code,"message":message}), + provider: PROVIDER.into(), session_type: SESSION_TYPE.into(), session_id: session_id.into(), + })); + } + + fn publish_codex_settings( + &self, + session_id: &str, + settings: &CodexEffectiveSettings, + request_id: Option<&str>, + ) { + let mut event = settings.as_json(); + event["type"] = json!("freshAgent.session.metadata"); + event["sessionId"] = json!(session_id); + event["effort"] = json!(settings.effort); + event["settingsScope"] = json!("next-turn"); + if let Some(request_id) = request_id { + event["requestId"] = json!(request_id); + } + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event, + provider: PROVIDER.into(), + session_type: SESSION_TYPE.into(), + session_id: session_id.into(), + })); } // ── freshAgent.interrupt (WS) ──────────────────────────────────────────── - /// Handle a `freshAgent.interrupt` for codex: issue `turn/interrupt` for the tracked - /// active turn (`activeTurnByThread.get(sessionId)`, adapter.ts:1009) and clear it on - /// success (adapter.ts:1027). There is NO wire ack on success — the app-server's - /// resulting `turn/completed{interrupted}` notification flows through the existing - /// STATUS-GUARDED consumer (`reduce_notification` -> `CodexSubscription::on_turn_completed`), - /// which emits the idle `freshAgent.session.snapshot` with NO `freshAgent.turn.complete` - /// edge: the interrupt is USER-initiated, so this lane arms the per-session - /// `user_interrupt_pending` marker BEFORE the RPC (opencode's `turn_aborted` - /// precedent) and disarms it again when the RPC fails (the interrupt never - /// landed — a later NON-user `interrupted` must still ring the unified edge). - /// A completion the marker did not cover (automation/rollback-forced) rings. The - /// marker is bounded to the CURRENT turn: the consumer clears it at the next - /// `turn/started` on the thread (the full opencode dispatch-clear discipline), - /// so an armed marker whose `interrupted` completion never arrived cannot - /// cross a turn boundary and silence a later turn's non-user `interrupted`. - /// Mirrors `ws-handler.ts:3503-3516` (fire-and-forget; `INTERNAL_ERROR` on failure). + /// Stop the exact owned dispatch/native turn, or latch Stop until its + /// native ID is known. An RPC acknowledgement resolves issuance only; + /// matching native terminal evidence settles work. An ambiguous response + /// retains both the intent and the new-start gate until proven recovery. pub async fn handle_interrupt(&self, msg: FreshAgentInterrupt) { - let session_id = msg.session_id.clone(); - - let looked_up = { - let guard = self.sessions.lock().await; - guard.get(&session_id).map(|s| { - ( - s.client.clone(), - s.active_turn.clone(), - s.turn_in_flight.clone(), - s.user_interrupt_pending.clone(), - // FR1-1: the retire helper clears the retired-turn slot - // with the tracker + latch (a user-initiated interrupt - // leaves no idle→completed gap to bridge). (E3R1: the - // durable already-rang set is deliberately NOT cleared - // — a stale entry can only gate its own turn's late - // duplicate.) - s.retired_turn_id.clone(), - ) - }) - }; - let Some((client, active_turn, turn_in_flight, user_interrupt_pending, retired_turn_id)) = - looked_up - else { - self.send_error(&None, "SESSION_NOT_FOUND", "codex session not found"); - return; - }; - - let turn_id = active_turn.lock().expect("active_turn mutex").clone(); - let Some(turn_id) = turn_id else { - // adapter.ts:1017-1019 — no tracked active turn to target. + let session_id = msg.session_id; + let handles = self.sessions.lock().await.get(&session_id).map(|s| { + ( + s.client.clone(), + s.turn_state.clone(), + s.active_turn.clone(), + s.user_interrupt_pending.clone(), + ) + }); + let Some((client, turns, active, marker)) = handles else { self.send_error( - &None, - "CODEX_INTERRUPT_FAILED", - &format!("No active Codex turn is tracked for {session_id}."), + &msg.request_id, + "SESSION_NOT_FOUND", + "codex session not found", ); return; }; + let disposition = { + let active = active.lock().expect("active_turn mutex").clone(); + let mut state = turns.lock().expect("codex turn state"); + if let Some(id) = active.as_deref() { + state.observe_started(id); + } + state.request_stop() + }; + match disposition { + StopDisposition::Interrupt { dispatch, turn_id } => { + marker.store(true, Ordering::SeqCst); + self.issue_codex_interrupt(&session_id, client, turns, dispatch, turn_id) + .await; + } + StopDisposition::Latched => { + tracing::info!(provider=PROVIDER, session_id=%session_id, "freshagent.codex.stop_latched"); + } + StopDisposition::None => { + if !turns.lock().expect("codex turn state").has_owed_work() { + self.send_error( + &msg.request_id, + "CODEX_INTERRUPT_FAILED", + &format!("No active Codex turn is tracked for {session_id}."), + ); + } + } + } + } - // Arm the USER-interrupt marker BEFORE the RPC: the resulting - // `turn/completed{interrupted}` consumes it in the guard and stays - // silent. Set-first closes the race with a completion the app-server - // pushes immediately after processing the interrupt. - user_interrupt_pending.store(true, Ordering::SeqCst); - match client.interrupt_turn(&session_id, &turn_id).await { + async fn issue_codex_interrupt( + &self, + session_id: &str, + client: Arc, + turns: SharedCodexTurnState, + dispatch: DispatchToken, + turn_id: String, + ) { + // Native validates the ID then queues an unqualified core interrupt. + // Keep new-start issuance closed until THIS operation is resolved, even + // if natural completion wins while its reply is held or lost. + let marker = self + .sessions + .lock() + .await + .get(session_id) + .filter(|s| Arc::ptr_eq(&s.client, &client)) + .map(|s| s.user_interrupt_pending.clone()); + if let Some(marker) = &marker { + marker.store(true, Ordering::SeqCst); + } + match client.interrupt_turn(session_id, &turn_id).await { Ok(()) => { - // adapter.ts:1027 — the turn is over from this call's perspective; the - // resulting turn/completed notification also clears it (redundant, harmless). - // The user-initiated interrupt ends the turn SILENTLY: retire the - // in-flight latch too, so a later idle crash cannot ring for a turn - // that already ended at the user's own hand. - retire_observed_turn_end(&active_turn, &turn_in_flight, &retired_turn_id); + turns + .lock() + .expect("codex turn state") + .interrupt_ack(dispatch, &turn_id); + tracing::info!(provider=PROVIDER, session_id, native_turn_id=%turn_id, dispatch=?dispatch, "freshagent.codex.interrupt_acknowledged"); } Err(err) => { - // The interrupt never landed — disarm so a later NON-user - // `interrupted` completion is not silently swallowed (the - // opencode abort-failure discipline). - user_interrupt_pending.store(false, Ordering::SeqCst); - self.send_error(&None, "CODEX_INTERRUPT_FAILED", &err.to_string()); + let refused = err.is_not_submitted(); + if refused { + publish_codex_interrupt_refusal(&turns, dispatch, &turn_id, || { + if let Some(marker) = marker { + marker.store(false, Ordering::SeqCst); + } + }); + } + tracing::warn!(provider=PROVIDER, session_id, native_turn_id=%turn_id, dispatch=?dispatch, refused, error=%err, "freshagent.codex.interrupt_failed"); + self.emit_fresh_agent_error( + session_id, + if refused { + "CODEX_INTERRUPT_FAILED" + } else { + "CODEX_INTERRUPT_AMBIGUOUS" + }, + &err.to_string(), + ); } } } @@ -4289,6 +5150,11 @@ impl FreshCodexState { *compact_turn_id.lock().expect("compact_turn_id mutex") = None; } self.emit_fresh_agent_error(&session_id, "CODEX_COMPACT_FAILED", &err.to_string()); + } else if let Some(session) = self.sessions.lock().await.get(&session_id) { + let mut p = session.projection.lock().expect("codex projection lock"); + let ops = p.invalidate_history(&[]); + broadcast_codex_event(&session_id, p.patch(ops), &self.broadcast_tx); + broadcast_codex_event(&session_id, p.metadata_event(), &self.broadcast_tx); } } @@ -4647,6 +5513,18 @@ impl FreshCodexState { return; } + if let Some(session) = self.sessions.lock().await.get(&thread_id) { + let mut p = session.projection.lock().expect("codex projection lock"); + let removed_native_ids: Vec = raw_turns + .iter() + .skip_while(|turn| turn["id"] != before_turn_id) + .filter_map(|turn| turn["id"].as_str().map(str::to_owned)) + .collect(); + let ops = p.invalidate_history(&removed_native_ids); + broadcast_codex_event(&thread_id, p.patch(ops), &self.broadcast_tx); + broadcast_codex_event(&thread_id, p.metadata_event(), &self.broadcast_tx); + } + // Converge siblings + revoke attention (decisions 6/10). The broadcast // carries NO prompt text (other devices' composers are untouched), and // a rollback NEVER chimes (no freshAgent.turn.complete). @@ -4847,6 +5725,7 @@ impl FreshCodexState { s.cwd.clone(), s.sandbox.clone(), s.permission_mode.clone(), + s.collaboration_mode, // Fork precedence source (2) (focused-ep1-r3/r5): the // parent's PARKED provenance (the child pane continues // the parent's lineage in the same client context). @@ -4861,6 +5740,7 @@ impl FreshCodexState { parent_cwd, parent_sandbox, parent_permission_mode, + parent_collaboration_mode, parent_provenance, )) = looked_up else { @@ -5027,6 +5907,26 @@ impl FreshCodexState { None, ); + // The minted child identity and its existing ownership/cancellation + // guards are now armed. Refuse a changed policy through the same + // post-archive containment as every later startup failure, before spawn. + if let Err(error) = CodexAppServerClient::check_effective_approval_policy( + &fork_result, + parent_permission_mode.as_deref(), + "thread/fork", + ) { + tracing::warn!(event="freshagent.codex.fork_policy_refused", parent_session_id=%parent_id, session_id=%child_id, error=%error); + self.fail_fork_after_archive( + &parent_client, + &child_id, + &parent_id, + &reply_sink, + &error.to_string(), + ) + .await; + return; + } + // ── post-archive (failure containment applies to every step below) ── let fork_spawn_slot = Arc::clone(&fork_start_pid_slot); let fork_spawn_state = self.clone(); @@ -5131,6 +6031,24 @@ impl FreshCodexState { return; } + let mut effective = CodexEffectiveSettings { + model: eff_model, + effort: parent_effort, + cwd: eff_cwd, + sandbox: parent_sandbox, + permission_mode: parent_permission_mode, + collaboration_mode: parent_collaboration_mode, + }; + effective.adopt_native(&resumed.effective_settings); + let CodexEffectiveSettings { + model: eff_model, + effort: parent_effort, + cwd: eff_cwd, + sandbox: parent_sandbox, + permission_mode: parent_permission_mode, + collaboration_mode: parent_collaboration_mode, + } = effective; + // Register the child on its OWN sidecar (the shared registration tail, the // ensure_session_resumable shape), inheriting the parent's settings. The // child rollout's durable session_meta is the history-mode SoT (kata 1wxv @@ -5148,6 +6066,8 @@ impl FreshCodexState { eff_cwd.clone(), parent_sandbox.clone(), parent_permission_mode.clone(), + parent_collaboration_mode, + resumed.effective_settings.clone(), read_rollout_history_mode(&child_id), fork_provenance.clone(), ) @@ -5169,6 +6089,7 @@ impl FreshCodexState { parent_permission_mode.as_deref(), parent_effort.as_deref(), eff_cwd.as_deref(), + parent_collaboration_mode, None, fork_provenance.as_ref(), // Unified agent names (Task 4): the fork's naming classification @@ -5262,7 +6183,39 @@ impl FreshCodexState { /// unconditional `return true`, adapter.ts:1211-1215) — `ws-handler.ts:3607-3626` always /// sends `success:true`. Never touches a process this session did not itself spawn. pub async fn handle_kill(&self, msg: FreshAgentKill) { - self.handle_stop(msg, CodexStopMode::Close).await; + let _ = self.handle_stop(msg, CodexStopMode::Close, None).await; + } + + pub(crate) async fn abandon_created_runtime( + &self, + session_id: &str, + ownership_id: &str, + ) -> Result<(), crate::rest_lane::RestLaneError> { + let result = self + .handle_stop( + FreshAgentKill { + provider: freshell_protocol::AgentProvider::Codex, + session_type: freshell_protocol::SessionType::Freshcodex, + session_id: session_id.into(), + cwd: None, + observed_epoch: None, + observed_generation: None, + }, + CodexStopMode::Close, + Some(ownership_id), + ) + .await; + match &result { + Ok(()) => tracing::info!( + event = "freshagent.codex.rest_lane.create_abandoned", + session_id, + ownership_id + ), + Err(error) => { + tracing::warn!(event="freshagent.codex.rest_lane.create_abandon_failed", session_id, ownership_id, code=%error.code) + } + } + result } /// Stop only the owned writer so the persisted Codex thread can be resumed. @@ -5286,18 +6239,20 @@ impl FreshCodexState { let mode = CodexStopMode::Recovery { request_id: msg.request_id, }; - self.handle_stop( - FreshAgentKill { - provider: msg.provider, - session_id: msg.session_id, - session_type: msg.session_type, - cwd: None, - observed_epoch: msg.observed_epoch, - observed_generation: msg.observed_generation, - }, - mode, - ) - .await; + let _ = self + .handle_stop( + FreshAgentKill { + provider: msg.provider, + session_id: msg.session_id, + session_type: msg.session_type, + cwd: None, + observed_epoch: msg.observed_epoch, + observed_generation: msg.observed_generation, + }, + mode, + None, + ) + .await; } fn broadcast_stop_result(&self, mode: &CodexStopMode, frame: &ServerMessage) { @@ -5331,9 +6286,42 @@ impl FreshCodexState { } } - async fn handle_stop(&self, msg: FreshAgentKill, mode: CodexStopMode) { + async fn handle_stop( + &self, + msg: FreshAgentKill, + mode: CodexStopMode, + expected_owner: Option<&str>, + ) -> Result<(), crate::rest_lane::RestLaneError> { let session_id = msg.session_id.clone(); + // A create receipt identifies only the incarnation it established. Capture + // its retained authority before any stop claim or awaited durable work. + let expected_stamp = if let Some(expected) = expected_owner { + let sessions = self.sessions.lock().await; + let Some(session) = sessions + .get(&session_id) + .filter(|session| session.sidecar_ownership_id == expected) + else { + return Ok(()); + }; + if self.ownership.is_some() { + let stamp = + crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, &session_id) + .filter(|stamp| { + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + && stamp.owner.live_session_key.as_deref() + == Some(session_id.as_str()) + && stamp.owner.pid == session.sidecar_pid + }) + .ok_or_else(codex_cleanup_error)?; + Some(stamp) + } else { + None + } + } else { + None + }; + // b8ke delta review F7: a half-sent observed pair (exactly one of // epoch/generation) is the typed invalid-fence refusal — BEFORE the // durable close and any live-state mutation. The kill never @@ -5356,7 +6344,7 @@ impl FreshCodexState { message: Some(err.message().to_string()), }), ); - return; + return Err(codex_cleanup_error()); } }; @@ -5381,9 +6369,12 @@ impl FreshCodexState { // b8ke e3r3 F7: the granted stop's settlement guard. let mut _stop_settlement: Option = None; if let Some(registry) = self.ownership.as_ref() { - if let Some(stamp) = + let stamp = if expected_owner.is_some() { + expected_stamp + } else { crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, &session_id) - { + }; + if let Some(stamp) = stamp { let claim = crate::ownership_lane::stop_claim_from_stamp(&stamp, stop_fence); let kill_op_id = format!("kill-{}", uuid::Uuid::new_v4()); let stop_outcome = crate::ownership_lane::begin_fresh_agent_stop( @@ -5473,7 +6464,7 @@ impl FreshCodexState { message: Some(refusal_message), }), ); - return; + return Err(codex_cleanup_error()); } } else { // b8ke delta round-3 F1: NO retained stamp — the kill STILL @@ -5513,7 +6504,7 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } let claim = freshell_ownership::StopClaim { expected_kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, @@ -5613,7 +6604,7 @@ impl FreshCodexState { message: Some(refusal_message), }), ); - return; + return Err(codex_cleanup_error()); } } } @@ -5638,7 +6629,7 @@ impl FreshCodexState { .to_string(), ), })); - return; + return Err(codex_cleanup_error()); } freshell_ownership::OwnershipState::Starting { .. } | freshell_ownership::OwnershipState::Stopping { .. } => { @@ -5661,7 +6652,7 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } freshell_ownership::OwnershipState::Fenced { .. } => { tracing::warn!(target: "freshell_freshagent::codex", @@ -5684,7 +6675,7 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } _ => { // Genuinely Vacant (or residue): lane-local cleanup @@ -5697,16 +6688,46 @@ impl FreshCodexState { // Record the sidecar identity before the first teardown await. If the // bounded watcher window cannot prove the complete writer tree dead, // the deferred confirmation keeps this exact identity fenced. - let condemned_ownership_id = self.record_condemned_prior(&session_id).await; + let cleanup_removed = if let Some(expected) = expected_owner { + let mut sessions = self.sessions.lock().await; + if sessions + .get(&session_id) + .is_none_or(|session| session.sidecar_ownership_id != expected) + { + return Err(codex_cleanup_error()); + } + sessions.remove(&session_id) + } else { + None + }; + let condemned_ownership_id = if let Some(session) = &cleanup_removed { + if let Some(pid) = session.sidecar_pid { + let recorded = crate::session_lease::record_condemned_runtime_identity( + pid, + CODEX_SIDECAR_OWNERSHIP_ENV, + &session.sidecar_ownership_id, + ); + self.condemned_priors + .lock() + .expect("condemned priors lock") + .insert(session_id.clone(), recorded); + Some(session.sidecar_ownership_id.clone()) + } else { + None + } + } else { + self.record_condemned_prior(&session_id).await + }; // Ordinary close retires the pane-ledger row before teardown. Recovery // keeps that durable binding so a new writer can resume this thread. // A failed ordinary close stops before touching the live process. - let close_answer = if matches!(mode, CodexStopMode::Recovery { .. }) { - crate::identity_sink::CloseAnswer::Recorded - } else { - self.retire_closed_row(&session_id).await - }; + let close_answer = + if expected_owner.is_some() || matches!(mode, CodexStopMode::Recovery { .. }) { + crate::identity_sink::CloseAnswer::Recorded + } else { + self.retire_closed_row(&session_id).await + }; if close_answer == crate::identity_sink::CloseAnswer::Failed { // b8ke e3r2 F4 + e3r3 F2/F8: THE LIVENESS VERDICT COMES FIRST — // exactly ONE mutation path follows from it (pre-e3r3 the @@ -5785,13 +6806,22 @@ impl FreshCodexState { ), }), ); - return; + return Err(codex_cleanup_error()); } let close_reported_failure = close_answer == crate::identity_sink::CloseAnswer::Persisted; - self.clear_controls(&session_id).await; + if let Some(session) = &cleanup_removed { + self.clear_source_controls(&session_id, &session.projection) + .await; + } else { + self.clear_controls(&session_id).await; + } - let removed = self.sessions.lock().await.remove(&session_id); + let removed = if expected_owner.is_some() { + cleanup_removed + } else { + self.sessions.lock().await.remove(&session_id) + }; if let Some(session) = removed.as_ref() { // Requested teardown ends the probe's wait even when the provider never // emits turn/completed. Signal before closing the client so an in-flight @@ -5953,18 +6983,40 @@ impl FreshCodexState { )); } } - } - - // Explicit kill evicts this session's requestId dedup cache entries (mirrors - // `clearFreshAgentCreateCachesForSession`, `ws-handler.ts:1044-1050`, called from - // `ws-handler.ts:3673`) -- an EXPLICIT kill means a later duplicate `create` for - // the same requestId must genuinely mint a fresh session, not replay the one just - // killed. An UNREQUESTED sidecar exit does NOT reach this path (see - // [`crate::FreshAgentCreateDedup`]'s doc). - self.create_dedup - .clear_for_session(|record| record.session_id == session_id) + } else if expected_owner.is_some() { + match teardown { + crate::session_handoff::StopResult::Reaped + | crate::session_handoff::StopResult::AlreadyGone => {} + crate::session_handoff::StopResult::NotConfirmed { confirmation } => { + tokio::spawn(async move { + let _ = confirmation.await; + }); + kill_failure = Some(( + "TEARDOWN_NOT_CONFIRMED", + "the owned Codex process tree is not yet confirmed stopped".into(), + )); + } + crate::session_handoff::StopResult::PlatformLimited => { + kill_failure = Some(( + "TEARDOWN_PLATFORM_LIMITED", + "the complete owned Codex process tree could not be verified stopped" + .into(), + )); + } + } + } + + // Explicit kill evicts this session's requestId dedup cache entries (mirrors + // `clearFreshAgentCreateCachesForSession`, `ws-handler.ts:1044-1050`, called from + // `ws-handler.ts:3673`) -- an EXPLICIT kill means a later duplicate `create` for + // the same requestId must genuinely mint a fresh session, not replay the one just + // killed. An UNREQUESTED sidecar exit does NOT reach this path (see + // [`crate::FreshAgentCreateDedup`]'s doc). + self.create_dedup + .clear_for_session(|record| record.session_id == session_id) .await; + let success = !close_reported_failure && kill_failure.is_none(); self.broadcast_stop_result( &mode, &ServerMessage::FreshAgentKilled(FreshAgentKilled { @@ -5974,11 +7026,16 @@ impl FreshCodexState { // A persisted-despite-error close ends the session (consistent // with the durable close) but the kill visibly fails // (delta-r6-r4, focused-episode-6 round 3 Finding 3). - success: !close_reported_failure && kill_failure.is_none(), + success, code: kill_failure.as_ref().map(|(code, _)| code.to_string()), message: kill_failure.map(|(_, message)| message), }), ); + if success { + Ok(()) + } else { + Err(codex_cleanup_error()) + } } /// The kill-side lane of `retire_closed` (delta-review round 5): @@ -6022,6 +7079,29 @@ impl FreshCodexState { .is_some_and(|s| !s.exited.load(Ordering::SeqCst)) } + pub(crate) async fn created_rest_settings( + &self, + session_id: &str, + expected_owner: Option<&str>, + ) -> Option { + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id)?; + if session.exited.load(Ordering::SeqCst) + || expected_owner.is_some_and(|owner| owner != session.sidecar_ownership_id) + { + return None; + } + crate::ownership_lane::current_local_snapshot_owner( + &self.ownership, + &self.ownership_stamps, + PROVIDER, + session_id, + session_id, + session.sidecar_pid, + )?; + Some(session.effective_settings().as_json()) + } + pub(crate) async fn local_snapshot_owner( &self, native_id: &str, @@ -6343,10 +7423,9 @@ impl FreshCodexState { /// the OLD mint-new path silently discarded the model's memory of the conversation). /// /// If the app-server rejects the resume WITH those settings for any reason other than a - /// genuine "thread not found" (a stale/invalid model or sandbox value, say), one retry is - /// made with NO settings at all -- `handle_send`'s own "re-normalize on send" already - /// re-applies the real settings on the next turn, so a resume succeeding with defaults is - /// strictly better than failing over a value we can't currently validate. + /// genuine "thread not found", one retry may omit a stale model. File access + /// and approval remain exact on that retry. Untrusted policy refusal returns + /// directly, preserving its prepared-source diagnostic before any work. /// /// Only a genuine `threadNotFound` on resume ([`is_codex_thread_not_found`]) falls back to /// the ORIGINAL mint-new-thread behavior ([`Self::respawn_as_new_thread_after_crash`]): @@ -6400,6 +7479,7 @@ impl FreshCodexState { session_effort, session_sandbox, session_permission_mode, + session_collaboration_mode, session_provenance, ) = { let guard = self.sessions.lock().await; @@ -6413,6 +7493,7 @@ impl FreshCodexState { session.effort.clone(), session.sandbox.clone(), session.permission_mode.clone(), + session.collaboration_mode, // D8 (focused-ep1-r3): the parked provenance rides ACROSS the // recovery rebuild below (and into the mint-new fallback) — the // same logical session continues; nothing new is invented (the @@ -6425,25 +7506,30 @@ impl FreshCodexState { // P1.13 (Task 5, R2): a blank stored model means this registration came from a // settings-less resume (a record miss at R3 time) -- fall back to the ledger // record so crash recovery doesn't silently respawn onto defaults. - let (model, sandbox, permission_mode, effort) = if session_model.is_empty() { - let rec = self - .identity_sink() - .and_then(|s| s.load_settings("codex", session_id)) - .unwrap_or_default(); - ( - rec.model.unwrap_or_default(), - rec.sandbox, - rec.permission_mode, - rec.effort, - ) - } else { - ( - session_model, - session_sandbox, - session_permission_mode, - session_effort, - ) - }; + let (model, sandbox, permission_mode, effort, collaboration_mode) = + if session_model.is_empty() { + let rec = self + .identity_sink() + .and_then(|s| s.load_settings("codex", session_id)) + .unwrap_or_default(); + ( + rec.model.unwrap_or_default(), + rec.sandbox, + rec.permission_mode, + rec.effort, + rec.collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), + ) + } else { + ( + session_model, + session_sandbox, + session_permission_mode, + session_effort, + session_collaboration_mode, + ) + }; // Single-flight: acquire (and possibly create) this session id's per-thread lock, // shared with `ensure_session_resumable`'s map so a concurrent historical-thread @@ -6612,6 +7698,7 @@ impl FreshCodexState { effort, sandbox, permission_mode, + collaboration_mode, lease_guard, session_provenance, ) @@ -6685,18 +7772,22 @@ impl FreshCodexState { let resume_result = match first_attempt { Ok(started) => Ok(started), Err(err) if is_codex_thread_not_found(&err) => Err(err), - // Not a "thread not found" -- retry once with no settings at all in case the - // app-server rejected one of them; `handle_send` re-applies the real settings - // on the next turn regardless. - Err(_) => { + // A retry without settings would drop prepared/explicit untrusted + // intent. Preserve the original typed refusal before any work. + Err(error) if permission_mode.as_deref() == Some("untrusted") => Err(error), + // A stale model may be unavailable after a provider update. Keep + // file access and approval intent on this retry: native defaults + // must never turn a settings refusal into broader access. + Err(error) => { + tracing::warn!(provider=PROVIDER, session_id=%session_id, cause=%error, "freshagent.codex.resume_retry_without_model"); client .resume_thread( session_id, StartThreadParams { cwd: cwd.clone(), model: None, - sandbox: None, - approval_policy: None, + sandbox: sandbox.clone(), + approval_policy: permission_mode.clone(), history_mode: None, // resume carries no mode param }, ) @@ -6712,8 +7803,53 @@ impl FreshCodexState { // and fall back to the ORIGINAL mint-new-thread recovery on a fresh sidecar. client.close().await; let mut dead_child = child; - let _ = dead_child.start_kill(); - reap_owned_codex_sidecars(&ownership_id); + let recorded = crate::session_lease::record_condemned_runtime_identity( + dead_child.id().unwrap_or(0), + CODEX_SIDECAR_OWNERSHIP_ENV, + &ownership_id, + ); + if !confirm_codex_tree_dead(&recorded, self.sidecar_cleanup_probe.get().cloned()) + .await + { + // No user input was dispatched. Retain exact cleanup + // evidence and claims until the owned tree is confirmed. + self.condemned_priors + .lock() + .expect("condemned priors lock") + .insert(session_id.to_string(), recorded.clone()); + let confirmation = codex_tree_death_escalation( + recorded, + ownership_id.clone(), + session_id.to_string(), + self.sidecar_cleanup_probe.get().cloned(), + ); + let state = self.clone(); + let key = session_id.to_string(); + tokio::spawn(async move { + if confirmation.await { + let _ = dead_child.wait().await; + crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); + state.clear_condemned_prior_if_matching(&key, &ownership_id); + if let Some(mut guard) = lease_guard { + guard.fail(); + } + drop(own_ticket); + } else { + std::future::pending::<()>().await; + drop((dead_child, lease_guard, own_ticket)); + } + }); + tracing::warn!( + provider = PROVIDER, + session_id, + "freshagent.codex.failed_resume_cleanup_unconfirmed" + ); + return Err(EnsureAliveError::RespawnFailed( + "the failed Codex resume's owned process tree is not yet confirmed stopped" + .into(), + )); + } + let _ = dead_child.wait().await; crate::codex_sidecar_tracking::scrub_sidecar_record(&ownership_id); // FIX (CODEX-FIRST triage Finding 2): remember this id as genuinely gone so // a later attach/create against it fails fast instead of repeating this same @@ -6731,6 +7867,7 @@ impl FreshCodexState { effort, sandbox, permission_mode, + collaboration_mode, lease_guard, session_provenance, ) @@ -6774,6 +7911,24 @@ impl FreshCodexState { ))); } + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; + let active_turn: Arc>> = Arc::new(StdMutex::new(None)); let user_interrupt_pending: Arc = Arc::new(AtomicBool::new(false)); let quiet_deadman = QuietDeadman::new_shared(); @@ -6790,10 +7945,29 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + session_id.to_string(), + ))); + let prior_projection = self + .sessions + .lock() + .await + .get(session_id) + .map(|session| session.projection.clone()); + if let Some(prior) = prior_projection { + projection + .lock() + .expect("codex projection lock") + .inherit_finite_history(&prior.lock().expect("prior codex projection lock")); + } + let exited = Arc::new(AtomicBool::new(false)); let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: session_id.to_string(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -6805,13 +7979,14 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, session_id.to_string(), @@ -6825,6 +8000,8 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), + Some(self.clone()), ); // `HashMap::insert` on an existing key overwrites in place, dropping the old (dead @@ -6838,6 +8015,10 @@ impl FreshCodexState { guard.insert( session_id.to_string(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(started.effective_settings.clone()), + turn_state: turn_state.clone(), client, model: model.clone(), effort: effort.clone(), @@ -6855,6 +8036,7 @@ impl FreshCodexState { history_mode: read_rollout_history_mode(session_id), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -6889,6 +8071,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, None, None, // Unified agent names: a refresh carries no naming fact. @@ -6963,6 +8146,7 @@ impl FreshCodexState { effort: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, mut lease_guard: Option, // D8 (focused-ep1-r3): the crashed session's parked provenance, CARRIED // onto the mint-new record below (the identity moved; the same logical @@ -6998,8 +8182,8 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), }) .await; - let new_thread_id = match started { - Ok(started) => started.thread_id, + let started = match started { + Ok(started) => started, Err(err) => { client.close().await; let mut child = child; @@ -7014,6 +8198,25 @@ impl FreshCodexState { } }; + let new_thread_id = started.thread_id.clone(); + let mut effective = CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }; + effective.adopt_native(&started.effective_settings); + let CodexEffectiveSettings { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + } = effective; + // kata b8ke Task 3: the mint-new fallback MINTS the canonical thread // id — claim it under the NEW key (no fence: a minted id was never // observed) before the registration below (the caller already @@ -7100,10 +8303,16 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let exited = Arc::new(AtomicBool::new(false)); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + new_thread_id.to_string(), + ))); let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: new_thread_id.clone(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -7115,13 +8324,14 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, new_thread_id.clone(), @@ -7135,6 +8345,8 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), + Some(self.clone()), ); { @@ -7143,6 +8355,10 @@ impl FreshCodexState { guard.insert( new_thread_id.clone(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(started.effective_settings.clone()), + turn_state: turn_state.clone(), client, model: model.clone(), effort: effort.clone(), @@ -7156,6 +8372,7 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -7221,6 +8438,7 @@ impl FreshCodexState { permission_mode.as_deref(), effort.as_deref(), cwd.as_deref(), + collaboration_mode, Some(old_session_id), // D8: conn-less crash-respawn — provenance `None`; the ledger // inherits the superseded parent's stamps (fork-chain rule). @@ -7422,13 +8640,7 @@ impl FreshCodexState { #[cfg(target_os = "linux")] if self.sidecar_launch_context.provider_exec_profile { - let additional = if codex_program == "/usr/bin/setpriv" { - &["codex"][..] - } else { - &[] - }; - freshell_platform::provider_exec::check_command(cmd.as_std(), true, additional) - .map_err(|error| error.to_string())?; + freshell_codex::managed_launch::check_command(cmd.as_std())?; } let mut child = cmd .spawn() @@ -7536,6 +8748,7 @@ impl FreshCodexState { created_gate: Option>, ) -> tokio::task::JoinHandle<()> { let CodexConsumerRuntime { + turn_state, thread_id, active_turn, user_interrupt_pending, @@ -7547,11 +8760,32 @@ impl FreshCodexState { compact_in_flight, compact_turn_id, event_emission_gate, + projection, } = runtime; + let seeded = active_turn.lock().expect("active_turn mutex").clone(); + if let Some(id) = seeded.as_deref() { + turn_state + .lock() + .expect("codex turn state") + .observe_started(id); + } let broadcast_tx = self.broadcast_tx.clone(); // The deadman feed needs the state handle (window config + waiter spawn); a // clone is all-Arc, cheap, and adds no lifecycle coupling (the state is the // server-global `FreshCodexState`, alive for the process's whole life anyway). + let retained_floor = self + .attention + .snapshot() + .into_iter() + .find(|mark| mark.session_id == thread_id) + .and_then(|mark| mark.last_turn_complete_at); + { + let mut clock = last_turn_complete_at + .lock() + .expect("attention complete clock"); + *clock = (*clock).max(retained_floor); + } + self.attention.observe(&thread_id, SESSION_TYPE); let state = self.clone(); tokio::spawn(async move { if let Some(gate) = created_gate { @@ -7568,7 +8802,28 @@ impl FreshCodexState { let mut subscription = CodexSubscription::new(thread_id.clone()) .with_user_interrupt_pending(user_interrupt_pending) .with_last_turn_complete_at(last_turn_complete_at.clone()); - while let Some(notification) = notifs.recv().await { + let mut projection_announced = false; + let mut patch_tick = tokio::time::interval(Duration::from_millis(33)); + patch_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + let notification = tokio::select! { + notification = notifs.recv() => match notification { Some(n) => n, None => break }, + _ = patch_tick.tick() => { + let _emission = event_emission_gate.lock().await; + flush_codex_projection(&projection, &thread_id, &broadcast_tx); + continue; + } + }; + if projection + .lock() + .expect("codex projection lock") + .source_retired() + { + continue; + } + state + .observe_child_control_turn(&thread_id, &projection, ¬ification) + .await; // Unified agent names (Task 3): an upstream `thread/name/updated` // is a NATIVE title observation — automatic provider metadata, // folded at the store's current location revision. It emits no @@ -7621,9 +8876,11 @@ impl FreshCodexState { // turn. Every notification capable of emitting a wire frame stays // behind the barrier below. if matches!(¬ification, CodexNotification::TurnStarted(_)) { + project_codex_notification(&projection, ¬ification); let events = reduce_notification( &mut subscription, notification, + &turn_state, &active_turn, &turn_in_flight, &quiet_deadman, @@ -7644,8 +8901,58 @@ impl FreshCodexState { // also ensures an immediate completion cannot retire a just-started // turn before handle_send has recorded its returned turn id. let _emission = event_emission_gate.lock().await; + // A native item is projected before lifecycle/control emission. + // Appends wait at most 33 ms; every boundary flushes in order. + let append = matches!(¬ification, CodexNotification::Other { method, .. } if method.ends_with("Delta") || method.ends_with("/delta")); + if !append { + flush_codex_projection(&projection, &thread_id, &broadcast_tx); + } + let has_patch = project_codex_notification(&projection, ¬ification); + state.notify_retained(); + if has_patch { + if !projection_announced { + let p = projection.lock().expect("codex projection lock"); + broadcast_codex_event( + &thread_id, + json!({"type":"freshAgent.session.init","sessionId":thread_id,"projectionId":p.id,"historyGeneration":p.history_generation}), + &broadcast_tx, + ); + projection_announced = true; + } + if !append { + flush_codex_projection(&projection, &thread_id, &broadcast_tx); + } + } + if let CodexNotification::Other { + method, + params: Some(params), + } = ¬ification + { + if params["threadId"] == thread_id + && matches!( + method.as_str(), + "thread/tokenUsage/updated" | "turn/plan/updated" | "turn/diff/updated" + ) + { + let p = projection.lock().expect("codex projection lock"); + broadcast_codex_event(&thread_id, p.metadata_event(), &broadcast_tx); + } + if params["threadId"] == thread_id && method == "error" { + // Retry notices never replace the lifecycle's failed-turn + // result. Only native diagnostics are shown, never payloads. + let message = params["error"]["message"] + .as_str() + .unwrap_or("Codex reported a provider error"); + let event = if params["willRetry"] == true { + json!({"type":"freshAgent.retry","attempt":params["retryAttempt"].as_u64().unwrap_or(1),"error":message}) + } else { + json!({"type":"freshAgent.notice","level":"error","code":"CODEX_PROVIDER_ERROR","message":message}) + }; + broadcast_codex_event(&thread_id, event, &broadcast_tx); + } + } if state - .consume_control_notification(&thread_id, ¬ification) + .consume_control_notification(&thread_id, ¬ification, &projection) .await { // A control-consumed notification (an approval/question @@ -7659,6 +8966,7 @@ impl FreshCodexState { let events = reduce_notification( &mut subscription, notification, + &turn_state, &active_turn, &turn_in_flight, &quiet_deadman, @@ -7670,10 +8978,24 @@ impl FreshCodexState { &last_turn_complete_at, &broadcast_tx, ); - for event in events { + for mut event in events { + if turn_state + .lock() + .expect("codex turn state") + .has_stopping_work() + { + if let CodexAdapterEvent::StatusSnapshot { status, .. } = &mut event { + if *status == CodexStatus::Idle { + *status = CodexStatus::Running; + } + } + } // DIAG-01: the positive turn-complete chime only -- session_id // alone, never the turn's text/response content. - if let CodexAdapterEvent::TurnComplete { session_id, .. } = &event { + if let CodexAdapterEvent::TurnComplete { session_id, at } = &event { + state + .attention + .record_turn_complete(session_id, SESSION_TYPE, *at); tracing::info!(provider = PROVIDER, session_id = %session_id, "freshagent.turn.complete"); // Unified agent names (Task 2): a completed turn wrote // history, so the thread's rollout has materialized — @@ -7696,7 +9018,7 @@ impl FreshCodexState { None => None, }; if let Some(transition) = state - .try_bind_pending_naming(session_id, codex_home.as_deref()) + .try_materialize_naming(session_id, codex_home.as_deref()) .await { tracing::debug!( @@ -7707,13 +9029,23 @@ impl FreshCodexState { ); } } + if let CodexAdapterEvent::StatusSnapshot { status, .. } + | CodexAdapterEvent::Status { status, .. } = &event + { + projection + .lock() + .expect("codex projection lock") + .observe_status(status.as_str()); + } let frame = adapter_event_to_frame(&event, &thread_id); if let Some(frame) = frame { + state.notify_retained(); let _ = broadcast_tx.send(frame); } } } - state.clear_controls(&thread_id).await; + flush_codex_projection(&projection, &thread_id, &broadcast_tx); + state.clear_source_controls(&thread_id, &projection).await; }) } @@ -7750,6 +9082,9 @@ impl FreshCodexState { /// armed window) and the epoch hasn't already fired. Used by [`Self::get_snapshot`] /// after it seeds an observed mid-flight turn (the U-1 repair). fn arm_codex_quiet_if_absent(&self, thread_id: &str, quiet: &Arc>) { + if self.awaited_control_count(thread_id) != 0 { + return; + } let window_ms = self.codex_quiet_window_ms(); let Some(generation) = quiet .lock() @@ -7790,7 +9125,7 @@ impl FreshCodexState { // "Turn still in flight" reads the dispatch-time latch — the lane's // turn-in-flight authority — at the deadline. let fired = crate::quiet_deadman::wait_for_quiet_deadline(&quiet, generation, || { - turn_in_flight.load(Ordering::SeqCst) + turn_in_flight.load(Ordering::SeqCst) && self.awaited_control_count(&thread_id) == 0 }) .await; if !fired { @@ -7814,6 +9149,7 @@ impl FreshCodexState { &thread_id, &self.broadcast_tx, "quiet_deadman", + Some(&self), ); } @@ -7866,14 +9202,105 @@ impl FreshCodexState { // this port previously had no fallback at all, so ANY such rejection became an // unconditional 500. Retry once with `includeTurns:false`, matching the reference // exactly (still a valid, if turn-less, snapshot). - let raw = match client.read_thread(thread_id, true).await { - Ok(raw) => raw, - Err(err) if is_codex_include_turns_unavailable(&err) => client - .read_thread(thread_id, false) - .await - .map_err(CodexSnapshotError::AppServer)?, - Err(err) => return Err(CodexSnapshotError::AppServer(err)), - }; + let (projection, turn_state) = self + .sessions + .lock() + .await + .get(thread_id) + .filter(|session| Arc::ptr_eq(&session.client, &client)) + .map(|session| (session.projection.clone(), session.turn_state.clone())) + .ok_or(CodexSnapshotError::NotFound)?; + let mut selected = None; + for _ in 0..3 { + let owner = self.ownership_snapshot(PROVIDER, thread_id); + let fence = { + let mut p = projection.lock().expect("codex projection lock"); + p.bind_owner(owner.epoch, owner.generation); + // A read cannot cut through a coalesced append. Publish the + // entire pending batch before capturing its consumed prefix; + // future batches then contain only events after this fence. + if let Some(patch) = p.take_patch() { + broadcast_codex_event(thread_id, patch, &self.broadcast_tx); + } + p.begin_history_read() + }; + let read = match client.read_thread(thread_id, true).await { + Ok(raw) => Ok(raw), + Err(err) if is_codex_include_turns_unavailable(&err) => { + client.read_thread(thread_id, false).await + } + Err(err) => Err(err), + }; + let current = self.sessions.lock().await.get(thread_id).map(|session| { + ( + Arc::ptr_eq(&session.client, &client) + && Arc::ptr_eq(&session.projection, &projection), + session.exited.load(Ordering::SeqCst), + ) + }); + if !current.is_some_and(|(same, _)| same) { + return Err(CodexSnapshotError::HandoffInProgress { + generation: self.ownership_snapshot(PROVIDER, thread_id).generation, + }); + } + let owner_after = self.ownership_snapshot(PROVIDER, thread_id); + let mut p = projection.lock().expect("codex projection lock"); + p.bind_owner(owner_after.epoch, owner_after.generation); + if !p.matches_fence(&fence) { + continue; + } + let projected = match read { + Ok(raw) => p.accept_native_history(&raw, &fence), + Err(err) => { + if !p.has_usable_history() { + return Err(CodexSnapshotError::AppServer(err)); + } + if current.is_some_and(|(_, exited)| exited) { + p.freeze_active("interrupted"); + } + tracing::warn!(provider=PROVIDER, session_id=thread_id, projection_id=%p.id, "freshagent.codex.history_unavailable"); + if let Some(patch) = p.take_patch() { + broadcast_codex_event(thread_id, patch, &self.broadcast_tx); + } + let retained_fence = p.begin_history_read(); + p.unavailable_history(&retained_fence) + } + }; + match projected { + Ok(snapshot) => { + selected = Some(snapshot); + break; + } + Err(_) => { + tracing::debug!(provider=PROVIDER, session_id=thread_id, projection_id=%p.id, "freshagent.codex.stale_history_reread") + } + } + } + let projected = selected.ok_or_else(|| CodexSnapshotError::HandoffInProgress { + generation: self.ownership_snapshot(PROVIDER, thread_id).generation, + })?; + let raw = projected.raw; + { + let mut turns = turn_state.lock().expect("codex turn state"); + for turn in raw["thread"]["turns"].as_array().into_iter().flatten() { + let Some(id) = turn["id"].as_str() else { + continue; + }; + let terminal = match turn["status"].as_str() { + Some("completed") => Some(NativeSettlement::Completed), + Some("interrupted") => Some(NativeSettlement::Interrupted), + Some("failed") => Some(NativeSettlement::Failed), + Some("inProgress") => { + turns.observe_started(id); + None + } + _ => None, + }; + if let Some(terminal) = terminal { + turns.settle_native_read(id, terminal); + } + } + } // U-1 repair (wedged-sidecar deadman): a thread restored MID-TURN has no // adapter-observed `turn/started` (this process never saw one), so // `active_turn` is empty and the quiet deadman would never arm. When the FRESH @@ -7960,21 +9387,31 @@ impl FreshCodexState { stuck, ) .map_err(CodexSnapshotError::Protocol)?; + projection + .lock() + .expect("codex projection lock") + .overlay(&mut snapshot, projected.metadata); + if snapshot["status"] == "idle" + && turn_state.lock().expect("codex turn state").has_owed_work() + { + snapshot["status"] = json!("running"); + snapshot["capabilities"]["send"] = json!(false); + snapshot["capabilities"]["interrupt"] = json!(true); + } self.overlay_controls(thread_id, &mut snapshot).await; + self.overlay_settings_snapshot(thread_id, &mut snapshot) + .await; + Ok(snapshot) + } + + async fn overlay_settings_snapshot(&self, thread_id: &str, snapshot: &mut Value) { if let Some(session) = self.sessions.lock().await.get(thread_id) { - let mut settings = Map::new(); - if !session.model.is_empty() { - settings.insert("model".into(), json!(session.model)); - } - if let Some(effort) = &session.effort { - settings.insert("effort".into(), json!(effort)); - } - if let Some(permission) = &session.permission_mode { - settings.insert("permissionMode".into(), json!(permission)); - } - snapshot["settings"] = Value::Object(settings); + snapshot["settings"] = session.effective_settings().as_json(); + snapshot["extensions"]["codex"]["settings"] = json!({ + "scope":"next-turn", "currentTurn":session.native_turn_settings.as_ref().map(CodexEffectiveSettings::as_json), + "lastThreadResponse":session.native_thread_settings.as_ref(), + }); } - Ok(snapshot) } /// Resolve the live client + active-turn bit for `thread_id` — FULLY @@ -8482,7 +9919,7 @@ impl FreshCodexState { running with default model and permissions. Reconfirm your settings.", ); } - let rec = recovered.clone().unwrap_or_default(); + let mut rec = recovered.clone().unwrap_or_default(); // D8 (focused-ep1-r4 Finding 2 — the parking invariant's durable // half): this construction lane is CONN-LESS (attach / snapshot reads @@ -8558,6 +9995,26 @@ impl FreshCodexState { ))); } + if recovered.is_some() { + let mut effective = CodexEffectiveSettings { + model: rec.model.clone().unwrap_or_default(), + effort: rec.effort.clone(), + cwd: cwd.map(str::to_owned).or_else(|| rec.cwd.clone()), + sandbox: rec.sandbox.clone(), + permission_mode: rec.permission_mode.clone(), + collaboration_mode: rec + .collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), + }; + effective.adopt_native(&started.effective_settings); + rec.model = (!effective.model.is_empty()).then_some(effective.model); + rec.effort = effective.effort; + rec.cwd = effective.cwd; + rec.sandbox = effective.sandbox; + rec.permission_mode = effective.permission_mode; + } + // FIX (CODEX-FIRST triage Finding 2): the app-server just proved this id alive -- // clear any stale "recently gone" marking so it doesn't linger. self.clear_dead_thread(thread_id).await; @@ -8605,9 +10062,13 @@ impl FreshCodexState { child, rec.model.clone().unwrap_or_default(), rec.effort.clone(), - cwd.map(str::to_string).or_else(|| rec.cwd.clone()), + rec.cwd.clone().or_else(|| cwd.map(str::to_string)), rec.sandbox.clone(), rec.permission_mode.clone(), + rec.collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), + started.effective_settings.clone(), // Kata 1wxv Task 2 (r3): the durable rollout meta carries the // mode across resume (only a missing/unparseable meta ⇒ legacy). read_rollout_history_mode(thread_id), @@ -8714,7 +10175,10 @@ impl FreshCodexState { rec.sandbox.as_deref(), rec.permission_mode.as_deref(), rec.effort.as_deref(), - cwd.or(rec.cwd.as_deref()), + rec.cwd.as_deref().or(cwd), + rec.collaboration_mode + .as_deref() + .and_then(CodexWorkMode::parse), None, // D8: conn-less attach-resume refresh — provenance `None` // keeps the row's existing stamps. @@ -8834,6 +10298,8 @@ impl FreshCodexState { cwd: Option, sandbox: Option, permission_mode: Option, + collaboration_mode: Option, + native_thread_settings: Value, // Kata 1wxv Task 2: the thread's durable history mode (the rollout-meta // parse the resume/fork callers perform post-RPC). history_mode: Option, @@ -8853,10 +10319,16 @@ impl FreshCodexState { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let exited = Arc::new(AtomicBool::new(false)); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))); let consumer = self.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: thread_id.to_string(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -8868,13 +10340,14 @@ impl FreshCodexState { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.clone(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id, thread_id.to_string(), @@ -8888,10 +10361,16 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), + Some(self.clone()), ); self.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode, + native_turn_settings: None, + native_thread_settings: Some(native_thread_settings), + turn_state: turn_state.clone(), client, model, effort, @@ -8905,6 +10384,7 @@ impl FreshCodexState { history_mode, turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -8951,6 +10431,7 @@ impl FreshCodexState { cmd.kill_on_drop(true); let child = cmd.spawn().expect("spawn sleep fixture"); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let consumer = tokio::spawn(async {}); let (kill_tx, kill_rx) = oneshot::channel(); let exited = Arc::new(AtomicBool::new(false)); @@ -8967,7 +10448,7 @@ impl FreshCodexState { // b8ke focused round-2 R2-1: the condemned-prior identity (pid + tag). let sidecar_pid = child.id(); let sidecar_ownership_id = format!("codex-sidecar-test-snapshot-router-{thread_id}"); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, format!("codex-sidecar-test-snapshot-router-{thread_id}"), thread_id.to_string(), @@ -8981,10 +10462,16 @@ impl FreshCodexState { last_turn_complete_at.clone(), Arc::clone(&self.condemned_priors), self.ownership_watch(), + Some(turn_state.clone()), + Some(self.clone()), ); self.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, + turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), effort: None, @@ -8999,6 +10486,10 @@ impl FreshCodexState { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate: Arc::new(TokioMutex::new(())), + projection: Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))), consumer, kill_tx: Some(kill_tx), watcher, @@ -9062,6 +10553,9 @@ pub enum CodexSnapshotError { HandoffInProgress { generation: u64 }, } +/// Retained reads share the existing source/ownership/protocol refusal contract. +pub type RetainedStateError = CodexSnapshotError; + impl std::fmt::Display for CodexSnapshotError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -9108,6 +10602,16 @@ fn is_codex_include_turns_unavailable(err: &CodexAppServerError) -> bool { /// app-server's own error text says so, so a garbage/expired thread id (as opposed to a /// real spawn/RPC failure) doesn't masquerade as a server error. fn is_codex_thread_not_found(err: &CodexAppServerError) -> bool { + // The qualified native resume rejection uses "no rollout found", which + // does not contain "not found". The same fork text can mean an empty + // parent; only the actual resume RPC and its native error code qualify. + if matches!(err, CodexAppServerError::Rpc { method, error } + if method == "thread/resume" && error.code == -32600 + && (error.message == "no rollout found for thread id" + || error.message.starts_with("no rollout found for thread id "))) + { + return true; + } let message = err.to_string().to_lowercase(); message.contains("not found") || message.contains("no such thread") @@ -9120,7 +10624,8 @@ fn codex_normalize_command_status(status: Option<&str>) -> &'static str { Some("inProgress") => "running", Some("declined") => "declined", Some("failed") => "failed", - _ => "completed", + Some("completed") => "completed", + _ => "running", } } @@ -9129,7 +10634,8 @@ fn codex_normalize_tool_status(status: Option<&str>) -> &'static str { match status { Some("inProgress") => "running", Some("failed") => "failed", - _ => "completed", + Some("completed") => "completed", + _ => "running", } } @@ -9171,6 +10677,18 @@ fn codex_user_message_text_parts(item: &Value) -> Vec<(usize, String)> { .and_then(Value::as_str) .unwrap_or("") .to_string() + } else if matches!(part_type, Some("image" | "localImage" | "input_image")) { + let reference = part["path"] + .as_str() + .or(part["url"].as_str()) + .or(part["image_url"].as_str()) + .unwrap_or(""); + // Never place inline image bytes in a transcript string. + if reference.is_empty() || reference.starts_with("data:") { + "Image attachment".to_string() + } else { + format!("Image: {reference}") + } } else { format!("[{}]", part_type.unwrap_or("input")) }; @@ -9188,29 +10706,10 @@ fn codex_user_message_text_parts(item: &Value) -> Vec<(usize, String)> { vec![(0, String::new())] } -/// `normalizeCodexItem` (`normalize.ts:238-473`): map ONE raw codex thread item into its -/// `FreshAgentTranscriptItemSchema`-shaped item(s). Every `CodexThreadItemTypeSchema` variant -/// (`protocol.ts:113-129`) is covered. -/// -/// DELIBERATE DEVIATION from legacy for an unrecognized `type`: the reference's -/// `readCodexThreadItemType`/`assertNever` both throw `Unsupported Codex thread item type: -/// ${value}` (`normalize.ts:141-147,123-125`), which `router.ts`'s catch-all turns into a bare -/// 500 for the ENTIRE thread (`router.ts:165-166`) -- legacy would 500 here too, so this is not -/// a port regression, but it is a real bug either way. The real codex CLI (observed: 0.144.5) -/// emits `subAgentActivity`, an item type absent from BOTH the frozen legacy protocol -/// (`protocol.ts:113-129`, 16 variants) and current `origin/main`. Hard-failing an entire -/// historical thread over one item type neither codebase has caught up to yet makes the -/// snapshot endpoint unusable for real transcripts -- proven against real staging data (a -/// genuinely readable thread 500'd solely because of one `subAgentActivity` item). So an -/// unrecognized item type returns `Ok(vec![])` (the item is silently omitted; every other item -/// in the thread still renders) instead of `Err`. This mirrors the opencode side's existing -/// precedent: an unrecognized opencode part also degrades to `[]`, not a hard error (see -/// `opencode_item_from_part`). -/// -/// Unlike the reference, a missing `item.id` does NOT throw (`readCodexItemId`, -/// `normalize.ts:149-156`) -- the caller ([`build_codex_turn_json`]) already falls back to a -/// synthetic `{turnId}:item-{index}` id, matching this module's existing tolerant-read -/// convention elsewhere (documented divergence, not a silent one). +/// Normalize the visible Codex 0.164 thread items into the shared transcript. +/// Unknown future items emit a structured diagnostic and are omitted before +/// role bookkeeping, so they cannot make a blank conversation row or fail +/// otherwise readable history. Missing IDs use the caller's stable synthetic ID. fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result, String> { match item_type { "userMessage" => Ok(codex_user_message_text_parts(item) @@ -9225,7 +10724,11 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result { let summary = codex_string_array(item.get("summary")); @@ -9258,15 +10761,18 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result Ok(vec![json!({ + "mcpToolCall" => { + let mut mapped = json!({ "id": item_id, "kind": "mcp_tool", "server": item.get("server").and_then(Value::as_str).unwrap_or(""), @@ -9275,7 +10781,10 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result Ok(vec![json!({ "id": item_id, "kind": "dynamic_tool", @@ -9310,6 +10819,24 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result { + let Some(child) = item["agentThreadId"].as_str().filter(|id| !id.is_empty()) else { + tracing::warn!(provider=PROVIDER, native_item_id=item_id, item_type, reason="missing_child_identity", "freshagent.codex.item_unavailable"); + return Ok(vec![]); + }; + let status = match item["kind"].as_str() { + Some("completed") => "completed", + Some("interrupted") => "interrupted", + _ => "running", + }; + let mut mapped = json!({"id":item_id, "kind":"task_delegation", "status":status, "title":format!("Child agent {}", item["agentPath"].as_str().unwrap_or(child)), "childSessionId":child, "provider":"codex", "nativeStatus":item["kind"]}); + for field in ["model", "reasoningEffort"] { + if let Some(value) = item[field].as_str() { mapped[field] = json!(value); } + } + Ok(vec![mapped]) + } + "functionCallOutput" => Ok(vec![json!({"id":item_id, "kind":"tool_result", "toolUseId":item_id, "content":item.get("output").cloned().unwrap_or_else(|| json!("Function output unavailable")), "isError":false})]), + "sleep" => Ok(vec![json!({"id":item_id, "kind":"dynamic_tool", "tool":"Sleep", "status":"completed", "arguments":{"durationMs":item["durationMs"]}})]), "webSearch" => Ok(vec![json!({ "id": item_id, "kind": "web_search", @@ -9350,16 +10877,14 @@ fn map_codex_item(item_id: &str, item: &Value, item_type: &str) -> Result skip, don't error the whole thread. // See the doc comment above for the full rationale (real codex 0.144.5's // `subAgentActivity`, unknown to both frozen legacy and current `origin/main`). - _ => Ok(vec![]), + _ => { + tracing::warn!(provider=PROVIDER, native_item_id=item_id, item_type, reason="unsupported_native_item", "freshagent.codex.item_unavailable"); + Ok(vec![]) + }, } } -/// `classifyCodexItemRole(item)` (`normalize.ts:475-501`): every `CodexThreadItemTypeSchema` -/// variant maps to exactly one display role. The caller ([`build_codex_turn_json`]) classifies -/// EVERY raw item, INCLUDING ones [`map_codex_item`] mapped to an empty item list -/// (unrecognized types) -- the catch-all arm IS reachable there and decides whether that item -/// pushes a zero-item display row -- so the catch-all is a runtime path, not just a -/// compile-time safety net (it still matches the reference's `assertNever` default in spirit). +/// Select the display role of a successfully mapped native item. fn classify_codex_item_role(item_type: &str) -> &'static str { match item_type { "userMessage" => "user", @@ -9369,6 +10894,9 @@ fn classify_codex_item_role(item_type: &str) -> &'static str { | "mcpToolCall" | "dynamicToolCall" | "collabAgentToolCall" + | "subAgentActivity" + | "functionCallOutput" + | "sleep" | "webSearch" | "imageView" | "imageGeneration" => "tool", @@ -9517,13 +11045,9 @@ fn summarize_codex_items(items: &[Value]) -> (String, &'static str) { /// `normalizeCodexThreadSnapshot` (`normalize.ts:748-787`): map a raw `thread/read` result /// into the `FreshAgentSnapshotSchema` shape. /// -/// `tokenUsage` is always the zero-fallback (`normalize.ts:774-779`'s `?? {...zeros}` branch): -/// `CodexThreadReadResultSchema` (`protocol.ts:258-259`) is `{ thread: CodexThreadSchema }`, and -/// neither `CodexThreadSchema` (`protocol.ts:148-167`) nor anywhere else in the codex app-server -/// RPC surface exposes a `tokenUsage` field -- confirmed by inspection of the full protocol -/// schema, not by omission. The reference's `rawSnapshot.tokenUsage` is therefore ALWAYS -/// `undefined` on this path too; this is not a Rust-side gap, it is the reference's own honest -/// zero, faithfully reproduced. +/// `thread/read` carries no measured usage. The base snapshot stays explicitly +/// unobserved; a tracked session overlays the actual native usage notification +/// (total counters plus its separate latest sample) from its projection. fn build_codex_snapshot_json( thread_id: &str, raw: &Value, @@ -9592,7 +11116,11 @@ fn build_codex_snapshot_json( // NOT per-raw-turn, it is the display row's position in the final transcript. let turns: Vec = raw_turns .iter() - .map(|raw_turn| build_codex_turn_json(raw_turn, 0)) + .map(|raw_turn| { + let mut rows = build_codex_turn_json(raw_turn, 0)?; + projection::stamp_saved_output_state(raw_turn, &mut rows); + Ok(rows) + }) .collect::>, String>>()? .into_iter() .flatten() @@ -9637,9 +11165,12 @@ fn build_codex_snapshot_json( "effort": "per-send", "sandbox": "per-send", "permissionMode": "per-send", + "cwd": "per-send", + "collaborationMode": "per-send", }, }, "tokenUsage": { + "observed": false, "inputTokens": 0, "outputTokens": 0, "cachedTokens": 0, @@ -9681,21 +11212,9 @@ struct CodexPendingRow { items: Vec, } -/// `normalizeCodexDisplayTurns` (`normalize.ts:600-684`), restricted to this committed-turns -/// REST READ path (`getSnapshot`, `adapter.ts:1082-1122`): SPLIT one raw codex `turn` record -/// (`makeThread`/real app-server shape: `{id, status, error?, items:[{type,id,...}], ...}`) -/// into MULTIPLE `FreshAgentTurnSchema`-shaped display turns, one per maximal run of -/// contiguous-same-role raw items (`classifyCodexItemRole`, `normalize.ts:475-501` -- -/// ported as [`classify_codex_item_role`]). Every raw item is mapped via [`map_codex_item`] -/// (the full `normalizeCodexItem` switch, `normalize.ts:238-473`) before being folded into its -/// row. DELIBERATE DEVIATION: an unrecognized item type no longer fails the turn -- per -/// [`map_codex_item`]'s doc comment, it maps to an empty item list. The loop does NOT skip it: -/// its role is still classified (the catch-all arm of [`classify_codex_item_role`] yields -/// `assistant`) and folded into `has_assistant_output`/`has_user_output`/`all_items_are_user`, -/// and when that role differs from the previous row's a new row is pushed whose `items` is the -/// empty list -- a ZERO-ITEM display row with a BLANK summary (`summarize_codex_items(&[])` -/// returns `""`). When the role matches the previous row, `row.items.extend(mapped)` extends by -/// nothing and no empty row appears. Every other item in the turn still renders normally. +/// Split each raw native turn into maximal runs of contiguous display roles. +/// Unknown or unsupported items are omitted before role bookkeeping; they +/// never manufacture an empty assistant or user row. /// /// A turn-level `error` (`normalize.ts:509-519,640-641`) or a completed turn whose only items /// are `user`-role with no `assistant` output (`normalize.ts:642-652`) each APPEND A NEW @@ -9746,6 +11265,9 @@ fn build_codex_turn_json(raw_turn: &Value, ordinal: usize) -> Result, .map(str::to_string) .unwrap_or_else(|| format!("{turn_id}:item-{index}")); let mapped = map_codex_item(&item_id, item, item_type)?; + if mapped.is_empty() { + continue; + } let role = classify_codex_item_role(item_type); match role { "assistant" => has_assistant_output = true, @@ -9861,6 +11383,16 @@ fn checkpoint_codex_turn_identity( Some((native_turn_id.to_string(), display_turn_id.to_string())) } +fn codex_cleanup_error() -> crate::rest_lane::RestLaneError { + let mut error = crate::rest_lane::RestLaneError::new( + crate::rest_lane::RestLaneErrorKind::Unavailable, + "CODEX_CREATE_CLEANUP_UNCONFIRMED", + "the exact created Codex runtime could not be confirmed stopped", + ); + error.retryable = Some(true); + error +} + /// Watch an owned sidecar child to completion. Two ways out: /// /// - The child exits ON ITS OWN (crash / unexpected disconnect, never requested): self-heal @@ -9878,30 +11410,69 @@ fn watcher_stop_not_confirmed() -> crate::session_handoff::StopResult { } } -/// Continue an incomplete Codex tree reap after the bounded watcher window. The first -/// watcher pass has already stopped the direct child; this continuation keeps probing the -/// recorded incarnation and tagged descendants until the complete writer tree is confirmed -/// gone. An incomplete identity capture cannot be repaired safely, so it stays fail-closed. -fn codex_tree_death_escalation( - recorded: crate::session_lease::CondemnedRuntimeIdentity, - ownership_id: String, - thread_id: String, +fn with_codex_turn_exit_confirmation( + confirmation: std::pin::Pin + Send + 'static>>, + turns: Option, ) -> std::pin::Pin + Send + 'static>> { Box::pin(async move { - if !recorded.capture_complete { - tracing::error!(target: "freshell_freshagent::codex", - session_id = %thread_id, - ownership_id = %ownership_id, - "freshagent.codex.teardown_escalation_unavailable: the recorded writer identity was incomplete; keeping ownership fenced"); - return false; + let confirmed = confirmation.await; + if confirmed { + if let Some(turns) = turns { + turns + .lock() + .expect("codex turn state") + .settle_native("", NativeSettlement::OwnedExit); + } } - loop { - if crate::session_lease::kill_and_confirm_recorded_tree_dead( - &recorded, - CODEX_SIDECAR_OWNERSHIP_ENV, + confirmed + }) +} + +/// Keep the same recorded process identity when a hosted provider needs its +/// own uid to inspect and terminate the tree. Local lanes use the same primitive. +async fn confirm_codex_tree_dead( + recorded: &crate::session_lease::CondemnedRuntimeIdentity, + probe: Option, +) -> bool { + match probe { + Some(probe) => match probe(recorded.clone()).await { + Ok(confirmed) => confirmed, + Err(error) => { + tracing::warn!(provider=PROVIDER, pid=recorded.pid, ownership_id=%recorded.ownership_id, %error, + "freshagent.codex.owned_cleanup_unavailable"); + false + } + }, + None => { + crate::session_lease::kill_and_confirm_recorded_tree_dead( + recorded, + CODEX_SIDECAR_OWNERSHIP_ENV, ) .await - { + } + } +} + +/// Continue the existing exact-owned teardown until confirmed. A hosted +/// identity helper may complete a capture its unprivileged host could not read. +fn codex_tree_death_escalation( + recorded: crate::session_lease::CondemnedRuntimeIdentity, + ownership_id: String, + thread_id: String, + cleanup_probe: Option, +) -> std::pin::Pin + Send + 'static>> { + Box::pin(async move { + if (!recorded.capture_complete && cleanup_probe.is_none()) + || (recorded.start_time.is_none() && cleanup_probe.is_some()) + { + tracing::error!(target: "freshell_freshagent::codex", + session_id = %thread_id, + ownership_id = %ownership_id, + "freshagent.codex.teardown_escalation_unavailable: the recorded writer identity was incomplete; keeping ownership fenced"); + return false; + } + loop { + if confirm_codex_tree_dead(&recorded, cleanup_probe.clone()).await { return true; } tracing::warn!(target: "freshell_freshagent::codex", @@ -10020,8 +11591,61 @@ fn detach_codex_confirmation( Box::pin(async move { settled_rx.await.unwrap_or(false) }) } +#[cfg(test)] #[allow(clippy::too_many_arguments)] pub(crate) fn spawn_exit_watcher( + child: tokio::process::Child, + ownership_id: String, + thread_id: String, + broadcast_tx: Arc>, + kill_rx: oneshot::Receiver<()>, + exited: Arc, + leases: Arc, + quiet_deadman: Arc>, + // The session's turn-in-flight latch (the dispatch-time authority) + its + // shared turn-complete clock: the crash self-heal arm mints the unified + // attention edge for a turn in flight at crash time on the session's ONE + // clock; a requested kill retires the latch silently. + turn_in_flight: Arc, + // FR1-1: the session's retired-turn slot — both terminal arms (the + // requested kill, the crash self-heal) end the turn, so the slot's + // idle→completed bridge dies with the latch: a respawned incarnation's + // fresh notifications must never consult a pre-teardown slot. + // (E3R1: the durable already-rang set is deliberately NOT threaded + // into the watcher — teardown voids would reopen the gate for a + // buffered late completion folding after the exit, and a respawned + // incarnation never consults the pre-teardown set anyway: every + // respawn site constructs FRESH Arcs.) + retired_turn_id: Arc>>, + last_turn_complete_at: Arc>>, + condemned_priors: Arc< + StdMutex>, + >, + // kata b8ke Task 3: release the retained coordinator stamp on exit with + // its fenced claim (None when the lane is unwired — pre-existing tests). + ownership: Option, +) -> tokio::task::JoinHandle { + spawn_exit_watcher_tracking_turns( + child, + ownership_id, + thread_id, + broadcast_tx, + kill_rx, + exited, + leases, + quiet_deadman, + turn_in_flight, + retired_turn_id, + last_turn_complete_at, + condemned_priors, + ownership, + None, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn spawn_exit_watcher_tracking_turns( mut child: tokio::process::Child, ownership_id: String, thread_id: String, @@ -10052,7 +11676,19 @@ pub(crate) fn spawn_exit_watcher( // kata b8ke Task 3: release the retained coordinator stamp on exit with // its fenced claim (None when the lane is unwired — pre-existing tests). ownership: Option, + turn_state: Option, + retained_state: Option, ) -> tokio::task::JoinHandle { + if let (Some(turns), Some(state)) = (&turn_state, &retained_state) { + turns + .lock() + .expect("codex turn state") + .bind_settlement_stream( + thread_id.clone(), + ownership_id.clone(), + state.turn_events.clone(), + ); + } // wfah: the thread id is fixed by the time the watcher is constructed at // every successful spawn site; enrich the durable record once, here. crate::codex_sidecar_tracking::enrich_record_session_id(&ownership_id, &thread_id); @@ -10065,7 +11701,19 @@ pub(crate) fn spawn_exit_watcher( &ownership_id, ) }); + #[cfg(any(test, feature = "test-support"))] + if let (Some(observer), Some(recorded)) = ( + retained_state + .as_ref() + .and_then(|state| state.sidecar_capture_observer.get()), + recorded.as_ref(), + ) { + observer(&thread_id, recorded); + } let ownership = ownership.map(|watch| watch.for_runtime(&thread_id, child.id())); + let cleanup_probe = retained_state + .as_ref() + .and_then(|state| state.sidecar_cleanup_probe.get().cloned()); tokio::spawn(async move { // `biased` + the REQUESTED-kill arm listed FIRST: a `freshAgent.kill` signals // `kill_tx` right before `start_kill()`s the child, so `child.wait()` can become @@ -10094,23 +11742,32 @@ pub(crate) fn spawn_exit_watcher( // Arcs at its rebuild, and a teardown void would reopen // the gate for a buffered late completion of the // already-rang turn. - let _ = child.start_kill(); - let wait_result = child.wait().await; - let confirmed = if cfg!(target_os = "linux") { - match recorded.as_ref() { - Some(recorded) => { - crate::session_lease::kill_and_confirm_recorded_tree_dead( - recorded, - CODEX_SIDECAR_OWNERSHIP_ENV, - ) - .await - } + let (wait_result, confirmed) = if cleanup_probe.is_some() { + let confirmed = match recorded.as_ref() { + Some(recorded) => confirm_codex_tree_dead(recorded, cleanup_probe.clone()).await, None => false, - } + }; + let waited = if confirmed { + child.wait().await + } else { + Err(std::io::Error::other("owned provider exit remains unconfirmed")) + }; + (waited, confirmed) } else { - false + let _ = child.start_kill(); + let waited = child.wait().await; + let confirmed = if cfg!(target_os = "linux") { + match recorded.as_ref() { + Some(recorded) => confirm_codex_tree_dead(recorded, None).await, + None => false, + } + } else { + false + }; + (waited, confirmed) }; if confirmed { + if let Some(turns) = &turn_state { turns.lock().expect("codex turn state").settle_native("", NativeSettlement::OwnedExit); } cleanup_confirmed_codex_teardown( &leases, ownership.as_ref(), @@ -10155,9 +11812,11 @@ pub(crate) fn spawn_exit_watcher( recorded, ownership_id.clone(), thread_id.clone(), + cleanup_probe.clone(), ) }) .unwrap_or_else(|| Box::pin(async { false })); + let confirmation = with_codex_turn_exit_confirmation(confirmation, turn_state.clone()); crate::session_handoff::StopResult::NotConfirmed { confirmation: wrap_codex_confirmation_with_cleanup( confirmation, @@ -10179,11 +11838,7 @@ pub(crate) fn spawn_exit_watcher( let confirmed = if cfg!(target_os = "linux") { match recorded.as_ref() { Some(recorded) => { - crate::session_lease::kill_and_confirm_recorded_tree_dead( - recorded, - CODEX_SIDECAR_OWNERSHIP_ENV, - ) - .await + confirm_codex_tree_dead(recorded, cleanup_probe.clone()).await } None => false, } @@ -10191,6 +11846,7 @@ pub(crate) fn spawn_exit_watcher( false }; let stop_result = if confirmed { + if let Some(turns) = &turn_state { turns.lock().expect("codex turn state").settle_native("", NativeSettlement::OwnedExit); } cleanup_confirmed_codex_teardown( &leases, ownership.as_ref(), @@ -10239,9 +11895,11 @@ pub(crate) fn spawn_exit_watcher( recorded, ownership_id.clone(), thread_id.clone(), + cleanup_probe.clone(), ) }) .unwrap_or_else(|| Box::pin(async { false })); + let confirmation = with_codex_turn_exit_confirmation(confirmation, turn_state.clone()); let confirmation = wrap_codex_confirmation_with_cleanup( confirmation, ownership_id.clone(), @@ -10262,6 +11920,9 @@ pub(crate) fn spawn_exit_watcher( // PR-4: flip the lazy-restart flag BEFORE broadcasting, so a client that // reacts to the `exited` status by immediately sending/attaching never // races ahead of `ensure_session_alive` observing a stale `false`. + if let Some(state) = &retained_state { + state.retain_native_exit(&thread_id, &ownership_id).await; + } exited.store(true, Ordering::SeqCst); let event = CodexAdapterEvent::Status { session_id: thread_id.clone(), @@ -10295,6 +11956,7 @@ pub(crate) fn spawn_exit_watcher( &thread_id, &broadcast_tx, "crash_self_heal", + retained_state.as_ref(), ); } stop_result @@ -10436,7 +12098,10 @@ fn note_codex_activity( let window = Duration::from_millis(window_ms); let feed = { let mut q = quiet.lock().expect("quiet deadman lock"); - q.feed(turn_in_flight.load(Ordering::SeqCst), window) + q.feed( + turn_in_flight.load(Ordering::SeqCst) && state.awaited_control_count(thread_id) == 0, + window, + ) }; if feed.resolved { tracing::info!(provider = PROVIDER, session_id = %thread_id, phase = "resolved", "freshagent.codex.quiet_deadman"); @@ -10526,6 +12191,7 @@ fn disarm_codex_quiet( fn reduce_notification( subscription: &mut CodexSubscription, notification: CodexNotification, + turn_state: &SharedCodexTurnState, active_turn: &Arc>>, turn_in_flight: &Arc, quiet_deadman: &Arc>, @@ -10618,6 +12284,7 @@ fn reduce_notification( subscription.session_id(), broadcast_tx, "system_error_status", + Some(state), ); let rang_turn_id = active_turn .lock() @@ -10662,6 +12329,7 @@ fn reduce_notification( // `active_turn` guard, and dropped before the // `quiet_deadman` lock below (the file-wide order is // `quiet_deadman` outer / `active_turn`-tier inner). + turn_state.lock().expect("codex turn state").observe_idle(); let retired_tracker_id = active_turn.lock().expect("active_turn mutex").take(); if let Some(id) = retired_tracker_id { @@ -10693,6 +12361,19 @@ fn reduce_notification( // the retired-turn latch each have their narrower FIFO-safe // ownership exceptions below. if event.thread_id == subscription.session_id() { + let terminal = match freshell_codex::turn_status(&event.params).as_deref() { + Some("completed") => Some(NativeSettlement::Completed), + Some("interrupted") => Some(NativeSettlement::Interrupted), + Some("failed") => Some(NativeSettlement::Failed), + Some("inProgress") => return subscription.on_turn_completed(&event, now_ms()), + _ => None, + }; + if let (Some(turn_id), Some(terminal)) = (event.turn_id.as_deref(), terminal) { + turn_state + .lock() + .expect("codex turn state") + .settle_native(turn_id, terminal); + } // Scoped: the `active_turn` guard must be dropped BEFORE the // quiet-deadman disarm below (the file-wide lock order is // `quiet_deadman` outer / `active_turn` inner — never lock @@ -10940,7 +12621,43 @@ fn reduce_notification( // `interrupted` (e.g. the app-server completed the turn // as `completed`) must never cross the turn boundary and // silence a later NON-user `interrupted` (a missed bell). - subscription.clear_user_interrupt(); + let disposition = turn_state + .lock() + .expect("codex turn state") + .observe_started(turn_id); + let user_stopped = turn_state + .lock() + .expect("codex turn state") + .user_stop_turn_id() + == Some(turn_id.as_str()); + if !user_stopped { + subscription.clear_user_interrupt(); + } + if let StopDisposition::Interrupt { dispatch, turn_id } = disposition { + let state = state.clone(); + let turns = turn_state.clone(); + let session_id = event.thread_id.clone(); + tokio::spawn(async move { + let client = state + .sessions + .lock() + .await + .get(&session_id) + .filter(|session| Arc::ptr_eq(&session.turn_state, &turns)) + .map(|session| session.client.clone()); + if let Some(client) = client { + state + .issue_codex_interrupt( + &session_id, + client, + turns, + dispatch, + turn_id, + ) + .await; + } + }); + } // `handle_send` records the provider-returned turn id before its // matching `turn/started` notification necessarily reaches this // consumer. Treat that direct record as authoritative over a @@ -11022,6 +12739,7 @@ fn reduce_notification( subscription.session_id(), broadcast_tx, "thread_closed", + Some(state), ); } let rang_turn_id = active_turn @@ -11060,6 +12778,82 @@ fn reduce_notification( /// Map an adapter event to a `freshAgent.event` wire frame (sdk-events.ts normalization: /// `sdk.*` → `freshAgent.*`). Returns the pre-serialized JSON, or `None` on a serialize error. +fn project_codex_notification( + projection: &Arc>, + notification: &CodexNotification, +) -> bool { + let (method, params) = match notification { + CodexNotification::Other { method, params } => { + (method.as_str(), params.clone().unwrap_or(Value::Null)) + } + CodexNotification::TurnStarted(event) => { + ("turn/started", Value::Object(event.params.clone())) + } + CodexNotification::TurnCompleted(event) => { + ("turn/completed", Value::Object(event.params.clone())) + } + CodexNotification::ThreadClosed { thread_id } => { + let mut p = projection.lock().expect("codex projection lock"); + if p.owns_thread(thread_id) { + p.freeze_active("interrupted"); + } + return false; + } + CodexNotification::ThreadStatusChanged { thread_id, status } => { + let mut p = projection.lock().expect("codex projection lock"); + if p.owns_thread(thread_id) + && normalize_codex_thread_status(status) == CodexStatus::Exited + { + p.freeze_active("interrupted"); + } + return false; + } + _ => return false, + }; + let mut p = projection.lock().expect("codex projection lock"); + match p.apply(method, ¶ms) { + Ok(ops) => { + let has_ops = !ops.is_empty(); + p.enqueue(ops); + has_ops + } + Err(error) => { + tracing::warn!(provider=PROVIDER, projection_id=%p.id, method=%error.method, native_turn_id=?error.turn_id, native_item_id=?error.item_id, reason=error.reason, "freshagent.codex.projection_refused"); + false + } + } +} + +fn broadcast_codex_event( + thread_id: &str, + event: Value, + tx: &tokio::sync::broadcast::Sender, +) { + let msg = ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.to_owned(), + session_type: SESSION_TYPE.to_owned(), + session_id: thread_id.to_owned(), + event, + }); + if let Ok(frame) = serde_json::to_string(&msg) { + let _ = tx.send(frame); + } +} + +fn flush_codex_projection( + projection: &Arc>, + thread_id: &str, + tx: &tokio::sync::broadcast::Sender, +) { + let patch = projection + .lock() + .expect("codex projection lock") + .take_patch(); + if let Some(patch) = patch { + broadcast_codex_event(thread_id, patch, tx); + } +} + fn adapter_event_to_frame(event: &CodexAdapterEvent, thread_id: &str) -> Option { let inner = match event { CodexAdapterEvent::StatusSnapshot { @@ -11111,9 +12905,16 @@ fn mint_synthesized_attention_edge( thread_id: &str, broadcast_tx: &tokio::sync::broadcast::Sender, source: &'static str, + state: Option<&FreshCodexState>, ) { let clock = crate::attention::AttentionClock::from(Arc::clone(last_turn_complete_at)); let at = crate::attention::mint_synthesized_edge(&clock, PROVIDER, thread_id, source); + if let Some(state) = state { + state + .attention + .record_turn_complete(thread_id, SESSION_TYPE, at); + state.notify_retained(); + } let event = CodexAdapterEvent::TurnComplete { session_id: thread_id.to_string(), at, @@ -11470,6 +13271,31 @@ fn now_iso() -> String { format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}.{millis:03}Z") } +struct LocalCodexCatalogProbe { + state: FreshCodexState, + context: crate::model_capabilities::CodexCatalogContext, + expected_source: String, +} +impl crate::model_capabilities::ModelCatalogProbe for LocalCodexCatalogProbe { + fn probe<'a>( + &'a self, + _cwd: Option<&'a str>, + ) -> freshell_opencode::serve::BoxFuture<'a, crate::model_capabilities::CatalogOut> { + Box::pin(async move { + if self.state.catalog_binding(&self.context).await?.0 != self.expected_source { + return Err(crate::model_capabilities::source_changed()); + } + let rows = self.state.model_catalog(self.context.clone()).await?; + let models = serde_json::from_value(rows).expect("typed native catalog rows"); + Ok(crate::model_capabilities::codex_capabilities(models)) + }) + } +} + +#[cfg(test)] +#[path = "codex_rest_lane_tests.rs"] +mod rest_lane_tests; + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -11983,6 +13809,7 @@ pub(crate) mod tests { ownership_id: &str, ) -> tokio::sync::broadcast::Receiver { // no-op consumer: these tests drive the reducer/RPC surfaces directly. + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let consumer = tokio::spawn(async {}); let (kill_tx, kill_rx) = oneshot::channel(); let exited = Arc::new(AtomicBool::new(false)); @@ -12001,7 +13828,7 @@ pub(crate) mod tests { let last_turn_complete_at: Arc>> = Arc::new(StdMutex::new(None)); let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.to_string(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id.to_string(), thread_id.to_string(), @@ -12015,10 +13842,16 @@ pub(crate) mod tests { last_turn_complete_at.clone(), Arc::clone(&state.condemned_priors), None, + Some(turn_state.clone()), + Some(state.clone()), ); state.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, + turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), effort: None, @@ -12033,6 +13866,10 @@ pub(crate) mod tests { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate: Arc::new(TokioMutex::new(())), + projection: Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))), consumer, kill_tx: Some(kill_tx), watcher, @@ -12090,9 +13927,15 @@ pub(crate) mod tests { let compact_in_flight = Arc::new(AtomicBool::new(false)); let compact_turn_id = Arc::new(StdMutex::new(None)); let event_emission_gate = Arc::new(TokioMutex::new(())); + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); + let projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + thread_id.to_string(), + ))); let consumer = state.spawn_consumer( notifs, CodexConsumerRuntime { + turn_state: turn_state.clone(), thread_id: thread_id.to_string(), active_turn: active_turn.clone(), user_interrupt_pending: user_interrupt_pending.clone(), @@ -12104,13 +13947,14 @@ pub(crate) mod tests { compact_in_flight: compact_in_flight.clone(), compact_turn_id: compact_turn_id.clone(), event_emission_gate: event_emission_gate.clone(), + projection: projection.clone(), }, ); let (kill_tx, kill_rx) = oneshot::channel(); let exited = Arc::new(AtomicBool::new(false)); let sidecar_pid = child.id(); let sidecar_ownership_id = ownership_id.to_string(); - let watcher = spawn_exit_watcher( + let watcher = spawn_exit_watcher_tracking_turns( child, ownership_id.to_string(), thread_id.to_string(), @@ -12124,10 +13968,16 @@ pub(crate) mod tests { last_turn_complete_at.clone(), Arc::clone(&state.condemned_priors), None, + Some(turn_state.clone()), + Some(state.clone()), ); state.sessions.lock().await.insert( thread_id.to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, + turn_state: turn_state.clone(), client, model: "gpt-5.3-codex-spark".to_string(), effort: None, @@ -12142,6 +13992,7 @@ pub(crate) mod tests { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate, + projection, consumer, kill_tx: Some(kill_tx), watcher, @@ -12511,6 +14362,7 @@ pub(crate) mod tests { let sink = crate::naming::test_support::RecordingSink::new(); st.set_session_naming(sink.clone()); let create = FreshAgentCreate { + collaboration_mode: None, pane_id: None, observed_epoch: None, observed_generation: None, @@ -13297,91 +15149,685 @@ pub(crate) mod tests { ); } - /// b8ke ext r29 F3 + focused ep5 r1 F2: the post-send binding refresh - /// carries the operation's OWN claim pair — the lane's retained - /// OwnershipStamp for the session (written only by this lane's claim - /// commits), never a fresh registry observation — so the ledger's - /// delayed-write fence can refuse a refresh that lands after a - /// handoff reaped the runtime and bound a terminal under a NEWER - /// generation. Pre-r29 the refresh passed NO pair (the pre-r22 - /// unfenced shape), so the late write was accepted and rewrote the - /// terminal's authoritative recovery row; the ep5-r1 review found - /// the ext-r29 fresh-observe capture handed a mid-flight handoff the - /// POST-handoff generation (the same-generation trap). - #[tokio::test] - async fn the_post_send_binding_refresh_carries_the_observed_ownership_pair() { + async fn task008_settings_fixture() -> ( + FreshCodexState, + freshell_codex::ChannelPeer, + tokio::sync::broadcast::Receiver, + Arc, + ) { let (transport, peer) = freshell_codex::new_channel_transport(); - let (client, notifs) = CodexAppServerClient::connect(transport); - let (mut st, _) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(Arc::clone(®istry)); - let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - // Seed the runtime's ownership the way the LANE does (the claim + - // commit that retains the stamp — production-faithful: every live - // codex session's stamp exists because its create/respawn/resume - // committed through this very path). - let mut seed_ticket = - match st.begin_lane_claim_at("thread-r29-f3", "op-r29-f3-seed", None, "test") { - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - _ => panic!("the seed claim must grant"), - }; - let generation = seed_ticket.as_ref().expect("the seed ticket").generation(); - assert!(st - .commit_lane_claim_at(&mut seed_ticket, "thread-r29-f3", "thread-r29-f3", None,) - .is_ok()); - - insert_fake_session_with_real_consumer( + let (client, notifications) = CodexAppServerClient::connect(transport); + let (st, _) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let wire = insert_fake_session_with_real_consumer( &st, - "thread-r29-f3", + "thread-settings", Arc::new(client), Arc::new(StdMutex::new(None)), - notifs, + notifications, spawn_sleeper(), - "codex-r29-f3-refresh", + &format!("task008-settings-{}", uuid::Uuid::new_v4()), ) .await; + (st, peer, wire, sink) + } + + async fn task008_settings_send( + st: &FreshCodexState, + peer: &freshell_codex::ChannelPeer, + settings: Value, + ) -> Value { + task008_settings_send_named(st, peer, settings, "turn-settings").await + } + + async fn task008_settings_send_named( + st: &FreshCodexState, + peer: &freshell_codex::ChannelPeer, + settings: Value, + turn_id: &str, + ) -> Value { let task = tokio::spawn({ let st = st.clone(); async move { - st.handle_send(send_msg("thread-r29-f3", "hello")).await; + st.handle_send(serde_json::from_value(json!({ + "provider":"codex", "sessionType":"freshcodex", "sessionId":"thread-settings", + "requestId":"settings-send", "text":"settings probe", "settings":settings, + })).unwrap()).await; } }); - let (id, method, _) = peer.expect_request().await; - assert_eq!(method, "initialize"); - peer.respond(&id, json!({})); - peer.expect_notification().await; - let (id, method, _) = peer.expect_request().await; + let (mut id, mut method, mut params) = peer.expect_request().await; + if method == "initialize" { + peer.respond(&id, json!({})); + peer.expect_notification().await; + (id, method, params) = peer.expect_request().await; + } assert_eq!(method, "turn/start"); - peer.respond(&id, json!({"turn":{"id":"turn-r29-f3"}})); + peer.respond(&id, json!({"turn":{"id":turn_id}})); task.await.unwrap(); + params + } + + async fn task008_settings_snapshot( + st: &FreshCodexState, + peer: &freshell_codex::ChannelPeer, + ) -> Value { + let task = tokio::spawn({ + let st = st.clone(); + async move { st.get_snapshot("thread-settings", None).await.unwrap() } + }); + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + peer.respond( + &id, + json!({"thread":{"id":"thread-settings","status":{"type":"idle"},"turns":[]}}), + ); + task.await.unwrap() + } + + #[tokio::test] + async fn task008_settings_configure_cwd_reaches_the_next_native_start() { + let (st, peer, _, sink) = task008_settings_fixture().await; + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","settings":{"cwd":"/next","sandbox":"read-only","permissionMode":"never","model":"provider-current","effort":"ultra"}})).unwrap()).await; + let params = task008_settings_send(&st, &peer, Value::Null).await; + let saved = sink.load_settings("codex", "thread-settings"); peer.disconnect(); st.shutdown().await; + assert_eq!(params["cwd"], "/next"); + assert_eq!(params["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(saved.unwrap().cwd.as_deref(), Some("/next")); + } - // THE CONTRACT: the post-send refresh carried the observed pair — - // (None, None) pre-r29. - let pair = fake - .binding_pairs - .lock() - .unwrap() - .get(&("codex".to_string(), "thread-r29-f3".to_string())) - .cloned() - .expect("the post-send refresh wrote the binding row"); + #[tokio::test] + async fn task008_settings_snapshot_exposes_complete_next_turn_defaults() { + let (st, peer, _, _) = task008_settings_fixture().await; + task008_settings_send(&st, &peer, json!({"cwd":"/next","sandbox":"read-only","permissionMode":"never","model":"provider-current","effort":"ultra"})).await; + let snapshot = task008_settings_snapshot(&st, &peer).await; + peer.disconnect(); + st.shutdown().await; assert_eq!( - pair, - (Some(registry.boot_epoch()), Some(generation)), - "the refresh carries the session's CURRENT ownership pair: {pair:?}" + snapshot["settings"], + json!({"cwd":"/next","sandbox":"read-only","permissionMode":"never","model":"provider-current","effort":"ultra","collaborationMode":"default"}) ); + assert_eq!(snapshot["capabilities"]["settingScopes"]["cwd"], "per-send"); } - /// b8ke focused ep5 r1 F2: the harmful ordering the ep5-r1 review - /// named — a handoff that has ALREADY ENTERED (the record sits in - /// Handoff; nothing reaped the in-map session, so the send sees the - /// UNGUARDED AlreadyRunning return) must NOT hand the delayed - /// refresh the handoff/target generation: a terminal binding written - /// at that same generation is not older than the refresh, so the - /// late refresh would pass the ledger's fence and rewrite the + #[tokio::test] + async fn task008_settings_plan_and_explicit_build_use_native_collaboration_mode() { + let mut observed = Vec::new(); + for mode in ["plan", "default"] { + let (st, peer, _, _) = task008_settings_fixture().await; + let params = task008_settings_send(&st, &peer, json!({"model":"provider-current","effort":"ultra","cwd":"/next","sandbox":"read-only","permissionMode":"never","collaborationMode":mode})).await; + peer.disconnect(); + st.shutdown().await; + assert_eq!(params["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(params["approvalPolicy"], "never"); + observed.push(params["collaborationMode"].clone()); + } + assert_eq!(observed, ["plan", "default"].map(|mode| json!({"mode":mode,"settings":{"model":"provider-current","reasoning_effort":"ultra","developer_instructions":null}}))); + } + + #[tokio::test] + async fn task008_settings_newer_configure_survives_a_held_older_send_response() { + let (st, peer, mut wire, sink) = task008_settings_fixture().await; + let send = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_send(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","text":"old settings","settings":{"model":"old-model","effort":"low","cwd":"/old","sandbox":"read-only","permissionMode":"on-request"}})).unwrap()).await; + } + }); + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond(&id, json!({})); + peer.expect_notification().await; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + let configure = st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","requestId":"new-settings","settings":{"model":"new-model","effort":"ultra","cwd":"/new","sandbox":"workspace-write","permissionMode":"never","collaborationMode":"plan"}})).unwrap()); + tokio::pin!(configure); + // Give the public configure an actual admission opportunity. It may + // finish now or serialize behind the held send; either ordering is valid. + let admitted = futures_util::poll!(&mut configure).is_ready(); + peer.respond(&id, json!({"turn":{"id":"turn-old"}})); + send.await.unwrap(); + if !admitted { + configure.await; + } + drain_wire(&mut wire); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-settings","turn":{"id":"turn-old","status":"completed"}}), + ); + await_next_idle_snapshot(&mut wire, "thread-settings").await; + let params = task008_settings_send(&st, &peer, Value::Null).await; + let saved = sink.load_settings("codex", "thread-settings").unwrap(); + peer.disconnect(); + st.shutdown().await; + assert_eq!(params["model"], "new-model"); + assert_eq!(params["effort"], "ultra"); + assert_eq!(params["cwd"], "/new"); + assert_eq!(params["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(params["approvalPolicy"], "never"); + assert_eq!(saved.model.as_deref(), Some("new-model")); + assert_eq!(saved.cwd.as_deref(), Some("/new")); + } + + #[tokio::test] + async fn task008_settings_durable_configure_failure_refuses_without_false_success() { + let (events, _capture) = tracing_capture::capture(); + let (st, peer, mut wire, sink) = task008_settings_fixture().await; + task008_settings_send(&st, &peer, json!({"model":"saved-model","effort":"low"})).await; + drain_wire(&mut wire); + sink.set_fail_writes(true); + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","requestId":"refused-settings","settings":{"model":"unsaved-model","effort":"ultra"}})).unwrap()).await; + let frames = drain(&mut wire).await; + let snapshot = task008_settings_snapshot(&st, &peer).await; + let saved = sink.load_settings("codex", "thread-settings").unwrap(); + peer.disconnect(); + st.shutdown().await; + assert!( + frames + .iter() + .any(|frame| frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "CODEX_SETTINGS_PERSIST_FAILED" + && frame["event"]["requestId"] == "refused-settings"), + "typed correlated refusal missing: {frames:?}" + ); + assert!(!frames + .iter() + .any(|frame| frame["event"]["type"] == "freshAgent.session.metadata")); + assert_eq!(snapshot["settings"]["model"], "saved-model"); + assert_eq!(saved.model.as_deref(), Some("saved-model")); + assert!(events + .lock() + .unwrap() + .iter() + .any(|event| event.message.contains("configure_persist_failed"))); + } + + #[tokio::test] + async fn task008_settings_configure_carries_its_retained_owner_fence() { + let (mut st, peer, _, sink) = task008_settings_fixture().await; + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(registry.clone()); + let mut ticket = + match st.begin_lane_claim_at("thread-settings", "settings-owner", None, "test") { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + _ => panic!("settings ownership claim must grant"), + }; + let expected = ( + Some(registry.boot_epoch()), + Some(ticket.as_ref().unwrap().generation()), + ); + assert!(st + .commit_lane_claim_at(&mut ticket, "thread-settings", "thread-settings", None) + .is_ok()); + st.handle_configure(configure_msg( + "thread-settings", + Some("provider-current"), + Some("ultra"), + )) + .await; + let observed = sink + .binding_pairs + .lock() + .unwrap() + .get(&("codex".into(), "thread-settings".into())) + .cloned(); + peer.disconnect(); + st.shutdown().await; + assert_eq!(observed, Some(expected)); + } + + #[tokio::test] + async fn task008_settings_refinement_retained_snapshot_keeps_complete_staged_defaults() { + let (st, peer, _, _) = task008_settings_fixture().await; + task008_settings_send(&st, &peer, json!({"model":"provider-current","effort":"ultra","cwd":"/first","sandbox":"read-only","permissionMode":"never"})).await; + task008_settings_snapshot(&st, &peer).await; + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","settings":{"cwd":"/next","collaborationMode":"plan"}})).unwrap()).await; + let retained = st.retained_state("thread-settings").await.unwrap(); + peer.disconnect(); + st.shutdown().await; + assert_eq!( + retained["snapshot"]["settings"], + json!({"model":"provider-current","effort":"ultra","cwd":"/next","sandbox":"read-only","permissionMode":"never","collaborationMode":"plan"}) + ); + assert_eq!( + retained["snapshot"]["extensions"]["codex"]["settings"]["currentTurn"]["cwd"], + "/first" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_recovery_refusal_keeps_file_access_and_approval_on_retry() { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("restricted-recovery.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log}).to_string()); + let (st, mut wire) = state_with_bus(); + let mut create = create_msg("restricted-settings-recovery"); + create.cwd = Some(temp.path().to_string_lossy().into()); + create.sandbox = Some(freshell_protocol::Sandbox::ReadOnly); + create.permission_mode = Some("on-request".into()); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut wire, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.created" + }) + .await; + assert!(created + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.created")); + simulate_unrequested_crash(&st, "thread-new-1").await; + configure_fake_codex_cmd( + &json!({"appendRequestLogPath":log,"rejectResumeOverrides":true}).to_string(), + ); + drain_wire(&mut wire); + st.handle_send(send_msg( + "thread-new-1", + "restricted recovery must not run with native full access", + )) + .await; + let frames = drain(&mut wire).await; + st.shutdown().await; + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let observed = json!({ + "resumes":requests.iter().filter(|row| row["method"] == "thread/resume").map(|row| json!({"sandbox":row["params"]["sandbox"],"approval":row["params"]["approvalPolicy"]})).collect::>(), + "work":requests.iter().filter(|row| row["method"] == "turn/start").map(|row| json!({"sandbox":row["params"]["sandboxPolicy"]["type"],"approval":row["params"]["approvalPolicy"]})).collect::>(), + "refused":frames.iter().any(|frame| frame["type"] == "error" && frame["message"].as_str().is_some_and(|message| message.starts_with("CODEX_RESPAWN_FAILED:") && message.contains("resume sandbox or approval"))) + }); + assert_eq!( + observed, + json!({"resumes":[{"sandbox":"read-only","approval":"on-request"},{"sandbox":"read-only","approval":"on-request"}],"work":[],"refused":true}), + "native refusal cannot become a no-policy full-access recovery" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_refinement_untrusted_recovery_refuses_without_dropping_policy() { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("untrusted-recovery.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log}).to_string()); + let (st, mut wire) = state_with_bus(); + let mut create = create_msg("untrusted-settings-recovery"); + create.model = Some("provider-current".into()); + create.effort = Some(Some("ultra".into())); + create.cwd = Some(temp.path().to_string_lossy().into()); + create.permission_mode = Some("untrusted".into()); + create.collaboration_mode = Some("plan".into()); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut wire, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.created" + }) + .await; + assert!( + created + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.created"), + "native untrusted creation is ready" + ); + simulate_unrequested_crash(&st, "thread-new-1").await; + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"configRequirements":{"allowedApprovalPolicies":["never"]}}).to_string()); + drain_wire(&mut wire); + st.handle_send(send_msg("thread-new-1", "must be refused before work")) + .await; + let frames = drain(&mut wire).await; + st.shutdown().await; + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert!( + !requests + .iter() + .any(|row| row["method"] == "thread/resume" || row["method"] == "turn/start"), + "requirements refusal must not retry without untrusted approval: {requests:?}" + ); + assert!(frames.iter().any(|frame| frame["type"] == "error" + && frame["message"] + .as_str() + .is_some_and(|message| message.contains("approval_policy untrusted")))); + assert!(!frames + .iter() + .any(|frame| frame["type"] == "freshAgent.send.accepted")); + } + + #[tokio::test] + async fn task008_settings_active_steer_keeps_the_native_turn_and_stages_the_next_start() { + let (st, peer, mut wire, sink) = task008_settings_fixture().await; + let first = task008_settings_send(&st, &peer, json!({"model":"first-model","effort":"low","cwd":"/first","sandbox":"read-only","permissionMode":"on-request"})).await; + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-settings","settings":{"model":"next-model","effort":"ultra","cwd":"/next","sandbox":"workspace-write","permissionMode":"never","collaborationMode":"plan"}})).unwrap()).await; + let steer = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_send(send_msg("thread-settings", "add context")) + .await; + } + }); + let (id, method, params) = peer.expect_request().await; + peer.respond(&id, json!({"turnId":"turn-settings"})); + steer.await.unwrap(); + let snapshot = task008_settings_snapshot(&st, &peer).await; + drain_wire(&mut wire); + peer.emit_notification("turn/completed", json!({"threadId":"thread-settings","turn":{"id":"turn-settings","status":"completed"}})); + await_next_idle_snapshot(&mut wire, "thread-settings").await; + let next = task008_settings_send_named(&st, &peer, Value::Null, "turn-next").await; + let saved = sink.load_settings("codex", "thread-settings").unwrap(); + peer.disconnect(); + st.shutdown().await; + // Existing steering is an honest baseline: these assertions precede + // the newly missing staged/current-settings assertions. + assert_eq!(method, "turn/steer"); + assert_eq!( + params, + json!({"threadId":"thread-settings","expectedTurnId":"turn-settings","input":[{"type":"text","text":"add context","text_elements":[]}]}) + ); + assert_eq!(first["model"], "first-model"); + assert_eq!(first["effort"], "low"); + assert_eq!(next["cwd"], "/next"); + assert_eq!(next["model"], "next-model"); + assert_eq!(next["effort"], "ultra"); + assert_eq!(next["approvalPolicy"], "never"); + assert_eq!(next["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(next["collaborationMode"]["mode"], "plan"); + assert_eq!( + snapshot["extensions"]["codex"]["settings"]["scope"], + "next-turn" + ); + assert_eq!( + snapshot["extensions"]["codex"]["settings"]["currentTurn"]["model"], + "first-model" + ); + assert_eq!(snapshot["settings"]["model"], "next-model"); + assert_eq!(saved.model.as_deref(), Some("next-model")); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_refinement_thread_effort_echo_preserves_next_turn_selection_and_native_default( + ) { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("effort-scopes.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"effectiveThreadSettings":{"reasoningEffort":"low","collaborationMode":{"mode":"default","settings":{"model":"provider-current","reasoning_effort":"low","developer_instructions":null}}}}).to_string()); + for (index, effort) in [None, Some("ultra"), Some("max"), Some("future-effort")] + .into_iter() + .enumerate() + { + let (st, _) = state_with_bus(); + let mut create = create_msg(&format!("effort-scope-{index}")); + create.model = Some("provider-current".into()); + create.effort = effort.map(str::to_owned).map(Some); + create.collaboration_mode = Some("plan".into()); + st.handle_create(create, None).await; + st.handle_send(send_msg("thread-new-1", "selected next-turn effort")) + .await; + st.shutdown().await; + } + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turns: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .collect(); + let actual: Vec<_> = turns.iter().map(|row| json!({"effort":row["params"]["effort"],"mode":row["params"]["collaborationMode"]["mode"],"modeEffort":row["params"]["collaborationMode"]["settings"]["reasoning_effort"]})).collect(); + let expected: Vec<_> = [ + Value::Null, + json!("ultra"), + json!("max"), + json!("future-effort"), + ] + .into_iter() + .map(|effort| json!({"effort":effort,"mode":"plan","modeEffort":effort})) + .collect(); + assert_eq!(actual, expected, "thread/start has no effort override input; its current/default echo cannot replace next-turn intent"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_clear_effort_configure_preserves_cwd_only_changes_and_survives_resume( + ) { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let next_cwd = temp.path().join("next"); + std::fs::create_dir(&next_cwd).unwrap(); + let log = temp.path().join("clear-effort.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"recordTurns":true,"effectiveThreadSettings":{"reasoningEffort":"low"}}).to_string()); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let (st, mut wire) = state_with_bus(); + st.set_identity_sink(sink.clone()); + let mut create = create_msg("clear-effort-create"); + create.cwd = Some(temp.path().to_string_lossy().into()); + create.model = Some("provider-current".into()); + create.effort = Some(Some("ultra".into())); + create.sandbox = Some(freshell_protocol::Sandbox::ReadOnly); + create.permission_mode = Some("never".into()); + create.collaboration_mode = Some("plan".into()); + st.handle_create(create, None).await; + for (index, patch) in [ + None, + Some(json!({"cwd":next_cwd})), + Some(json!({"effort":null})), + ] + .into_iter() + .enumerate() + { + if let Some(settings) = patch { + st.handle_configure(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-new-1","requestId":format!("clear-settings-{index}"),"settings":settings})).unwrap()).await; + } + drain_wire(&mut wire); + st.handle_send(send_msg("thread-new-1", &format!("effort stage {index}"))) + .await; + collect_frames_until(&mut wire, Duration::from_secs(10), |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + } + let saved = sink.load_settings("codex", "thread-new-1").unwrap(); + st.shutdown().await; + let (resumed, _) = state_with_bus(); + resumed.set_identity_sink(sink); + resumed.handle_create(serde_json::from_value(json!({"requestId":"clear-effort-resume","provider":"codex","sessionType":"freshcodex","sessionRef":{"provider":"codex","sessionId":"thread-new-1"}})).unwrap(), None).await; + resumed + .handle_send(send_msg("thread-new-1", "after clearing and resume")) + .await; + resumed.shutdown().await; + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turns: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .collect(); + assert_eq!( + turns + .iter() + .map(|row| row["params"]["effort"].clone()) + .collect::>(), + vec![json!("ultra"), json!("ultra"), Value::Null, Value::Null] + ); + assert!(saved.effort.is_none()); + for row in &turns[1..] { + assert_eq!(row["params"]["cwd"], json!(next_cwd)); + assert_eq!(row["params"]["collaborationMode"]["mode"], "plan"); + assert_eq!(row["params"]["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(row["params"]["approvalPolicy"], "never"); + } + assert_eq!( + turns[2]["params"]["collaborationMode"]["settings"]["reasoning_effort"], + Value::Null + ); + assert_eq!( + turns[3]["params"]["collaborationMode"]["settings"]["reasoning_effort"], + Value::Null + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_native_effective_values_win_on_create_resume_and_fork() { + let _environment_lock = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let cwd = temp.path().join("effective"); + std::fs::create_dir(&cwd).unwrap(); + let log = temp.path().join("settings-native-echo.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"recordTurns":true,"effectiveThreadSettings":{"model":"native-effective","reasoningEffort":"ultra","cwd":cwd,"sandbox":"read-only","approvalPolicy":"on-request"}}).to_string()); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let (st, mut rx) = state_with_bus(); + st.set_identity_sink(sink.clone()); + st.handle_create(serde_json::from_value(json!({"requestId":"settings-echo-create","provider":"codex","sessionType":"freshcodex","model":"stale-create","effort":"low","cwd":temp.path(),"sandbox":"danger-full-access","permissionMode":"never","collaborationMode":"plan"})).unwrap(), None).await; + st.handle_send(send_msg("thread-new-1", "after effective create")) + .await; + collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + let (reply, frames) = capturing_sink(); + st.handle_fork( + fork_msg( + "thread-new-1", + "settings-echo-fork", + Some(json!({"model":"stale-fork","cwd":temp.path()})), + ), + None, + reply, + ) + .await; + let child = captured_frames(&frames) + .iter() + .find(|frame| frame["type"] == "freshAgent.forked") + .expect("native fork completes")["sessionId"] + .as_str() + .unwrap() + .to_string(); + st.handle_send(send_msg(&child, "after effective fork")) + .await; + collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + st.shutdown().await; + let (resumed, _) = state_with_bus(); + resumed.set_identity_sink(sink.clone()); + resumed.handle_create(serde_json::from_value(json!({"requestId":"settings-echo-resume","provider":"codex","sessionType":"freshcodex","sessionRef":{"provider":"codex","sessionId":"thread-new-1"},"model":"stale-resume","effort":"low","cwd":temp.path(),"sandbox":"danger-full-access","permissionMode":"never"})).unwrap(), None).await; + resumed + .handle_send(send_msg("thread-new-1", "after effective resume")) + .await; + let snapshot = resumed.get_snapshot("thread-new-1", None).await.unwrap(); + resumed.shutdown().await; + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let starts: Vec<_> = requests.iter().filter(|row| row["method"] == "turn/start").map(|row| json!({"model":row["params"]["model"],"effort":row["params"]["effort"],"cwd":row["params"]["cwd"],"sandbox":row["params"]["sandboxPolicy"],"approval":row["params"]["approvalPolicy"],"mode":row["params"]["collaborationMode"]})).collect(); + let expected = json!({"model":"native-effective","effort":"low","cwd":cwd,"sandbox":{"type":"readOnly"},"approval":"on-request","mode":{"mode":"plan","settings":{"model":"native-effective","reasoning_effort":"low","developer_instructions":null}}}); + assert_eq!(starts, vec![expected; 3]); + assert_eq!(snapshot["settings"]["effort"], "low"); + assert_eq!( + snapshot["extensions"]["codex"]["settings"]["lastThreadResponse"]["reasoningEffort"], + "ultra" + ); + let saved = sink.load_settings("codex", "thread-new-1").unwrap(); + assert_eq!(saved.model.as_deref(), Some("native-effective")); + } + + /// b8ke ext r29 F3 + focused ep5 r1 F2: the post-send binding refresh + /// carries the operation's OWN claim pair — the lane's retained + /// OwnershipStamp for the session (written only by this lane's claim + /// commits), never a fresh registry observation — so the ledger's + /// delayed-write fence can refuse a refresh that lands after a + /// handoff reaped the runtime and bound a terminal under a NEWER + /// generation. Pre-r29 the refresh passed NO pair (the pre-r22 + /// unfenced shape), so the late write was accepted and rewrote the + /// terminal's authoritative recovery row; the ep5-r1 review found + /// the ext-r29 fresh-observe capture handed a mid-flight handoff the + /// POST-handoff generation (the same-generation trap). + #[tokio::test] + async fn the_post_send_binding_refresh_carries_the_observed_ownership_pair() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (mut st, _) = state_with_bus(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(Arc::clone(®istry)); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + + // Seed the runtime's ownership the way the LANE does (the claim + + // commit that retains the stamp — production-faithful: every live + // codex session's stamp exists because its create/respawn/resume + // committed through this very path). + let mut seed_ticket = + match st.begin_lane_claim_at("thread-r29-f3", "op-r29-f3-seed", None, "test") { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + _ => panic!("the seed claim must grant"), + }; + let generation = seed_ticket.as_ref().expect("the seed ticket").generation(); + assert!(st + .commit_lane_claim_at(&mut seed_ticket, "thread-r29-f3", "thread-r29-f3", None,) + .is_ok()); + + insert_fake_session_with_real_consumer( + &st, + "thread-r29-f3", + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifs, + spawn_sleeper(), + "codex-r29-f3-refresh", + ) + .await; + let task = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_send(send_msg("thread-r29-f3", "hello")).await; + } + }); + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond(&id, json!({})); + peer.expect_notification().await; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond(&id, json!({"turn":{"id":"turn-r29-f3"}})); + task.await.unwrap(); + peer.disconnect(); + st.shutdown().await; + + // THE CONTRACT: the post-send refresh carried the observed pair — + // (None, None) pre-r29. + let pair = fake + .binding_pairs + .lock() + .unwrap() + .get(&("codex".to_string(), "thread-r29-f3".to_string())) + .cloned() + .expect("the post-send refresh wrote the binding row"); + assert_eq!( + pair, + (Some(registry.boot_epoch()), Some(generation)), + "the refresh carries the session's CURRENT ownership pair: {pair:?}" + ); + } + + /// b8ke focused ep5 r1 F2: the harmful ordering the ep5-r1 review + /// named — a handoff that has ALREADY ENTERED (the record sits in + /// Handoff; nothing reaped the in-map session, so the send sees the + /// UNGUARDED AlreadyRunning return) must NOT hand the delayed + /// refresh the handoff/target generation: a terminal binding written + /// at that same generation is not older than the refresh, so the + /// late refresh would pass the ledger's fence and rewrite the /// terminal's authoritative recovery row. The refresh now fences /// with the lane's RETAINED STAMP (the pre-handoff pair by /// construction); the pre-ep5-r1 fresh observe captured the Handoff @@ -13585,7 +16031,7 @@ pub(crate) mod tests { } #[tokio::test] - async fn handle_interrupt_issues_rpc_for_tracked_turn_and_clears_it() { + async fn handle_interrupt_issues_rpc_for_tracked_turn_and_retains_it_until_terminal_evidence() { let (transport, peer) = freshell_codex::new_channel_transport(); let (client, _notifs) = CodexAppServerClient::connect(transport); let client = Arc::new(client); @@ -13634,9 +16080,9 @@ pub(crate) mod tests { driver.await.expect("handle_interrupt task"); assert_eq!( - *active_turn.lock().unwrap(), - None, - "active turn cleared on a successful interrupt (adapter.ts:1027)" + active_turn.lock().unwrap().as_deref(), + Some("turn-1"), + "successful interrupt acknowledgement cannot settle a native turn" ); } @@ -13696,6 +16142,7 @@ pub(crate) mod tests { #[tokio::test] async fn handle_kill_removes_session_kills_owned_child_and_broadcasts_killed() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let (transport, _peer) = freshell_codex::new_channel_transport(); let (client, _notifs) = CodexAppServerClient::connect(transport); let client = Arc::new(client); @@ -13743,6 +16190,7 @@ pub(crate) mod tests { #[tokio::test] async fn recovery_stop_reaps_the_writer_without_retiring_the_durable_thread() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let (transport, _peer) = freshell_codex::new_channel_transport(); let (client, _notifs) = CodexAppServerClient::connect(transport); let (st, mut rx, sink) = state_with_sink(); @@ -13783,6 +16231,7 @@ pub(crate) mod tests { #[tokio::test] async fn requested_exit_watcher_reports_confirmed_reap() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let (broadcast_tx, _rx) = tokio::sync::broadcast::channel::(16); let (kill_tx, kill_rx) = oneshot::channel(); let child = spawn_sleeper(); @@ -14138,12 +16587,17 @@ pub(crate) mod tests { // A session whose watcher never resolves (the stalled teardown) and // whose kill_tx is absent — the teardown block parks on the watcher // join forever. + let turn_state = Arc::new(StdMutex::new(CodexTurnState::default())); let consumer = tokio::spawn(async {}); let watcher = tokio::spawn(std::future::pending::()); let exited = Arc::new(AtomicBool::new(false)); st.sessions.lock().await.insert( "thread-stall".to_string(), CodexSession { + collaboration_mode: None, + native_turn_settings: None, + native_thread_settings: None, + turn_state: turn_state.clone(), client, sidecar_pid: None, sidecar_ownership_id: String::new(), @@ -14159,6 +16613,10 @@ pub(crate) mod tests { history_mode: Some(HistoryMode::Paginated), turn_lock: Arc::new(TokioMutex::new(())), event_emission_gate: Arc::new(TokioMutex::new(())), + projection: Arc::new(StdMutex::new(projection::CodexProjection::new( + mint_ownership_id(), + "thread-stall".to_string(), + ))), consumer, kill_tx: None, watcher, @@ -14495,6 +16953,7 @@ pub(crate) mod tests { let create = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -14671,6 +17130,7 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -14832,6 +17292,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -15236,6 +17697,15 @@ pub(crate) mod tests { peer.respond(&compact_id, json!({})); compact_1.await.expect("compact-1 task"); + // Accepted compaction advances the transcript history fence before + // another request can observe this window. Consume those additive + // projection frames; the refusal below must still beat every RPC. + let projection: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + assert_eq!(projection["event"]["type"], "freshAgent.transcript.patch"); + assert_eq!(projection["event"]["historyGeneration"], 1); + let metadata: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + assert_eq!(metadata["event"]["type"], "freshAgent.session.metadata"); + // Compact-2 in the window: REFUSED BUSY_TURN with ZERO RPCs reaching // the app-server (the red shape instead issues a second // thread/compact/start — surface it for a loud failure). @@ -16708,6 +19178,150 @@ pub(crate) mod tests { ); } + #[tokio::test] + async fn handle_fork_policy_mismatch_refuses_before_child_spawn_and_settles_cleanup() { + // Existing ENV_LOCK/configure_fake_codex_cmd make accidental RED-path + // spawns hermetic; this test does not need to launch a parent app-server. + let _env = ENV_LOCK.lock().await; + configure_fake_codex_cmd("{}"); + + let (mut st, _rx, fake) = state_with_sink(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(registry.clone()); + + const PARENT: &str = "parent-untrusted-fork"; + const CHILD: &str = "child-policy-mismatch"; + let mut ticket = match st.begin_lane_claim_at(PARENT, "seed-parent", None, "test") { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + _ => panic!("parent seed must grant"), + }; + assert!(st + .commit_lane_claim_at(&mut ticket, PARENT, PARENT, None) + .is_ok()); + + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, _notifs) = CodexAppServerClient::connect(transport); + let parent_client = Arc::new(client); + insert_fake_session( + &st, + PARENT, + parent_client.clone(), + Arc::new(StdMutex::new(None)), + spawn_sleeper(), + "codex-policy-fork-parent", + ) + .await; + { + let mut sessions = st.sessions.lock().await; + sessions.get_mut(PARENT).unwrap().permission_mode = Some("untrusted".into()); + } + let parent_before = registry.observe(PROVIDER, PARENT); + let capture = tracing_capture::capture_by_session("fork-policy-test-unused"); + let (sink, captured) = capturing_sink(); + let driver = tokio::spawn({ + let st = st.clone(); + async move { + st.handle_fork(fork_msg(PARENT, "fork-policy", None), None, sink) + .await; + } + }); + + answer_initialize(&peer).await; + let (requirements_id, method, _) = peer.expect_request().await; + assert_eq!(method, "configRequirements/read"); + peer.respond(&requirements_id, json!({"requirements": null})); + + let (fork_id, method, params) = peer.expect_request().await; + assert_eq!(method, "thread/fork"); + assert_eq!(params["threadId"], PARENT); + assert_eq!(params["approvalPolicy"], "untrusted"); + peer.respond( + &fork_id, + json!({ + "thread": {"id": CHILD}, + "approvalPolicy": "never" + }), + ); + + let (archive_id, method, params) = peer.expect_request().await; + assert_eq!(method, "thread/archive"); + assert_eq!(params["threadId"], CHILD); + peer.respond(&archive_id, json!({})); + + // The established post-archive containment must now run. Keep its + // response pending so guard ordering is observable without a sleep. + let (cleanup_id, method, params) = + tokio::time::timeout(std::time::Duration::from_secs(2), peer.expect_request()) + .await + .expect("mismatched fork must enter guarded post-archive cleanup"); + assert_eq!(method, "thread/unarchive"); + assert_eq!(params["threadId"], CHILD); + let child_during_cleanup = registry.observe(PROVIDER, CHILD); + assert!(child_during_cleanup.generation > 0); + assert!(matches!( + child_during_cleanup.state, + freshell_ownership::OwnershipState::Starting { .. } + )); + + // A witnessed, still-running cleanup must survive the normal stale + // start sweep; this fails if cancellation/settle was armed too late. + let recovered = registry.recover_stale_starts(0, 0); + assert!(!recovered.iter().any(|entry| entry.session_id == CHILD)); + assert!(matches!( + registry.observe(PROVIDER, CHILD).state, + freshell_ownership::OwnershipState::Starting { .. } + )); + + peer.respond(&cleanup_id, json!({})); + tokio::time::timeout(std::time::Duration::from_secs(2), driver) + .await + .expect("fork refusal settles") + .expect("fork task"); + + assert_single_fork_error_frame( + &captured_frames(&captured), + PARENT, + "INTERNAL_ERROR", + "approval_policy untrusted", + ); + let child_after = registry.observe(PROVIDER, CHILD); + assert_eq!(child_after.generation, child_during_cleanup.generation); + assert!(matches!( + child_after.state, + freshell_ownership::OwnershipState::Vacant + )); + + let parent_after = registry.observe(PROVIDER, PARENT); + assert_eq!(parent_after.epoch, parent_before.epoch); + assert_eq!(parent_after.generation, parent_before.generation); + assert_eq!(parent_after.state, parent_before.state); + { + let sessions = st.sessions.lock().await; + assert_eq!(sessions.len(), 1); + let parent = sessions.get(PARENT).unwrap(); + assert!(Arc::ptr_eq(&parent.client, &parent_client)); + assert_eq!(parent.permission_mode.as_deref(), Some("untrusted")); + assert!(!parent.exited.load(Ordering::SeqCst)); + assert!(!sessions.contains_key(CHILD)); + } + assert_eq!(spawn_count(&capture), 0); + assert!(st.fork_in_flight.try_acquire(PARENT).is_some()); + assert!(!fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == CHILD)); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), peer.next_frame()) + .await + .is_err() + ); + + peer.disconnect(); + st.shutdown().await; + } + #[tokio::test] async fn handle_fork_rpc_error_replies_on_the_sink_without_archiving_or_spawning() { let (st, _rx_boot) = state_with_bus(); @@ -17928,6 +20542,7 @@ pub(crate) mod tests { None, None, None, + None, // no work-mode override in this lease fixture // kata b8ke Task 3: no coordinator claim in this direct-drive // test path (the lease-claim skip mirrors handle_create's // pre-claim fast paths). @@ -18407,6 +21022,12 @@ pub(crate) mod tests { // Conn-less attach reconstructs the session (all-blank settings), // seeding the OLD row's stamps (focused-ep1-r4 Finding 2). st.handle_attach(attach_msg("thread-lineage-adopt")).await; + let observed = st.get_snapshot("thread-lineage-adopt", None).await.unwrap(); + assert_eq!(observed["settings"]["model"], "fixture-model"); + assert_eq!( + observed["extensions"]["codex"]["settings"]["lastThreadResponse"]["model"], + "fixture-model" + ); // A NEW connection's recovery-create adopts the live incumbent. let mut resume = create_msg("req-adopt-lineage"); @@ -18427,27 +21048,50 @@ pub(crate) mod tests { "the adopt answers created: {created}" ); - let bindings = fake.bindings.lock().expect("bindings mutex"); - let b = bindings - .iter() - .rev() - .find(|b| b.session_id == "thread-lineage-adopt") - .expect("the adopt's refresh write must NOT be eaten by the no-laundering guard"); - assert_eq!( - b.asserted_stamps().client_instance_id.as_deref(), - Some("client-new"), - "the provenance refresh must not be gated on settings presence" - ); - assert_eq!(b.asserted_stamps().device_id.as_deref(), Some("device-new")); - assert_eq!( - b.asserted_stamps().tab_key.as_deref(), - Some("device-new:tab-new") - ); + { + let bindings = fake.bindings.lock().expect("bindings mutex"); + let b = bindings + .iter() + .rev() + .find(|b| b.session_id == "thread-lineage-adopt") + .expect("the adopt's refresh write must NOT be eaten by the no-laundering guard"); + assert_eq!( + b.asserted_stamps().client_instance_id.as_deref(), + Some("client-new"), + "the provenance refresh must not be gated on settings presence" + ); + assert_eq!(b.asserted_stamps().device_id.as_deref(), Some("device-new")); + assert_eq!( + b.asserted_stamps().tab_key.as_deref(), + Some("device-new:tab-new") + ); + assert_eq!( + b.settings, + crate::identity_sink::FreshAgentSettings::default(), + "the settings payload is untouched by the provenance refresh (stays lineage-only)" + ); + } + // Accepted work materializes the observed defaults as durable intent; + // it must use that exact native model, not a fabricated fallback. + st.handle_send(send_msg( + "thread-lineage-adopt", + "first accepted lineage work", + )) + .await; + let accepted = collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.send.accepted" + && frame["sessionId"] == "thread-lineage-adopt" + }) + .await; + assert!(accepted.matched, "{:?}", accepted.frames); assert_eq!( - b.settings, - crate::identity_sink::FreshAgentSettings::default(), - "the settings payload is untouched by the provenance refresh (stays lineage-only)" + fake.load_settings("codex", "thread-lineage-adopt") + .unwrap() + .model + .as_deref(), + Some("fixture-model") ); + st.shutdown().await; } /// Focused-ep1-r4 Finding 2 (codex): the CONN-LESS cold attach @@ -18492,6 +21136,7 @@ pub(crate) mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: None, permission_mode: None, @@ -18597,6 +21242,7 @@ pub(crate) mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: None, permission_mode: None, @@ -18639,18 +21285,94 @@ pub(crate) mod tests { } #[tokio::test] - async fn onexit_self_heal_emits_exited_and_rings_the_unified_edge_when_a_turn_was_in_flight() { + async fn onexit_self_heal_emits_exited_and_rings_the_unified_edge_when_a_turn_was_in_flight() { + let (transport, _peer) = freshell_codex::new_channel_transport(); + let (client, _notifs) = CodexAppServerClient::connect(transport); + let client = Arc::new(client); + + let (st, mut rx) = state_with_bus(); + + // A child that exits ON ITS OWN almost immediately -- the UNREQUESTED-exit / crash + // path (never signaled via kill_tx) -- while a turn is IN FLIGHT: the crash ends a + // turn whose end the notification consumer never observed, so the self-heal must + // ring the unified attention edge for it (identical wire treatment to a completed or + // errored turn end -- one binary "needs attention" signal). + let mut cmd = tokio::process::Command::new("true"); + cmd.kill_on_drop(true); + let child = cmd.spawn().expect("spawn true fixture"); + + insert_fake_session( + &st, + "thread-1", + client, + Arc::new(StdMutex::new(Some("turn-in-flight".to_string()))), + child, + "codex-sidecar-test-exit", + ) + .await; + + let frame: Value = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Ok(raw) = rx.recv().await { + return serde_json::from_str::(&raw).unwrap(); + } + } + }) + .await + .expect("the watcher self-heals within the budget"); + + assert_eq!(frame["type"], "freshAgent.event"); + assert_eq!(frame["provider"], "codex"); + assert_eq!(frame["sessionId"], "thread-1"); + assert_eq!(frame["event"]["type"], "freshAgent.status"); + assert_eq!(frame["event"]["status"], "exited"); + + // The unified attention edge for the turn end the consumer never saw: a + // `freshAgent.turn.complete` for the SAME session with a finite numeric `at`. + let edge: Value = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let Ok(raw) = rx.recv().await { + let parsed: Value = serde_json::from_str(&raw).unwrap(); + if parsed["event"]["type"] == "freshAgent.turn.complete" { + return parsed; + } + } + } + }) + .await + .expect("a crash with a turn in flight rings the unified attention edge"); + assert_eq!(edge["provider"], "codex"); + assert_eq!(edge["sessionId"], "thread-1"); + assert_eq!(edge["event"]["sessionId"], "thread-1"); + assert!( + edge["event"]["at"].is_i64(), + "finite numeric `at` on the crash-minted edge: {edge}" + ); + + // The session STAYS mapped (adapter.ts:937-944 invariant -- PR-1 leaves the + // actual lazy-restart-on-next-send unimplemented; see report), and nothing + // else rides along: one status frame + one attention edge. + assert!( + rx.try_recv().is_err(), + "exited + exactly one attention edge, no more frames: {edge:?}" + ); + assert!( + st.sessions.lock().await.contains_key("thread-1"), + "the session stays mapped after an unrequested exit" + ); + } + + #[tokio::test] + async fn onexit_self_heal_emits_exited_without_an_edge_when_idle() { let (transport, _peer) = freshell_codex::new_channel_transport(); let (client, _notifs) = CodexAppServerClient::connect(transport); let client = Arc::new(client); let (st, mut rx) = state_with_bus(); - // A child that exits ON ITS OWN almost immediately -- the UNREQUESTED-exit / crash - // path (never signaled via kill_tx) -- while a turn is IN FLIGHT: the crash ends a - // turn whose end the notification consumer never observed, so the self-heal must - // ring the unified attention edge for it (identical wire treatment to a completed or - // errored turn end -- one binary "needs attention" signal). + // An idle crash: no turn is in flight, so the self-heal is the terminal + // `exited` clear ONLY -- no attention edge (nothing ended that the user + // could have missed). let mut cmd = tokio::process::Command::new("true"); cmd.kill_on_drop(true); let child = cmd.spawn().expect("spawn true fixture"); @@ -18659,7 +21381,7 @@ pub(crate) mod tests { &st, "thread-1", client, - Arc::new(StdMutex::new(Some("turn-in-flight".to_string()))), + Arc::new(StdMutex::new(None)), child, "codex-sidecar-test-exit", ) @@ -18681,92 +21403,562 @@ pub(crate) mod tests { assert_eq!(frame["event"]["type"], "freshAgent.status"); assert_eq!(frame["event"]["status"], "exited"); - // The unified attention edge for the turn end the consumer never saw: a - // `freshAgent.turn.complete` for the SAME session with a finite numeric `at`. - let edge: Value = tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - if let Ok(raw) = rx.recv().await { - let parsed: Value = serde_json::from_str(&raw).unwrap(); - if parsed["event"]["type"] == "freshAgent.turn.complete" { - return parsed; - } - } + // No attention edge, and the session STAYS mapped (adapter.ts:937-944 + // invariant). + assert!( + rx.try_recv().is_err(), + "no turn.complete edge alongside the exit status for an idle crash" + ); + assert!( + st.sessions.lock().await.contains_key("thread-1"), + "the session stays mapped after an unrequested exit" + ); + } + + #[test] + fn task4_refusal_marker_publication_cannot_clear_a_newer_stop() { + let turns = Arc::new(StdMutex::new(CodexTurnState::default())); + let marker = Arc::new(AtomicBool::new(true)); + let old = { + let mut state = turns.lock().unwrap(); + let old = state.begin_dispatch("old"); + state.accept(old, "old"); + state.request_stop(); + state.settle_native("old", NativeSettlement::Completed); + old + }; + let (entered, at_publication) = std::sync::mpsc::channel(); + let (release, may_publish) = std::sync::mpsc::channel(); + let publisher = { + let turns = turns.clone(); + let marker = marker.clone(); + std::thread::spawn(move || { + publish_codex_interrupt_refusal(&turns, old, "old", || { + // This is the actual compatibility publication boundary, held + // with deterministic barriers, not a delayed native ACK model. + entered.send(()).unwrap(); + may_publish.recv_timeout(Duration::from_secs(5)).unwrap(); + marker.store(false, Ordering::SeqCst); + }) + }) + }; + at_publication.recv_timeout(Duration::from_secs(5)).unwrap(); + let mut admitted_new = false; + let blocked = match turns.try_lock() { + Ok(mut state) => { + let new = state.begin_dispatch("new"); + state.accept(new, "new"); + state.request_stop(); + marker.store(true, Ordering::SeqCst); + admitted_new = true; + false } + Err(std::sync::TryLockError::WouldBlock) => true, + Err(error) => panic!("unexpected state-lock failure: {error}"), + }; + release.send(()).unwrap(); + assert!(publisher.join().unwrap()); + if !admitted_new { + let mut state = turns.lock().unwrap(); + let new = state.begin_dispatch("new"); + state.accept(new, "new"); + state.request_stop(); + marker.store(true, Ordering::SeqCst); + } + assert!( + marker.load(Ordering::SeqCst), + "an old refusal publication must never clear the newer Stop marker" + ); + assert!(blocked, "new dispatch/Stop cannot acquire ownership before the old compatibility publication completes"); + assert_eq!(turns.lock().unwrap().user_stop_turn_id(), Some("new")); + } + + fn task4_stop(session_id: &str) -> FreshAgentInterrupt { + FreshAgentInterrupt { + session_id: session_id.into(), + session_type: freshell_protocol::SessionType::Freshcodex, + provider: freshell_protocol::AgentProvider::Codex, + request_id: None, + cwd: None, + } + } + + async fn task4_no_native_frame(peer: &freshell_codex::ChannelPeer) { + let mut next = Box::pin(peer.next_raw_frame()); + let pending = std::future::poll_fn(|cx| { + std::task::Poll::Ready(std::future::Future::poll(next.as_mut(), cx).is_pending()) }) - .await - .expect("a crash with a turn in flight rings the unified attention edge"); - assert_eq!(edge["provider"], "codex"); - assert_eq!(edge["sessionId"], "thread-1"); - assert_eq!(edge["event"]["sessionId"], "thread-1"); + .await; assert!( - edge["event"]["at"].is_i64(), - "finite numeric `at` on the crash-minted edge: {edge}" + pending, + "the native issuance gate must have issued no frame" + ); + } + + #[tokio::test] + async fn task4_held_stop_allows_terminal_consumption_but_blocks_new_start_until_its_reply() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-held", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-held", + ) + .await; + let stop = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-held")).await }) + }; + answer_initialize(&peer).await; + let (stop_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + // Hold the exact dispatch serialization lock while terminal evidence + // is consumed. A consumer that waits for this lock cannot release the + // issuance gate, and this deterministic barrier would fail. + let dispatch_lock = st + .sessions + .lock() + .await + .get("thread-held") + .unwrap() + .turn_lock + .clone(); + let dispatch_guard = dispatch_lock.lock().await; + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-held", "turn":{"id":"old","status":"completed"}}), + ); + let terminal = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + assert!( + terminal.matched, + "consumer must progress with Stop RPC held" + ); + drop(dispatch_guard); + let turns = st + .sessions + .lock() + .await + .get("thread-held") + .unwrap() + .turn_state + .clone(); + assert!(!turns.lock().unwrap().has_owed_work()); + assert!(turns.lock().unwrap().has_unresolved_stop()); + let waiting = turns.lock().unwrap().waiting_for_stop.clone(); + let next = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-held", "new input")).await }) + }; + tokio::time::timeout(Duration::from_secs(2), waiting.notified()) + .await + .unwrap(); + task4_no_native_frame(&peer).await; + peer.respond(&stop_id, json!({})); + stop.await.unwrap(); + let (start_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond(&start_id, json!({"turn":{"id":"new"}})); + next.await.unwrap(); + // Adversarial old replies after a supported resolved rollover; this is + // deliberately not described as normal native ACK ordering. + peer.respond(&stop_id, json!({})); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-held", "turn":{"id":"old","status":"interrupted"}}), + ); + peer.emit_notification( + "thread/name/updated", + json!({"threadId":"thread-held","name":"consumer barrier"}), + ); + let stop_new = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-held")).await }) + }; + let (new_stop, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "new"); + assert!(turns.lock().unwrap().has_owed_work()); + peer.respond(&new_stop, json!({})); + stop_new.await.unwrap(); + assert!(turns.lock().unwrap().has_owed_work()); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_stop_before_start_acceptance_interrupts_once_and_preserves_acceptance_order() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-pending", + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifs, + spawn_sleeper(), + "task4-pending", + ) + .await; + let send = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-pending", "first")).await }) + }; + answer_initialize(&peer).await; + let (start_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + st.handle_interrupt(task4_stop("thread-pending")).await; + task4_no_native_frame(&peer).await; + peer.respond(&start_id, json!({"turn":{"id":"accepted"}})); + let (stop_id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "accepted"); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-pending","turn":{"id":"accepted","status":"interrupted"}}), + ); + peer.respond(&stop_id, json!({})); + send.await.unwrap(); + let frames = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["event"]["type"] == "freshAgent.session.snapshot" && w["event"]["status"] == "idle" + }) + .await; + assert!(frames.matched); + let accepted = frames + .frames + .iter() + .position(|w| w["type"] == "freshAgent.send.accepted") + .unwrap(); + let terminal = frames + .frames + .iter() + .position(|w| w["event"]["type"] == "freshAgent.session.snapshot") + .unwrap(); + assert!(accepted < terminal); + task4_no_native_frame(&peer).await; + assert!(!st + .sessions + .lock() + .await + .get("thread-pending") + .unwrap() + .turn_state + .lock() + .unwrap() + .has_owed_work()); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_unknown_start_delivery_is_not_replayed_and_native_evidence_can_recover_it() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-unknown", + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifs, + spawn_sleeper(), + "task4-unknown", + ) + .await; + let send = { + let st = st.clone(); + tokio::spawn(async move { + let mut msg = send_msg("thread-unknown", "original"); + msg.request_id = Some("delivery-receipt".into()); + st.handle_send(msg).await; + }) + }; + answer_initialize(&peer).await; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond_error(&id, -32603, "internal failure after possible admission"); + send.await.unwrap(); + let failed = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["type"] == "error" && w["requestId"] == "delivery-receipt" + }) + .await; + assert!(failed.matched); + assert!(failed.frames.last().unwrap()["message"] + .as_str() + .unwrap() + .starts_with("CODEX_DELIVERY_AMBIGUOUS:")); + let turns = st + .sessions + .lock() + .await + .get("thread-unknown") + .unwrap() + .turn_state + .clone(); + assert!(turns.lock().unwrap().has_owed_work()); + st.handle_send(send_msg("thread-unknown", "later input")) + .await; + task4_no_native_frame(&peer).await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-unknown","turn":{"id":"recovered","status":"inProgress"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-unknown","turn":{"id":"recovered","status":"completed"}}), + ); + assert!( + collect_frames_until(&mut rx, Duration::from_secs(2), |w| w["event"]["type"] + == "freshAgent.turn.complete") + .await + .matched + ); + assert!(!turns.lock().unwrap().has_owed_work()); + assert_eq!( + turns.lock().unwrap().facts().front().unwrap().request_id, + "delivery-receipt" + ); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_ambiguous_stop_timeout_keeps_issuance_closed_after_terminal_evidence() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = + CodexAppServerClient::connect_with_timeout(transport, Duration::from_millis(100)); + insert_fake_session_with_real_consumer( + &st, + "thread-timeout", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-timeout", + ) + .await; + let stop = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-timeout")).await }) + }; + answer_initialize(&peer).await; + let (_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + tokio::time::pause(); + tokio::time::advance(Duration::from_millis(200)).await; + stop.await.unwrap(); + tokio::time::resume(); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-timeout","turn":{"id":"old","status":"interrupted"}}), + ); + assert!( + collect_frames_until(&mut rx, Duration::from_secs(2), |w| w["event"]["type"] + == "freshAgent.session.snapshot" + && w["event"]["status"] == "idle") + .await + .matched + ); + let turns = st + .sessions + .lock() + .await + .get("thread-timeout") + .unwrap() + .turn_state + .clone(); + assert!(!turns.lock().unwrap().has_owed_work()); + assert!(turns.lock().unwrap().has_unresolved_stop()); + let waiting = turns.lock().unwrap().waiting_for_stop.clone(); + let send = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-timeout", "later")).await }) + }; + tokio::time::timeout(Duration::from_secs(2), waiting.notified()) + .await + .unwrap(); + task4_no_native_frame(&peer).await; + drop(peer); + st.shutdown().await; + tokio::time::timeout(Duration::from_secs(2), send) + .await + .unwrap() + .unwrap(); + assert!( + !turns.lock().unwrap().has_unresolved_stop(), + "confirmed owned exit resolves the outstanding operation" + ); + } + + #[tokio::test] + async fn task4_ack_and_idle_cannot_make_rest_sendable_before_matching_terminal_evidence() { + let (st, mut rx) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-rest", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-rest", + ) + .await; + let stop = { + let st = st.clone(); + tokio::spawn(async move { st.handle_interrupt(task4_stop("thread-rest")).await }) + }; + answer_initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({})); + stop.await.unwrap(); + peer.emit_notification( + "thread/status/changed", + json!({"threadId":"thread-rest","status":{"type":"idle"}}), + ); + let status = collect_frames_until(&mut rx, Duration::from_secs(2), |w| { + w["event"]["type"] == "freshAgent.session.snapshot" + }) + .await; + assert_eq!(status.frames.last().unwrap()["event"]["status"], "running"); + let snapshot = { + let st = st.clone(); + tokio::spawn(async move { st.get_snapshot("thread-rest", None).await.unwrap() }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + peer.respond( + &id, + json!({"thread":{"id":"thread-rest","status":"idle","turns":[]}}), ); - - // The session STAYS mapped (adapter.ts:937-944 invariant -- PR-1 leaves the - // actual lazy-restart-on-next-send unimplemented; see report), and nothing - // else rides along: one status frame + one attention edge. - assert!( - rx.try_recv().is_err(), - "exited + exactly one attention edge, no more frames: {edge:?}" + let snapshot = snapshot.await.unwrap(); + assert_eq!(snapshot["status"], "running"); + assert_eq!(snapshot["capabilities"]["send"], false); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-rest","turn":{"id":"old","status":"interrupted"}}), ); assert!( - st.sessions.lock().await.contains_key("thread-1"), - "the session stays mapped after an unrequested exit" + collect_frames_until(&mut rx, Duration::from_secs(2), |w| w["event"]["status"] + == "idle") + .await + .matched + ); + let snapshot = { + let st = st.clone(); + tokio::spawn(async move { st.get_snapshot("thread-rest", None).await.unwrap() }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + peer.respond( + &id, + json!({"thread":{"id":"thread-rest","status":"idle","turns":[]}}), ); + assert_eq!(snapshot.await.unwrap()["capabilities"]["send"], true); + drop(peer); + st.shutdown().await; } #[tokio::test] - async fn onexit_self_heal_emits_exited_without_an_edge_when_idle() { - let (transport, _peer) = freshell_codex::new_channel_transport(); - let (client, _notifs) = CodexAppServerClient::connect(transport); - let client = Arc::new(client); - - let (st, mut rx) = state_with_bus(); - - // An idle crash: no turn is in flight, so the self-heal is the terminal - // `exited` clear ONLY -- no attention edge (nothing ended that the user - // could have missed). - let mut cmd = tokio::process::Command::new("true"); - cmd.kill_on_drop(true); - let child = cmd.spawn().expect("spawn true fixture"); - - insert_fake_session( + async fn task4_interrupt_ack_does_not_settle_owed_work() { + let (st, _) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( &st, - "thread-1", - client, - Arc::new(StdMutex::new(None)), - child, - "codex-sidecar-test-exit", + "thread-ack", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-ack", ) .await; - - let frame: Value = tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - if let Ok(raw) = rx.recv().await { - return serde_json::from_str::(&raw).unwrap(); - } - } - }) - .await - .expect("the watcher self-heals within the budget"); - - assert_eq!(frame["type"], "freshAgent.event"); - assert_eq!(frame["provider"], "codex"); - assert_eq!(frame["sessionId"], "thread-1"); - assert_eq!(frame["event"]["type"], "freshAgent.status"); - assert_eq!(frame["event"]["status"], "exited"); - - // No attention edge, and the session STAYS mapped (adapter.ts:937-944 - // invariant). + let interrupt = { + let st = st.clone(); + tokio::spawn(async move { + st.handle_interrupt(FreshAgentInterrupt { + session_id: "thread-ack".into(), + session_type: freshell_protocol::SessionType::Freshcodex, + provider: freshell_protocol::AgentProvider::Codex, + request_id: None, + cwd: None, + }) + .await; + }) + }; + answer_initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "old"); + peer.respond(&id, json!({})); + interrupt.await.unwrap(); + let owed = st + .sessions + .lock() + .await + .get("thread-ack") + .unwrap() + .turn_in_flight + .load(Ordering::SeqCst); assert!( - rx.try_recv().is_err(), - "no turn.complete edge alongside the exit status for an idle crash" + owed, + "interrupt ACK proves submission, never terminal settlement" ); + drop(peer); + st.shutdown().await; + } + + #[tokio::test] + async fn task4_active_input_steers_expected_turn_and_rejection_preserves_it() { + let (st, _) = state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + insert_fake_session_with_real_consumer( + &st, + "thread-steer", + Arc::new(client), + Arc::new(StdMutex::new(Some("old".into()))), + notifs, + spawn_sleeper(), + "task4-steer", + ) + .await; + let send = { + let st = st.clone(); + tokio::spawn(async move { st.handle_send(send_msg("thread-steer", "adjust")).await }) + }; + answer_initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/steer"); + assert_eq!(params["expectedTurnId"], "old"); assert!( - st.sessions.lock().await.contains_key("thread-1"), - "the session stays mapped after an unrequested exit" + params.get("model").is_none(), + "steer cannot override turn settings" + ); + peer.respond_error( + &id, + -32600, + "expected active turn id `old` but found `different`", ); + send.await.unwrap(); + let guard = st.sessions.lock().await; + let session = guard.get("thread-steer").unwrap(); + assert!(session.turn_in_flight.load(Ordering::SeqCst)); + assert_eq!(session.active_turn.lock().unwrap().as_deref(), Some("old")); + drop(guard); + drop(peer); + st.shutdown().await; } #[tokio::test] @@ -22014,6 +25206,7 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -22566,6 +25759,7 @@ pub(crate) mod tests { "codex", "thread-9", crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: Some("workspace-write".into()), permission_mode: Some("on-request".into()), @@ -22710,6 +25904,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -22764,6 +25959,7 @@ pub(crate) mod tests { /// `request_id`. fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -23110,6 +26306,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -23181,6 +26378,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -23263,6 +26461,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -23333,6 +26532,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -23998,7 +27198,16 @@ pub(crate) mod tests { /// to depending on a host-installed `codex` binary. pub(crate) fn configure_fake_codex_cmd(behavior_json: &str) { std::env::set_var("CODEX_CMD", format!("node {}", fake_codex_app_server_cmd())); - std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior_json); + let behavior = match serde_json::from_str::(behavior_json) { + Ok(Value::Object(mut behavior)) => { + behavior.entry("echoRequestedModel").or_insert(json!(true)); + Value::Object(behavior).to_string() + } + // Existing fault cases deliberately pass malformed/non-object + // input. Let the native fake consume and refuse it as before. + _ => behavior_json.to_owned(), + }; + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior); } /// Create a session whose sidecar is a REAL fake-app-server-driven codex process (not @@ -24021,6 +27230,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24073,6 +27283,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, observed_epoch: None, observed_generation: None, @@ -24270,6 +27481,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24285,7 +27497,7 @@ pub(crate) mod tests { model_selection: None, permission_mode: Some("on-request".to_string()), sandbox: Some(freshell_protocol::Sandbox::WorkspaceWrite), - effort: Some("high".to_string()), + effort: Some(Some("high".to_string())), plugins: None, tab_id: None, }, @@ -24340,6 +27552,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24423,6 +27636,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -24591,6 +27805,56 @@ pub(crate) mod tests { /// brand-new id, conversation memory for the old thread genuinely lost. #[tokio::test(flavor = "multi_thread")] async fn send_after_crash_falls_back_to_mint_new_thread_when_resume_reports_not_found() { + assert_crash_mints_new_thread(json!({"code":-32001,"message":"Thread not found"})).await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn send_after_crash_falls_back_for_native_missing_rollout_rpc() { + assert_crash_mints_new_thread( + json!({"code":-32600,"message":"no rollout found for thread id thread-original"}), + ) + .await; + } + + #[test] + fn native_missing_rollout_requires_the_resume_rpc_and_known_error_code() { + for (method, code, expected) in [ + ("thread/resume", -32600, true), + ("thread/fork", -32600, false), + ("thread/read", -32600, false), + ("thread/resume", -32000, false), + ] { + let error = CodexAppServerError::Rpc { + method: method.into(), + error: Box::new(freshell_codex::protocol::RpcError { + code, + message: "no rollout found for thread id thread-original".into(), + data: None, + }), + }; + assert_eq!( + is_codex_thread_not_found(&error), + expected, + "{method}/{code}" + ); + } + assert!(!is_codex_thread_not_found( + &CodexAppServerError::Transport { + method: "thread/resume".into(), + message: "no rollout found for thread id thread-original".into() + } + )); + assert!(!is_codex_thread_not_found(&CodexAppServerError::Rpc { + method: "thread/resume".into(), + error: Box::new(freshell_codex::protocol::RpcError { + code: -32600, + message: "approval_policy untrusted is not supported".into(), + data: None + }) + })); + } + + async fn assert_crash_mints_new_thread(resume_error: Value) { let _guard = ENV_LOCK.lock().await; let (st, mut rx) = state_with_bus(); @@ -24608,7 +27872,7 @@ pub(crate) mod tests { "threadStartThreadId": "thread-respawned", "overrides": { "thread/resume": { - "error": { "code": -32001, "message": "Thread not found" } + "error": resume_error } } }) @@ -24776,7 +28040,7 @@ pub(crate) mod tests { simulate_unrequested_crash(&st, &thread_id).await; // A transient failure (NOT a "not found"-shaped message) on EVERY `thread/resume` - // attempt -- the first (with settings) AND the retry (with settings dropped). + // attempt -- the first and the retry that omits only the model. configure_fake_codex_cmd( &json!({ "overrides": { @@ -24950,6 +28214,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -25280,6 +28545,246 @@ pub(crate) mod tests { } } + #[derive(Clone, Debug)] + struct SidecarIncarnation { + ownership_id: String, + pid: u32, + } + + async fn snapshot_live_sidecars( + st: &FreshCodexState, + session_ids: &[&str], + ) -> Vec { + let sessions = st.sessions.lock().await; + assert_eq!( + sessions.len(), + session_ids.len(), + "exact live registrations" + ); + session_ids + .iter() + .map(|id| { + let session = sessions.get(*id).expect("the sidecar is registered"); + assert!( + !session.exited.load(Ordering::SeqCst), + "the registered sidecar for {id} is live" + ); + assert!(!session.sidecar_ownership_id.is_empty()); + SidecarIncarnation { + ownership_id: session.sidecar_ownership_id.clone(), + pid: session.sidecar_pid.expect("a genuine sidecar child PID"), + } + }) + .collect() + } + + #[derive(Debug, serde::Deserialize)] + #[serde(rename_all = "camelCase")] + struct SidecarThreadOperation { + method: String, + thread_id: String, + params: Value, + listen_url: String, + sidecar_ownership_id: String, + pid: u32, + } + + /// Join real RPC witnesses to the captured launch owners. Retired endpoints and + /// PIDs may be reused; the inherited ownership tag identifies each incarnation. + fn assert_sidecar_operation_sequences( + log_text: &str, + expected: &[(&SidecarIncarnation, &[(&str, &str)])], + ) -> Vec { + let operations: Vec = log_text + .lines() + .map(|line| serde_json::from_str(line).expect("owned operation witness parses")) + .collect(); + let owners: std::collections::HashSet<_> = expected + .iter() + .map(|(owner, _)| owner.ownership_id.as_str()) + .collect(); + assert_eq!( + owners.len(), + expected.len(), + "distinct launch ownership tags" + ); + assert_eq!( + operations.len(), + expected + .iter() + .map(|(_, sequence)| sequence.len()) + .sum::(), + "exactly the expected operations: {log_text}" + ); + expected + .iter() + .map(|(owner, sequence)| { + assert!(!owner.ownership_id.is_empty()); + let rows: Vec<_> = operations + .iter() + .filter(|row| row.sidecar_ownership_id == owner.ownership_id) + .collect(); + let actual: Vec<_> = rows + .iter() + .map(|row| (row.method.as_str(), row.thread_id.as_str())) + .collect(); + assert_eq!(actual, *sequence, "RPC sequence for {owner:?}: {log_text}"); + let url = rows + .first() + .expect("each launch served an operation") + .listen_url + .clone(); + assert!(!url.is_empty(), "the endpoint remains diagnostic evidence"); + for row in rows { + assert_eq!(row.pid, owner.pid, "RPC served by the owned child: {row:?}"); + assert_eq!(row.listen_url, url, "one endpoint per launch: {row:?}"); + if row.method != "thread/start" && row.method != "thread/fork" { + assert_eq!( + row.params["threadId"].as_str(), + Some(row.thread_id.as_str()), + "handoff targets the witnessed thread: {row:?}" + ); + } + } + url + }) + .collect() + } + + /// Match PID occurrences from the same global event window as `spawn_count`. + /// The operation audit separately joins these real launches to ownership tags. + fn assert_spawn_pids_match_incarnations( + capture: &tracing_capture::GlobalCapture, + incarnations: &[SidecarIncarnation], + ) { + let mut spawned_pids: Vec = capture + .untagged_events_since_start() + .into_iter() + .filter(|event| event.message == "freshagent.sidecar.spawned") + .map(|event| event.fields["pid"].parse().expect("successful spawn PID")) + .collect(); + let mut owned_pids: Vec<_> = incarnations.iter().map(|owner| owner.pid).collect(); + spawned_pids.sort_unstable(); + owned_pids.sort_unstable(); + assert_eq!( + spawned_pids, owned_pids, + "one successful spawn per owned incarnation" + ); + } + + #[tokio::test] + async fn sidecar_operation_identity_survives_retired_endpoint_reuse() { + let _guard = ENV_LOCK.lock().await; + let dir = tempfile::tempdir().expect("fixture evidence directory"); + let log_path = dir.path().join("operations.jsonl"); + let port_file = dir.path().join("port"); + let mut endpoint = "ws://127.0.0.1:0".to_string(); + let mut incarnations = Vec::new(); + for thread_id in ["reuse-first", "reuse-second"] { + let ownership_id = mint_ownership_id(); + let mut command = tokio::process::Command::new("node"); + command + .arg(fake_codex_app_server_cmd()) + .arg("--listen") + .arg(&endpoint) + .env(CODEX_SIDECAR_OWNERSHIP_ENV, &ownership_id) + .env( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + json!({ + "threadStartThreadId": thread_id, + "appendThreadOperationLogPath": log_path, + }) + .to_string(), + ) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .kill_on_drop(true); + if incarnations.is_empty() { + command.env("FAKE_CODEX_APP_SERVER_PORT_FILE", &port_file); + } else { + command.env_remove("FAKE_CODEX_APP_SERVER_PORT_FILE"); + } + let mut child = command.spawn().expect("spawn this test's own fixture"); + let owner = SidecarIncarnation { + ownership_id, + pid: child.id().expect("fixture child PID"), + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + let transport = loop { + if incarnations.is_empty() { + if let Ok(report) = std::fs::read_to_string(&port_file) { + if let Ok(port) = report.trim().parse::() { + endpoint = format!("ws://127.0.0.1:{port}"); + } + } + } + if let Ok(Some(status)) = child.try_wait() { + panic!("owned endpoint-reuse fixture exited before listening: {status}"); + } + if let Ok(Ok(transport)) = tokio::time::timeout( + Duration::from_millis(500), + TungsteniteTransport::connect(&endpoint), + ) + .await + { + break transport; + } + assert!( + tokio::time::Instant::now() < deadline, + "owned fixture did not bind {endpoint}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + }; + let (client, _notifications) = CodexAppServerClient::connect(Arc::new(transport)); + let started = client + .start_thread(StartThreadParams::default()) + .await + .expect("real RPC served by the owned fixture"); + assert_eq!(started.thread_id, thread_id); + client.close().await; + tokio::time::timeout(Duration::from_secs(5), child.kill()) + .await + .expect("owned child teardown completed") + .expect("kill and reap only this child"); + incarnations.push(owner); + } + let log_text = read_op_log_when_complete(&log_path, 2).await; + let urls = assert_sidecar_operation_sequences( + &log_text, + &[ + (&incarnations[0], &[("thread/start", "reuse-first")]), + (&incarnations[1], &[("thread/start", "reuse-second")]), + ], + ); + assert_eq!( + urls[0], urls[1], + "the two genuine launches reused one endpoint" + ); + assert_eq!( + urls.iter().collect::>().len(), + 1, + "historical URL grouping conflates two distinct launch owners" + ); + let aliased_owner = SidecarIncarnation { + ownership_id: incarnations[1].ownership_id.clone(), + pid: incarnations[0].pid, + }; + assert!( + std::panic::catch_unwind(|| { + assert_sidecar_operation_sequences( + &log_text, + &[ + (&aliased_owner, &[("thread/start", "reuse-first")]), + (&incarnations[0], &[("thread/start", "reuse-second")]), + ], + ); + }) + .is_err(), + "a wrong owner must fail despite two genuine launches" + ); + } + /// Whole-branch review M-2: a Compact click on a crash-stale session (sidecar /// hand-flagged exited-unrequested, see `simulate_unrequested_crash`; the /// self-heal design keeps the session MAPPED) must transparently @@ -25409,6 +28914,7 @@ pub(crate) mod tests { .to_string(), ); let parent_id = create_real_fake_session(&st, &mut rx).await; + let original_owner = snapshot_live_sidecars(&st, &[&parent_id]).await.remove(0); simulate_unrequested_crash(&st, &parent_id).await; assert_eq!( spawn_count(&capture), @@ -25453,54 +28959,46 @@ pub(crate) mod tests { spawn_count(&capture) ); - // Per-connection RPC order over THREE sidecars: first spawn (kept alive, - // hand-flagged crashed — D8) = thread/start; respawned parent = - // thread/resume → thread/fork → thread/archive; child = - // thread/unarchive → thread/resume. + let mut incarnations = vec![original_owner]; + incarnations.extend(snapshot_live_sidecars(&st, &[&parent_id, "child-after-crash"]).await); + assert_spawn_pids_match_incarnations(&capture, &incarnations); let log_text = read_op_log_when_complete(&log_path, 6).await; - let mut by_url: HashMap> = HashMap::new(); - for line in log_text.lines() { - let entry: Value = serde_json::from_str(line).expect("op log line parses"); - by_url - .entry(entry["listenUrl"].as_str().expect("listenUrl").to_string()) - .or_default() - .push(entry["method"].as_str().expect("method").to_string()); - } - assert_eq!( - by_url.len(), - 3, - "exactly three sidecar connections (first + respawned parent + child): {log_text}" - ); - let mut sequences: Vec> = by_url.values().cloned().collect(); - sequences.sort(); - // Sorted order: ["thread/resume", ...] < ["thread/start"] < ["thread/unarchive", ...]. - assert_eq!( - sequences[0], - vec!["thread/resume", "thread/fork", "thread/archive"], - "the respawned parent was forked, never the dead connection: {log_text}" - ); - assert_eq!( - sequences[1], - vec!["thread/start"], - "the first spawn served only the create: {log_text}" - ); - assert_eq!( - sequences[2], - vec!["thread/unarchive", "thread/resume"], - "the child sidecar completed the archive→unarchive→resume handoff: {log_text}" + let urls = assert_sidecar_operation_sequences( + &log_text, + &[ + (&incarnations[0], &[("thread/start", &parent_id)]), + ( + &incarnations[1], + &[ + ("thread/resume", &parent_id), + ("thread/fork", "child-after-crash"), + ("thread/archive", "child-after-crash"), + ], + ), + ( + &incarnations[2], + &[ + ("thread/unarchive", "child-after-crash"), + ("thread/resume", "child-after-crash"), + ], + ), + ], ); - - // Both the respawned parent and the child are registered and live. - let guard = st.sessions.lock().await; - assert!( - guard.contains_key(&parent_id), - "the respawned parent stays registered under the SAME id" + assert_ne!( + urls[1], urls[2], + "the live parent and child have distinct endpoints" ); - assert!( - guard.contains_key("child-after-crash"), - "the forked child is registered on its own sidecar" + let targets: Vec = log_text + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|row| row["method"] == "thread/fork") + .map(|row| row["params"]["threadId"].clone()) + .collect(); + assert_eq!( + targets, + vec![json!(parent_id)], + "only the recovered parent is forked" ); - drop(guard); // The recovery was transparent: no user-facing error frame names the parent. let deadline = std::time::Instant::now() + std::time::Duration::from_millis(400); @@ -25564,6 +29062,7 @@ pub(crate) mod tests { ); let old_id = create_real_fake_session(&st, &mut rx).await; assert_eq!(old_id, "parent-old-mint", "fixture sanity: the clicked id"); + let original_owner = snapshot_live_sidecars(&st, &[&old_id]).await.remove(0); simulate_unrequested_crash(&st, &old_id).await; assert_eq!( spawn_count(&capture), @@ -25619,69 +29118,52 @@ pub(crate) mod tests { spawn_count(&capture) ); - // Per-connection RPC order over THREE sidecars: first spawn (kept alive, - // hand-flagged crashed — D8) = thread/start; respawned parent = - // thread/start (the mint) → thread/fork → thread/archive, with the fork - // RPC TARGETING the minted parent id; child = unarchive → resume. - let log_text = read_op_log_when_complete(&log_path, 6).await; - let mut by_url: HashMap> = HashMap::new(); - let mut fork_target: Option = None; - for line in log_text.lines() { - let entry: Value = serde_json::from_str(line).expect("op log line parses"); - by_url - .entry(entry["listenUrl"].as_str().expect("listenUrl").to_string()) - .or_default() - .push(entry["method"].as_str().expect("method").to_string()); - if entry["method"].as_str() == Some("thread/fork") { - fork_target = Some( - entry["params"]["threadId"] - .as_str() - .expect("fork threadId") - .to_string(), - ); - } - } - assert_eq!( - fork_target.as_deref(), - Some("parent-new-mint"), - "thread/fork runs against the RESPAWNED parent thread: {log_text}" - ); - assert_eq!( - by_url.len(), - 3, - "exactly three sidecar connections (first + respawned parent + child): {log_text}" + let mut incarnations = vec![original_owner]; + incarnations + .extend(snapshot_live_sidecars(&st, &["parent-new-mint", "child-after-mint"]).await); + assert!( + !st.sessions.lock().await.contains_key(&old_id), + "old record retired" ); - let mut sequences: Vec> = by_url.values().cloned().collect(); - sequences.sort(); - // Sorted order: ["thread/start"] < ["thread/start", "thread/fork", ...] < ["thread/unarchive", ...]. - assert_eq!( - sequences[0], - vec!["thread/start"], - "the first spawn served only the original create: {log_text}" + assert_spawn_pids_match_incarnations(&capture, &incarnations); + let log_text = read_op_log_when_complete(&log_path, 6).await; + let urls = assert_sidecar_operation_sequences( + &log_text, + &[ + (&incarnations[0], &[("thread/start", &old_id)]), + ( + &incarnations[1], + &[ + ("thread/start", "parent-new-mint"), + ("thread/fork", "child-after-mint"), + ("thread/archive", "child-after-mint"), + ], + ), + ( + &incarnations[2], + &[ + ("thread/unarchive", "child-after-mint"), + ("thread/resume", "child-after-mint"), + ], + ), + ], ); - assert_eq!( - sequences[1], - vec!["thread/start", "thread/fork", "thread/archive"], - "the respawned parent minted its new thread, then ran fork + archive: {log_text}" + assert_ne!( + urls[1], urls[2], + "the live parent and child have distinct endpoints" ); + let targets: Vec = log_text + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|row| row["method"] == "thread/fork") + .map(|row| row["params"]["threadId"].clone()) + .collect(); assert_eq!( - sequences[2], - vec!["thread/unarchive", "thread/resume"], - "the child sidecar completed the archive→unarchive→resume handoff: {log_text}" + targets, + vec![json!("parent-new-mint")], + "only the resolved parent is forked" ); - // Registrations: the old id is GONE (the respawn re-keyed the session map), the - // minted parent and the child are live. - { - let guard = st.sessions.lock().await; - assert!( - !guard.contains_key(&old_id), - "the mint-new respawn removed the old parent id" - ); - assert!(guard.contains_key("parent-new-mint")); - assert!(guard.contains_key("child-after-mint")); - } - // Broadcast leg audit: the materialized OLD→NEW pair DID go out (the client re-key // this whole finding hinges on), and NO broadcast error frame is keyed to the old id // (the expected THREAD_MEMORY_LOST degradation frame is keyed to the NEW id). @@ -25881,6 +29363,7 @@ pub(crate) mod tests { ); let old_id = create_real_fake_session(&st, &mut rx).await; assert_eq!(old_id, "parent-old-mint", "fixture sanity: the clicked id"); + let original_owner = snapshot_live_sidecars(&st, &[&old_id]).await.remove(0); simulate_unrequested_crash(&st, &old_id).await; // Dead-thread negative cache: ensure-alive goes straight to the mint-new // respawn fallback (the F-1 route). @@ -26040,7 +29523,7 @@ pub(crate) mod tests { // Wire audit: exactly TWO thread/fork RPCs crossed the wire (fork #1 and the // post-completion fork #3 — the refused duplicate produced NONE), both - // targeting the respawned parent, over exactly four sidecar connections + // targeting the respawned parent, across exactly four owned launches // (parent spawn + respawn + one sidecar per child). assert_eq!( spawn_count(&capture), @@ -26065,19 +29548,45 @@ pub(crate) mod tests { vec!["parent-new-mint".to_string(), "parent-new-mint".to_string()], "exactly the two legitimate forks hit the wire — the refused duplicate none: {log_text}" ); - let urls: std::collections::HashSet = log_text - .lines() - .map(|line| { - serde_json::from_str::(line).unwrap()["listenUrl"] - .as_str() - .unwrap() - .to_string() - }) - .collect(); + let mut incarnations = vec![original_owner]; + incarnations + .extend(snapshot_live_sidecars(&st, &["parent-new-mint", &child1, &child2]).await); + assert!( + !st.sessions.lock().await.contains_key(&old_id), + "old record retired" + ); + assert_spawn_pids_match_incarnations(&capture, &incarnations); + let urls = assert_sidecar_operation_sequences( + &log_text, + &[ + (&incarnations[0], &[("thread/start", &old_id)]), + ( + &incarnations[1], + &[ + ("thread/start", "parent-new-mint"), + ("thread/fork", &child1), + ("thread/archive", &child1), + ("thread/fork", &child2), + ("thread/archive", &child2), + ], + ), + ( + &incarnations[2], + &[("thread/unarchive", &child1), ("thread/resume", &child1)], + ), + ( + &incarnations[3], + &[("thread/unarchive", &child2), ("thread/resume", &child2)], + ), + ], + ); assert_eq!( - urls.len(), - 4, - "crashed + respawned parent + two child sidecars: {log_text}" + urls[1..] + .iter() + .collect::>() + .len(), + 3, + "the recovered parent and both live child sidecars have distinct endpoints: {log_text}" ); let _ = std::fs::remove_file(&log_path); @@ -26336,9 +29845,15 @@ pub(crate) mod tests { assert_eq!(snapshot["capabilities"]["interrupt"], json!(false)); assert_eq!(snapshot["tokenUsage"]["inputTokens"], json!(0)); assert_eq!(snapshot["pendingApprovals"], json!([])); - assert_eq!(snapshot["extensions"]["codex"], json!({})); - - // kata z7j7: codex honors all four settings knobs per send (merged over + assert_eq!(snapshot["tokenUsage"]["observed"], false); + let projection = &snapshot["extensions"]["codex"]["projection"]; + assert!(projection["id"].as_str().is_some_and(|id| !id.is_empty())); + assert_eq!(projection["eventRevision"], 0); + assert_eq!(projection["historyGeneration"], 0); + assert_eq!(projection["nativeFinalTurnIds"], json!(["turn-1"])); + assert_eq!(projection["turnRows"]["turn-1"], json!(["turn-1"])); + + // Codex honors these settings per send (merged over // the session baseline before turn/start). assert_eq!( snapshot["capabilities"]["settingScopes"], @@ -26347,6 +29862,8 @@ pub(crate) mod tests { "effort": "per-send", "sandbox": "per-send", "permissionMode": "per-send", + "cwd": "per-send", + "collaborationMode": "per-send", }) ); @@ -26469,6 +29986,67 @@ pub(crate) mod tests { // -- Batch D PR-6: rich transcript items for the codex snapshot endpoint -- + #[test] + fn native_child_activity_function_output_and_sleep_remain_visible() { + for (native, status) in [ + ("started", "running"), + ("interacted", "running"), + ("interrupted", "interrupted"), + ("completed", "completed"), + ] { + let mapped = map_codex_item("activity", &json!({"type":"subAgentActivity", "id":"activity", "agentPath":"/reviewer", "agentThreadId":"exact-child", "kind":native, "model":"native-model"}), "subAgentActivity").unwrap(); + assert_eq!( + mapped.len(), + 1, + "native activity must remain visible: {native}" + ); + assert_eq!(mapped[0]["childSessionId"], "exact-child"); + assert_eq!(mapped[0]["status"], status); + assert_eq!(mapped[0]["model"], "native-model"); + } + for output in [ + json!("Exact function output"), + json!([{"type":"input_text","text":"Typed function output"},{"type":"input_image","image_url":"/tmp/output.png"},{"type":"future","result":"visible value"}]), + ] { + let mapped = map_codex_item( + "function-output", + &json!({"output":output}), + "functionCallOutput", + ) + .unwrap(); + assert_eq!(mapped[0]["kind"], "tool_result"); + assert_eq!(mapped[0]["toolUseId"], "function-output"); + assert_eq!(mapped[0]["content"], output); + } + let sleep = map_codex_item("sleep", &json!({"durationMs":1250}), "sleep").unwrap(); + assert_eq!(sleep[0]["tool"], "Sleep"); + assert_eq!(sleep[0]["arguments"]["durationMs"], 1250); + } + + #[test] + fn native_images_keep_paths_and_unknown_items_never_create_blank_rows() { + let mapped = map_codex_item("image-user", &json!({"content":[{"type":"localImage","path":"/tmp/upload.png"},{"type":"image","url":"https://example.test/image.png"}]}), "userMessage").unwrap(); + assert!(mapped[0]["text"] + .as_str() + .unwrap() + .contains("/tmp/upload.png")); + assert!(mapped[1]["text"] + .as_str() + .unwrap() + .contains("https://example.test/image.png")); + let turns = build_codex_turn_json( + &json!({"id":"unknown-turn","items":[{"type":"futureVariant","id":"future"}]}), + 0, + ) + .unwrap(); + assert!( + turns.is_empty() + || turns + .iter() + .all(|turn| !turn["items"].as_array().unwrap().is_empty()) + ); + } + #[test] fn map_codex_item_command_execution_renders_command_kind_with_exact_schema_keys() { let item = json!({ @@ -26593,7 +30171,7 @@ pub(crate) mod tests { ); assert_eq!( mapped[1], - json!({ "id": "item-6:part:1", "kind": "text", "text": "[image]" }) + json!({ "id": "item-6:part:1", "kind": "text", "text": "Image attachment" }) ); } @@ -27551,6 +31129,241 @@ pub(crate) mod tests { // ── freshAgent.configure: live model convergence ───────────────────────── + #[tokio::test(flavor = "multi_thread")] + async fn model_catalog_only_maps_legacy_max_when_selected_model_advertises_xhigh_without_max() { + let _guard = ENV_LOCK.lock().await; + for (levels, expected) in [ + (vec!["xhigh"], "xhigh"), + (vec!["xhigh", "max"], "max"), + (vec!["ultra"], "max"), + ] { + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("native-requests.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath": log,"modelCatalogPages":[{"data":[{ + "id":"model-record","model":"gpt-6-astra","displayName":"Installed Astra", + "supportedReasoningEfforts":levels.iter().map(|effort|json!({"reasoningEffort":effort})).collect::>(), + "defaultReasoningEffort":levels[0],"inputModalities":["text"] + }],"nextCursor":null}]}).to_string()); + let (st, mut rx) = state_with_bus(); + let mut create = create_msg("legacy-effort-create"); + create.model = Some("gpt-6-astra".into()); + create.effort = Some(Some("max".into())); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { + f["type"] == "freshAgent.created" + }) + .await; + let id = created + .frames + .iter() + .find(|f| f["type"] == "freshAgent.created") + .unwrap()["sessionId"] + .as_str() + .unwrap(); + st.handle_send(send_msg(id, "legacy max selection")).await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["type"] == "freshAgent.send.accepted" + }) + .await; + st.shutdown().await; + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turn = requests + .iter() + .find(|r| r["method"] == "turn/start") + .unwrap(); + assert_eq!( + turn["params"]["effort"], expected, + "installed levels: {levels:?}" + ); + } + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn model_catalog_omission_preserves_exact_choice_and_native_refusal_is_typed_without_retry( + ) { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("native-requests.jsonl"); + configure_fake_codex_cmd(&json!({"appendRequestLogPath":log,"modelCatalogPages":[{"data":[],"nextCursor":null}],"overrides":{"turn/start":{"error":{"code":-32602,"message":"Native provider rejected provider-current effort ultra"}}}}).to_string()); + let (st, mut rx) = state_with_bus(); + let mut create = create_msg("native-model-refusal-create"); + create.model = Some("provider-current".into()); + create.effort = Some(Some("ultra".into())); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |frame| { + frame["type"] == "freshAgent.created" + }) + .await; + let id = created + .frames + .iter() + .find(|frame| frame["type"] == "freshAgent.created") + .unwrap()["sessionId"] + .as_str() + .unwrap() + .to_string(); + let catalog = st + .model_catalog(crate::model_capabilities::CodexCatalogContext { + session_id: id.clone(), + session_ref: None, + cwd: None, + observed_epoch: None, + observed_generation: None, + }) + .await + .unwrap(); + let mut send = send_msg(&id, "Native must validate the exact choice"); + send.request_id = Some("native-model-refusal-send".into()); + st.handle_send(send).await; + let failed = collect_frames_until(&mut rx, Duration::from_secs(5), |frame| { + frame["type"] == "error" && frame["requestId"] == "native-model-refusal-send" + }) + .await; + st.shutdown().await; + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + assert_eq!(catalog, json!([])); + assert!(failed.matched, "{:?}", failed.frames); + let error = failed + .frames + .iter() + .find(|frame| frame["requestId"] == "native-model-refusal-send") + .unwrap(); + assert!(error["message"] + .as_str() + .unwrap() + .starts_with("CODEX_TURN_START_FAILED:")); + assert!(error["message"] + .as_str() + .unwrap() + .contains("Native provider rejected provider-current effort ultra")); + assert!(!failed + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.send.accepted")); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let turns: Vec<_> = requests + .iter() + .filter(|frame| frame["method"] == "turn/start") + .collect(); + assert_eq!(turns.len(), 1, "no retry or replacement of exact intent"); + assert_eq!(turns[0]["params"]["model"], "provider-current"); + assert_eq!(turns[0]["params"]["effort"], "ultra"); + assert_eq!( + requests + .iter() + .filter(|frame| frame["method"] == "thread/start") + .count(), + 1 + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn native_model_choices_reach_create_send_configure_resume_and_fork_requests() { + let _guard = ENV_LOCK.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log = temp.path().join("native-requests.jsonl"); + configure_fake_codex_cmd( + &json!({"appendRequestLogPath": log, "recordTurns": true}).to_string(), + ); + let (st, mut rx) = state_with_bus(); + let mut create = create_msg("native-model-create"); + create.model_selection = Some(Some(freshell_protocol::ModelSelection { + kind: "exact".into(), + model_id: "provider-current".into(), + })); + create.effort = Some(Some("ultra".into())); + st.handle_create(create, None).await; + let created = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { + f["type"] == "freshAgent.created" + }) + .await; + let session_id = created + .frames + .iter() + .find(|f| f["type"] == "freshAgent.created") + .unwrap()["sessionId"] + .as_str() + .unwrap() + .to_string(); + st.handle_send(send_msg(&session_id, "first native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + st.handle_configure(configure_msg( + &session_id, + Some("provider-current"), + Some("ultra"), + )) + .await; + st.handle_send(send_msg(&session_id, "configured native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + let (sink, fork_frames) = capturing_sink(); + st.handle_fork(fork_msg(&session_id, "native-model-fork", None), None, sink) + .await; + let forked = captured_frames(&fork_frames); + let child = forked + .iter() + .find(|f| f["type"] == "freshAgent.forked") + .unwrap()["sessionId"] + .as_str() + .unwrap() + .to_string(); + st.handle_send(send_msg(&child, "forked native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(5), |f| { + f["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + simulate_unrequested_crash(&st, &session_id).await; + st.handle_send(send_msg(&session_id, "resumed native choice")) + .await; + let _ = collect_frames_until(&mut rx, Duration::from_secs(10), |f| { + f["type"] == "freshAgent.send.accepted" + }) + .await; + st.shutdown().await; + std::env::remove_var("CODEX_CMD"); + std::env::remove_var("FAKE_CODEX_APP_SERVER_BEHAVIOR"); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + for method in ["thread/start", "thread/resume", "thread/fork", "turn/start"] { + let rows: Vec<_> = requests + .iter() + .filter(|row| row["method"] == method) + .collect(); + assert!( + !rows.is_empty(), + "native method not exercised: {method}: {requests:?}" + ); + for row in rows { + assert_eq!(row["params"]["model"], "provider-current", "{method}"); + if method == "turn/start" { + assert_eq!(row["params"]["effort"], "ultra"); + } + } + } + } + fn configure_msg( session_id: &str, model: Option<&str>, @@ -27563,11 +31376,12 @@ pub(crate) mod tests { cwd: None, request_id: Some("req-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }, } } @@ -27634,11 +31448,9 @@ pub(crate) mod tests { assert_eq!(metadata["event"]["effort"], json!("high")); } - /// An unknown-menu effort clamps to the model's menu default (the same - /// two-stage normalization `handle_send` applies), and the broadcast - /// carries the CLAMPED value — the record never holds a raw pick. + /// An opaque native effort is retained for the next turn and broadcast. #[tokio::test(flavor = "multi_thread")] - async fn configure_normalizes_the_effort_before_recording_it() { + async fn configure_preserves_native_effort_before_recording_it() { let (st, rx) = state_with_bus(); let mut rx = rx; inserted_session(&st, "thread-cfg-clamp").await; @@ -27656,8 +31468,8 @@ pub(crate) mod tests { }; assert_eq!( effort.as_deref(), - Some("max"), - "an unknown-menu effort clamps to gpt-5.3-codex-spark's default" + Some("bogus"), + "native validation, not a static menu, decides support" ); let frames = drain(&mut rx).await; let metadata = frames @@ -27667,7 +31479,7 @@ pub(crate) mod tests { && f["event"]["type"] == "freshAgent.session.metadata" }) .expect("a metadata frame is broadcast"); - assert_eq!(metadata["event"]["effort"], json!("max")); + assert_eq!(metadata["event"]["effort"], json!("bogus")); } /// An idempotent configure (same effective pair) stays silent on the bus. @@ -27751,11 +31563,12 @@ pub(crate) mod tests { images: None, request_id: None, settings: Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: Some(model.to_string()), permission_mode: None, sandbox: None, - effort: Some(effort.to_string()), + effort: Some(Some(effort.to_string())), }), observed_epoch: None, observed_generation: None, @@ -28007,12 +31820,20 @@ pub(crate) mod tests { }) .await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + let mut correlated_refusal = false; loop { let frame: Value = tokio::time::timeout(std::time::Duration::from_secs(15), rx.recv()) .await .expect("a frame within budget") .map(|raw| serde_json::from_str(&raw).expect("json frame")) .expect("the bus stays open"); + if frame["type"] == "error" && frame["requestId"] == "req-r8-stale-send" { + assert!(frame["message"] + .as_str() + .unwrap() + .starts_with("SESSION_RESERVED:")); + correlated_refusal = true; + } if frame["type"] == "freshAgent.event" && frame["event"]["type"] == json!("freshAgent.error") && frame["event"]["code"] == json!("SESSION_RESERVED") @@ -28028,16 +31849,33 @@ pub(crate) mod tests { "the stale send never answered typed: {frame}" ); } + assert!( + correlated_refusal, + "the pane refusal must also release its request-owned composer" + ); } #[tokio::test(flavor = "multi_thread")] async fn checkpoint_send_with_stale_fence_is_refused_for_an_already_running_codex_session() { let _guard = ENV_LOCK.lock().await; - let (mut st, mut rx) = state_with_bus(); + let (mut st, mut rx, sink) = state_with_sink(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(Arc::clone(®istry)); configure_fake_codex_cmd("{}"); let thread_id = create_real_fake_session(&st, &mut rx).await; + let mut rollback = crate::rollback_record::RollbackRecord::empty(1); + rollback.push_entry( + crate::rollback_record::RollbackEntry { + removed_turns: vec![json!({"id":"owned-history"})], + prompt_text: "retained".into(), + at_ms: 2, + epoch: 0, + }, + 3, + ); + sink.record_rollback(PROVIDER, &thread_id, rollback.clone()) + .await + .unwrap(); let before = registry.observe(PROVIDER, &thread_id); let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_handoff( PROVIDER, @@ -28068,7 +31906,7 @@ pub(crate) mod tests { st.handle_send(FreshAgentSend { request_id: Some("req-stale-checkpoint-codex".into()), provider: freshell_protocol::AgentProvider::Codex, - session_id: thread_id, + session_id: thread_id.clone(), session_type: freshell_protocol::SessionType::Freshcodex, text: "delayed checkpoint prompt".into(), images: None, @@ -28090,6 +31928,11 @@ pub(crate) mod tests { saw_reserved |= frame.to_string().contains("SESSION_RESERVED"); saw_accepted |= frame.to_string().contains("freshAgent.send.accepted"); } + assert_eq!( + sink.load_rollback(PROVIDER, &thread_id), + Some(rollback), + "a stale refused send cannot mutate saved rollback state before its ownership guard" + ); assert!(saw_reserved, "the stale owner fence is refused typed"); assert!( !saw_accepted, @@ -28743,6 +32586,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -28854,6 +32698,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -28970,6 +32815,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -29384,6 +33230,7 @@ pub(crate) mod tests { // adopted without consulting the coordinator. st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -29475,6 +33322,7 @@ pub(crate) mod tests { // observed generation — the fence discipline refuses typed. st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: Some(registry.boot_epoch()), @@ -29543,10 +33391,67 @@ pub(crate) mod tests { #[cfg(all(test, target_os = "linux"))] mod provider_exec_tests { use super::*; + + const CASE: &str = "FRESHELL_CODEX_EXEC_PROFILE_CHILD"; + const TEST: &str = "codex::provider_exec_tests::provider_exec_profile_rejects_actual_managed_fresh_codex_spawn"; + + fn process_observation(pid: u32) -> Result { + let start = crate::session_lease::proc_starttime(pid as i32) + .ok_or_else(|| format!("process {pid} has no live start time"))?; + let status = std::fs::read_to_string(format!("/proc/{pid}/status")) + .map_err(|error| error.to_string())?; + let parent = status + .lines() + .find_map(|line| line.strip_prefix("PPid:")?.trim().parse::().ok()) + .ok_or_else(|| format!("process {pid} has no parent observation"))?; + Ok(json!({"pid":pid,"start":start,"ppid":parent})) + } + + fn assert_joined_cases(outputs: Vec) { + assert_eq!(outputs.len(), 2); + for (case, output) in ["large", "small"].into_iter().zip(outputs) { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + eprintln!("[provider-exec-child stdout] {line}"); + } + assert!(stdout.lines().any(|line| line == "running 1 test")); + assert!(stdout + .lines() + .any(|line| line == format!("test {TEST} ... ok"))); + assert!(stdout.contains("test result: ok. 1 passed; 0 failed; 0 ignored;")); + let observations: Vec = String::from_utf8_lossy(&output.stderr) + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|value| value["event"] == "provider_exec_fixture_closed") + .collect(); + assert_eq!(observations.len(), 1, "{observations:?}"); + let observation = &observations[0]; + assert_eq!(observation["test"], TEST); + assert_eq!(observation["case"], case); + let supervisor = &observation["supervisor"]; + let native = observation["native"].as_array().unwrap(); + assert_eq!(native.len(), usize::from(case == "small")); + for process in std::iter::once(supervisor).chain(native) { + let pid = u32::try_from(process["pid"].as_u64().unwrap()).unwrap(); + let start = process["start"].as_u64().unwrap(); + assert_eq!( + crate::session_lease::recorded_process_evidence(pid, Some(start)), + crate::session_lease::RecordedProcessEvidence::Gone + ); + } + for process in native { + assert_eq!(process["ppid"], supervisor["pid"]); + } + eprintln!("{observation}"); + } + } + #[tokio::test] async fn provider_exec_profile_rejects_actual_managed_fresh_codex_spawn() { - const CASE: &str = "FRESHELL_CODEX_EXEC_PROFILE_CHILD"; if let Ok(case) = std::env::var(CASE) { + let supervisor = process_observation(std::process::id()); + let spawned = std::sync::Mutex::new(Vec::new()); + let on_spawned = |pid| spawned.lock().unwrap().push(process_observation(pid)); let count = if case == "large" { 17 } else { 1 }; let (tx, _rx) = tokio::sync::broadcast::channel(16); let state = FreshCodexState::new( @@ -29570,11 +33475,12 @@ mod provider_exec_tests { env!("CARGO_MANIFEST_DIR") ), ); - let mut result = state.spawn_sidecar_notifying(None, None).await; - if let Ok((_, _, _, child)) = &mut result { - child.kill().await.unwrap(); - child.wait().await.unwrap(); - } + let mut result = state.spawn_sidecar_notifying(None, Some(&on_spawned)).await; + let cleanup = if let Ok((_, _, _, child)) = &mut result { + Some((child.kill().await, child.wait().await)) + } else { + None + }; if case == "large" { assert!( matches!(result,Err(ref cause) if cause.contains("provider exec profile")), @@ -29586,18 +33492,71 @@ mod provider_exec_tests { "valid selected fresh Codex inputs must execute the actual app-server handshake" ); } + drop(result); + if let Some((killed, waited)) = cleanup { + killed.unwrap(); + waited.unwrap(); + } + let native: Vec = spawned + .into_inner() + .unwrap() + .into_iter() + .collect::>() + .expect("observe actual spawned Codex identity"); + assert_eq!(native.len(), usize::from(case == "small")); + let observation = json!({"severity":"info","event":"provider_exec_fixture_closed", + "test":TEST,"case":case,"supervisor":supervisor.expect("observe child libtest identity"), + "native":native}); + use std::io::Write; + writeln!(std::io::stderr(), "{observation}").expect("report joined fixture identities"); return; } + assert_joined_cases(run_profile_cases().await); + } + + // The original exact test and the guard regression share this physical path. + async fn run_profile_cases() -> Vec { + let _process_fixture = crate::session_lease::process_fixture::lock().await; + let mut outputs = Vec::new(); for case in ["large", "small"] { - let output=std::process::Command::new(std::env::current_exe().unwrap()).env_clear() - .env("PATH","/usr/local/bin:/usr/bin:/bin").env(CASE,case) - .args(["--exact","codex::provider_exec_tests::provider_exec_profile_rejects_actual_managed_fresh_codex_spawn","--nocapture"]) - .output().unwrap(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .env_clear() + .env("PATH", "/usr/local/bin:/usr/bin:/bin") + .env(CASE, case) + .args(["--exact", TEST, "--nocapture"]) + .output() + .unwrap(); assert!( output.status.success(), "actual fresh Codex exec fixture failed: {}", String::from_utf8_lossy(&output.stderr) ); + outputs.push(output); } + outputs + } + + #[tokio::test] + async fn provider_exec_parent_waits_for_process_fixture() { + let held = crate::session_lease::process_fixture::lock().await; + let mut parent = Box::pin(run_profile_cases()); + // Synchronous output either joins real children or waits at the parent guard. + let first_poll = futures_util::poll!(&mut parent); + let waited = first_poll.is_pending(); + drop(held); + let outputs = match first_poll { + std::task::Poll::Ready(outputs) => outputs, + std::task::Poll::Pending => parent.await, + }; + assert_joined_cases(outputs); + eprintln!( + "{}", + json!({"severity":"info","event":"provider_exec_parent_polled", + "test":TEST,"firstPollPending":waited}) + ); + assert!( + waited, + "actual Codex provider fixture spawned while the canonical process guard was held" + ); } } diff --git a/crates/freshell-freshagent/src/codex/children.rs b/crates/freshell-freshagent/src/codex/children.rs new file mode 100644 index 0000000000..ae074fe034 --- /dev/null +++ b/crates/freshell-freshagent/src/codex/children.rs @@ -0,0 +1,566 @@ +//! Parent-scoped child history reads. This path never acquires a child writer. +use super::*; + +#[derive(Debug)] +pub enum ChildReadError { + InvalidFence, + UnrelatedChild, + SourceUnavailable, +} + +impl FreshCodexState { + pub async fn read_child( + &self, + parent_session_id: &str, + child_thread_id: &str, + observed_epoch: Option, + observed_generation: Option, + ) -> Result { + crate::ownership_lane::wire_fence(observed_epoch, observed_generation) + .map_err(|_| ChildReadError::InvalidFence)?; + let parent = self.resolve_ownership_key(parent_session_id); + let owner = self.ownership_snapshot(PROVIDER, &parent); + if observed_epoch.is_some_and(|value| value != owner.epoch) + || observed_generation.is_some_and(|value| value != owner.generation) + { + return Err(ChildReadError::SourceUnavailable); + } + // Resolve only the parent's existing registration. No child lifecycle, + // provider startup, alternate HOME or gateway-side saved-history fallback. + let (client, _) = self + .snapshot_runtime_for(&parent) + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + let parent_snapshot = self + .get_snapshot(&parent, None) + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + if !parent_snapshot["childThreads"] + .as_array() + .into_iter() + .flatten() + .any(|child| child["threadId"] == child_thread_id) + { + return Err(ChildReadError::UnrelatedChild); + } + let projection = self + .sessions + .lock() + .await + .get(&parent) + .filter(|session| Arc::ptr_eq(&session.client, &client)) + .map(|session| session.projection.clone()) + .ok_or(ChildReadError::SourceUnavailable)?; + let fence = projection + .lock() + .expect("codex projection lock") + .begin_history_read(); + let read = client.read_thread(child_thread_id, true).await; + let (raw, materialized, unmaterialized) = match read { + Ok(raw) => (raw, true, false), + Err(error) => { + let unmaterialized = + is_codex_include_turns_unavailable(&error) || is_codex_thread_not_found(&error); + tracing::warn!(provider=PROVIDER, session_id=%parent, child_thread_id, error=%error, "freshagent.codex.child_history_unavailable"); + match client.read_thread(child_thread_id, false).await { + Ok(raw) => (raw, false, unmaterialized), + Err(_) => { + self.check_child_source(&parent, &client, &projection, &fence, &owner) + .await?; + if !unmaterialized { + return Err(ChildReadError::SourceUnavailable); + } + return Ok(observation( + &parent, + child_thread_id, + "unavailable", + "history_unavailable", + false, + )); + } + } + } + }; + self.check_child_source(&parent, &client, &projection, &fence, &owner) + .await?; + if raw["thread"]["id"].as_str() != Some(child_thread_id) + || raw["thread"]["parentThreadId"] + .as_str() + .is_some_and(|id| id != parent) + { + tracing::warn!(provider=PROVIDER, session_id=%parent, child_thread_id, reason="native_identity_mismatch", "freshagent.codex.child_read_rejected"); + return Err(ChildReadError::UnrelatedChild); + } + if raw["thread"]["ephemeral"] == true { + return Ok(observation( + &parent, + child_thread_id, + "unavailable", + "ephemeral_history_unavailable", + false, + )); + } + if !materialized && !unmaterialized { + return Err(ChildReadError::SourceUnavailable); + } + if !materialized || raw["thread"]["turns"].as_array().is_none_or(Vec::is_empty) { + let live = + normalize_codex_thread_status(&raw["thread"]["status"]) == CodexStatus::Running; + let mut attempts = self.child_reads.lock().await; + // The registration/projection id changes at a parent source replacement. + let source = projection.lock().expect("codex projection lock").id.clone(); + let count = attempts + .entry((parent.clone(), child_thread_id.to_owned(), source)) + .or_default(); + *count = count.saturating_add(1); + return Ok(if live && *count < 3 { + observation( + &parent, + child_thread_id, + "pending", + "history_unmaterialized", + true, + ) + } else { + observation( + &parent, + child_thread_id, + "unavailable", + "history_unavailable", + false, + ) + }); + } + let mut snapshot = + build_codex_snapshot_json(child_thread_id, &raw, false, None, None, false) + .map_err(|_| ChildReadError::SourceUnavailable)?; + if snapshot["turns"].as_array().is_none_or(Vec::is_empty) { + tracing::warn!(provider=PROVIDER, session_id=%parent, child_thread_id, reason="no_supported_history_items", "freshagent.codex.child_history_unavailable"); + return Ok(observation( + &parent, + child_thread_id, + "unavailable", + "history_unavailable", + false, + )); + } + stamp_child_history_previews(&mut snapshot, &raw)?; + stamp_readonly_child(&mut snapshot, &parent); + // Availability does not imply a turn completed or persisted every token. + snapshot["extensions"]["codex"]["childHistory"] = json!({ + "threadStatus":raw["thread"]["status"], "ephemeral":raw["thread"]["ephemeral"], + "turns":raw["thread"]["turns"].as_array().into_iter().flatten().map(|turn| json!({"turnId":turn["id"],"status":turn["status"],"itemsView":turn["itemsView"]})).collect::>() + }); + let status = + if normalize_codex_thread_status(&raw["thread"]["status"]) == CodexStatus::Running { + "live" + } else { + "saved" + }; + Ok( + json!({"parentThreadId":parent, "childThreadId":child_thread_id, "status":status, "snapshot":snapshot}), + ) + } + + async fn check_child_source( + &self, + parent: &str, + client: &Arc, + projection: &Arc>, + fence: &projection::SnapshotReadFence, + owner: &freshell_ownership::OwnershipSnapshot, + ) -> Result<(), ChildReadError> { + let current = self.ownership_snapshot(PROVIDER, parent); + let same = self + .sessions + .lock() + .await + .get(parent) + .is_some_and(|session| { + Arc::ptr_eq(&session.client, client) && Arc::ptr_eq(&session.projection, projection) + }); + if !same + || current.epoch != owner.epoch + || current.generation != owner.generation + || current.state != owner.state + || !projection + .lock() + .expect("codex projection lock") + .matches_fence(fence) + { + tracing::warn!( + provider = PROVIDER, + session_id = parent, + "freshagent.codex.child_source_changed" + ); + return Err(ChildReadError::SourceUnavailable); + } + Ok(()) + } +} + +fn stamp_child_history_previews(snapshot: &mut Value, raw: &Value) -> Result<(), ChildReadError> { + let preview_ids: std::collections::HashSet = raw["thread"]["turns"] + .as_array() + .into_iter() + .flatten() + .filter(|turn| turn["itemsView"] == "summary") + .map(|turn| build_codex_turn_json(turn, 0)) + .collect::, _>>() + .map_err(|_| ChildReadError::SourceUnavailable)? + .into_iter() + .flatten() + .filter_map(|row| row["id"].as_str().map(str::to_owned)) + .collect(); + for row in snapshot["turns"].as_array_mut().into_iter().flatten() { + if row["id"] + .as_str() + .is_some_and(|id| preview_ids.contains(id)) + { + // Keep native interruption/truncation diagnostics, while never + // presenting a provider summary as the complete saved body. + if row.get("outputState").is_none() { + row["outputState"] = json!({}); + } + row["outputState"]["provenance"] = json!("preview"); + row["outputState"]["refreshPending"] = json!(false); + } + } + Ok(()) +} + +pub fn stamp_readonly_child(snapshot: &mut Value, parent: &str) { + if let Some(capabilities) = snapshot["capabilities"].as_object_mut() { + for value in capabilities.values_mut() { + if value.is_boolean() { + *value = json!(false); + } + } + capabilities.remove("settingScopes"); + } + snapshot["pendingApprovals"] = json!([]); + snapshot["pendingQuestions"] = json!([]); + snapshot["extensions"]["codex"]["observer"] = json!({"parentThreadId":parent, "readOnly":true}); +} + +pub fn observation( + parent: &str, + child: &str, + status: &str, + reason: &str, + retryable: bool, +) -> Value { + json!({"parentThreadId":parent,"childThreadId":child,"status":status,"reason":reason,"retryable":retryable}) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parent() -> Value { + json!({"thread":{"id":"parent","status":{"type":"idle"},"ephemeral":false,"turns":[{"id":"parent-turn","status":"completed","items":[{"id":"spawn","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"parent","receiverThreadIds":["child"],"agentsStates":{"child":{"status":"running"}}}]}]}}) + } + + async fn fixture( + child: Value, + ) -> ( + FreshCodexState, + tokio::task::JoinHandle>, + ) { + fixture_for(parent(), child).await + } + + async fn fixture_for( + parent: Value, + child: Value, + ) -> ( + FreshCodexState, + tokio::task::JoinHandle>, + ) { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let (state, _) = super::super::tests::state_with_bus(); + state + .insert_session_for_test( + parent["thread"]["id"].as_str().unwrap(), + Arc::new(client), + None, + ) + .await; + let driver = tokio::spawn(async move { + let mut reads = vec![]; + loop { + let request = + tokio::time::timeout(Duration::from_secs(2), peer.expect_request()).await; + let Ok((id, method, params)) = request else { + break; + }; + reads.push((method.clone(), params.clone())); + if method == "initialize" { + peer.respond(&id, json!({"userAgent":"fixture", "codexHome":"/owned", "platformFamily":"unix", "platformOs":"linux"})); + let _ = peer.expect_notification().await; + } else if method == "thread/read" { + if params["threadId"] != parent["thread"]["id"] + && params["includeTurns"] == true + && child["_historyUnavailable"] == true + { + peer.respond_error( + &id, + -32600, + child["_historyError"] + .as_str() + .unwrap_or("includeTurns is unavailable before first user message"), + ); + continue; + } + peer.respond( + &id, + if params["threadId"] == parent["thread"]["id"] { + parent.clone() + } else { + child.clone() + }, + ); + } else { + panic!("observer dispatched a writer operation: {method}"); + } + } + reads + }); + (state, driver) + } + + #[tokio::test] + async fn child_read_replays_captured_0164_active_final_and_cold_saved_histories() { + let capture: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/fresh-agent/codex/native-children-0.164.json" + )) + .unwrap(); + let parent = capture["parent"]["thread"]["id"].as_str().unwrap(); + for phase in ["active", "final", "saved"] { + let raw = capture[phase].clone(); + let child = raw["thread"]["id"].as_str().unwrap().to_owned(); + let (state, driver) = fixture_for(capture["parent"].clone(), raw.clone()).await; + let result = state.read_child(parent, &child, None, None).await.unwrap(); + assert_eq!(result["parentThreadId"], parent); + assert_eq!(result["childThreadId"], child); + assert_eq!( + result["snapshot"]["extensions"]["codex"]["childHistory"]["threadStatus"], + raw["thread"]["status"] + ); + assert_eq!(result["snapshot"]["capabilities"]["send"], false); + if phase != "active" { + assert!(result["snapshot"] + .to_string() + .contains("Exact child final answer.")); + } + state.shutdown().await; + assert!(driver + .await + .unwrap() + .iter() + .all(|(method, _)| matches!(method.as_str(), "initialize" | "thread/read"))); + } + } + + #[tokio::test] + async fn child_read_uses_only_parent_client_and_preserves_exact_saved_body() { + let (state, driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","status":{"type":"idle"},"ephemeral":false,"turns":[{"id":"child-turn","status":"completed","items":[{"id":"answer","type":"agentMessage","text":"Exact child answer"}]}]}})).await; + let observed = state.read_child("parent", "child", Some(0), Some(0)).await; + state.shutdown().await; + let reads = driver.await.unwrap(); + let observed = observed.expect("available parent-scoped native history"); + assert_eq!(observed["status"], "saved"); + assert_eq!(observed["parentThreadId"], "parent"); + assert_eq!(observed["childThreadId"], "child"); + assert_eq!(observed["snapshot"]["threadId"], "child"); + assert_eq!( + observed["snapshot"]["turns"][0]["items"][0]["text"], + "Exact child answer" + ); + for flag in [ + "send", + "interrupt", + "fork", + "approvals", + "questions", + "undo", + "redo", + ] { + assert_eq!(observed["snapshot"]["capabilities"][flag], false); + } + assert!(reads + .iter() + .all(|(method, _)| matches!(method.as_str(), "initialize" | "thread/read"))); + assert!(reads + .iter() + .any(|(_, params)| params["threadId"] == "child" && params["includeTurns"] == true)); + } + + #[tokio::test] + async fn child_read_rejects_unrelated_identity_without_reading_it() { + let (state, driver) = fixture(json!({})).await; + let result = state.read_child("parent", "unrelated", None, None).await; + state.shutdown().await; + let reads = driver.await.unwrap(); + assert!(matches!(result, Err(ChildReadError::UnrelatedChild))); + assert!(!reads + .iter() + .any(|(_, params)| params["threadId"] == "unrelated")); + } + + #[tokio::test] + async fn child_read_pending_materialization_is_bounded_and_ephemeral_is_terminal() { + for ephemeral in [false, true] { + let (state, driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","ephemeral":ephemeral,"status":{"type":"active"},"turns":[]}})).await; + for attempt in 1..=4 { + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + assert!(value.get("snapshot").is_none()); + if ephemeral { + assert_eq!(value["status"], "unavailable"); + assert_eq!(value["reason"], "ephemeral_history_unavailable"); + assert_eq!(value["retryable"], false); + } else if attempt < 3 { + assert_eq!(value["status"], "pending"); + assert_eq!(value["reason"], "history_unmaterialized"); + } else { + assert_eq!(value["status"], "unavailable"); + } + } + state.shutdown().await; + assert!(driver + .await + .unwrap() + .iter() + .all(|(method, _)| matches!(method.as_str(), "initialize" | "thread/read"))); + } + } + + #[tokio::test] + async fn child_read_ephemeral_metadata_refusal_has_no_empty_success_snapshot() { + let (state,driver) = fixture(json!({"_historyUnavailable":true,"thread":{"id":"child","parentThreadId":"parent","ephemeral":true,"status":{"type":"active"},"turns":[]}})).await; + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + assert_eq!(value["reason"], "ephemeral_history_unavailable"); + assert!(value.get("snapshot").is_none()); + assert_eq!(value["retryable"], false); + state.shutdown().await; + let reads = driver.await.unwrap(); + assert!(reads + .iter() + .any(|(_, params)| params["threadId"] == "child" && params["includeTurns"] == false)); + } + + #[tokio::test] + async fn child_read_unknown_only_history_does_not_return_an_empty_readable_snapshot() { + let (state,driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","status":{"type":"idle"},"turns":[{"id":"future-turn","items":[{"id":"future","type":"futureVariant"}]}]}})).await; + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + state.shutdown().await; + let _ = driver.await.unwrap(); + assert_eq!(value["status"], "unavailable"); + assert_eq!(value["reason"], "history_unavailable"); + assert!(value.get("snapshot").is_none()); + } + + #[tokio::test] + async fn child_read_temporary_source_failure_is_retryable_source_unavailable() { + let (state,driver) = fixture(json!({"_historyUnavailable":true,"_historyError":"temporary provider read failure","thread":{"id":"child","parentThreadId":"parent","ephemeral":false,"status":{"type":"active"},"turns":[]}})).await; + let result = state.read_child("parent", "child", None, None).await; + state.shutdown().await; + let _ = driver.await.unwrap(); + assert!(matches!(result,Err(ChildReadError::SourceUnavailable)),"temporary source failure must not become pending or permanently missing history: {result:?}"); + } + + #[tokio::test] + async fn child_read_native_summary_is_preview_separate_from_readable_availability() { + let (state,driver) = fixture(json!({"thread":{"id":"child","parentThreadId":"parent","status":{"type":"idle"},"turns":[{"id":"summary-turn","status":"interrupted","itemsView":"summary","items":[{"id":"preview","type":"agentMessage","summary":"Native child preview"}]}]}})).await; + let value = state + .read_child("parent", "child", None, None) + .await + .unwrap(); + state.shutdown().await; + let _ = driver.await.unwrap(); + assert_eq!(value["status"], "saved"); + assert_eq!( + value["snapshot"]["turns"][0]["items"][0]["text"], + "Native child preview" + ); + assert_eq!( + value["snapshot"]["turns"][0]["outputState"]["provenance"], + "preview" + ); + assert_eq!( + value["snapshot"]["turns"][0]["outputState"]["diagnostic"], + "interrupted" + ); + assert_eq!( + value["snapshot"]["turns"][0]["outputState"]["refreshPending"], + false + ); + } + + #[tokio::test] + async fn child_read_rejects_replaced_parent_source_during_paused_read() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, _) = CodexAppServerClient::connect(transport); + let (state, _) = super::super::tests::state_with_bus(); + state + .insert_session_for_test("parent", Arc::new(client), None) + .await; + let read = { + let state = state.clone(); + tokio::spawn(async move { state.read_child("parent", "child", None, None).await }) + }; + let (id, method, _) = tokio::time::timeout(Duration::from_secs(3), peer.expect_request()) + .await + .expect("parent initialization must be requested"); + assert_eq!(method, "initialize"); + peer.respond(&id,json!({"userAgent":"fixture","codexHome":"/owned","platformFamily":"unix","platformOs":"linux"})); + let _ = tokio::time::timeout(Duration::from_secs(3), peer.expect_notification()) + .await + .expect("initialized notification"); + let (id, method, params) = + tokio::time::timeout(Duration::from_secs(3), peer.expect_request()) + .await + .expect("parent history read"); + assert_eq!(method, "thread/read"); + assert_eq!(params["threadId"], "parent"); + peer.respond(&id, parent()); + let (id, method, params) = + tokio::time::timeout(Duration::from_secs(3), peer.expect_request()) + .await + .expect("child history read after parent membership"); + assert_eq!(method, "thread/read"); + assert_eq!(params["threadId"], "child"); + state + .sessions + .lock() + .await + .get_mut("parent") + .unwrap() + .projection = Arc::new(StdMutex::new(projection::CodexProjection::new( + "replacement".into(), + "parent".into(), + ))); + peer.respond(&id,json!({"thread":{"id":"child","parentThreadId":"parent","turns":[{"id":"t","items":[{"id":"a","type":"agentMessage","text":"Stale body"}]}]}})); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(3), read) + .await + .expect("child source refusal must settle") + .unwrap(), + Err(ChildReadError::SourceUnavailable) + )); + // ChannelTransport's reader owns the receive lock while awaiting the + // peer. End that owned peer before asking the client to close it. + drop(peer); + state.shutdown().await; + } +} diff --git a/crates/freshell-freshagent/src/codex/controls.rs b/crates/freshell-freshagent/src/codex/controls.rs index 9b12dde6f4..5db5033583 100644 --- a/crates/freshell-freshagent/src/codex/controls.rs +++ b/crates/freshell-freshagent/src/codex/controls.rs @@ -1,10 +1,34 @@ //! Translate Codex's bidirectional requests into the shared approval/question UI. use super::*; use freshell_codex::protocol::RequestId; -use freshell_protocol::{FreshAgentApprovalRespond, FreshAgentQuestionRespond, StringOrNumber}; +use freshell_protocol::{ + FreshAgentApprovalRespond, FreshAgentElicitationRespond, FreshAgentQuestionRespond, + StringOrNumber, +}; +use std::collections::HashSet; -pub(super) type ControlRegistry = - Arc>>>>; +pub(super) type ControlRegistry = Arc>>>; +type ControlSource = StdMutex; + +#[derive(Default)] +pub(super) struct SessionControls { + pending: TokioMutex, + child_turns: StdMutex>, + /// Actual unresolved reply-bearing native RPCs, independent of UI metadata. + awaited_control_count: std::sync::atomic::AtomicUsize, + last_waiting_at: std::sync::atomic::AtomicI64, + last_question_available_at: std::sync::atomic::AtomicI64, +} +struct ChildControlTurn { + turn_id: String, + native_client: Arc, + source: std::sync::Weak, +} +impl SessionControls { + async fn lock(&self) -> tokio::sync::MutexGuard<'_, PendingControls> { + self.pending.lock().await + } +} #[derive(Default)] pub(super) struct PendingControls { @@ -12,6 +36,16 @@ pub(super) struct PendingControls { /// The session's waiting-edge clock: each 0→≥1 pending transition rings /// `freshAgent.turn.waiting` at a strictly-monotonic `at`. waiting_clock: crate::attention::AttentionClock, + async_question_turn: Option, + async_question_ids: HashSet, + async_actions: HashMap<(String, String), i64>, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ControlKind { + Approval, + Question, + Elicitation, } struct PendingRequest { @@ -19,7 +53,9 @@ struct PendingRequest { method: String, params: Value, public: Value, - question: bool, + kind: ControlKind, + native_client: Arc, + source: std::sync::Weak, } fn request_key(id: &RequestId) -> String { @@ -32,12 +68,41 @@ fn response_key(id: &StringOrNumber) -> String { } } -fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option { - let key = request_key(&id); +fn normalize_request( + id: RequestId, + method: &str, + params: &Value, + native_client: Arc, + source: &Arc, +) -> Option { + // Native IDs can be reused after resolution or a sidecar restart. The public + // key identifies this operation; the original typed ID stays on its client. + let key = json!([id.to_json(), uuid::Uuid::new_v4().to_string()]).to_string(); let question = method == "item/tool/requestUserInput"; - let public = if question { + if method != "mcpServer/elicitation/request" + && !question + && params + .get("availableDecisions") + .and_then(Value::as_array) + .is_some_and(|choices| { + !choices.iter().any(|choice| { + choice.as_str().is_some_and(|value| { + matches!(value, "accept" | "acceptForSession" | "decline" | "cancel") + }) + }) + }) + { + return None; + } + let public = if method == "mcpServer/elicitation/request" { + let mut value = elicitation::normalize(params)?; + value["requestId"] = json!(key); + value + } else if question { let questions = params.get("questions")?.as_array()?.iter().filter_map(|entry| { let mut value = json!({ "id": entry.get("id")?.as_str()?, "question": entry.get("question")?.as_str()? }); + value["isOther"] = json!(entry.get("isOther").and_then(Value::as_bool).unwrap_or(false)); + value["isSecret"] = json!(entry.get("isSecret").and_then(Value::as_bool).unwrap_or(false)); if let Some(header) = entry.get("header").and_then(Value::as_str) { value["header"] = json!(header); } if let Some(options) = entry.get("options").and_then(Value::as_array) { value["options"] = json!(options.iter().filter_map(|option| Some(json!({ @@ -50,7 +115,11 @@ fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option ( @@ -66,7 +135,7 @@ fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option return None, }; - let mut value = json!({ "requestId": key, "toolName": tool, "input": input }); + let mut value = json!({ "requestId": key, "toolName": tool, "input": input, "native": params, "providerRequest": {"id": id.to_json(), "method": method} }); if let Some(item_id) = params.get("itemId").and_then(Value::as_str) { value["toolUseID"] = json!(item_id); } @@ -78,24 +147,203 @@ fn normalize_request(id: RequestId, method: &str, params: &Value) -> Option Value { + let mut event = request.public.clone(); + event["type"] = json!(match request.kind { + ControlKind::Approval => "freshAgent.permission.request", + ControlKind::Question => "freshAgent.question.request", + ControlKind::Elicitation => "freshAgent.elicitation.request", + }); + event["sessionId"] = json!(session_id); + if request.kind == ControlKind::Approval { + event["tool"] = json!({"name":request.public["toolName"],"input":request.public["input"]}); + } + event +} + impl FreshCodexState { - async fn session_controls(&self, session_id: &str) -> Arc> { + async fn session_controls(&self, session_id: &str) -> Arc { self.controls .lock() - .await + .expect("codex controls registry") .entry(session_id.into()) .or_default() .clone() } + pub(super) fn awaited_control_count(&self, session_id: &str) -> usize { + self.controls + .lock() + .expect("codex controls registry") + .get(session_id) + .map_or(0, |controls| { + controls.awaited_control_count.load(Ordering::SeqCst) + }) + } + + async fn publish_control_membership( + &self, + session_id: &str, + controls: &SessionControls, + pending: &PendingControls, + ) { + controls + .awaited_control_count + .store(pending.requests.len(), Ordering::SeqCst); + if pending.requests.is_empty() { + controls.last_waiting_at.store(0, Ordering::SeqCst); + self.attention.clear_waiting(session_id); + } + if let Some((_, quiet, in_flight, _, _)) = self.quiet_handles(session_id).await { + note_codex_activity(self, session_id, &in_flight, &quiet); + } + } + + pub(super) async fn contextual_answer_targets( + &self, + session_id: &str, + text: &str, + ) -> HashMap<(String, String), i64> { + let keys = text + .lines() + .filter_map(|line| { + let value: Value = + serde_json::from_str(line.strip_prefix("Answer to Codex question: ")?).ok()?; + value["question"].as_str()?; + value["answer"].as_str()?; + Some(( + value["itemId"].as_str()?.to_owned(), + value["questionId"].as_str()?.to_owned(), + )) + }) + .collect::>(); + if keys.is_empty() { + return HashMap::new(); + } + let controls = self + .controls + .lock() + .expect("codex controls registry") + .get(session_id) + .cloned(); + let Some(controls) = controls else { + return HashMap::new(); + }; + let pending = controls.lock().await; + keys.into_iter() + .filter_map(|key| pending.async_actions.get(&key).map(|at| (key, *at))) + .collect() + } + + pub(super) async fn acknowledge_contextual_answers( + &self, + session_id: &str, + targets: HashMap<(String, String), i64>, + ) { + if targets.is_empty() { + return; + } + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + pending + .async_actions + .retain(|key, at| targets.get(key) != Some(at)); + let available = pending.async_actions.values().copied().max().unwrap_or(0); + controls + .last_question_available_at + .store(available, Ordering::SeqCst); + self.notify_retained(); + } + + /// Ready-frame marks read retained runtime state only. They never hydrate or resume native history. + pub async fn attention_marks(&self) -> Vec { + let sessions = self + .sessions + .lock() + .await + .iter() + .map(|(id, session)| (id.clone(), session.last_turn_complete_at.clone())) + .collect::>(); + let controls = self.controls.lock().expect("codex controls registry"); + let mut marks: std::collections::BTreeMap< + String, + freshell_protocol::FreshAgentAttentionMark, + > = self + .attention + .snapshot() + .into_iter() + .map(|mark| { + let id = mark.session_id.clone(); + ( + id, + freshell_protocol::FreshAgentAttentionMark { + provider: PROVIDER.into(), + session_type: mark.session_type, + session_id: mark.session_id, + last_turn_complete_at: mark.last_turn_complete_at, + last_waiting_at: mark.last_waiting_at, + last_question_available_at: None, + }, + ) + }) + .collect(); + for (id, complete) in sessions { + let mark = marks.entry(id.clone()).or_insert_with(|| { + freshell_protocol::FreshAgentAttentionMark { + provider: PROVIDER.into(), + session_type: SESSION_TYPE.into(), + session_id: id, + last_turn_complete_at: None, + last_waiting_at: None, + last_question_available_at: None, + } + }); + mark.last_turn_complete_at = mark + .last_turn_complete_at + .max(*complete.lock().expect("attention complete clock")); + } + for (id, mark) in &mut marks { + if let Some(retained) = controls.get(id) { + let waiting = retained.last_waiting_at.load(Ordering::SeqCst); + let available = retained.last_question_available_at.load(Ordering::SeqCst); + mark.last_waiting_at = (waiting > 0).then_some(waiting); + mark.last_question_available_at = (available > 0).then_some(available); + } + } + marks.into_values().collect() + } + fn control_event(&self, session_id: &str, event: Value) { self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { provider: PROVIDER.into(), @@ -107,8 +355,8 @@ impl FreshCodexState { fn cancelled_control(&self, session_id: &str, request: &PendingRequest) { self.control_event(session_id, json!({ - "type": if request.question { "freshAgent.question.cancelled" } else { "freshAgent.permission.cancelled" }, - "sessionId": session_id, "requestId": request_key(&request.id), + "type": match request.kind { ControlKind::Question => "freshAgent.question.cancelled", ControlKind::Approval => "freshAgent.permission.cancelled", ControlKind::Elicitation => "freshAgent.elicitation.cancelled" }, + "sessionId": session_id, "requestId": request.public["requestId"], })); } @@ -118,6 +366,41 @@ impl FreshCodexState { for request in pending.requests.drain(..) { self.cancelled_control(session_id, &request); } + controls + .child_turns + .lock() + .expect("child control turns") + .clear(); + self.publish_control_membership(session_id, &controls, &pending) + .await; + } + + /// Retire only operations owned by this exact source. A late old consumer + /// cannot cancel a replacement incarnation's controls with reused IDs. + pub(super) async fn clear_source_controls( + &self, + session_id: &str, + source: &Arc, + ) { + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + let source = Arc::downgrade(source); + pending.requests.retain(|request| { + if std::sync::Weak::ptr_eq(&request.source, &source) { + self.cancelled_control(session_id, request); + false + } else { + true + } + }); + controls + .child_turns + .lock() + .expect("child control turns") + .retain(|_, child| !std::sync::Weak::ptr_eq(&child.source, &source)); + self.publish_control_membership(session_id, &controls, &pending) + .await; + self.notify_retained(); } pub(super) async fn overlay_controls(&self, session_id: &str, snapshot: &mut Value) { @@ -125,149 +408,454 @@ impl FreshCodexState { snapshot["capabilities"]["questions"] = json!(true); let controls = self.session_controls(session_id).await; let pending = controls.lock().await; + snapshot["pendingElicitations"] = json!(pending + .requests + .iter() + .filter(|r| r.kind == ControlKind::Elicitation) + .map(|r| &r.public) + .collect::>()); snapshot["pendingApprovals"] = json!(pending .requests .iter() - .filter(|r| !r.question) + .filter(|r| r.kind == ControlKind::Approval) .map(|r| &r.public) .collect::>()); snapshot["pendingQuestions"] = json!(pending .requests .iter() - .filter(|r| r.question) + .filter(|r| r.kind == ControlKind::Question) .map(|r| &r.public) .collect::>()); } + /// Authoritative retained controls for bridge loss reconciliation; never reads/resumes native history. + pub async fn retained_control_events(&self, session_id: &str) -> Vec { + let controls = self + .controls + .lock() + .expect("codex controls registry") + .get(session_id) + .cloned(); + let Some(controls) = controls else { + return Vec::new(); + }; + let pending = controls.lock().await; + pending + .requests + .iter() + .map(|request| control_request_event(session_id, request)) + .collect() + } + pub(super) async fn consume_control_notification( &self, session_id: &str, notification: &CodexNotification, + source: &Arc>, ) -> bool { + let client = self + .sessions + .lock() + .await + .get(session_id) + .filter(|session| Arc::ptr_eq(&session.projection, source)) + .map(|session| session.client.clone()); + let Some(client) = client else { + return matches!(notification, CodexNotification::ServerRequest { .. }); + }; match notification { CodexNotification::ServerRequest { id, method, params } => { - let client = self - .sessions - .lock() - .await - .get(session_id) - .map(|s| s.client.clone()); - if params.get("threadId").and_then(Value::as_str) != Some(session_id) { - if let Some(client) = client { - let _ = client - .reject_request(id, "Request does not belong to this conversation") - .await; - } + let scope_matches = self + .control_scope_matches(session_id, params, &client, source, false) + .await; + if !scope_matches { + let _ = client.reject_request(id, "Request does not belong to this native conversation or active child turn").await; + self.emit_fresh_agent_error( + session_id, + "REQUEST_UNAVAILABLE", + "This Codex request no longer belongs to the active native conversation or child turn.", + ); return true; } - let Some(request) = normalize_request(id.clone(), method, params) else { + let Some(mut request) = + normalize_request(id.clone(), method, params, client.clone(), source) + else { tracing::warn!( provider = PROVIDER, session_id, method, + request_id = %request_key(id), + native_turn_id = params.get("turnId").and_then(|value| value.as_str()), + native_item_id = params.get("itemId").and_then(|value| value.as_str()), "freshagent.codex.unsupported_request" ); - if let Some(client) = client { + if method == "mcpServer/elicitation/request" { + let _ = client + .respond(id, json!({"action":"decline","content":null})) + .await; + } else { let _ = client .reject_request(id, "This Codex request is not supported by Freshell") .await; } self.emit_fresh_agent_error( session_id, - "UNSUPPORTED_CAPABILITY", - "Codex requested an unsupported action. Try continuing with a message.", + if method == "mcpServer/elicitation/request" {"UNSUPPORTED_ELICITATION"} else {"UNSUPPORTED_CAPABILITY"}, + &format!("Codex requested an unsupported action ({}). Freshell supports flat primitive and enum forms; nested forms and verification actions must be completed outside this form. Try continuing with a message.", params.get("mode").and_then(Value::as_str).unwrap_or(method)), ); return true; }; let controls = self.session_controls(session_id).await; let mut pending = controls.lock().await; let was_empty = pending.requests.is_empty(); - pending.requests.retain(|r| r.id != request.id); - let event = if request.question { - json!({ "type": "freshAgent.question.request", "sessionId": session_id, "requestId": request_key(id), "questions": request.public["questions"] }) - } else { - json!({ "type": "freshAgent.permission.request", "sessionId": session_id, "requestId": request_key(id), "tool": { "name": request.public["toolName"], "input": request.public["input"] } }) - }; + if let Some(index) = pending.requests.iter().position(|r| r.id == request.id) { + let previous = pending.requests.remove(index); + if previous.method == request.method + && previous.params == request.params + && Arc::ptr_eq(&previous.native_client, &request.native_client) + && std::sync::Weak::ptr_eq(&previous.source, &request.source) + { + // Repeated native delivery and snapshot replay refer to + // the same unresolved operation, including its public key. + request.public["requestId"] = previous.public["requestId"].clone(); + } else { + self.cancelled_control(session_id, &previous); + } + } + let event = control_request_event(session_id, &request); pending.requests.push(request); + self.publish_control_membership(session_id, &controls, &pending) + .await; self.control_event(session_id, event); if was_empty { let at = pending.waiting_clock.mint(); + controls.last_waiting_at.store(at, Ordering::SeqCst); + self.attention.record_waiting(session_id, SESSION_TYPE, at); self.control_event(session_id, json!({ "type": "freshAgent.turn.waiting", "sessionId": session_id, "at": at })); } tracing::info!( provider = PROVIDER, session_id, method, + request_id = %request_key(id), + native_turn_id = params.get("turnId").and_then(|value| value.as_str()), + native_item_id = params.get("itemId").and_then(|value| value.as_str()), + awaited_control_count = pending.requests.len(), "freshagent.codex.request_pending" ); true } + CodexNotification::Other { + method, + params: Some(params), + } if method == "item/completed" + && params["threadId"] == session_id + && params["item"]["type"] == "agentMessage" + && params["item"]["delivery"] == "async" => + { + let item = ¶ms["item"]; + let (Some(item_id), Some(turn_id), Some(questions)) = ( + item["id"].as_str(), + params["turnId"].as_str(), + item["questions"].as_array(), + ) else { + return false; + }; + if questions.is_empty() + || !self + .control_scope_matches(session_id, params, &client, source, true) + .await + { + return false; + } + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + if pending.async_question_turn.as_deref() != Some(turn_id) { + pending.async_question_ids.clear(); + pending.async_question_turn = Some(turn_id.into()); + } + if pending.async_question_ids.insert(item_id.into()) { + let at = pending.waiting_clock.mint(); + for (index, question) in questions.iter().enumerate() { + if question["title"].is_string() { + let id = question["id"] + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| index.to_string()); + pending.async_actions.insert((item_id.to_owned(), id), at); + } + } + controls + .last_question_available_at + .store(at, Ordering::SeqCst); + self.control_event(session_id,json!({"type":"freshAgent.question.available","sessionId":session_id,"itemId":item_id,"turnId":turn_id,"awaited":false,"at":at})); + } + false + } CodexNotification::Other { method, params } if method == "serverRequest/resolved" => { if let Some(params) = params { - if params.get("threadId").and_then(Value::as_str) == Some(session_id) { + if self + .control_scope_matches(session_id, params, &client, source, false) + .await + { let controls = self.session_controls(session_id).await; let mut pending = controls.lock().await; let id = params.get("requestId"); pending.requests.retain(|request| { - if id == Some(&request.id.to_json()) { + if id == Some(&request.id.to_json()) + && params["threadId"] == request.params["threadId"] + { self.cancelled_control(session_id, request); false } else { true } }); + self.publish_control_membership(session_id, &controls, &pending) + .await; } } true } - CodexNotification::TurnCompleted(event) if event.thread_id == session_id => { + CodexNotification::TurnCompleted(event) + if (event.thread_id == session_id + || self + .control_scope_matches( + session_id, + &json!({"threadId":event.thread_id,"turnId":event.turn_id}), + &client, + source, + false, + ) + .await) + && event + .params + .get("status") + .or_else(|| event.params.get("turn").and_then(|turn| turn.get("status"))) + .and_then(Value::as_str) + != Some("inProgress") => + { let controls = self.session_controls(session_id).await; let mut pending = controls.lock().await; pending.requests.retain(|request| { - if event.turn_id.as_deref().is_none_or(|id| { - request.params.get("turnId").and_then(Value::as_str) == Some(id) - }) { + if request.params["threadId"] == event.thread_id + && event.turn_id.as_deref().is_none_or(|id| { + request.params.get("turnId").and_then(Value::as_str) == Some(id) + }) + { self.cancelled_control(session_id, request); false } else { true } }); + if event.thread_id != session_id { + controls + .child_turns + .lock() + .expect("child control turns") + .remove(&event.thread_id); + } + self.publish_control_membership(session_id, &controls, &pending) + .await; false } CodexNotification::ThreadClosed { thread_id } if thread_id == session_id => { self.clear_controls(session_id).await; false } + CodexNotification::ThreadClosed { thread_id } + if self + .control_scope_matches( + session_id, + &json!({"threadId":thread_id}), + &client, + source, + false, + ) + .await => + { + let controls = self.session_controls(session_id).await; + let mut pending = controls.lock().await; + pending.requests.retain(|request| { + if request.params["threadId"] == *thread_id { + self.cancelled_control(session_id, request); + false + } else { + true + } + }); + controls + .child_turns + .lock() + .expect("child control turns") + .remove(thread_id); + self.publish_control_membership(session_id, &controls, &pending) + .await; + false + } _ => false, } } + /// Observe child lifecycle before the consumer's turn/started fast path. + /// Its native spawn, current source/client and actual child turn are required. + pub(super) async fn observe_child_control_turn( + &self, + session_id: &str, + source: &Arc>, + notification: &CodexNotification, + ) { + let CodexNotification::TurnStarted(event) = notification else { + return; + }; + if event.thread_id == session_id { + return; + } + let Some(turn_id) = &event.turn_id else { + return; + }; + let client = self + .sessions + .lock() + .await + .get(session_id) + .filter(|session| Arc::ptr_eq(&session.projection, source)) + .map(|session| session.client.clone()); + let Some(client) = client else { + return; + }; + if !source + .lock() + .expect("codex projection lock") + .knows_child_thread(&event.thread_id) + { + return; + } + let controls = self.session_controls(session_id).await; + controls + .child_turns + .lock() + .expect("child control turns") + .insert( + event.thread_id.clone(), + ChildControlTurn { + turn_id: turn_id.clone(), + native_client: client, + source: Arc::downgrade(source), + }, + ); + } + + async fn control_scope_matches( + &self, + session_id: &str, + params: &Value, + native_client: &Arc, + source: &Arc, + allow_retired: bool, + ) -> bool { + let sessions = self.sessions.lock().await; + let Some(session) = sessions.get(session_id) else { + return false; + }; + if session.exited.load(Ordering::SeqCst) + || source + .lock() + .expect("codex projection lock") + .source_retired() + || !Arc::ptr_eq(&session.client, native_client) + || !Arc::ptr_eq(&session.projection, source) + { + return false; + } + let Some(thread_id) = params.get("threadId").and_then(Value::as_str) else { + return false; + }; + let turn_id = params.get("turnId").and_then(Value::as_str); + if thread_id != session_id { + let controls = self + .controls + .lock() + .expect("codex controls registry") + .get(session_id) + .cloned(); + return controls.is_some_and(|controls| { + controls + .child_turns + .lock() + .expect("child control turns") + .get(thread_id) + .is_some_and(|child| { + Arc::ptr_eq(&child.native_client, native_client) + // The native relationship was proved at child start. + // Display eviction must not erase that proof, and a + // weak reference must not retain old display payloads. + && child.source.upgrade().is_some_and(|proof| Arc::ptr_eq(&proof, source)) + && turn_id.is_none_or(|id| child.turn_id == id) + }) + }); + } + let Some(turn_id) = turn_id else { + return true; + }; + let active_matches = + session.active_turn.lock().expect("active turn").as_deref() == Some(turn_id); + if active_matches { + return true; + } + // Drop the active-turn guard before consulting the retired slot. Async + // content can remain actionable there; awaited RPCs require a live turn. + allow_retired + && session + .retired_turn_id + .lock() + .expect("retired turn") + .as_deref() + == Some(turn_id) + } + pub async fn handle_approval_respond(&self, msg: FreshAgentApprovalRespond) { - let allow = match msg.decision.get("behavior").and_then(Value::as_str) { - Some("allow") => true, - Some("deny") => false, - _ => { - self.emit_fresh_agent_error( - &msg.session_id, - "INVALID_DECISION", - "Choose Allow or Deny for the Codex request.", - ); - return; + self.try_handle_approval_respond(msg).await; + } + + pub async fn try_handle_approval_respond(&self, msg: FreshAgentApprovalRespond) -> bool { + self.respond_control(&msg.session_id, &response_key(&msg.request_id), ControlKind::Approval, |request| { + let decision = msg.decision.get("nativeDecision").and_then(Value::as_str).or_else(|| { + match msg.decision.get("behavior").and_then(Value::as_str) { + Some("allow") => Some("accept"), Some("deny") => Some("decline"), _ => None, + } + }).ok_or("Choose an offered decision for this Codex request.")?; + if request.method == "item/permissions/requestApproval" { + return match decision { + "accept" => Ok(json!({"permissions":request.params["permissions"], "scope":"turn"})), + "decline" => Ok(json!({"permissions":{}, "scope":"turn"})), + _ => Err("This permission request supports allowing or declining the requested permissions."), + }; } - }; - self.respond_control(&msg.session_id, &response_key(&msg.request_id), false, |request| { - Ok(if request.method == "item/permissions/requestApproval" { - json!({ "permissions": if allow { request.params["permissions"].clone() } else { json!({}) }, "scope": "turn" }) - } else { json!({ "decision": if allow { "accept" } else { "decline" } }) }) - }).await; + if !matches!(decision, "accept" | "acceptForSession" | "decline" | "cancel") { + return Err("This native approval decision is not supported."); + } + if let Some(offered) = request.params.get("availableDecisions").and_then(Value::as_array) { + if !offered.iter().any(|value| value.as_str() == Some(decision)) { + return Err("Codex did not offer this approval decision."); + } + } else if decision == "acceptForSession" { + return Err("Codex did not offer session approval."); + } + Ok(json!({"decision":decision})) + }).await } pub async fn handle_question_respond(&self, msg: FreshAgentQuestionRespond) { + self.try_handle_question_respond(msg).await; + } + + pub async fn try_handle_question_respond(&self, msg: FreshAgentQuestionRespond) -> bool { self.respond_control( &msg.session_id, &response_key(&msg.request_id), - true, + ControlKind::Question, |request| { let mut answers = Map::new(); for question in request.public["questions"] @@ -275,25 +863,48 @@ impl FreshCodexState { .expect("normalized questions") { let id = question["id"].as_str().expect("normalized id"); - let text = question["question"].as_str().expect("normalized question"); - let Some(answer) = msg.answers.get(id).or_else(|| msg.answers.get(text)) else { + let Some(answer) = msg.answers.get(id) else { return Err("Answer every question before submitting."); }; + if question["isOther"] == false + && question["options"].as_array().is_some_and(|options| { + !options.is_empty() + && !options + .iter() + .any(|option| option["label"].as_str() == Some(answer)) + }) + { + return Err("Choose one of the options Codex offered."); + } answers.insert(id.into(), json!({ "answers": [answer] })); } Ok(json!({ "answers": answers })) }, ) - .await; + .await + } + + pub async fn handle_elicitation_respond(&self, msg: FreshAgentElicitationRespond) { + self.try_handle_elicitation_respond(msg).await; + } + + pub async fn try_handle_elicitation_respond(&self, msg: FreshAgentElicitationRespond) -> bool { + self.respond_control( + &msg.session_id, + &response_key(&msg.request_id), + ControlKind::Elicitation, + |request| elicitation::response(&request.params, &msg.action, &msg.content), + ) + .await } async fn respond_control( &self, session_id: &str, key: &str, - question: bool, + kind: ControlKind, build: impl FnOnce(&PendingRequest) -> Result, - ) { + ) -> bool { let client = self .sessions .lock() @@ -306,7 +917,7 @@ impl FreshCodexState { "INVALID_SESSION_ID", "Codex session is no longer available.", ); - return; + return false; }; // Keep membership until the transport write succeeds; duplicate clicks cannot // answer twice and a failed write leaves the banner available for retry. @@ -315,39 +926,70 @@ impl FreshCodexState { let Some(index) = pending .requests .iter() - .position(|r| request_key(&r.id) == key && r.question == question) + .position(|r| r.public["requestId"].as_str() == Some(key) && r.kind == kind) else { self.emit_fresh_agent_error( session_id, "REQUEST_UNAVAILABLE", "This Codex request is no longer waiting for an answer.", ); - return; + return false; }; let request = &pending.requests[index]; + let Some(source) = request.source.upgrade() else { + self.emit_fresh_agent_error( + session_id, + "REQUEST_UNAVAILABLE", + "This Codex request belongs to an earlier native source.", + ); + return false; + }; + if !Arc::ptr_eq(&client, &request.native_client) + || !self + .control_scope_matches( + session_id, + &request.params, + &request.native_client, + &source, + false, + ) + .await + { + self.emit_fresh_agent_error( + session_id, + "REQUEST_UNAVAILABLE", + "This Codex request belongs to an earlier native session or turn.", + ); + return false; + } let result = match build(request) { Ok(value) => value, Err(message) => { + tracing::warn!(provider=PROVIDER,session_id,request_id=%key,method=%request.method,native_turn_id=request.params.get("turnId").and_then(|value| value.as_str()),native_item_id=request.params.get("itemId").and_then(|value| value.as_str()),"freshagent.codex.invalid_control_answer"); self.emit_fresh_agent_error(session_id, "INVALID_ANSWER", message); - return; + return false; } }; - if let Err(error) = client.respond(&request.id, result).await { - tracing::warn!(provider = PROVIDER, session_id, error = %error, "freshagent.codex.respond_failed"); + if let Err(error) = request.native_client.respond(&request.id, result).await { + tracing::warn!(provider = PROVIDER, session_id, request_id = %key, error = %error, "freshagent.codex.respond_failed"); self.emit_fresh_agent_error( session_id, "RESPONSE_FAILED", &format!("Could not send your answer to Codex: {error}"), ); - return; + return false; } let request = pending.requests.remove(index); + self.publish_control_membership(session_id, &controls, &pending) + .await; self.cancelled_control(session_id, &request); tracing::info!( provider = PROVIDER, session_id, + request_id = %key, "freshagent.codex.request_answered" ); + true } } diff --git a/crates/freshell-freshagent/src/codex/controls/tests.rs b/crates/freshell-freshagent/src/codex/controls/tests.rs index 7c79422d09..0ee631fcd2 100644 --- a/crates/freshell-freshagent/src/codex/controls/tests.rs +++ b/crates/freshell-freshagent/src/codex/controls/tests.rs @@ -6,15 +6,26 @@ async fn runtime() -> ( FreshCodexState, ChannelPeer, tokio::sync::broadcast::Receiver, +) { + runtime_for("thread-1", "turn-1").await +} + +async fn runtime_for( + session_id: &str, + turn_id: &str, +) -> ( + FreshCodexState, + ChannelPeer, + tokio::sync::broadcast::Receiver, ) { let (transport, peer) = freshell_codex::new_channel_transport(); let (client, notifications) = CodexAppServerClient::connect(transport); let (state, rx) = state_with_bus(); insert_fake_session_with_real_consumer( &state, - "thread-1", + session_id, Arc::new(client), - Arc::new(StdMutex::new(Some("turn-1".into()))), + Arc::new(StdMutex::new(Some(turn_id.into()))), notifications, spawn_sleeper(), "codex-controls-fixture", @@ -23,6 +34,419 @@ async fn runtime() -> ( (state, peer, rx) } +fn task001_child_control_frames() -> Vec { + serde_json::from_str::(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/fresh-agent/codex/task001-child-approval.json" + ))) + .unwrap()["frames"] + .as_array() + .unwrap() + .clone() +} + +async fn child_control_runtime() -> ( + FreshCodexState, + ChannelPeer, + tokio::sync::broadcast::Receiver, + Vec, +) { + let frames = task001_child_control_frames(); + let (state, peer, rx) = runtime_for( + frames[0]["params"]["threadId"].as_str().unwrap(), + frames[0]["params"]["turnId"].as_str().unwrap(), + ) + .await; + for frame in &frames[..2] { + peer.emit_notification(frame["method"].as_str().unwrap(), frame["params"].clone()); + } + (state, peer, rx, frames) +} + +async fn child_approval_event(rx: &mut tokio::sync::broadcast::Receiver) -> Value { + loop { + let next = event(rx).await; + if next["type"] == "freshAgent.permission.request" { + return next; + } + } +} + +#[tokio::test] +async fn task5_actual_parent_owned_child_approval_uses_parent_client_without_a_child_writer() { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + let child = frames[2]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = tokio::select! { + request = child_approval_event(&mut rx) => request, + frame = peer.next_raw_frame() => panic!("projection-proven child approval was refused: {frame:?}"), + }; + assert_eq!(request["sessionId"], parent); + assert_eq!(request["providerRequest"]["id"], 0); + assert_eq!(request["providerRequest"]["threadId"], child); + let mut snapshot = json!({"capabilities":{}}); + state.overlay_controls(parent, &mut snapshot).await; + assert_eq!( + snapshot["pendingApprovals"][0]["requestId"], + request["requestId"] + ); + assert_eq!(state.awaited_control_count(parent), 1); + { + let sessions = state.sessions.lock().await; + assert_eq!(sessions.len(), 1); + assert!(!sessions.contains_key(child)); + assert_eq!( + sessions[parent].active_turn.lock().unwrap().as_deref(), + Some(parent_turn) + ); + } + assert!(state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":0,"result":{"decision":"cancel"}}) + ); + assert!(state.retained_control_events(parent).await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_child_control_still_refuses_unrelated_threads_and_wrong_native_child_turns() { + for invalid_thread in [true, false] { + let (state, peer, _rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let mut params = frames[2]["params"].clone(); + params[if invalid_thread { "threadId" } else { "turnId" }] = + json!("unrelated-native-identity"); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + params, + ); + assert!(peer.next_raw_frame().await.unwrap().get("error").is_some()); + assert!(state.retained_control_events(parent).await.is_empty()); + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_child_control_outlives_parent_terminal_and_keeps_its_native_owner() { + // This is a deliberate interleaving, not the capture's completion ordering. + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = child_approval_event(&mut rx).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":parent,"turn":{"id":parent_turn,"status":"completed","items":[]}}), + ); + loop { + let next = event(&mut rx).await; + assert_ne!(next["type"], "freshAgent.permission.cancelled"); + if next["type"] == "freshAgent.turn.complete" { + break; + } + } + assert_eq!(state.awaited_control_count(parent), 1); + assert_eq!( + state.retained_control_events(parent).await[0]["requestId"], + request["requestId"] + ); + assert!(state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":0,"result":{"decision":"cancel"}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_child_resolution_and_terminal_only_cancel_the_matching_native_thread() { + for finish in ["serverRequest/resolved", "turn/completed", "thread/closed"] { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + let child = frames[2]["params"]["threadId"].as_str().unwrap(); + let child_turn = frames[2]["params"]["turnId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let child_request = child_approval_event(&mut rx).await; + peer.request_client( + &RequestId::Int(301), + "item/commandExecution/requestApproval", + json!({"threadId":parent,"turnId":parent_turn,"command":"parent fixture"}), + ); + let parent_request = child_approval_event(&mut rx).await; + peer.emit_notification(finish, match finish { + "serverRequest/resolved" => json!({"threadId":child,"requestId":0}), + "turn/completed" => json!({"threadId":child,"turn":{"id":child_turn,"status":"completed","items":[]}}), + _ => json!({"threadId":child}), + }); + loop { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.permission.cancelled" { + assert_eq!(next["requestId"], child_request["requestId"]); + break; + } + } + let retained = state.retained_control_events(parent).await; + assert_eq!(retained.len(), 1); + assert_eq!(retained[0]["requestId"], parent_request["requestId"]); + assert_eq!(state.awaited_control_count(parent), 1); + assert_eq!( + state.sessions.lock().await[parent] + .active_turn + .lock() + .unwrap() + .as_deref(), + Some(parent_turn) + ); + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_parent_source_close_drains_child_and_parent_controls_once() { + for disconnect in [false, true] { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let child_request = child_approval_event(&mut rx).await; + peer.request_client( + &RequestId::Int(301), + "item/commandExecution/requestApproval", + json!({"threadId":parent,"turnId":parent_turn,"command":"parent fixture"}), + ); + let parent_request = child_approval_event(&mut rx).await; + let remaining_peer = if disconnect { + peer.disconnect(); + None + } else { + peer.emit_notification("thread/closed", json!({"threadId":parent})); + Some(peer) + }; + let mut cancelled = Vec::new(); + while cancelled.len() < 2 { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.permission.cancelled" { + cancelled.push(next["requestId"].clone()); + } + } + assert!(cancelled.contains(&child_request["requestId"])); + assert!(cancelled.contains(&parent_request["requestId"])); + assert_eq!(state.awaited_control_count(parent), 0); + assert!(state.retained_control_events(parent).await.is_empty()); + state.clear_controls(parent).await; + while let Ok(frame) = rx.try_recv() { + let frame: Value = serde_json::from_str(&frame).unwrap(); + assert_ne!(frame["event"]["type"], "freshAgent.permission.cancelled"); + } + if let Some(peer) = remaining_peer { + peer.disconnect(); + } + tokio::time::timeout(Duration::from_secs(3), state.shutdown()) + .await + .expect("owned channel fixture teardown must complete"); + } +} + +#[tokio::test] +async fn task5_child_relationship_requires_the_exact_native_parent_sender() { + let mut frames = task001_child_control_frames(); + let parent = frames[0]["params"]["threadId"].as_str().unwrap().to_owned(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap().to_owned(); + frames[0]["params"]["item"]["senderThreadId"] = json!("unrelated-parent"); + let (state, peer, _rx) = runtime_for(&parent, &parent_turn).await; + for frame in &frames[..2] { + peer.emit_notification(frame["method"].as_str().unwrap(), frame["params"].clone()); + } + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + assert!(peer.next_raw_frame().await.unwrap().get("error").is_some()); + assert!(state.retained_control_events(&parent).await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_child_control_survives_real_parent_display_eviction_without_restoring_rows() { + // Deliberate bounded continuation after the captured relationship, not a + // claim that the Task001 capture contains forty subsequent parent turns. + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + let parent_turn = frames[0]["params"]["turnId"].as_str().unwrap(); + let child = frames[2]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = child_approval_event(&mut rx).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":parent,"turn":{"id":parent_turn,"status":"completed","items":[]}}), + ); + loop { + if event(&mut rx).await["type"] == "freshAgent.turn.complete" { + break; + } + } + for index in 0..40 { + let turn = format!("later-parent-turn-{index}"); + peer.emit_notification( + "turn/started", + json!({"threadId":parent,"turn":{"id":turn,"status":"inProgress","items":[]}}), + ); + peer.emit_notification("item/completed", json!({"threadId":parent,"turnId":turn,"item":{"id":format!("later-parent-item-{index}"),"type":"agentMessage","text":"Parent continuation"}})); + peer.emit_notification( + "turn/completed", + json!({"threadId":parent,"turn":{"id":turn,"status":"completed","items":[]}}), + ); + loop { + let next = event(&mut rx).await; + assert_ne!(next["type"], "freshAgent.permission.cancelled"); + if next["type"] == "freshAgent.turn.complete" { + break; + } + } + } + let source = state.sessions.lock().await[parent].projection.clone(); + assert!( + !source.lock().unwrap().knows_child_thread(child), + "the actual projection must have evicted the old spawn payload" + ); + assert_eq!( + state.retained_control_events(parent).await[0]["requestId"], + request["requestId"] + ); + assert!(state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await, "display retention must not invalidate the still-awaited child control"); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":0,"result":{"decision":"cancel"}}) + ); + assert!( + !source.lock().unwrap().knows_child_thread(child), + "answering must not revive the evicted display row" + ); + assert_eq!(state.sessions.lock().await.len(), 1); + peer.disconnect(); + state.shutdown().await; +} + +async fn replace_child_control_source( + state: &FreshCodexState, + parent: &str, + frames: &[Value], +) -> Arc> { + let source = Arc::new(StdMutex::new(projection::CodexProjection::new( + "replacement-parent-source".into(), + parent.into(), + ))); + source + .lock() + .unwrap() + .apply(frames[0]["method"].as_str().unwrap(), &frames[0]["params"]) + .unwrap(); + state + .sessions + .lock() + .await + .get_mut(parent) + .unwrap() + .projection = source.clone(); + let child_start = freshell_codex::protocol::classify_notification( + frames[1]["method"].as_str().unwrap(), + Some(&frames[1]["params"]), + ); + state + .observe_child_control_turn(parent, &source, &child_start) + .await; + source +} + +#[tokio::test] +async fn task5_old_child_answer_cannot_cross_projection_replacement_with_reused_native_scope() { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let request = child_approval_event(&mut rx).await; + let source = replace_child_control_source(&state, parent, &frames).await; + assert!(source + .lock() + .unwrap() + .knows_child_thread(frames[2]["params"]["threadId"].as_str().unwrap())); + assert!(!state.try_handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":parent,"requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await, "the old operation must stay bound to its original projection even if a new source proves the same child IDs"); + assert_eq!(state.retained_control_events(parent).await.len(), 1); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err() + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_reissued_child_rpc_on_new_projection_gets_a_new_public_operation_key() { + let (state, peer, mut rx, frames) = child_control_runtime().await; + let parent = frames[0]["params"]["threadId"].as_str().unwrap(); + peer.request_client( + &RequestId::Int(0), + frames[2]["method"].as_str().unwrap(), + frames[2]["params"].clone(), + ); + let original = child_approval_event(&mut rx).await; + let source = replace_child_control_source(&state, parent, &frames).await; + let notification = CodexNotification::ServerRequest { + id: RequestId::Int(0), + method: frames[2]["method"].as_str().unwrap().into(), + params: frames[2]["params"].clone(), + }; + assert!( + state + .consume_control_notification(parent, ¬ification, &source) + .await + ); + let current = state.retained_control_events(parent).await; + assert_eq!(current.len(), 1); + assert_ne!( + current[0]["requestId"], original["requestId"], + "source replacement must not reuse the old public answer key" + ); + assert_eq!(current[0]["providerRequest"]["id"], 0); + peer.disconnect(); + state.shutdown().await; +} + async fn event(rx: &mut tokio::sync::broadcast::Receiver) -> Value { let frame = tokio::time::timeout(Duration::from_secs(3), rx.recv()) .await @@ -273,3 +697,1142 @@ async fn codex_user_controls_failed_response_keeps_request_available() { assert_eq!(pending["pendingApprovals"].as_array().unwrap().len(), 1); state.shutdown().await; } + +#[tokio::test] +async fn codex_question_keeps_secret_other_and_blocking_flags_on_reload() { + let (state, peer, mut rx) = runtime().await; + peer.request_client( + &RequestId::Int(51), + "item/tool/requestUserInput", + json!({ + "threadId":"thread-1","turnId":"turn-1","itemId":"ask-1","isBlocking":false, + "questions":[{"id":"token","header":"Token","question":"Enter token", + "options":null,"isOther":false,"isSecret":true}] + }), + ); + let request = event(&mut rx).await; + assert_eq!(request["questions"][0]["isSecret"], true); + assert_eq!(request["isBlocking"], false); + let pending = snapshot(&state, &peer).await; + assert_eq!( + pending["pendingQuestions"][0]["questions"][0]["isOther"], + false + ); + assert_eq!( + pending["pendingQuestions"][0]["questions"][0]["isSecret"], + true + ); + let answer = " token, punctuation! "; + state.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"token":answer}})).unwrap()).await; + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":51,"result":{"answers":{"token":{"answers":[answer]}}}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_awaited_questions_pause_quiet_deadman_regardless_of_blocking_flag() { + for blocking in [false, true] { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(80); + peer.request_client(&RequestId::Int(61), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":blocking,"questions":[{"id":"a","question":"A?"}]})); + assert_eq!(event(&mut rx).await["type"], "freshAgent.question.request"); + assert_eq!(event(&mut rx).await["type"], "freshAgent.turn.waiting"); + tokio::time::sleep(Duration::from_millis(300)).await; + while let Ok(frame) = rx.try_recv() { + let value: Value = serde_json::from_str(&frame).unwrap(); + assert_ne!( + value["event"]["status"], "stuck", + "awaited native RPC must pause quiet detection: {value}" + ); + } + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_last_awaited_reply_rearms_a_full_quiet_window() { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(120); + let mut keys = Vec::new(); + for id in [71, 72] { + peer.request_client(&RequestId::Int(id), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":false,"questions":[{"id":"a","question":"A?"}]})); + let request = event(&mut rx).await; + assert_eq!(request["type"], "freshAgent.question.request"); + keys.push(request["requestId"].clone()); + if id == 71 { + event(&mut rx).await; + } + } + let respond = |id| { + serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":id,"answers":{"a":"one, exact answer"}})).unwrap() + }; + state.handle_question_respond(respond(&keys[0])).await; + peer.next_raw_frame().await.unwrap(); + event(&mut rx).await; + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + rx.try_recv().is_err(), + "one unresolved native control must still pause detection" + ); + state.handle_question_respond(respond(&keys[1])).await; + peer.next_raw_frame().await.unwrap(); + event(&mut rx).await; + assert!( + tokio::time::timeout(Duration::from_millis(60), rx.recv()) + .await + .is_err(), + "last response starts a NEW quiet window" + ); + let stuck = event(&mut rx).await; + assert_eq!( + stuck["status"], "stuck", + "owed native work must be watched after final answer: {stuck}" + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_approval_metadata_and_offered_cancel_are_preserved() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Str("41".into()), "item/commandExecution/requestApproval", json!({"threadId":"thread-1","turnId":"turn-1","itemId":"command","approvalId":"approval-distinct","networkApprovalContext":{"host":"example.com","protocol":"https"},"availableDecisions":["accept","cancel"]})); + let request = event(&mut rx).await; + assert_eq!( + request["native"]["availableDecisions"], + json!(["accept", "cancel"]) + ); + assert_eq!( + request["native"]["networkApprovalContext"]["host"], + "example.com" + ); + event(&mut rx).await; + state.handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"decision":{"nativeDecision":"cancel"}})).unwrap()).await; + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":"41","result":{"decision":"cancel"}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_approval_cannot_grant_a_decision_native_did_not_offer() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(91), "item/commandExecution/requestApproval", json!({"threadId":"thread-1","turnId":"turn-1","command":"echo x","availableDecisions":["cancel"]})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_approval_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"decision":{"behavior":"allow"}})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err() + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_approval_without_offered_decisions_refuses_session_grant_and_allows_retry() { + let (state, peer, mut rx) = runtime().await; + for native_id in [RequestId::Int(92), RequestId::Str("92".into())] { + peer.request_client( + &native_id, + "item/commandExecution/requestApproval", + json!({"threadId":"thread-1","turnId":"turn-1","itemId":"command-92","command":"npm test"}), + ); + let request = event(&mut rx).await; + assert_eq!(request["type"], "freshAgent.permission.request"); + assert_eq!(event(&mut rx).await["type"], "freshAgent.turn.waiting"); + let before = snapshot(&state, &peer).await; + assert_eq!(before["pendingApprovals"].as_array().unwrap().len(), 1); + assert_eq!( + before["pendingApprovals"][0]["requestId"], + request["requestId"] + ); + assert_eq!( + before["pendingApprovals"][0]["providerRequest"]["id"], + native_id.to_json() + ); + assert_eq!(state.awaited_control_count("thread-1"), 1); + let response = |decision| { + serde_json::from_value(json!({ + "provider":"codex","sessionType":"freshcodex","sessionId":"thread-1", + "requestId":request["requestId"],"decision":{"nativeDecision":decision} + })) + .unwrap() + }; + + state + .handle_approval_respond(response("acceptForSession")) + .await; + let refused = event(&mut rx).await; + assert_eq!(refused["type"], "freshAgent.error"); + assert_eq!(refused["code"], "INVALID_ANSWER"); + assert_eq!(refused["sessionId"], "thread-1"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(), + "an unoffered session grant must not answer the native RPC" + ); + assert_eq!( + snapshot(&state, &peer).await["pendingApprovals"], + before["pendingApprovals"] + ); + assert_eq!(state.awaited_control_count("thread-1"), 1); + + state.handle_approval_respond(response("accept")).await; + assert_eq!( + tokio::time::timeout(Duration::from_secs(3), peer.next_raw_frame()) + .await + .expect("the supported retry answers the original native RPC") + .unwrap(), + json!({"id":native_id.to_json(),"result":{"decision":"accept"}}) + ); + let cancelled = event(&mut rx).await; + assert_eq!(cancelled["type"], "freshAgent.permission.cancelled"); + assert_eq!(cancelled["requestId"], request["requestId"]); + assert_eq!(snapshot(&state, &peer).await["pendingApprovals"], json!([])); + assert_eq!(state.awaited_control_count("thread-1"), 0); + } + peer.disconnect(); + tokio::time::timeout(Duration::from_secs(3), state.shutdown()) + .await + .expect("owned controls fixture shuts down"); +} + +#[tokio::test] +async fn task5_stale_turn_control_is_refused_before_it_becomes_answerable() { + let (state, peer, mut rx) = runtime().await; + peer.request_client( + &RequestId::Int(99), + "item/tool/requestUserInput", + json!({"threadId":"thread-1","turnId":"old-turn","questions":[{"id":"a","question":"A?"}]}), + ); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("stale native request receives a refusal") + .unwrap(); + assert!(response.get("error").is_some(), "{response}"); + assert_ne!(event(&mut rx).await["type"], "freshAgent.question.request"); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_mcp_form_and_url_requests_are_retained_as_elicitations() { + for (mode, body) in [ + ( + "form", + json!({"requestedSchema":{"type":"object","properties":{"token":{"type":"string","writeOnly":true}},"required":["token"]}}), + ), + ( + "openai/form", + json!({"requestedSchema":{"type":"object","properties":{"age":{"type":"integer","minimum":1}},"required":["age"]}}), + ), + ( + "openaiForm", + json!({"requestedSchema":{"type":"object","properties":{"enabled":{"type":"boolean"}}}}), + ), + ( + "url", + json!({"url":"https://example.com/verify","elicitationId":"public-url-id"}), + ), + ] { + let (state, peer, mut rx) = runtime().await; + let mut params = json!({"threadId":"thread-1","turnId":null,"serverName":"contract","mode":mode,"message":"Fixture request"}); + params + .as_object_mut() + .unwrap() + .extend(body.as_object().unwrap().clone()); + peer.request_client(&RequestId::Int(41), "mcpServer/elicitation/request", params); + let request = event(&mut rx).await; + assert_eq!( + request["type"], "freshAgent.elicitation.request", + "{request}" + ); + assert_eq!(request["mode"], mode); + let pending = snapshot(&state, &peer).await; + assert_eq!( + pending["pendingElicitations"][0]["requestId"], + request["requestId"] + ); + assert_eq!( + pending["pendingElicitations"][0]["providerRequest"]["id"], + 41 + ); + peer.disconnect(); + state.shutdown().await; + } +} + +#[test] +fn task5_elicitation_response_is_a_typed_client_message() { + let response = json!({"type":"freshAgent.elicitation.respond","provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":"41","action":"accept","content":{"age":3,"enabled":true,"name":"hello","colors":["blue"]}}); + let parsed = serde_json::from_value::(response.clone()); + assert!( + parsed.is_ok(), + "new response must have ordinary WS ingress: {parsed:?}" + ); + assert_eq!(serde_json::to_value(parsed.unwrap()).unwrap(), response); +} + +#[tokio::test] +async fn task5_unsupported_elicitation_declines_once_and_explains_subtype() { + for (mode, schema) in [ + ("openai/userVerification", json!(null)), + ( + "form", + json!({"type":"object","properties":{"nested":{"type":"object","properties":{}}}}), + ), + ] { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(101), "mcpServer/elicitation/request", json!({"threadId":"thread-1","serverName":"contract","mode":mode,"title":"Verify", "message":"Fixture", "requestedSchema":schema})); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":101,"result":{"action":"decline","content":null}}) + ); + let notice = event(&mut rx).await; + assert_eq!(notice["code"], "UNSUPPORTED_ELICITATION"); + assert!(notice["message"].as_str().unwrap().contains(mode)); + peer.disconnect(); + state.shutdown().await; + } +} + +async fn invalid_elicitation(content: Value) { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(111), "mcpServer/elicitation/request", json!({"threadId":"thread-1","turnId":"turn-1","serverName":"contract","mode":"form","message":"Fixture","requestedSchema":{"type":"object","properties":{"count":{"type":"integer","minimum":1,"maximum":5},"color":{"type":"string","enum":["blue","green"]},"name":{"type":"string","minLength":2,"maxLength":8},"colors":{"type":"array","items":{"type":"string","enum":["blue","green"]},"minItems":1,"maxItems":2}},"required":["count","color","name","colors"]}})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_elicitation_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"action":"accept","content":content})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(), + "invalid answer must leave native RPC pending" + ); + assert_eq!(state.retained_control_events("thread-1").await.len(), 1); + peer.disconnect(); + state.shutdown().await; +} +macro_rules! invalid_elicitation_case { + ($name:ident, $patch:expr) => { + #[tokio::test] + async fn $name() { + let mut content = json!({"count":3,"color":"blue","name":"valid","colors":["green"]}); + ($patch)(&mut content); + invalid_elicitation(content).await; + } + } +} +invalid_elicitation_case!(task5_form_rejects_missing_required, |v: &mut Value| { + v.as_object_mut().unwrap().remove("name"); +}); +invalid_elicitation_case!(task5_form_rejects_wrong_type, |v: &mut Value| { + v["count"] = json!("3"); +}); +invalid_elicitation_case!(task5_form_rejects_fractional_integer, |v: &mut Value| { + v["count"] = json!(1.5); +}); +invalid_elicitation_case!(task5_form_rejects_numeric_range, |v: &mut Value| { + v["count"] = json!(10); +}); +invalid_elicitation_case!(task5_form_rejects_unknown_enum, |v: &mut Value| { + v["color"] = json!("red"); +}); +invalid_elicitation_case!(task5_form_rejects_string_bounds, |v: &mut Value| { + v["name"] = json!("x"); +}); +invalid_elicitation_case!(task5_form_rejects_array_enum, |v: &mut Value| { + v["colors"] = json!(["red"]); +}); +invalid_elicitation_case!(task5_form_rejects_extra_fields, |v: &mut Value| { + v["extra"] = json!(true); +}); + +#[tokio::test] +async fn task5_form_and_url_actions_preserve_original_native_id_type() { + let (state, peer, mut rx) = runtime().await; + let mut keys = Vec::new(); + for id in [RequestId::Int(41), RequestId::Str("41".into())] { + peer.request_client(&id, "mcpServer/elicitation/request", json!({"threadId":"thread-1","serverName":"contract","mode":"url","message":"Visit then confirm","url":"https://example.com","elicitationId":"public-url"})); + let request = event(&mut rx).await; + if id == RequestId::Int(41) { + event(&mut rx).await; + } + assert_eq!(request["providerRequest"]["id"], id.to_json()); + keys.push(request["requestId"].clone()); + } + assert_eq!(state.retained_control_events("thread-1").await.len(), 2); + for (key, id, action) in [ + (&keys[1], json!("41"), "cancel"), + (&keys[0], json!(41), "accept"), + ] { + state.handle_elicitation_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":key,"action":action,"content":null})).unwrap()).await; + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":id,"result":{"action":action,"content":null}}) + ); + assert_eq!( + event(&mut rx).await["type"], + "freshAgent.elicitation.cancelled" + ); + } + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_question_rejects_other_when_native_only_offers_choices() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(121),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":false,"questions":[{"id":"pick","question":"Pick","isOther":false,"options":[{"label":"A","description":"Alpha"}]}]})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"pick":"unoffered"}})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + peer.disconnect(); + state.shutdown().await; +} + +#[test] +fn task5_async_agent_message_retains_delivery_and_native_identity() { + let items=map_codex_item("async-question",&json!({"id":"async-question","type":"agentMessage","text":"Where?","delivery":"async","questions":[{"title":"Where?","options":["Staging","Production"]}]}),"agentMessage").unwrap(); + assert_eq!(items[0]["extensions"]["codex"]["delivery"], "async"); + assert_eq!(items[0]["extensions"]["codex"]["itemId"], "async-question"); +} + +#[tokio::test] +async fn task5_async_attention_is_nonblocking_deduplicated_and_does_not_hide_silent_work() { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(150); + let item = json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"async-one","type":"agentMessage","text":"Work continues","delivery":"async","questions":[{"title":"Where?","options":["Staging","Production"]}]}}); + peer.emit_notification("item/completed", item.clone()); + let available = tokio::time::timeout(Duration::from_millis(500), async { + loop { + let value = event(&mut rx).await; + if value["type"] == "freshAgent.question.available" { + break value; + } + } + }) + .await + .expect("async question must emit an actionable, nonblocking attention edge"); + assert_eq!(available["awaited"], false); + assert_eq!(available["itemId"], "async-one"); + assert!(available["at"].as_i64().is_some()); + assert!( + state.retained_control_events("thread-1").await.is_empty(), + "async questions never enter pending decisions" + ); + peer.emit_notification("item/completed", item); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"command","type":"commandExecution","command":"keep working","status":"inProgress"}})); + let stuck = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let value = event(&mut rx).await; + assert_ne!( + value["type"], "freshAgent.question.available", + "same native item must not ring twice" + ); + if value["status"] == "stuck" { + break value; + } + } + }) + .await + .expect("ordinary owed work must still trip the quiet deadman"); + assert_eq!(stuck["status"], "stuck"); + assert!( + tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(), + "async visibility never creates a native response RPC" + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_nonterminal_completion_cannot_cancel_awaited_controls() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(141),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","isBlocking":false,"questions":[{"id":"q","question":"Question"}]})); + event(&mut rx).await; + event(&mut rx).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-1","turnId":"turn-1","status":"inProgress"}), + ); + tokio::time::sleep(Duration::from_millis(50)).await; + assert_eq!(state.retained_control_events("thread-1").await.len(), 1); + while let Ok(raw) = rx.try_recv() { + let frame: Value = serde_json::from_str(&raw).unwrap(); + assert_ne!( + frame["event"]["type"], "freshAgent.question.cancelled", + "nonterminal notification must not dismiss the question" + ); + } + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_unrepresentable_openai_schema_is_declined_instead_of_silently_dropping_constraints() +{ + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(151),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openai/form","message":"Constrained form","requestedSchema":{"type":"object","properties":{"name":{"type":"string"}},"anyOf":[{"required":["name"]}]}})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("unsupported schema must release its native waiter") + .unwrap(); + assert_eq!( + response, + json!({"id":151,"result":{"action":"decline","content":null}}) + ); + let notice = event(&mut rx).await; + assert_eq!(notice["code"], "UNSUPPORTED_ELICITATION"); + assert!(notice["message"].as_str().unwrap().contains("flat")); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_structured_only_approval_choices_are_explicitly_refused() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(161),"item/commandExecution/requestApproval",json!({"threadId":"thread-1","turnId":"turn-1","itemId":"command","availableDecisions":[{"acceptWithExecpolicyAmendment":{"execpolicy_amendment":["echo"]}}]})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("unsupported decision subtype must receive an explicit native refusal") + .unwrap(); + assert!(response.get("error").is_some()); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_CAPABILITY"); + assert!(state.retained_control_events("thread-1").await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_nonprimitive_form_default_is_declined_before_publication() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(171),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openaiForm","message":"Form","requestedSchema":{"type":"object","properties":{"name":{"type":"string","default":{"nested":"value"}}}}})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("nonprimitive default must not break snapshot publication") + .unwrap(); + assert_eq!( + response, + json!({"id":171,"result":{"action":"decline","content":null}}) + ); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_ELICITATION"); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_native_question_answers_require_question_ids_when_titles_repeat() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(181),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","questions":[{"id":"first","question":"Same title"},{"id":"second","question":"Same title"}]})); + let request = event(&mut rx).await; + event(&mut rx).await; + state.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"Same title":"ambiguous"}})).unwrap()).await; + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + assert_eq!(state.retained_control_events("thread-1").await.len(), 1); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_mcp_enum_names_are_labels_and_do_not_replace_native_values() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(191),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"form","message":"Pick","requestedSchema":{"type":"object","properties":{"color":{"type":"string","enum":["blue","green"],"enumNames":["Blue hue","Green hue"]}}}})); + let request = event(&mut rx).await; + assert_eq!( + request["fields"][0]["choices"][0], + json!({"value":"blue","label":"Blue hue"}) + ); + peer.disconnect(); + state.shutdown().await; +} + +async fn task5_unsupported_field(field: Value) { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(201),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openaiForm","message":"Form","requestedSchema":{"type":"object","properties":{"field":field}}})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("unsupported field must release the native waiter") + .unwrap(); + assert_eq!( + response, + json!({"id":201,"result":{"action":"decline","content":null}}) + ); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_ELICITATION"); + peer.disconnect(); + state.shutdown().await; +} +macro_rules! unsupported_field_case { + ($name:ident,$field:expr) => { + #[tokio::test] + async fn $name() { + task5_unsupported_field($field).await; + } + }; +} +unsupported_field_case!( + task5_unsupported_leaf_constraint, + json!({"type":"string","allOf":[{"minLength":2}]}) +); +unsupported_field_case!( + task5_unsupported_description_type, + json!({"type":"string","description":{"nested":"value"}}) +); +unsupported_field_case!( + task5_unsupported_numeric_bound_type, + json!({"type":"number","minimum":"one"}) +); +unsupported_field_case!( + task5_unsupported_format, + json!({"type":"string","format":"unknown-format"}) +); +unsupported_field_case!( + task5_unsupported_array_item_type, + json!({"type":"array","items":{"type":"integer","enum":["blue"]}}) +); +unsupported_field_case!( + task5_unsupported_secret_hint_type, + json!({"type":"string","writeOnly":"yes"}) +); + +#[test] +fn task5_rejects_non_array_required_metadata() { + assert!(elicitation::normalize(&json!({"mode":"openaiForm","serverName":"contract","message":"Form","requestedSchema":{"type":"object","required":"field","properties":{"field":{"type":"string"}}}})).is_none()); +} +#[test] +fn task5_rejects_non_array_enum_metadata() { + assert!(elicitation::normalize(&json!({"mode":"openai/form","serverName":"contract","message":"Form","requestedSchema":{"type":"object","properties":{"field":{"type":"string","enum":"blue"}}}})).is_none()); +} + +#[test] +fn task5_rejects_non_string_url_identity_before_publication() { + assert!(elicitation::normalize(&json!({"mode":"url","serverName":"contract","message":"Visit","url":"https://example.com","elicitationId":{"nested":"unsupported"}})).is_none()); +} + +#[tokio::test] +async fn task5_approval_and_elicitation_waiters_pause_quiet_deadman() { + for (method, params) in [ + ( + "item/commandExecution/requestApproval", + json!({"command":"fixture","availableDecisions":["cancel"]}), + ), + ( + "mcpServer/elicitation/request", + json!({"mode":"url","serverName":"contract","message":"Visit","url":"https://example.com","elicitationId":"url-id"}), + ), + ] { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(60); + let mut params = params; + params["threadId"] = json!("thread-1"); + params["turnId"] = json!("turn-1"); + peer.request_client(&RequestId::Int(271), method, params); + event(&mut rx).await; + assert_eq!(event(&mut rx).await["type"], "freshAgent.turn.waiting"); + assert_eq!(state.awaited_control_count("thread-1"), 1); + tokio::time::sleep(Duration::from_millis(240)).await; + while let Ok(frame) = rx.try_recv() { + let frame: Value = serde_json::from_str(&frame).unwrap(); + assert_ne!(frame["event"]["status"], "stuck"); + assert_ne!(frame["event"]["type"], "freshAgent.turn.complete"); + } + peer.disconnect(); + state.shutdown().await; + } +} + +#[tokio::test] +async fn task5_secret_form_defaults_stay_private_while_original_native_identity_is_retained() { + let (state, peer, mut rx) = runtime().await; + let secret = "sensitive-default-fixture"; + peer.request_client(&RequestId::Int(211),"mcpServer/elicitation/request",json!({"threadId":"thread-1","serverName":"contract","mode":"openaiForm","message":"Form","requestedSchema":{"type":"object","properties":{"token":{"type":"string","writeOnly":true,"default":secret}},"required":["token"]}})); + let request = event(&mut rx).await; + assert_eq!(request["providerRequest"]["id"], 211); + assert_eq!( + request["providerRequest"]["method"], + "mcpServer/elicitation/request" + ); + assert!( + !request.to_string().contains(secret), + "secret defaults must not enter public controls or durable permission events" + ); + let snapshot = snapshot(&state, &peer).await; + assert!(!snapshot["pendingElicitations"].to_string().contains(secret)); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_reissued_native_id_rejects_old_public_answer_but_duplicate_replay_is_stable() { + let (state, peer, mut rx) = runtime().await; + let params = json!({"threadId":"thread-1","turnId":"turn-1","questions":[{"id":"answer","question":"Current question"}]}); + peer.request_client( + &RequestId::Int(251), + "item/tool/requestUserInput", + params.clone(), + ); + let original = event(&mut rx).await; + event(&mut rx).await; + peer.request_client( + &RequestId::Int(251), + "item/tool/requestUserInput", + params.clone(), + ); + assert_eq!(event(&mut rx).await["requestId"], original["requestId"]); + assert_eq!( + snapshot(&state, &peer).await["pendingQuestions"][0]["requestId"], + original["requestId"] + ); + assert_eq!(state.awaited_control_count("thread-1"), 1); + peer.emit_notification( + "serverRequest/resolved", + json!({"threadId":"thread-1","requestId":251}), + ); + assert_eq!(event(&mut rx).await["requestId"], original["requestId"]); + peer.request_client(&RequestId::Int(251), "item/tool/requestUserInput", params); + let current = event(&mut rx).await; + event(&mut rx).await; + assert_ne!( + current["requestId"], original["requestId"], + "a new native operation must not accept an old browser answer" + ); + let answer = |key: &Value| { + serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":key,"answers":{"answer":"exact value"}})).unwrap() + }; + assert!( + !state + .try_handle_question_respond(answer(&original["requestId"])) + .await + ); + assert_eq!(event(&mut rx).await["code"], "REQUEST_UNAVAILABLE"); + assert_eq!(state.awaited_control_count("thread-1"), 1); + assert!( + state + .try_handle_question_respond(answer(¤t["requestId"])) + .await + ); + assert_eq!( + peer.next_raw_frame().await.unwrap(), + json!({"id":251,"result":{"answers":{"answer":{"answers":["exact value"]}}}}) + ); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_pending_answer_cannot_cross_native_client_incarnations() { + let (state, peer, mut rx) = runtime().await; + peer.request_client(&RequestId::Int(253), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","questions":[{"id":"answer","question":"Old native client"}]})); + let original = event(&mut rx).await; + event(&mut rx).await; + let (transport, new_peer) = freshell_codex::new_channel_transport(); + let (client, _notifications) = CodexAppServerClient::connect(transport); + state + .sessions + .lock() + .await + .get_mut("thread-1") + .unwrap() + .client = Arc::new(client); + assert!(!state.try_handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":original["requestId"],"answers":{"answer":"stale answer"}})).unwrap()).await, "an old question must not be written into the new native client"); + assert_eq!(event(&mut rx).await["code"], "REQUEST_UNAVAILABLE"); + assert!( + tokio::time::timeout(Duration::from_millis(30), new_peer.next_raw_frame()) + .await + .is_err() + ); + peer.disconnect(); + new_peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_retired_turn_cannot_register_a_new_awaited_question() { + let (state, peer, mut rx) = runtime().await; + *state + .sessions + .lock() + .await + .get("thread-1") + .unwrap() + .retired_turn_id + .lock() + .unwrap() = Some("retired-turn".into()); + peer.request_client(&RequestId::Int(281), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"retired-turn","questions":[{"id":"answer","question":"Retired question"}]})); + let response = tokio::time::timeout(Duration::from_millis(300), peer.next_raw_frame()) + .await + .expect("a retired turn's late control must receive a native refusal") + .unwrap(); + assert_eq!(response["id"], 281); + assert!(response.get("error").is_some()); + assert_eq!(event(&mut rx).await["code"], "REQUEST_UNAVAILABLE"); + assert_eq!(state.awaited_control_count("thread-1"), 0); + assert!(state.retained_control_events("thread-1").await.is_empty()); + peer.disconnect(); + state.shutdown().await; +} + +#[tokio::test] +async fn task5_control_diagnostics_keep_locators_and_severity_without_secret_payloads() { + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + let (events, _guard) = capture(); + let (state, peer, mut rx) = runtime().await; + let secret = "sensitive-answer-fixture"; + peer.request_client(&RequestId::Int(261), "mcpServer/elicitation/request", json!({"threadId":"thread-1","turnId":"turn-1","itemId":"form-item","serverName":"contract","mode":"form","requestedSchema":{"type":"object","properties":{"token":{"type":"object","default":secret}}}})); + assert_eq!( + peer.next_raw_frame().await.unwrap()["result"]["action"], + "decline" + ); + assert_eq!(event(&mut rx).await["code"], "UNSUPPORTED_ELICITATION"); + peer.request_client(&RequestId::Int(262), "item/tool/requestUserInput", json!({"threadId":"thread-1","turnId":"turn-1","itemId":"question-item","questions":[{"id":"token","question":"Secret?","isSecret":true,"isOther":false,"options":[{"label":"offered","description":"A choice"}]}]})); + let request = event(&mut rx).await; + event(&mut rx).await; + assert!(!state.try_handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request["requestId"],"answers":{"token":secret}})).unwrap()).await); + assert_eq!(event(&mut rx).await["code"], "INVALID_ANSWER"); + let invalid = with_message(&events, "freshagent.codex.invalid_control_answer"); + assert_eq!(invalid.len(), 1); + assert_eq!(invalid[0].level, tracing::Level::WARN); + assert_eq!(invalid[0].field("native_item_id"), Some("question-item")); + let unsupported = with_message(&events, "freshagent.codex.unsupported_request"); + assert_eq!(unsupported.len(), 1); + assert_eq!(unsupported[0].level, tracing::Level::WARN); + assert_eq!(unsupported[0].field("native_turn_id"), Some("turn-1")); + assert!(!format!("{:?}", events.lock().unwrap()).contains(secret)); + peer.disconnect(); + state.shutdown().await; +} + +async fn task6_shutdown(state: &FreshCodexState, peer: ChannelPeer) { + // ChannelTransport recv holds its mutex until the owning peer closes. + // Close exactly once before waiting for the consumer and sidecar watcher. + drop(peer); + tokio::time::timeout(Duration::from_secs(10), state.shutdown()) + .await + .expect("fixture shutdown"); +} + +#[cfg(unix)] +#[tokio::test] +async fn task6_native_death_retains_finite_output_and_completion_but_no_dead_control() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifications) = CodexAppServerClient::connect(transport); + let (state, mut rx) = state_with_bus(); + let child = spawn_sleeper(); + let pid = child.id().unwrap(); + insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(StdMutex::new(Some("turn-1".into()))), + notifications, + child, + "codex-task6-death", + ) + .await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"answer","type":"agentMessage","text":""}})); + peer.emit_notification("item/agentMessage/delta",json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer","delta":"Available interrupted output"})); + peer.request_client(&RequestId::Int(91),"item/tool/requestUserInput",json!({"threadId":"thread-1","turnId":"turn-1","itemId":"question","isBlocking":false,"questions":[{"id":"choice","header":"Choice","question":"Pick","options":null}]})); + loop { + if event(&mut rx).await["type"] == "freshAgent.turn.waiting" { + break; + } + } + // This exact PID is the fixture child just transferred to its watcher. + // The entire test runs in the strict disposable process-kill sandbox. + unsafe { libc::kill(pid as i32, libc::SIGKILL) }; + let complete = loop { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.turn.complete" { + break next; + } + }; + let retained = state.retained_state("thread-1").await.unwrap(); + let before = retained["snapshot"]["turns"].clone(); + peer.emit_notification( + "item/agentMessage/delta", + json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer","delta":" late append"}), + ); + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"late-dead-turn","status":"inProgress"}}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"late-dead-turn","item":{"id":"late-dead-item","type":"agentMessage","text":"Queued dead source must stay finite"}})); + + tokio::time::sleep(Duration::from_millis(50)).await; + let after = state.retained_state("thread-1").await.unwrap(); + task6_shutdown(&state, peer).await; + assert!( + retained["pendingControls"].as_array().unwrap().is_empty(), + "dead native controls must be drained before the completion can be replayed: {retained}" + ); + assert!(retained["attentionMarks"][0].get("lastWaitingAt").is_none()); + assert_eq!( + retained["attentionMarks"][0]["lastTurnCompleteAt"], + complete["at"] + ); + assert_eq!( + retained["snapshot"]["extensions"]["codex"]["projection"]["turnOutputStates"]["turn-1"] + ["diagnostic"], + "interrupted" + ); + assert!(serde_json::to_string(&before) + .unwrap() + .contains("Available interrupted output")); + assert_eq!( + after["snapshot"]["turns"], before, + "late buffered appends cannot reopen a dead source" + ); +} + +#[tokio::test] +async fn task6_admission_continues_the_retained_completion_clock_floor() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifications) = CodexAppServerClient::connect(transport); + let (state, mut rx) = state_with_bus(); + let floor = crate::rollback_record::now_ms() + 60_000; + state + .attention + .record_turn_complete("thread-1", SESSION_TYPE, floor); + insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(StdMutex::new(Some("turn-1".into()))), + notifications, + spawn_sleeper(), + "codex-task6-clock", + ) + .await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"completed"}}), + ); + let complete = loop { + let next = event(&mut rx).await; + if next["type"] == "freshAgent.turn.complete" { + break next; + } + }; + task6_shutdown(&state, peer).await; + assert!(complete["at"].as_i64().unwrap()>floor,"an admitted incarnation must not regress below the retained clock even if wall time moved backwards: {complete}"); +} + +#[tokio::test] +async fn task6_retained_source_preserves_synthesized_stuck_status_without_native_read() { + let (state, peer, mut rx) = runtime().await; + state.set_codex_quiet_window_ms_for_tests(80); + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/started", json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"partial","type":"agentMessage","text":"Available quiet output"}})); + let complete = tokio::time::timeout(Duration::from_secs(3), async { + loop { + let value = event(&mut rx).await; + if value["type"] == "freshAgent.turn.complete" { + break value; + } + } + }) + .await + .expect("actual quiet deadline produces an attention edge"); + let retained = state.retained_state("thread-1").await.unwrap(); + let unsolicited = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()).await; + task6_shutdown(&state, peer).await; + assert_eq!( + retained["snapshot"]["status"], "stuck", + "retained recovery must preserve the authoritative quiet deadline status" + ); + assert_eq!( + retained["attentionMarks"][0]["lastTurnCompleteAt"], + complete["at"] + ); + assert!(retained["pendingControls"].as_array().unwrap().is_empty()); + assert!(retained["snapshot"] + .to_string() + .contains("Available quiet output")); + assert!( + unsolicited.is_err(), + "a retained read cannot hydrate native history" + ); +} + +async fn task6_send_context_answer( + state: &FreshCodexState, + peer: &ChannelPeer, + question_id: &str, + outcome: &str, +) { + let text = format!( + "Answer to Codex question: {}", + json!({"itemId":"async-answerable","questionId":question_id,"question":"A contextual question","answer":"Chosen ordinary answer"}) + ); + let request_id = format!("answer-{question_id}"); + let send = tokio::spawn({ + let state = state.clone(); + let text = text.clone(); + async move { + state.handle_send(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-1","requestId":request_id,"text":text})).unwrap()).await + } + }); + let wire = tokio::time::timeout(Duration::from_secs(3), async { + let (mut id, mut method, mut params) = peer.expect_request().await; + if method == "initialize" { + peer.respond(&id, json!({})); + peer.expect_notification().await; + (id, method, params) = peer.expect_request().await; + } + (id, method, params) + }) + .await + .expect("explicit ordinary answer reaches native transport"); + assert_eq!(wire.1, "turn/steer"); + assert_eq!(wire.2["input"][0]["text"], text); + match outcome { + "accepted" => peer.respond(&wire.0, json!({"turnId":"turn-1"})), + "refused" => peer.respond_error(&wire.0, -32602, "native input refused"), + "rpc-invalid" => peer.respond_error(&wire.0, -32600, "native input refused"), + "ambiguous" => peer.respond(&wire.0, json!({})), + _ => unreachable!(), + } + tokio::time::timeout(Duration::from_secs(3), send) + .await + .unwrap() + .unwrap(); +} + +async fn task6_context_answer_outcome(outcome: &str) { + let (state, peer, mut rx) = runtime().await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/completed",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"async-answerable","type":"agentMessage","text":"Ongoing work with contextual choices","delivery":"async","questions":[{"id":"one","title":"First?"},{"id":"two","title":"Second?"}]}})); + loop { + if event(&mut rx).await["type"] == "freshAgent.question.available" { + break; + } + } + let original = state.attention_marks().await[0].last_question_available_at; + task6_send_context_answer(&state, &peer, "one", outcome).await; + let after_one = state.attention_marks().await[0].clone(); + if outcome == "accepted" { + task6_send_context_answer(&state, &peer, "two", outcome).await; + } + let after_two = state.attention_marks().await[0].clone(); + let receipts = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|frame| serde_json::from_str::(&frame).unwrap()) + .filter(|frame| frame["requestId"] == "answer-one" || frame["requestId"] == "answer-two") + .collect::>(); + assert_eq!(receipts.len(), if outcome == "accepted" { 2 } else { 1 }); + for receipt in &receipts { + if outcome == "accepted" { + assert_eq!(receipt["type"], "freshAgent.send.accepted"); + assert_eq!(receipt["submittedTurnId"], "turn-1"); + assert_eq!(receipt["sessionId"], "thread-1"); + } else { + assert_eq!(receipt["type"], "error"); + let prefix = if outcome == "refused" { + "CODEX_TURN_START_FAILED:" + } else { + "CODEX_DELIVERY_AMBIGUOUS:" + }; + assert!(receipt["message"].as_str().unwrap().starts_with(prefix), "the public receipt must distinguish explicit refusal and uncertain admission: {receipt}"); + } + } + let controls = state.retained_control_events("thread-1").await; + let unsolicited = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()).await; + task6_shutdown(&state, peer).await; + assert_eq!( + after_one.last_question_available_at, original, + "answering one question must preserve the remaining independent action" + ); + assert_eq!( + after_two.last_question_available_at, + if outcome == "accepted" { + None + } else { + original + }, + "only accepted contextual input clears its action; refusal and uncertainty preserve it" + ); + assert!(after_one.last_waiting_at.is_none() && after_two.last_waiting_at.is_none()); + assert!(after_two.last_turn_complete_at.is_none()); + assert!( + controls.is_empty(), + "async content never becomes a durable native decision" + ); + assert!(unsolicited.is_err(),"ordinary answers must never be automatically retried or converted to native control replies"); +} +#[tokio::test] +async fn task6_accepted_contextual_answers_clear_only_their_actions() { + task6_context_answer_outcome("accepted").await; +} +#[tokio::test] +async fn task6_refused_contextual_answer_preserves_action_attention() { + task6_context_answer_outcome("refused").await; +} +#[tokio::test] +async fn task6_ambiguous_contextual_answer_preserves_action_without_resend() { + task6_context_answer_outcome("ambiguous").await; +} + +#[tokio::test] +async fn task6_invalid_request_contextual_answer_preserves_exact_ambiguity_without_resend() { + task6_context_answer_outcome("rpc-invalid").await; +} + +#[tokio::test] +async fn task6_retained_read_follows_only_its_materialized_identity() { + let (state, peer, mut rx) = runtime().await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"turn-1","status":"inProgress"}}), + ); + peer.emit_notification("item/completed",json!({"threadId":"thread-1","turnId":"turn-1","item":{"id":"alias-output","type":"agentMessage","text":"Owned materialized output"}})); + loop { + let patch = event(&mut rx).await; + if patch["type"] == "freshAgent.transcript.patch" + && patch.to_string().contains("Owned materialized output") + { + break; + } + } + state.attention.observe("materialization-old", SESSION_TYPE); + state.broadcast(&serde_json::from_value(json!({"type":"freshAgent.session.materialized","provider":"codex","sessionType":"freshcodex","previousSessionId":"materialization-old","sessionId":"thread-1"})).unwrap()); + // Losing that first materialization must not force a scan of all sessions. + let retained = state.retained_state("materialization-old").await; + let unrelated = state.retained_state("unrelated-owner").await; + task6_shutdown(&state, peer).await; + let retained = retained.expect( + "a retained read must follow its actual materialization alias without another frame", + ); + assert_eq!(retained["snapshot"]["threadId"], "thread-1"); + assert!(retained["snapshot"] + .to_string() + .contains("Owned materialized output")); + assert!( + unrelated.is_err(), + "an arbitrary missing identity must never discover another registered source" + ); +} diff --git a/crates/freshell-freshagent/src/codex/elicitation.rs b/crates/freshell-freshagent/src/codex/elicitation.rs new file mode 100644 index 0000000000..7278ff36f7 --- /dev/null +++ b/crates/freshell-freshagent/src/codex/elicitation.rs @@ -0,0 +1,323 @@ +//! Bounded native MCP elicitation projection. No provider RPC or UI schema interpreter. +use super::*; + +pub(super) fn normalize(params: &Value) -> Option { + let mode = params.get("mode")?.as_str()?; + let mut public = json!({"mode":mode,"serverName":params.get("serverName")?.as_str()?,"message":params.get("message").and_then(Value::as_str).unwrap_or("")}); + if let Some(title) = params.get("title").and_then(Value::as_str) { + public["title"] = json!(title); + } + if mode == "url" { + public["url"] = json!(params.get("url")?.as_str()?); + public["elicitationId"] = json!(params.get("elicitationId")?.as_str()?); + return Some(public); + } + if !matches!(mode, "form" | "openai/form" | "openaiForm") { + return None; + } + let schema = params.get("requestedSchema")?; + if schema.get("type")?.as_str()? != "object" { + return None; + } + let object = schema.as_object()?; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "$schema" + | "type" + | "properties" + | "required" + | "title" + | "description" + | "additionalProperties" + ) + }) || schema + .get("additionalProperties") + .is_some_and(|value| !value.is_boolean()) + { + return None; + } + let properties = schema.get("properties")?.as_object()?; + if properties.len() > 32 { + return None; + } + if schema + .get("required") + .is_some_and(|value| !value.is_null() && !value.is_array()) + { + return None; + } + let required = schema.get("required").and_then(Value::as_array); + if required.is_some_and(|ids| { + ids.iter() + .any(|id| id.as_str().is_none_or(|id| !properties.contains_key(id))) + }) { + return None; + } + let mut fields = Vec::new(); + for (id, raw) in properties { + let kind = raw.get("type")?.as_str()?; + if !matches!(kind, "string" | "number" | "integer" | "boolean" | "array") { + return None; + } + if !representable_field(raw, kind) { + return None; + } + if let Some(default) = raw.get("default").filter(|value| !value.is_null()) { + let valid = match kind { + "string" => default.is_string(), + "boolean" => default.is_boolean(), + "number" => default.is_number(), + "integer" => default.as_f64().is_some_and(|value| value.fract() == 0.0), + "array" => default + .as_array() + .is_some_and(|values| values.iter().all(Value::is_string)), + _ => false, + }; + if !valid { + return None; + } + } + let mut field = json!({"id":id,"type":kind,"title":raw.get("title").and_then(Value::as_str).unwrap_or(id),"required":required.is_some_and(|ids| ids.iter().any(|value|value.as_str()==Some(id))),"secret":raw["writeOnly"]==true || raw["isSecret"]==true}); + let choices = if kind == "array" { &raw["items"] } else { raw }; + if let Some(values) = choices.get("enum").and_then(Value::as_array) { + if values.iter().any(|v| !v.is_string()) { + return None; + } + field["choices"] = json!(values.iter().enumerate().map(|(index,value)| json!({"value":value,"label":choices.get("enumNames").and_then(Value::as_array).and_then(|names|names.get(index)).and_then(Value::as_str).map(Value::from).unwrap_or_else(||value.clone())})).collect::>()); + } else if let Some(values) = choices + .get(if kind == "array" { "anyOf" } else { "oneOf" }) + .and_then(Value::as_array) + { + let values = values.iter().map(|value| Some(json!({"value":value.get("const")?.as_str()?,"label":value.get("title")?.as_str()?}))).collect::>>()?; + field["choices"] = json!(values); + } else if kind == "array" { + return None; + } + for key in [ + "description", + "default", + "minimum", + "maximum", + "minLength", + "maxLength", + "minItems", + "maxItems", + "format", + ] { + if key == "default" && field["secret"] == true { + continue; + } + if let Some(value) = raw.get(key).filter(|v| !v.is_null()) { + field[key] = value.clone(); + } + } + if raw.get("pattern").is_some() { + return None; + } + fields.push(field); + } + public["fields"] = json!(fields); + Some(public) +} + +fn representable_field(raw: &Value, kind: &str) -> bool { + let Some(object) = raw.as_object() else { + return false; + }; + if object.keys().any(|key| { + !matches!( + key.as_str(), + "type" + | "title" + | "description" + | "default" + | "writeOnly" + | "isSecret" + | "enum" + | "enumNames" + | "oneOf" + | "items" + | "minimum" + | "maximum" + | "minLength" + | "maxLength" + | "minItems" + | "maxItems" + | "format" + ) + }) { + return false; + } + for key in ["title", "description"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_string()) + { + return false; + } + } + for key in ["writeOnly", "isSecret"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_boolean()) + { + return false; + } + } + for key in ["minimum", "maximum"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && !value.is_number()) + { + return false; + } + } + for key in ["minLength", "maxLength", "minItems", "maxItems"] { + if raw + .get(key) + .is_some_and(|value| !value.is_null() && value.as_u64().is_none()) + { + return false; + } + } + if raw.get("format").is_some_and(|value| { + !value.is_null() && !matches!(value.as_str(), Some("email" | "uri" | "date" | "date-time")) + }) { + return false; + } + if kind == "array" { + let Some(items) = raw["items"].as_object() else { + return false; + }; + if items + .keys() + .any(|key| !matches!(key.as_str(), "type" | "enum" | "anyOf")) + || items + .get("type") + .is_some_and(|value| value.as_str() != Some("string")) + { + return false; + } + } + let choices = if kind == "array" { &raw["items"] } else { raw }; + for key in ["enum", "enumNames"] { + if choices.get(key).is_some_and(|value| { + !value.is_null() + && value + .as_array() + .is_none_or(|values| values.iter().any(|value| !value.is_string())) + }) { + return false; + } + } + let choice_key = if kind == "array" { "anyOf" } else { "oneOf" }; + if choices + .get(choice_key) + .is_some_and(|value| !value.is_null() && !value.is_array()) + { + return false; + } + + if let Some(options) = choices + .get(if kind == "array" { "anyOf" } else { "oneOf" }) + .and_then(Value::as_array) + { + if options.iter().any(|value| { + value.as_object().is_none_or(|object| { + object + .keys() + .any(|key| !matches!(key.as_str(), "const" | "title")) + }) + }) { + return false; + } + } + true +} + +pub(super) fn response( + params: &Value, + action: &freshell_protocol::ElicitationAction, + content: &Option>, +) -> Result { + use freshell_protocol::ElicitationAction; + if *action != ElicitationAction::Accept || params["mode"] == "url" { + if content.is_some() { + return Err("This elicitation action requires empty content."); + } + return Ok(json!({"action":action,"content":null})); + } + let public = normalize(params).ok_or("This elicitation form is no longer supported.")?; + let fields = public["fields"] + .as_array() + .ok_or("This request has no form fields.")?; + let content = content + .as_ref() + .ok_or("Complete the required form fields before continuing.")?; + if content + .keys() + .any(|key| !fields.iter().any(|field| field["id"].as_str() == Some(key))) + { + return Err("The answer contains a field not requested by the server."); + } + for field in fields { + let id = field["id"].as_str().expect("normalized id"); + let Some(value) = content.get(id) else { + if field["required"] == true { + return Err("Complete every required form field."); + } + continue; + }; + let value = serde_json::to_value(value).map_err(|_| "The answer could not be encoded.")?; + let kind = field["type"].as_str().expect("normalized type"); + let valid = match kind { + "string" => value.as_str().is_some_and(|s| { + within( + s.chars().count() as f64, + &field["minLength"], + &field["maxLength"], + ) && allowed(&value, field) + && format_matches(s, field["format"].as_str()) + }), + "number" | "integer" => value.as_f64().is_some_and(|n| { + n.is_finite() + && (kind != "integer" || n.fract() == 0.0) + && within(n, &field["minimum"], &field["maximum"]) + }), + "boolean" => value.is_boolean(), + "array" => value.as_array().is_some_and(|values| { + within(values.len() as f64, &field["minItems"], &field["maxItems"]) + && values.iter().all(|v| v.is_string() && allowed(v, field)) + }), + _ => false, + }; + if !valid { + return Err("An answer does not match its requested type, range or choices."); + } + } + Ok(json!({"action":action,"content":content})) +} + +fn within(value: f64, minimum: &Value, maximum: &Value) -> bool { + minimum.as_f64().is_none_or(|min| value >= min) + && maximum.as_f64().is_none_or(|max| value <= max) +} +fn allowed(value: &Value, field: &Value) -> bool { + field["choices"] + .as_array() + .is_none_or(|choices| choices.iter().any(|choice| choice["value"] == *value)) +} +fn format_matches(text: &str, format: Option<&str>) -> bool { + match format { + Some("email") => text.split_once('@').is_some_and(|(local, host)| { + !local.is_empty() && !host.is_empty() && !text.contains(char::is_whitespace) + }), + Some("uri") => text.split_once(':').is_some_and(|(scheme, rest)| { + !scheme.is_empty() && !rest.is_empty() && !text.contains(char::is_whitespace) + }), + // Native date formats are displayed as input hints; their string bounds + // remain enforced here. No locale-dependent date coercion takes place. + _ => true, + } +} diff --git a/crates/freshell-freshagent/src/codex/metadata.rs b/crates/freshell-freshagent/src/codex/metadata.rs index 252bb84607..1369473bc3 100644 --- a/crates/freshell-freshagent/src/codex/metadata.rs +++ b/crates/freshell-freshagent/src/codex/metadata.rs @@ -43,9 +43,35 @@ pub(super) fn transcript_metadata(turns: &[Value]) -> (Vec, Vec) { .filter(|id| !id.is_empty()) { // Keep the first origin (spawn), not a later wait/send operation. - children.entry(id.to_owned()).or_insert_with(|| json!({ + let child = children.entry(id.to_owned()).or_insert_with(|| json!({ "id": id, "threadId": id, "origin": item.get("tool").and_then(Value::as_str).unwrap_or("codex"), })); + let state = &item["agentsStates"][id]; + for field in ["status", "model", "result", "error"] { + let value = state.get(field).or_else(|| match field { + "result" => state.get("message"), + "model" => item.get("model"), + _ => None, + }); + if let Some(value) = value.filter(|value| !value.is_null()) { + child[field] = json!(value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string())); + } + } + } + } + Some("task_delegation") => { + if let Some(id) = item["childSessionId"].as_str().filter(|id| !id.is_empty()) { + let child = children.entry(id.to_owned()).or_insert_with( + || json!({"id":id,"threadId":id,"origin":"codex","title":item["title"]}), + ); + for field in ["status", "model", "result"] { + if let Some(value) = item[field].as_str() { + child[field] = json!(value); + } + } } } _ => {} diff --git a/crates/freshell-freshagent/src/codex/native_history.rs b/crates/freshell-freshagent/src/codex/native_history.rs index d6a059857d..5f53cd55e7 100644 --- a/crates/freshell-freshagent/src/codex/native_history.rs +++ b/crates/freshell-freshagent/src/codex/native_history.rs @@ -160,6 +160,13 @@ pub(super) fn read_rollout(path: &Path, id: &str) -> Result { } target.bytes = 0; target.finished |= finished; + if finished { + target.value["status"] = json!(if payload["type"] == "turn_aborted" { + "interrupted" + } else { + "completed" + }); + } } _ => {} } diff --git a/crates/freshell-freshagent/src/codex/projection.rs b/crates/freshell-freshagent/src/codex/projection.rs new file mode 100644 index 0000000000..e328bdf3c1 --- /dev/null +++ b/crates/freshell-freshagent/src/codex/projection.rs @@ -0,0 +1,2320 @@ +//! The event-owned part of a Codex transcript. Reads and notifications have +//! independent clocks; reading a body never makes it a delta baseline. +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; + +const MAX_FROZEN_BYTES: usize = 8 * 1024 * 1024; +const MAX_FROZEN_TURNS: usize = 32; +const MAX_SAVE_READS: u8 = 3; + +#[derive(Debug)] +pub(super) struct ProjectionError { + pub method: String, + pub turn_id: Option, + pub item_id: Option, + pub reason: &'static str, +} + +#[derive(Debug)] +pub(super) struct SnapshotStale; + +#[derive(Clone)] +struct EventTurn { + raw: Value, + row_by_item: HashMap, + completed_items: HashSet, + // Recovery reads can supply display context, never an append baseline. + read_only_items: HashSet, + emitted: bool, + terminal: bool, + save_reads: u8, + allow_missing_membership: bool, +} + +impl EventTurn { + fn nonappendable_ids(&self) -> Vec { + self.raw["items"] + .as_array() + .into_iter() + .flatten() + .filter(|item| { + item["id"] + .as_str() + .is_some_and(|id| self.read_only_items.contains(id)) + }) + .flat_map(|item| { + super::map_codex_item( + item["id"].as_str().unwrap_or(""), + item, + item["type"].as_str().unwrap_or(""), + ) + .unwrap_or_default() + }) + .filter_map(|item| item["id"].as_str().map(str::to_owned)) + .collect() + } + + fn refresh_read_only(&mut self, native: &Value) { + for item in self.raw["items"].as_array_mut().into_iter().flatten() { + if item["id"] + .as_str() + .is_some_and(|id| self.read_only_items.contains(id)) + { + if let Some(saved) = native["items"] + .as_array() + .into_iter() + .flatten() + .find(|saved| saved["id"] == item["id"] && saved["type"] == item["type"]) + { + *item = saved.clone(); + } + } + } + } + + fn native_can_finish_unobserved_items(&self, native: &Value, legacy: bool) -> bool { + // Lifecycle notifications establish status, not an empty body. When + // no item was observed, the caller's full terminal native read owns + // the body; there is no event content whose equality it must prove. + if self.raw["items"].as_array().is_some_and(Vec::is_empty) { + return true; + } + let mut candidate = self.raw.clone(); + let mut saved = native.clone(); + let mut finalizable = HashSet::new(); + for item in self.raw["items"].as_array().into_iter().flatten() { + let Some(id) = item["id"].as_str() else { + continue; + }; + if self.completed_items.contains(id) || self.read_only_items.contains(id) { + continue; + } + if native["items"] + .as_array() + .into_iter() + .flatten() + .any(|saved| saved["id"] == id && saved["type"] == item["type"]) + { + finalizable.insert(id.to_owned()); + } + } + for raw in [&mut candidate, &mut saved] { + if let Some(items) = raw["items"].as_array_mut() { + items.retain(|item| { + !item["id"] + .as_str() + .is_some_and(|id| finalizable.contains(id)) + }); + } + } + // Exact canonical identity permits finalizing a missed item boundary. + // A PTY can finish after its turn and be saved at that later position. + // Every completed role, ordered item and outcome must still agree; + // an unfinished item absent from persistence is retained, not erased. + !finalizable.is_empty() && equivalent_turns(&candidate, &saved, legacy) + } +} + +#[derive(Clone)] +pub(super) struct SnapshotReadFence { + projection_id: String, + owner_epoch: u64, + owner_generation: u64, + history_generation: u64, + event_revision: u64, + turns: Vec, + turn_order: Vec, +} + +pub(super) struct ProjectionSnapshot { + pub raw: Value, + pub metadata: Value, +} + +#[derive(Clone)] +pub(super) struct CodexProjection { + pub id: String, + thread_id: String, + owner_epoch: u64, + owner_generation: u64, + pub history_generation: u64, + revision: u64, + active_turn: Option, + retired: bool, + live_status: Option, + turns: Vec, + // Membership fences outlive payload retention. They are never the eight-turn + // attention cache: old native identities cannot reopen a historical operation. + known_turns: HashSet, + turn_order: Vec, + terminal_turns: HashSet, + removed_turns: HashSet, + unknown_methods: HashSet, + last_native: Option, + pub usage: Option, + pub plan: Option, + pub diff: Option, + hosted_naming_facts: Vec, + pending_ops: Vec, + pending_after_revision: Option, +} + +impl CodexProjection { + pub(super) fn new(projection_id: String, thread_id: String) -> Self { + Self { + id: projection_id, + thread_id, + owner_epoch: 0, + owner_generation: 0, + history_generation: 0, + revision: 0, + active_turn: None, + retired: false, + live_status: None, + turns: vec![], + known_turns: HashSet::new(), + turn_order: vec![], + removed_turns: HashSet::new(), + terminal_turns: HashSet::new(), + unknown_methods: HashSet::new(), + last_native: None, + usage: None, + plan: None, + diff: None, + hosted_naming_facts: vec![], + pending_ops: vec![], + pending_after_revision: None, + } + } + + pub(super) fn bind_owner(&mut self, epoch: u64, generation: u64) { + self.owner_epoch = epoch; + self.owner_generation = generation; + } + + pub(super) fn begin_history_read(&self) -> SnapshotReadFence { + SnapshotReadFence { + projection_id: self.id.clone(), + owner_epoch: self.owner_epoch, + owner_generation: self.owner_generation, + history_generation: self.history_generation, + event_revision: self.revision, + turns: self.turns.clone(), + turn_order: self.turn_order.clone(), + } + } + + pub(super) fn owns_thread(&self, id: &str) -> bool { + self.thread_id == id + } + + /// Exact native spawn membership, without starting or resuming a child writer. + pub(super) fn knows_child_thread(&self, id: &str) -> bool { + self.turns + .iter() + .map(|turn| &turn.raw) + .chain( + self.last_native + .iter() + .flat_map(|native| native["thread"]["turns"].as_array().into_iter().flatten()), + ) + .flat_map(|turn| turn["items"].as_array().into_iter().flatten()) + .any(|item| { + item["type"] == "collabAgentToolCall" + && item["tool"] == "spawnAgent" + && item["senderThreadId"] == self.thread_id + && item["receiverThreadIds"] + .as_array() + .is_some_and(|children| { + children.iter().any(|child| child.as_str() == Some(id)) + }) + }) + } + + pub(super) fn matches_fence(&self, fence: &SnapshotReadFence) -> bool { + fence.projection_id == self.id + && fence.owner_epoch == self.owner_epoch + && fence.owner_generation == self.owner_generation + && fence.history_generation == self.history_generation + } + + pub(super) fn has_usable_history(&self) -> bool { + self.last_native.is_some() || !self.turns.is_empty() + } + + pub(super) fn patch(&self, ops: Vec) -> Value { + let after_revision = ops + .first() + .and_then(|op| op["revision"].as_u64()) + .unwrap_or(self.revision) + .saturating_sub(1); + json!({"type":"freshAgent.transcript.patch", "sessionId":self.thread_id, + "projectionId":self.id, "historyGeneration":self.history_generation, "afterRevision":after_revision, "ops":ops}) + } + + pub(super) fn metadata_event(&self) -> Value { + json!({"type":"freshAgent.session.metadata","sessionId":self.thread_id,"codex":{"projectionId":self.id,"historyGeneration":self.history_generation,"usage":self.usage,"plan":self.plan,"diff":self.diff}}) + } + + pub(super) fn enqueue(&mut self, ops: Vec) { + if self.pending_ops.is_empty() { + self.pending_after_revision = ops + .first() + .and_then(|op| op["revision"].as_u64()) + .map(|revision| revision.saturating_sub(1)); + } + for op in ops { + if let Some(last) = self.pending_ops.last_mut() { + if last["op"] == "appendItem" + && op["op"] == "appendItem" + && ["nativeTurnId", "rowId", "itemId", "field", "index"] + .iter() + .all(|key| last[key] == op[key]) + { + append_string(last, "text", op["text"].as_str().unwrap_or("")); + last["revision"] = op["revision"].clone(); + continue; + } + } + self.pending_ops.push(op); + } + } + + pub(super) fn take_patch(&mut self) -> Option { + if self.pending_ops.is_empty() { + None + } else { + let ops = std::mem::take(&mut self.pending_ops); + let after_revision = self + .pending_after_revision + .take() + .expect("pending patch prefix"); + let mut patch = self.patch(ops); + patch["afterRevision"] = json!(after_revision); + Some(patch) + } + } + + fn op(&mut self, mut value: Value) -> Value { + // Revisions are consumed-notification clocks, never native-read watermarks. + self.revision = self.revision.saturating_add(1).min(9_007_199_254_740_991); + value["revision"] = json!(self.revision); + value + } + + fn open_turn(&mut self, id: &str) -> bool { + if self.removed_turns.contains(id) || self.terminal_turns.contains(id) { + return false; + } + if self.active_turn.as_deref() == Some(id) { + return true; + } + if self.known_turns.contains(id) && !self.recoverable_native_turn(id) { + return false; + } + // A new native turn is an operation boundary, including when the old + // completion was lost. Its unfinished body becomes a finite retained view. + self.freeze_active("interrupted"); + self.active_turn = Some(id.to_owned()); + if self.known_turns.insert(id.to_owned()) { + self.turn_order.push(id.to_owned()); + } + let preview = self.last_native.as_ref().and_then(|raw| { + raw["thread"]["turns"] + .as_array()? + .iter() + .find(|turn| turn["id"] == id && turn["status"] == "inProgress") + .map(|turn| (turn, raw["thread"]["historyMode"] == "legacy")) + }); + let items: Vec = preview + .into_iter() + .flat_map(|(turn, legacy)| { + turn["items"] + .as_array() + .into_iter() + .flatten() + .filter(move |item| { + // Legacy message ids are reconstructed, so only the immutable + // user prefix is safe to associate with new canonical items. + !legacy || item["type"] == "userMessage" + }) + .cloned() + }) + .collect(); + let read_only_items = items + .iter() + .filter_map(|item| item["id"].as_str().map(str::to_owned)) + .collect(); + self.turns.push(EventTurn { + raw: json!({"id":id,"status":"inProgress","items":items}), + row_by_item: HashMap::new(), + completed_items: HashSet::new(), + read_only_items, + emitted: false, + terminal: false, + save_reads: 0, + allow_missing_membership: true, + }); + true + } + + fn recoverable_native_turn(&self, id: &str) -> bool { + self.active_turn.is_none() + && self.last_native.as_ref().is_some_and(|raw| { + raw["thread"]["turns"].as_array().is_some_and(|turns| { + turns + .iter() + .any(|turn| turn["id"] == id && turn["status"] == "inProgress") + }) + }) + } + + pub(super) fn observe_status(&mut self, status: &str) { + if !self.retired { + self.live_status = Some(status.to_owned()); + } + } + + pub(super) fn live_status(&self) -> Option<&str> { + self.live_status.as_deref() + } + + /// A new source can disclose the prior source's finite evidence. It never + /// inherits pending operations, revision baselines, or append authority. + pub(super) fn inherit_finite_history(&mut self, prior: &Self) { + if self.thread_id != prior.thread_id { + return; + } + let mut finite = prior.clone(); + finite.freeze_active("interrupted"); + for turn in &mut finite.turns { + if !turn.terminal { + turn.raw["status"] = json!("interrupted"); + } + turn.terminal = true; + turn.save_reads = MAX_SAVE_READS; + if let Some(id) = turn.raw["id"].as_str() { + finite.terminal_turns.insert(id.to_owned()); + } + } + finite.trim_frozen(); + self.turns = finite.turns; + self.known_turns = finite.known_turns; + self.turn_order = finite.turn_order; + self.terminal_turns = finite.terminal_turns; + self.removed_turns = finite.removed_turns; + self.last_native = finite.last_native; + self.history_generation = finite.history_generation; + self.usage = finite.usage; + self.plan = finite.plan; + self.diff = finite.diff; + self.live_status = Some("idle".into()); + } + + pub(super) fn source_retired(&self) -> bool { + self.retired + } + + pub(super) fn retire_source(&mut self) { + self.retired = true; + self.live_status = Some("exited".into()); + self.freeze_active("interrupted"); + } + + pub(super) fn freeze_active(&mut self, status: &str) { + if let Some(id) = self.active_turn.take() { + self.terminal_turns.insert(id.clone()); + if let Some(turn) = self.turns.iter_mut().find(|t| t.raw["id"] == id) { + turn.terminal = true; + turn.raw["status"] = json!(status); + } + } + self.trim_frozen(); + } + + fn trim_frozen(&mut self) { + let mut bytes: usize = self + .turns + .iter() + .filter(|t| t.terminal) + .map(|t| t.raw.to_string().len()) + .sum(); + let mut count = self.turns.iter().filter(|t| t.terminal).count(); + self.turns.retain(|t| { + if t.terminal && (bytes > MAX_FROZEN_BYTES || count > MAX_FROZEN_TURNS) { + bytes = bytes.saturating_sub(t.raw.to_string().len()); + count -= 1; + false + } else { + true + } + }); + } + + pub(super) fn invalidate_history(&mut self, removed: &[String]) -> Vec { + self.history_generation += 1; + self.pending_ops.clear(); + if !removed.is_empty() { + self.freeze_active("interrupted"); + } + for turn in &mut self.turns { + if turn.terminal { + turn.allow_missing_membership = false; + } + } + self.removed_turns.extend(removed.iter().cloned()); + if self + .plan + .as_ref() + .is_some_and(|plan| removed.iter().any(|id| plan["turnId"] == *id)) + { + self.plan = None; + } + if self.diff.as_ref().is_some_and(|diff| { + removed + .iter() + .any(|id| diff["id"] == format!("codex-turn:{id}")) + }) { + self.diff = None; + } + self.turns + .retain(|t| !removed.iter().any(|id| t.raw["id"] == *id)); + if let Some(raw) = &mut self.last_native { + if let Some(turns) = raw["thread"]["turns"].as_array_mut() { + turns.retain(|t| !removed.iter().any(|id| t["id"] == *id)); + } + } + let mut resets: Vec = self.turns.iter().map(|turn| json!({"op":"resetTurn","nativeTurnId":turn.raw["id"],"turns":super::build_codex_turn_json(&turn.raw,0).unwrap_or_default(),"completedItemIds":turn.completed_items,"nonAppendableItemIds":turn.nonappendable_ids()})).collect(); + resets.extend( + removed + .iter() + .map(|id| json!({"op":"resetTurn","nativeTurnId":id,"turns":[]})), + ); + resets.into_iter().map(|op| self.op(op)).collect() + } + + pub(super) fn apply( + &mut self, + method: &str, + params: &Value, + ) -> Result, ProjectionError> { + if self.retired { + return Ok(vec![]); + } + if params.get("threadId").and_then(Value::as_str) != Some(&self.thread_id) { + return Ok(vec![]); + } + if method == "thread/tokenUsage/updated" { + if let Some(usage) = params + .get("tokenUsage") + .filter(|u| u["total"].is_object() && u["last"].is_object()) + { + self.usage = Some(usage.clone()); + } + return Ok(vec![]); + } + let turn_id = params + .get("turnId") + .and_then(Value::as_str) + .or_else(|| params.get("turn").and_then(|t| t["id"].as_str())); + let error = |reason| ProjectionError { + method: method.to_owned(), + turn_id: turn_id.map(str::to_owned), + item_id: params + .get("itemId") + .and_then(Value::as_str) + .map(str::to_owned), + reason, + }; + if method == "turn/started" { + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + self.open_turn(id); + return Ok(vec![]); + } + if method == "turn/completed" { + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + if self.active_turn.as_deref() != Some(id) { + return Ok(vec![]); + } + // Summary/NotLoaded is status-only. Even a Full notification is + // not persistence evidence; complete items already supplied bodies. + let turn = self + .turns + .iter_mut() + .find(|t| t.raw["id"] == id) + .expect("active event turn"); + let status = params["turn"]["status"] + .as_str() + .or_else(|| params["status"].as_str()) + .unwrap_or("completed"); + turn.raw["status"] = json!(status); + if let Some(err) = params["turn"].get("error") { + turn.raw["error"] = err.clone(); + } + self.freeze_active(status); + return Ok(vec![ + self.op(json!({"op":"settleTurn","nativeTurnId":id,"status":status})) + ]); + } + if matches!(method, "turn/plan/updated" | "turn/diff/updated") { + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + if self.active_turn.as_deref() != Some(id) { + return Ok(vec![]); + } + if method == "turn/plan/updated" { + self.plan = Some( + json!({"turnId":id,"explanation":params.get("explanation"),"plan":params.get("plan").cloned().unwrap_or_else(|| json!([]))}), + ); + } else if let Some(diff) = params["diff"].as_str() { + self.diff = Some( + json!({"id":format!("codex-turn:{id}"),"title":"Turn changes","diff":diff}), + ); + } + return Ok(vec![]); + } + let item_boundary = matches!(method, "item/started" | "item/completed"); + let delta_field = match method { + "item/agentMessage/delta" | "item/plan/delta" => Some(("text", "text", None)), + "item/commandExecution/outputDelta" | "item/fileChange/outputDelta" => { + Some(("aggregatedOutput", "output", None)) + } + "item/reasoning/summaryTextDelta" => Some(("summary", "summary", Some("summaryIndex"))), + "item/reasoning/textDelta" => Some(("content", "content", Some("contentIndex"))), + _ => None, + }; + if !item_boundary + && delta_field.is_none() + && !matches!( + method, + "item/fileChange/patchUpdated" + | "item/mcpToolCall/progress" + | "item/reasoning/summaryPartAdded" + ) + { + if self.unknown_methods.insert(method.to_owned()) { + tracing::debug!(provider="codex", session_id=%self.thread_id, projection_id=%self.id, method, "freshagent.codex.unprojected_notification"); + } + return Ok(vec![]); + } + let id = turn_id.ok_or_else(|| error("missing native turn id"))?; + // Only the first observed item may recover a missed turn/started. Later + // turns must be opened by their native lifecycle event. + if self.active_turn.is_none() + && item_boundary + && (self.known_turns.is_empty() || self.recoverable_native_turn(id)) + { + self.open_turn(id); + } + if self.active_turn.as_deref() != Some(id) { + return Ok(vec![]); + } + let turn_index = self + .turns + .iter() + .position(|t| t.raw["id"] == id) + .expect("active event turn"); + let item_id = if item_boundary { + params["item"]["id"].as_str() + } else { + params["itemId"].as_str() + } + .filter(|id| !id.is_empty()) + .ok_or_else(|| error("missing native item id"))?; + if self.turns[turn_index].completed_items.contains(item_id) { + return Ok(vec![]); + } + if item_boundary { + let item = params + .get("item") + .filter(|i| i["type"].is_string()) + .ok_or_else(|| error("missing native item type"))?; + let old_rows = super::build_codex_turn_json(&self.turns[turn_index].raw, 0) + .map_err(|_| error("unmappable native turn"))?; + let items = self.turns[turn_index].raw["items"] + .as_array_mut() + .expect("owned items array"); + match items.iter_mut().find(|i| i["id"] == item_id) { + Some(existing) => *existing = item.clone(), + None => items.push(item.clone()), + } + let complete = method == "item/completed"; + self.turns[turn_index].read_only_items.remove(item_id); + if complete { + self.turns[turn_index] + .completed_items + .insert(item_id.to_owned()); + } + let rows = super::build_codex_turn_json(&self.turns[turn_index].raw, 0) + .map_err(|_| error("unmappable native item"))?; + self.turns[turn_index].row_by_item = rows + .iter() + .flat_map(|row| { + row["items"] + .as_array() + .into_iter() + .flatten() + .filter_map(|item| { + Some(( + item["id"].as_str()?.to_owned(), + row["id"].as_str()?.to_owned(), + )) + }) + }) + .collect(); + let old_shape: Vec<_> = old_rows.iter().map(|r| (&r["id"], &r["role"])).collect(); + let new_shape: Vec<_> = rows.iter().map(|r| (&r["id"], &r["role"])).collect(); + let first = !self.turns[turn_index].emitted; + self.turns[turn_index].emitted = true; + if first || old_shape != new_shape { + return Ok(vec![self.op(json!({"op":"resetTurn","nativeTurnId":id,"turns":rows,"completedItemIds":self.turns[turn_index].completed_items,"nonAppendableItemIds":self.turns[turn_index].nonappendable_ids()}))]); + } + let mut ops = vec![]; + for row in rows { + for mapped in row["items"].as_array().into_iter().flatten().filter(|i| { + i["id"] == item_id + || i["id"] + .as_str() + .is_some_and(|v| v.starts_with(&format!("{item_id}:part:"))) + }) { + let mut header = row.clone(); + header["items"] = json!([]); + ops.push(self.op(json!({"op":"upsertItem","nativeTurnId":id,"row":header,"item":mapped,"complete":complete}))); + } + } + return Ok(ops); + } + let turn = &mut self.turns[turn_index]; + if turn.read_only_items.contains(item_id) { + return Ok(vec![]); + } + let Some(item) = turn.raw["items"] + .as_array_mut() + .expect("owned items array") + .iter_mut() + .find(|i| i["id"] == item_id) + else { + return Ok(vec![]); + }; + if let Some((native_field, field, index_field)) = delta_field { + let delta = params["delta"] + .as_str() + .ok_or_else(|| error("missing string delta"))?; + let expected_type = match field { + "text" => matches!(item["type"].as_str(), Some("agentMessage" | "plan")), + "output" => matches!( + item["type"].as_str(), + Some("commandExecution" | "fileChange") + ), + _ => item["type"] == "reasoning", + }; + if !expected_type { + return Err(error("delta does not match native item type")); + } + let index = index_field + .map(|key| { + params[key] + .as_u64() + .filter(|i| *i < 1024) + .ok_or_else(|| error("invalid native reasoning index")) + }) + .transpose()?; + if let Some(index) = index { + if !item[native_field].is_array() { + item[native_field] = json!([]); + } + let parts = item[native_field].as_array_mut().expect("indexed array"); + while parts.len() <= index as usize { + parts.push(json!("")); + } + // Each native indexed part is an owned String, as for ordinary text. + if let Value::String(target) = &mut parts[index as usize] { + target.push_str(delta); + } else { + parts[index as usize] = json!(delta); + } + } else { + append_string(item, native_field, delta); + } + let row_id = turn.row_by_item.get(item_id).cloned(); + // Do not emit accumulated bodies on token arrival. + return Ok(row_id.into_iter().map(|row_id| self.op(json!({"op":"appendItem","nativeTurnId":id,"rowId":row_id,"itemId":item_id,"field":field,"index":index,"text":delta}))).collect()); + } + if method == "item/fileChange/patchUpdated" { + if let Some(changes) = params.get("changes").filter(|v| v.is_array()) { + item["changes"] = changes.clone(); + } + } else if method == "item/mcpToolCall/progress" { + item["progress"] = params.get("message").cloned().unwrap_or(Value::Null); + } else { + return Ok(vec![]); + } + let rows = super::build_codex_turn_json(&turn.raw, 0) + .map_err(|_| error("unmappable native progress"))?; + let mut ops = vec![]; + for row in rows { + for mapped in row["items"] + .as_array() + .into_iter() + .flatten() + .filter(|i| i["id"] == item_id) + { + let mut header = row.clone(); + header["items"] = json!([]); + ops.push( + self.op( + json!({"op":"upsertItem","nativeTurnId":id,"row":header,"item":mapped}), + ), + ); + } + } + Ok(ops) + } + + pub(super) fn accept_native_history( + &mut self, + raw: &Value, + fence: &SnapshotReadFence, + ) -> Result { + if !self.matches_fence(fence) { + return Err(SnapshotStale); + } + if raw["thread"]["id"] + .as_str() + .is_some_and(|id| id != self.thread_id) + { + return Err(SnapshotStale); + } + self.last_native = Some(raw.clone()); + self.build_snapshot(raw, fence, false) + } + + /// Clone the current projection so recovery never advances native-read + /// settlement counters or changes the append baseline. + pub(super) fn retained_snapshot( + &self, + unavailable: bool, + ) -> Result { + let mut retained = self.clone(); + let raw = self.last_native.clone().unwrap_or_else( + || json!({"thread":{"id":self.thread_id,"status":{"type":"idle"},"turns":[]}}), + ); + retained.build_snapshot( + &raw, + &self.begin_history_read(), + unavailable || self.retired, + ) + } + + pub(super) fn unavailable_history( + &mut self, + fence: &SnapshotReadFence, + ) -> Result { + let raw = self.last_native.clone().unwrap_or_else( + || json!({"thread":{"id":self.thread_id,"status":{"type":"idle"},"turns":[]}}), + ); + self.build_snapshot(&raw, fence, true) + } + + fn build_snapshot( + &mut self, + raw: &Value, + fence: &SnapshotReadFence, + unavailable: bool, + ) -> Result { + if !self.matches_fence(fence) { + return Err(SnapshotStale); + } + let mut chosen = vec![]; + let mut event_owned = vec![]; + let mut previews = vec![]; + let mut finals = vec![]; + let mut states = Map::new(); + let mut turn_rows = Map::new(); + let mut nonappendable = Map::new(); + let mut native_ids = HashSet::new(); + let owned: HashMap<_, _> = fence + .turns + .iter() + .filter_map(|t| t.raw["id"].as_str().map(|id| (id, t))) + .collect(); + let legacy = raw["thread"]["historyMode"] == "legacy"; + for native in raw["thread"]["turns"].as_array().into_iter().flatten() { + let Some(id) = native["id"].as_str() else { + continue; + }; + if self.removed_turns.contains(id) { + continue; + } + native_ids.insert(id.to_owned()); + if self.known_turns.insert(id.to_owned()) { + self.turn_order.push(id.to_owned()); + } + let full = native["itemsView"] + .as_str() + .is_none_or(|view| view == "full"); + let terminal = matches!( + native["status"].as_str(), + Some("completed" | "failed" | "interrupted") + ); + if terminal { + self.terminal_turns.insert(id.to_owned()); + } + if let Some(event) = owned.get(id) { + let mut event = (*event).clone(); + event.refresh_read_only(native); + if let Some(current) = self.turns.iter_mut().find(|turn| turn.raw["id"] == id) { + // An item boundary during the RPC may already have claimed + // a field. Refresh only fields that remain read-only now. + current.refresh_read_only(native); + } + nonappendable.insert(id.to_owned(), json!(event.nonappendable_ids())); + if terminal && full { + let equal = equivalent_turns(&event.raw, native, legacy); + let interrupted = native["status"] == "interrupted"; + let truncated = provider_truncated(native); + // A native full read can finish an item whose completion + // this incarnation never observed (a PTY may outlive its + // originating turn). Only a completed event body demands + // semantic agreement; an unfinished body is not final. + let finalized_unobserved = + event.native_can_finish_unobserved_items(native, legacy); + if equal || interrupted || truncated || finalized_unobserved { + chosen.push(native.clone()); + finals.push(id.to_owned()); + let mut state = json!({"provenance":"saved","refreshPending":false}); + if interrupted { + state["diagnostic"] = json!("interrupted"); + } + if truncated { + state["diagnostic"] = json!("provider-truncated"); + } + if !equal && (interrupted || truncated) { + state["provenance"] = json!("saved-and-retained"); + state["retainedTurns"] = json!(super::build_codex_turn_json( + &event.raw, 0 + ) + .unwrap_or_default()); + } + states.insert(id.to_owned(), state); + // Only release the captured prefix, never newer event work. + if (equal || (finalized_unobserved && !interrupted && !truncated)) + && self.revision == fence.event_revision + { + if self.active_turn.as_deref() == Some(id) { + self.active_turn = None; + } + self.turns.retain(|t| t.raw["id"] != id); + } + continue; + } + } + event_owned.push(id.to_owned()); + chosen.push(event.raw.clone()); + let terminal_event = event.terminal || terminal; + let reads = self + .turns + .iter_mut() + .find(|t| t.raw["id"] == id) + .map(|t| { + if terminal_event { + t.save_reads = t.save_reads.saturating_add(1); + } + t.save_reads + }) + .unwrap_or(MAX_SAVE_READS); + let pending = terminal_event && reads < MAX_SAVE_READS && !unavailable; + let mut state = json!({"provenance":"retained","refreshPending":pending}); + if terminal_event && !pending { + state["diagnostic"] = json!(if event.raw["status"] == "interrupted" { + "interrupted" + } else { + "unsaved" + }); + } + if terminal && full { + state["savedTurns"] = + json!(super::build_codex_turn_json(native, 0).unwrap_or_default()); + } + states.insert(id.to_owned(), state); + } else { + chosen.push(native.clone()); + if terminal && full { + finals.push(id.to_owned()); + let mut state = json!({"provenance":"saved","refreshPending":false}); + if native["status"] == "interrupted" { + state["diagnostic"] = json!("interrupted"); + } + if provider_truncated(native) { + state["diagnostic"] = json!("provider-truncated"); + } + states.insert(id.to_owned(), state); + } else { + previews.push(id.to_owned()); + states.insert( + id.to_owned(), + json!({"provenance":"preview","refreshPending":false}), + ); + } + } + } + for event in &fence.turns { + let Some(id) = event.raw["id"].as_str() else { + continue; + }; + if native_ids.contains(id) + || self.removed_turns.contains(id) + || !event.allow_missing_membership + { + continue; + } + let after = fence + .turn_order + .iter() + .position(|known| known == id) + .map(|index| &fence.turn_order[index + 1..]) + .unwrap_or(&[]); + let insertion = after + .iter() + .find_map(|next| chosen.iter().position(|turn| turn["id"] == *next)) + .unwrap_or(chosen.len()); + chosen.insert(insertion, event.raw.clone()); + event_owned.push(id.to_owned()); + nonappendable.insert(id.to_owned(), json!(event.nonappendable_ids())); + let reads = self + .turns + .iter_mut() + .find(|t| t.raw["id"] == id) + .map(|t| { + if event.terminal { + t.save_reads = t.save_reads.saturating_add(1); + } + t.save_reads + }) + .unwrap_or(MAX_SAVE_READS); + let pending = event.terminal && reads < MAX_SAVE_READS && !unavailable; + let mut state = json!({"provenance":"retained","refreshPending":pending}); + if event.terminal && !pending { + state["diagnostic"] = json!(if event.raw["status"] == "interrupted" { + "interrupted" + } else { + "unsaved" + }); + } + states.insert(id.to_owned(), state); + } + for turn in &chosen { + let id = turn["id"].as_str().unwrap_or(""); + turn_rows.insert( + id.to_owned(), + json!(super::build_codex_turn_json(turn, 0) + .unwrap_or_default() + .iter() + .map(|r| r["id"].clone()) + .collect::>()), + ); + } + let mut order: Vec = chosen + .iter() + .filter_map(|turn| turn["id"].as_str().map(str::to_owned)) + .collect(); + let mut ordered: HashSet = order.iter().cloned().collect(); + for id in &self.turn_order { + if !self.removed_turns.contains(id) && ordered.insert(id.clone()) { + order.push(id.clone()); + } + } + self.turn_order = order; + let mut output = raw.clone(); + output["thread"]["turns"] = json!(chosen); + let completed_items: Map = fence + .turns + .iter() + .filter_map(|turn| { + Some(( + turn.raw["id"].as_str()?.to_owned(), + json!(turn.completed_items), + )) + }) + .collect(); + let settled: Vec<_> = fence + .turns + .iter() + .filter(|turn| turn.terminal) + .map(|turn| turn.raw["id"].clone()) + .collect(); + let mut metadata = json!({"id":self.id,"eventRevision":fence.event_revision,"historyGeneration":fence.history_generation,"completedItems":completed_items,"nonAppendableItemIds":nonappendable,"settledTurnIds":settled, + "eventOwnedTurnIds":event_owned,"nativePreviewTurnIds":previews,"nativeFinalTurnIds":finals,"turnOutputStates":states,"turnRows":turn_rows}); + if unavailable { + metadata["availability"] = + json!({"status":"unavailable","retryable":true,"code":"CODEX_HISTORY_UNAVAILABLE"}); + } + Ok(ProjectionSnapshot { + raw: output, + metadata, + }) + } + + pub(super) fn overlay(&self, snapshot: &mut Value, metadata: Value) { + if !self.hosted_naming_facts.is_empty() { + snapshot["extensions"]["codex"]["hostedNamingFacts"] = json!(self.hosted_naming_facts); + } + snapshot["extensions"]["codex"]["projection"] = metadata; + snapshot["tokenUsage"]["observed"] = json!(self.usage.is_some()); + if let Some(usage) = &self.usage { + snapshot["extensions"]["codex"]["usage"] = usage.clone(); + let total = &usage["total"]; + snapshot["tokenUsage"] = json!({"observed":true,"inputTokens":total["inputTokens"].as_u64().unwrap_or(0),"outputTokens":total["outputTokens"].as_u64().unwrap_or(0),"cachedTokens":total["cachedInputTokens"].as_u64().unwrap_or(0),"totalTokens":total["totalTokens"].as_u64().unwrap_or(0)}); + if let Some(window) = usage["modelContextWindow"].as_u64() { + snapshot["tokenUsage"]["modelContextWindow"] = json!(window); + } + } + if let Some(plan) = &self.plan { + snapshot["extensions"]["codex"]["plan"] = plan.clone(); + } + if let Some(diff) = &self.diff { + if let Some(diffs) = snapshot["diffs"].as_array_mut() { + diffs.push(diff.clone()); + } + snapshot["capabilities"]["diffs"] = json!(true); + } + } + + /// Retain the first accepted naming input and latest verified transition + /// with this source, so a dropped final event can reconcile without a token. + pub(super) fn retain_hosted_naming_fact(&mut self, fact: Value) -> bool { + if self.retired { + return false; + } + if let Some(existing) = self + .hosted_naming_facts + .iter_mut() + .find(|old| old["kind"] == fact["kind"]) + { + if fact["kind"] != "activity" { + *existing = fact; + } + } else { + self.hosted_naming_facts.push(fact); + } + true + } + + pub(super) fn has_hosted_naming_proof(&self) -> bool { + self.hosted_naming_facts + .iter() + .any(|fact| matches!(fact["kind"].as_str(), Some("transition" | "acquisition"))) + } + + pub(super) fn hosted_naming_activity(&self) -> Option { + self.hosted_naming_facts + .iter() + .find(|fact| fact["kind"] == "activity") + .cloned() + } + + #[cfg(test)] + fn snapshot_rows(&self, turn_id: &str) -> Option> { + super::build_codex_turn_json(&self.turns.iter().find(|t| t.raw["id"] == turn_id)?.raw, 0) + .ok() + } +} + +fn append_string(target: &mut Value, field: &str, delta: &str) { + match target.get_mut(field) { + Some(Value::String(text)) => text.push_str(delta), + _ => target[field] = Value::String(delta.to_owned()), + } +} + +pub(super) fn stamp_saved_output_state(raw: &Value, rows: &mut [Value]) { + let diagnostic = if raw["status"] == "interrupted" { + Some("interrupted") + } else if provider_truncated(raw) { + Some("provider-truncated") + } else { + None + }; + if let (Some(diagnostic), Some(row)) = (diagnostic, rows.first_mut()) { + row["outputState"] = + json!({"provenance":"saved","diagnostic":diagnostic,"refreshPending":false}); + } +} + +// Compare the complete ordered display representation, preserving roles, +// duplicate bodies, tool outcomes and errors. Legacy reconstructs item-N ids; +// only that evidenced presentation difference is normalized. +fn equivalent_turns(event: &Value, native: &Value, legacy: bool) -> bool { + let (Ok(mut event_rows), Ok(mut native_rows)) = ( + super::build_codex_turn_json(event, 0), + super::build_codex_turn_json(native, 0), + ) else { + return false; + }; + if event_rows.len() != native_rows.len() { + return false; + } + for (event, native) in event_rows.iter_mut().zip(native_rows.iter_mut()) { + if event["role"] != native["role"] { + return false; + } + let left = event["items"].as_array_mut().expect("mapped items"); + let right = native["items"].as_array_mut().expect("mapped items"); + if left.len() != right.len() { + return false; + } + for (event, native) in left.iter_mut().zip(right.iter_mut()) { + let reconstructed = legacy + && native["id"].as_str().is_some_and(|id| { + let base = id.split(":part:").next().unwrap_or(id); + base.strip_prefix("item-") + .is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit())) + }); + if reconstructed { + strip_presentation_id(event); + strip_presentation_id(native); + } + if event != native { + return false; + } + } + } + true +} + +fn strip_presentation_id(item: &mut Value) { + if let Some(object) = item.as_object_mut() { + object.remove("id"); + } + if let Some(raw) = item + .get_mut("extensions") + .and_then(|v| v.get_mut("codex")) + .and_then(Value::as_object_mut) + { + raw.remove("id"); + } +} + +fn provider_truncated(turn: &Value) -> bool { + turn["items"] + .as_array() + .into_iter() + .flatten() + .any(|item| match item["type"].as_str() { + Some("commandExecution") => item["aggregatedOutput"] + .as_str() + .is_some_and(|s| s.contains("... command output truncated for persistence ...")), + Some("mcpToolCall") => item["result"]["content"] + .as_array() + .into_iter() + .flatten() + .filter(|content| content["type"] == "text") + .filter_map(|content| content["text"].as_str()) + .any(has_native_mcp_truncation_marker), + _ => false, + }) +} + +fn has_native_mcp_truncation_marker(text: &str) -> bool { + // Codex 0.164 inserts an inline …N chars truncated… marker into saved MCP + // text. A mention in prose or non-text result metadata is not that marker. + text.split_inclusive('…').skip(1).any(|segment| { + segment + .strip_suffix(" chars truncated…") + .is_some_and(|count| { + !count.is_empty() && count.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::codex::tests as rig; + use freshell_codex::CodexAppServerClient; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + fn started(p: &mut CodexProjection, id: &str) { + p.apply( + "turn/started", + &json!({"threadId":"thread-1","turn":{"id":id,"status":"inProgress"}}), + ) + .unwrap(); + } + fn item(p: &mut CodexProjection, turn: &str, body: Value, completed: bool) -> Vec { + p.apply( + if completed { + "item/completed" + } else { + "item/started" + }, + &json!({"threadId":"thread-1","turnId":turn,"item":body}), + ) + .unwrap() + } + fn delta(p: &mut CodexProjection, turn: &str, text: &str) -> Vec { + p.apply( + "item/agentMessage/delta", + &json!({"threadId":"thread-1","turnId":turn,"itemId":"answer","delta":text}), + ) + .unwrap() + } + fn ended(p: &mut CodexProjection, turn: &str, status: &str) { + p.apply("turn/completed", &json!({"threadId":"thread-1","turn":{"id":turn,"status":status,"itemsView":"summary","items":[]}})).unwrap(); + } + fn history(turns: Value) -> Value { + json!({"thread":{"id":"thread-1","historyMode":"paginated","status":{"type":"idle"},"turns":turns}}) + } + fn snapshot(p: &mut CodexProjection, turns: Value) -> ProjectionSnapshot { + p.accept_native_history(&history(turns), &p.begin_history_read()) + .unwrap() + } + + #[test] + fn independent_read_never_becomes_the_event_delta_baseline() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + delta(&mut p, "t", "Hel"); + let ahead = snapshot( + &mut p, + json!([{"id":"t","status":"inProgress","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"Hello"}]}]), + ); + assert_eq!(ahead.metadata["eventOwnedTurnIds"], json!(["t"])); + delta(&mut p, "t", "lo"); + assert_eq!( + p.snapshot_rows("t").unwrap()[0]["items"][0]["text"], + "Hello" + ); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"Hello"}), + true, + ); + assert!(delta(&mut p, "t", "lo").is_empty()); + ended(&mut p, "t", "completed"); + let saved = snapshot( + &mut p, + json!([{"id":"t","status":"completed","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"Hello"}]}]), + ); + assert_eq!(saved.metadata["nativeFinalTurnIds"], json!(["t"])); + assert_eq!( + saved.raw["thread"]["turns"][0]["items"] + .as_array() + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn summary_interruption_retains_fragments_and_cold_recovery_is_saved_only() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + delta(&mut p, "t", "unfinished 🙂"); + ended(&mut p, "t", "interrupted"); + let raw = json!([{"id":"t","status":"interrupted","itemsView":"full","items":[{"type":"userMessage","id":"user","content":[{"type":"text","text":"go"}]}]}]); + let warm = snapshot(&mut p, raw.clone()); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["provenance"], + "saved-and-retained" + ); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["diagnostic"], + "interrupted" + ); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["refreshPending"], + false + ); + assert_eq!( + warm.metadata["turnOutputStates"]["t"]["retainedTurns"][0]["items"][0]["text"], + "unfinished 🙂" + ); + assert!(delta(&mut p, "t", "late").is_empty()); + let mut cold = CodexProjection::new("cold".into(), "thread-1".into()); + let saved = snapshot(&mut cold, raw); + assert_eq!( + saved.metadata["turnOutputStates"]["t"], + json!({"provenance":"saved","diagnostic":"interrupted","refreshPending":false}) + ); + } + + #[test] + fn missing_or_different_assistant_persistence_reaches_finite_unsaved_state() { + for turns in [ + json!([]), + json!([{"id":"t","status":"completed","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"short"}]}]), + ] { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"retained complete answer"}), + true, + ); + ended(&mut p, "t", "completed"); + for attempt in 1..=4 { + let view = snapshot(&mut p, turns.clone()); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][0]["text"], + "retained complete answer" + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["refreshPending"], + attempt < 3 + ); + if attempt >= 3 { + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "unsaved" + ); + } + } + } + } + + #[test] + fn legacy_comparison_preserves_duplicate_bodies_roles_and_outcomes() { + let event = json!({"id":"t","status":"completed","items":[{"type":"userMessage","id":"uuid-user","content":[{"type":"text","text":"same"}]},{"type":"agentMessage","id":"msg-1","text":"same"},{"type":"agentMessage","id":"msg-2","text":"same"},{"type":"commandExecution","id":"call-1","command":"pwd","status":"failed","exitCode":1}]}); + let mut native = event.clone(); + for (index, i) in native["items"] + .as_array_mut() + .unwrap() + .iter_mut() + .enumerate() + { + i["id"] = json!(format!("item-{}", index + 1)); + } + assert!(equivalent_turns(&event, &native, true)); + assert!(!equivalent_turns(&event, &native, false)); + // Legacy reconstructs message ids while preserving actual tool call + // ids in the same turn (also observed in the large-result capture). + native["items"][3]["id"] = json!("call-1"); + assert!(equivalent_turns(&event, &native, true)); + native["items"].as_array_mut().unwrap().remove(2); + assert!(!equivalent_turns(&event, &native, true)); + assert!(!provider_truncated( + &json!({"items":[{"type":"agentMessage","text":"short"}]}) + )); + } + + #[test] + fn native_tool_truncation_preserves_saved_markers_and_richer_retained_output() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + let item_body = json!({"type":"commandExecution","id":"cmd","command":"large","status":"completed","aggregatedOutput":"x".repeat(100_000)}); + item(&mut p, "t", item_body.clone(), true); + ended(&mut p, "t", "completed"); + let mut saved_item = item_body; + saved_item["aggregatedOutput"] = json!(format!( + "{}\n... command output truncated for persistence ...\n{}", + "x".repeat(32000), + "x".repeat(32000) + )); + let view = snapshot( + &mut p, + json!([{"id":"t","status":"completed","itemsView":"full","items":[saved_item.clone()]}]), + ); + assert_eq!(view.raw["thread"]["turns"][0]["items"][0], saved_item); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "provider-truncated" + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["refreshPending"], + false + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["retainedTurns"][0]["items"][0]["output"] + .as_str() + .unwrap() + .len(), + 100_000 + ); + } + + #[test] + fn mcp_truncation_mentions_do_not_end_persistence_refresh_or_mislabel_saved_output() { + for result in [ + json!({"content":[{"type":"text","text":"The report says 12 chars truncated by the old formatter."}]}), + json!({"content":[{"type":"text","text":"before… chars truncated…after"}]}), + json!({"content":[{"type":"text","text":"before…12 chars truncated after"}]}), + json!({"content":[{"type":"text","text":"before…12x chars truncated…after"}]}), + json!({"content":[{"type":"text","text":"before12 chars truncated…after"}]}), + json!({"content":[{"type":"text","text":"ordinary output"}],"structuredContent":{"note":"…12 chars truncated…"}}), + json!({"content":[{"type":"text","text":"ordinary output"}],"_meta":{"note":"…12 chars truncated…"}}), + json!({"content":[{"type":"resource","resource":{"text":"…12 chars truncated…"}}]}), + ] { + let saved_item = json!({"type":"mcpToolCall","id":"mcp","server":"fixture","tool":"report","status":"completed","arguments":{},"result":result}); + let saved_turn = json!({"id":"t","status":"completed","itemsView":"full","items":[saved_item.clone()]}); + for retained_differs in [false, true] { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + let mut retained_item = saved_item.clone(); + if retained_differs { + retained_item["result"] = + json!({"content":[{"type":"text","text":"retained complete result"}]}); + } + item(&mut p, "t", retained_item.clone(), true); + ended(&mut p, "t", "completed"); + for attempt in 1..=3 { + let view = snapshot(&mut p, json!([saved_turn.clone()])); + let state = &view.metadata["turnOutputStates"]["t"]; + assert_eq!(view.raw["thread"]["turns"][0]["items"][0], retained_item); + assert_eq!( + state["refreshPending"], + retained_differs && attempt < 3, + "ordinary MCP result must not end persistence refresh: {result}" + ); + if retained_differs { + assert_eq!(state["provenance"], "retained"); + assert_eq!(state["savedTurns"][0]["items"][0]["result"], result); + assert_eq!( + state["diagnostic"], + if attempt < 3 { + Value::Null + } else { + json!("unsaved") + } + ); + } else { + assert_eq!(state["provenance"], "saved"); + assert!( + state["diagnostic"].is_null(), + "ordinary MCP result was labeled truncated: {result}" + ); + } + } + } + } + } + + #[test] + fn captured_0164_mcp_truncation_preserves_exact_saved_and_richer_retained_results() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/fresh-agent/codex/native-projection-0.164.json" + )) + .unwrap(); + let case = corpus["cases"] + .as_array() + .unwrap() + .iter() + .find(|case| { + case["parameters"]["largeOutput"] == true + && case["parameters"]["historyMode"] == "paginated" + }) + .unwrap(); + let mut native = case["read"].clone(); + // Isolate MCP so a command's separate persistence marker cannot make + // this test pass without recognizing the captured MCP representation. + native["thread"]["turns"][0]["items"] + .as_array_mut() + .unwrap() + .retain(|item| item["type"] == "mcpToolCall"); + let thread = native["thread"]["id"].as_str().unwrap(); + let turn = native["thread"]["turns"][0]["id"].as_str().unwrap(); + let mut p = CodexProjection::new("actual-mcp-capture".into(), thread.into()); + let mut retained_result = Value::Null; + for notification in case["notifications"].as_array().unwrap() { + let method = notification["method"].as_str().unwrap(); + let params = ¬ification["params"]; + if matches!(method, "turn/started" | "turn/completed") + || params["item"]["type"] == "mcpToolCall" + { + p.apply(method, params).unwrap(); + } + if method == "item/completed" && params["item"]["type"] == "mcpToolCall" { + retained_result = params["item"]["result"].clone(); + } + } + let saved_result = &native["thread"]["turns"][0]["items"][0]["result"]; + assert!(saved_result["content"][0]["text"].as_str().unwrap().len() > 64_000); + assert!( + retained_result["content"][0]["text"] + .as_str() + .unwrap() + .len() + > 128_000 + ); + let view = p + .accept_native_history(&native, &p.begin_history_read()) + .unwrap(); + assert_eq!(view.raw["thread"]["turns"], native["thread"]["turns"]); + let state = &view.metadata["turnOutputStates"][turn]; + assert_eq!(state["provenance"], "saved-and-retained"); + assert_eq!(state["diagnostic"], "provider-truncated"); + assert_eq!(state["refreshPending"], false); + assert_eq!( + state["retainedTurns"][0]["items"][0]["result"], + retained_result + ); + let mut cold = CodexProjection::new("cold-mcp-capture".into(), thread.into()); + let saved = cold + .accept_native_history(&native, &cold.begin_history_read()) + .unwrap(); + assert_eq!(saved.raw["thread"]["turns"], native["thread"]["turns"]); + assert_eq!( + saved.metadata["turnOutputStates"][turn], + json!({"provenance":"saved","diagnostic":"provider-truncated","refreshPending":false}) + ); + } + + #[test] + fn reads_crossing_history_or_owner_mutation_are_rejected_and_removed_turns_never_reopen() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"before undo"}), + true, + ); + ended(&mut p, "t", "completed"); + let fence = p.begin_history_read(); + p.invalidate_history(&["t".into()]); + assert!(p + .accept_native_history(&history(json!([])), &fence) + .is_err()); + for index in 0..12 { + let id = format!("later-{index}"); + started(&mut p, &id); + ended(&mut p, &id, "completed"); + } + assert!(item( + &mut p, + "t", + json!({"type":"agentMessage","id":"late","text":"removed"}), + false + ) + .is_empty()); + assert!(delta(&mut p, "t", "removed").is_empty()); + assert_eq!( + snapshot(&mut p, json!([])).metadata["nativeFinalTurnIds"], + json!([]) + ); + let owner_fence = p.begin_history_read(); + p.bind_owner(1, 2); + assert!(p + .accept_native_history(&history(json!([])), &owner_fence) + .is_err()); + } + + #[test] + fn cold_native_preview_can_receive_a_normal_complete_item_without_appending_from_the_read() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + snapshot( + &mut p, + json!([{"id":"t","status":"inProgress","itemsView":"full","items":[{"type":"agentMessage","id":"answer","text":"preview"}]}]), + ); + assert!(delta(&mut p, "t", "not-a-baseline").is_empty()); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":"complete"}), + true, + ); + assert_eq!( + p.snapshot_rows("t").unwrap()[0]["items"][0]["text"], + "complete" + ); + } + + #[test] + fn cold_recovery_preserves_native_prefix_roles_and_finishes_as_one_saved_group() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + let mut native = json!({"id":"t","status":"inProgress","itemsView":"full","items":[ + {"id":"u","type":"userMessage","content":[{"type":"text","text":"go"}]}, + {"id":"prior","type":"agentMessage","text":"Prior commentary"}, + {"id":"cmd","type":"commandExecution","command":"pwd","status":"completed","aggregatedOutput":"/work"}, + {"id":"answer","type":"agentMessage","text":"partial"} + ]}); + snapshot(&mut p, json!([native.clone()])); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"complete"}), + true, + ); + let rows = p.snapshot_rows("t").unwrap(); + assert_eq!( + rows.len(), + 4, + "recovering the complete item cannot erase the independently read prefix" + ); + assert_eq!(rows[0]["role"], "user"); + assert_eq!(rows[1]["items"][0]["text"], "Prior commentary"); + assert!(p.apply("item/agentMessage/delta",&json!({"threadId":"thread-1","turnId":"t","itemId":"prior","delta":"must not append"})).unwrap().is_empty()); + ended(&mut p, "t", "completed"); + native["status"] = json!("completed"); + native["items"][3]["text"] = json!("complete"); + let view = snapshot(&mut p, json!([native])); + assert_eq!(view.metadata["nativeFinalTurnIds"], json!(["t"])); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["provenance"], + "saved" + ); + } + + #[test] + fn read_only_prefix_refresh_never_overwrites_live_fields_or_hides_a_completed_mismatch() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + let mut native = json!({"id":"t","status":"inProgress","itemsView":"full","items":[ + {"id":"prior","type":"agentMessage","text":"older preview"}, + {"id":"answer","type":"agentMessage","text":"read-only answer"} + ]}); + snapshot(&mut p, json!([native.clone()])); + let ops = item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":""}), + false, + ); + assert_eq!(ops[0]["nonAppendableItemIds"], json!(["prior"])); + let fence = p.begin_history_read(); + delta(&mut p, "t", "Hel"); + native["items"][0]["text"] = json!("newer preview"); + native["items"][1]["text"] = json!("Hello"); + let view = p + .accept_native_history(&history(json!([native.clone()])), &fence) + .unwrap(); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][0]["text"], + "newer preview" + ); + assert_eq!(view.raw["thread"]["turns"][0]["items"][1]["text"], ""); + assert_eq!(p.snapshot_rows("t").unwrap()[0]["items"][1]["text"], "Hel"); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"actual completed mismatch"}), + true, + ); + ended(&mut p, "t", "completed"); + native["status"] = json!("completed"); + let mut view = snapshot(&mut p, json!([native.clone()])); + for _ in 1..MAX_SAVE_READS { + view = snapshot(&mut p, json!([native.clone()])); + } + assert_eq!(view.metadata["nativeFinalTurnIds"], json!([])); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "unsaved" + ); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][1]["text"], + "actual completed mismatch" + ); + } + + #[test] + fn an_unfinished_sibling_cannot_excuse_a_completed_item_mismatch() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"id":"cmd","type":"commandExecution","command":"pwd","status":"inProgress","aggregatedOutput":""}), + false, + ); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"actual completed output"}), + true, + ); + ended(&mut p, "t", "completed"); + let native = json!([{"id":"t","status":"completed","itemsView":"full","items":[ + {"id":"cmd","type":"commandExecution","command":"pwd","status":"completed","aggregatedOutput":"/work"}, + {"id":"answer","type":"agentMessage","text":"different unexplained output"} + ]}]); + let mut view = snapshot(&mut p, native.clone()); + for _ in 1..MAX_SAVE_READS { + view = snapshot(&mut p, native.clone()); + } + assert_eq!(view.metadata["nativeFinalTurnIds"], json!([])); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["diagnostic"], + "unsaved" + ); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][1]["text"], + "actual completed output" + ); + } + + #[test] + fn indexed_reasoning_command_progress_and_file_patch_updates_keep_native_item_identity() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"id":"r","type":"reasoning","summary":[],"content":[]}), + false, + ); + for (index, text) in [(1, "思"), (0, "🙂"), (1, "考")] { + p.apply("item/reasoning/summaryTextDelta",&json!({"threadId":"thread-1","turnId":"t","itemId":"r","summaryIndex":index,"delta":text})).unwrap(); + } + assert_eq!( + p.snapshot_rows("t").unwrap()[0]["items"][0]["summary"], + json!(["🙂", "思考"]) + ); + item( + &mut p, + "t", + json!({"id":"cmd","type":"commandExecution","command":"echo hi","status":"inProgress"}), + false, + ); + let ops = p + .apply( + "item/commandExecution/outputDelta", + &json!({"threadId":"thread-1","turnId":"t","itemId":"cmd","delta":"live output"}), + ) + .unwrap(); + assert_eq!(ops[0]["text"], "live output"); + assert_eq!( + p.snapshot_rows("t").unwrap()[1]["items"][0]["output"], + "live output" + ); + item( + &mut p, + "t", + json!({"id":"files","type":"fileChange","status":"inProgress","changes":[]}), + false, + ); + for diff in ["+first", "+replacement"] { + p.apply("item/fileChange/patchUpdated",&json!({"threadId":"thread-1","turnId":"t","itemId":"files","changes":[{"path":"a","diff":diff}]})).unwrap(); + } + assert_eq!( + p.snapshot_rows("t").unwrap()[1]["items"][1]["changes"][0]["diff"], + "+replacement" + ); + p.apply( + "item/fileChange/outputDelta", + &json!({"threadId":"thread-1","turnId":"t","itemId":"files","delta":"Applying patch"}), + ) + .unwrap(); + assert_eq!( + p.snapshot_rows("t").unwrap()[1]["items"][1]["output"], + "Applying patch" + ); + } + + #[test] + fn usage_plan_diff_are_latest_aggregates_and_have_no_invented_context_or_cost() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + p.apply("thread/tokenUsage/updated",&json!({"threadId":"thread-1","turnId":"t","tokenUsage":{"last":{"totalTokens":20,"inputTokens":15,"outputTokens":5,"reasoningOutputTokens":3},"total":{"totalTokens":200,"inputTokens":150,"cachedInputTokens":80,"outputTokens":50},"modelContextWindow":258400}})).unwrap(); + for text in ["old", "latest"] { + p.apply("turn/plan/updated",&json!({"threadId":"thread-1","turnId":"t","explanation":text,"plan":[{"step":text,"status":"inProgress"}]})).unwrap(); + p.apply( + "turn/diff/updated", + &json!({"threadId":"thread-1","turnId":"t","diff":text}), + ) + .unwrap(); + } + let mut view = + json!({"extensions":{"codex":{}},"tokenUsage":{},"diffs":[],"capabilities":{}}); + p.overlay(&mut view, json!({})); + assert_eq!(view["tokenUsage"]["totalTokens"], 200); + assert_eq!( + view["extensions"]["codex"]["usage"]["last"]["totalTokens"], + 20 + ); + assert!(view["tokenUsage"].get("contextTokens").is_none()); + assert!(view["tokenUsage"].get("costUsd").is_none()); + assert_eq!(view["extensions"]["codex"]["plan"]["explanation"], "latest"); + assert_eq!( + view["diffs"], + json!([{"id":"codex-turn:t","title":"Turn changes","diff":"latest"}]) + ); + } + + #[test] + fn emitted_delta_bytes_grow_linearly_and_batch_adjacent_fields() { + fn run(count: usize) -> (usize, String) { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":""}), + false, + ); + let mut bytes = 0; + for _ in 0..count { + let ops = delta(&mut p, "t", "🙂"); + bytes += serde_json::to_vec(&ops).unwrap().len(); + p.enqueue(ops); + } + let batch = p.take_patch().unwrap(); + assert_eq!(batch["ops"].as_array().unwrap().len(), 1); + assert_eq!(batch["ops"][0]["text"], "🙂".repeat(count)); + ( + bytes, + p.snapshot_rows("t").unwrap()[0]["items"][0]["text"] + .as_str() + .unwrap() + .to_owned(), + ) + } + let (a, text) = run(1000); + let (b, other) = run(2000); + assert_eq!(text.len() * 2, other.len()); + assert!(b > a * 19 / 10 && b < a * 21 / 10); + } + + #[tokio::test] + async fn native_frames_reach_real_consumer_before_completion_with_thread_isolation_and_mcp_progress( + ) { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (state, _) = rig::state_with_bus(); + let mut rx = rig::insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(Mutex::new(None)), + notifs, + rig::spawn_sleeper(), + "codex-projection-consumer", + ) + .await; + peer.emit_notification( + "turn/started", + json!({"threadId":"thread-1","turn":{"id":"t","status":"inProgress"}}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"t","item":{"type":"agentMessage","id":"answer","text":""}})); + peer.emit_notification( + "item/agentMessage/delta", + json!({"threadId":"thread-1","turnId":"t","itemId":"answer","delta":"Hel"}), + ); + peer.emit_notification("item/agentMessage/delta",json!({"threadId":"foreign","turnId":"t","itemId":"answer","delta":"SECRET_OTHER_THREAD"})); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"t","item":{"type":"commandExecution","id":"cmd","command":"echo progress","status":"inProgress"}})); + peer.emit_notification( + "item/commandExecution/outputDelta", + json!({"threadId":"thread-1","turnId":"t","itemId":"cmd","delta":"tool progress"}), + ); + peer.emit_notification("item/started",json!({"threadId":"thread-1","turnId":"t","item":{"type":"mcpToolCall","id":"mcp","server":"fixture","tool":"probe","status":"inProgress","arguments":{}}})); + peer.emit_notification( + "item/mcpToolCall/progress", + json!({"threadId":"thread-1","turnId":"t","itemId":"mcp","message":"halfway"}), + ); + peer.emit_notification("error",json!({"threadId":"thread-1","turnId":"t","error":{"message":"temporary failure"},"willRetry":true})); + peer.emit_notification("error",json!({"threadId":"thread-1","turnId":"t","error":{"message":"provider notice"},"willRetry":false})); + let mut frames = vec![]; + tokio::time::timeout(Duration::from_secs(3), async { + loop { + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + let end = frame["event"]["type"] == "freshAgent.notice"; + frames.push(frame); + if end { + break; + } + } + }) + .await + .expect("consumer flushes before notice without a turn completion"); + let ops: Vec<_> = frames + .iter() + .flat_map(|f| f["event"]["ops"].as_array().into_iter().flatten()) + .collect(); + assert!(ops.iter().any(|op| op["text"] == "Hel")); + assert!(ops.iter().any(|op| op["text"] == "tool progress")); + assert!(ops.iter().any(|op| op["item"]["progress"] == "halfway")); + assert!(frames + .iter() + .any(|f| f["event"]["type"] == "freshAgent.retry")); + assert!(!serde_json::to_string(&frames) + .unwrap() + .contains("SECRET_OTHER_THREAD")); + assert!(!frames + .iter() + .any(|f| f["event"]["type"] == "freshAgent.turn.complete")); + // The in-memory transport's recv owns its mutex while awaiting a + // frame. End the scripted peer before close so cleanup can acquire it. + drop(peer); + state.shutdown().await; + } + + #[tokio::test] + async fn snapshot_rpc_held_across_history_mutation_is_reread_without_resuming_a_provider() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (state, _) = rig::state_with_bus(); + let _rx = rig::insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(Mutex::new(None)), + notifs, + rig::spawn_sleeper(), + "codex-projection-read-fence", + ) + .await; + let reader = state.clone(); + let snapshot = tokio::spawn(async move { reader.get_snapshot("thread-1", None).await }); + let init = peer.expect_request().await; + assert_eq!(init.1, "initialize"); + peer.respond(&init.0,json!({"userAgent":"fixture","codexHome":"/fixture","platformFamily":"unix","platformOs":"linux"})); + assert_eq!(peer.expect_notification().await.0, "initialized"); + let request = peer.expect_request().await; + assert_eq!(request.1, "thread/read"); + assert_eq!(request.2["includeTurns"], true); + state + .sessions + .lock() + .await + .get("thread-1") + .unwrap() + .projection + .lock() + .unwrap() + .invalidate_history(&["removed".into()]); + peer.respond(&request.0,history(json!([{"id":"removed","status":"completed","itemsView":"full","items":[{"id":"a","type":"agentMessage","text":"must disappear"}]}]))); + let reread = peer.expect_request().await; + assert_eq!(reread.1, "thread/read"); + peer.respond(&reread.0, history(json!([]))); + let result = snapshot.await.unwrap().unwrap(); + assert_eq!(result["turns"], json!([])); + assert_eq!( + result["extensions"]["codex"]["projection"]["historyGeneration"], + 1 + ); + drop(peer); + state.shutdown().await; + } + + #[test] + fn a_native_preview_cannot_reopen_an_already_ended_operation() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + snapshot(&mut p, json!([{"id":"t","status":"inProgress","items":[]}])); + item( + &mut p, + "t", + json!({"id":"answer","type":"agentMessage","text":"final"}), + true, + ); + ended(&mut p, "t", "completed"); + started(&mut p, "t"); + assert!(item( + &mut p, + "t", + json!({"id":"late","type":"agentMessage","text":"old"}), + false + ) + .is_empty()); + } + + #[test] + fn cold_saved_rollout_explains_interrupted_output_without_a_provider_runtime() { + let temp = tempfile::tempdir().unwrap(); + let file = temp.path().join("rollout.jsonl"); + let rows = [ + json!({"type":"session_meta","payload":{"id":"thread-1"}}), + json!({"type":"event_msg","payload":{"type":"task_started","turn_id":"t"}}), + json!({"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"go"}]}}), + json!({"type":"event_msg","payload":{"type":"turn_aborted","turn_id":"t","reason":"interrupted"}}), + ]; + std::fs::write( + &file, + rows.iter() + .map(Value::to_string) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + let saved = super::super::native_history::read_rollout(&file, "thread-1").unwrap(); + assert_eq!( + saved["turns"][0]["outputState"], + json!({"provenance":"saved","diagnostic":"interrupted","refreshPending":false}) + ); + assert_eq!(saved["tokenUsage"]["observed"], false); + } + + #[test] + fn read_unavailability_preserves_retained_output_and_reports_typed_status() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + delta(&mut p, "t", "retained"); + p.freeze_active("interrupted"); + let view = p.unavailable_history(&p.begin_history_read()).unwrap(); + assert_eq!( + view.raw["thread"]["turns"][0]["items"][0]["text"], + "retained" + ); + assert_eq!( + view.metadata["availability"], + json!({"status":"unavailable","retryable":true,"code":"CODEX_HISTORY_UNAVAILABLE"}) + ); + assert_eq!( + view.metadata["turnOutputStates"]["t"]["refreshPending"], + false + ); + } + + #[tokio::test] + async fn snapshot_cuts_pending_batch_before_capturing_its_event_prefix() { + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = CodexAppServerClient::connect(transport); + let (state, _) = rig::state_with_bus(); + let mut rx = rig::insert_fake_session_with_real_consumer( + &state, + "thread-1", + Arc::new(client), + Arc::new(Mutex::new(None)), + notifs, + rig::spawn_sleeper(), + "codex-projection-batch-fence", + ) + .await; + let projection = { + let sessions = state.sessions.lock().await; + let session = sessions.get("thread-1").unwrap(); + session.consumer.abort(); + session.projection.clone() + }; + // The consumer's owned batching state is seeded directly so a timer + // cannot accidentally flush the regression before the read captures it. + { + let mut p = projection.lock().unwrap(); + started(&mut p, "t"); + item( + &mut p, + "t", + json!({"type":"agentMessage","id":"answer","text":""}), + false, + ); + let ops = delta(&mut p, "t", "Hel"); + p.enqueue(ops); + } + let reader = state.clone(); + let snapshot = tokio::spawn(async move { reader.get_snapshot("thread-1", None).await }); + let init = peer.expect_request().await; + assert_eq!(init.1, "initialize"); + peer.respond(&init.0,json!({"userAgent":"fixture","codexHome":"/fixture","platformFamily":"unix","platformOs":"linux"})); + peer.expect_notification().await; + let request = peer.expect_request().await; + assert_eq!(request.1, "thread/read"); + { + let mut p = projection.lock().unwrap(); + let ops = delta(&mut p, "t", "lo"); + p.enqueue(ops); + } + peer.respond( + &request.0, + history(json!([{"id":"t","status":"inProgress","itemsView":"full","items":[]}])), + ); + let view = snapshot.await.unwrap().unwrap(); + assert_eq!(view["turns"][0]["items"][0]["text"], "Hel"); + let pending = projection.lock().unwrap().take_patch().unwrap(); + assert_eq!( + pending["ops"][0]["text"], "lo", + "the captured Hel must not be appended again by a coalesced Hello" + ); + let published: Value = serde_json::from_str( + &tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(published["event"]["ops"][0]["text"], "Hel"); + drop(peer); + state.shutdown().await; + } + + #[test] + fn missing_older_output_stays_before_a_later_native_turn_after_its_payload_is_released() { + let mut p = CodexProjection::new("runtime".into(), "thread-1".into()); + started(&mut p, "older"); + item( + &mut p, + "older", + json!({"id":"a","type":"agentMessage","text":"older unsaved"}), + true, + ); + ended(&mut p, "older", "completed"); + started(&mut p, "later"); + item( + &mut p, + "later", + json!({"id":"b","type":"agentMessage","text":"later saved"}), + true, + ); + ended(&mut p, "later", "completed"); + let native = json!([{"id":"later","status":"completed","itemsView":"full","items":[{"id":"b","type":"agentMessage","text":"later saved"}]}]); + for _ in 0..4 { + let view = snapshot(&mut p, native.clone()); + let ids: Vec<_> = view.raw["thread"]["turns"] + .as_array() + .unwrap() + .iter() + .map(|turn| turn["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, vec!["older", "later"]); + } + } + + #[test] + fn native_text_delta_then_full_item_is_one_exact_item() { + let mut p = CodexProjection::new("runtime-1".into(), "thread-1".into()); + p.apply("item/started", &json!({"threadId":"thread-1","turnId":"turn-1", "item":{"type":"agentMessage","id":"answer-1","text":""}})).unwrap(); + let ops = p + .apply( + "item/agentMessage/delta", + &json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer-1","delta":"Hel"}), + ) + .unwrap(); + assert!(ops.iter().any(|op| op["text"] == "Hel")); + p.apply( + "item/agentMessage/delta", + &json!({"threadId":"thread-1","turnId":"turn-1","itemId":"answer-1","delta":"lo"}), + ) + .unwrap(); + p.apply("item/completed", &json!({"threadId":"thread-1","turnId":"turn-1", "item":{"type":"agentMessage","id":"answer-1","text":"Hello"}})).unwrap(); + let rows = p.snapshot_rows("turn-1").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0]["items"], + json!([{"id":"answer-1","kind":"text","text":"Hello"}]) + ); + assert!(p + .apply( + "item/agentMessage/delta", + &json!({"threadId":"other","turnId":"turn-1","itemId":"answer-1","delta":"leak"}) + ) + .unwrap() + .is_empty()); + } + + #[test] + fn actual_0164_captures_converge_legacy_duplicates_tool_outcomes_and_bounded_native_results() { + let corpus: Value = serde_json::from_str(include_str!( + "../../../../test/fixtures/fresh-agent/codex/native-projection-0.164.json" + )) + .unwrap(); + for case in corpus["cases"].as_array().unwrap() { + let native = &case["read"]; + let thread = native["thread"]["id"].as_str().unwrap(); + let mut p = CodexProjection::new("actual-capture".into(), thread.into()); + for notification in case["notifications"].as_array().unwrap() { + p.apply( + notification["method"].as_str().unwrap(), + ¬ification["params"], + ) + .unwrap(); + } + let saved = p + .accept_native_history(native, &p.begin_history_read()) + .unwrap(); + if case["scenario"] == "approval-policy-restricted-exec" { + // The first PTY finishes after its turn and is persisted after + // the answer. A later running PTY is absent from full native + // persistence. Keep that omitted body as finite retained data. + p.accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let finite = p + .accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let turns = native["thread"]["turns"].as_array().unwrap(); + for index in [0, 1, 3] { + assert_eq!(finite.raw["thread"]["turns"][index], turns[index]); + } + let omitted = &turns[2]; + let state = &finite.metadata["turnOutputStates"][omitted["id"].as_str().unwrap()]; + assert_eq!(state["diagnostic"], "unsaved"); + assert_eq!(state["refreshPending"], false); + assert_eq!( + state["savedTurns"], + json!(super::super::build_codex_turn_json(omitted, 0).unwrap()) + ); + assert!(finite.raw["thread"]["turns"][2]["items"] + .as_array() + .unwrap() + .iter() + .any(|item| item["type"] == "commandExecution")); + assert_eq!( + finite.metadata["nativeFinalTurnIds"] + .as_array() + .unwrap() + .len(), + 3 + ); + continue; + } + if case["scenario"] == "mcp-form-url" && case["parameters"]["historyMode"] == "legacy" { + // This actual Legacy read omits the command altogether while + // preserving the full MCP result. It cannot agree with the + // richer event turn. Retain it with its exact saved view and + // settle finitely, rather than inventing truncation/equality. + p.accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let finite = p + .accept_native_history(native, &p.begin_history_read()) + .unwrap(); + let turn = &native["thread"]["turns"][0]; + let state = &finite.metadata["turnOutputStates"][turn["id"].as_str().unwrap()]; + assert_eq!(state["diagnostic"], "unsaved"); + assert_eq!(state["refreshPending"], false); + assert!( + state["savedTurns"] + == json!(super::super::build_codex_turn_json(turn, 0).unwrap()), + "the native saved representation is preserved exactly" + ); + assert!( + finite.raw["thread"]["turns"][0]["items"] + .as_array() + .unwrap() + .iter() + .any(|item| item["type"] == "commandExecution"), + "richer retained command output remains available" + ); + continue; + } + assert!( + saved.raw["thread"]["turns"] == native["thread"]["turns"], + "actual saved representation: {}", + case["scenario"] + ); + assert_eq!( + saved.metadata["nativeFinalTurnIds"] + .as_array() + .unwrap() + .len(), + native["thread"]["turns"].as_array().unwrap().len(), + "all captured terminal native groups are final: {}", + case["scenario"] + ); + if case["parameters"]["largeOutput"] == true { + let states = saved.metadata["turnOutputStates"].as_object().unwrap(); + assert!(states + .values() + .any(|state| state["diagnostic"] == "provider-truncated" + && state["refreshPending"] == false + && state["retainedTurns"].is_array())); + } + } + } +} diff --git a/crates/freshell-freshagent/src/codex/settings.rs b/crates/freshell-freshagent/src/codex/settings.rs new file mode 100644 index 0000000000..8cafacf9a4 --- /dev/null +++ b/crates/freshell-freshagent/src/codex/settings.rs @@ -0,0 +1,183 @@ +//! Codex next-turn defaults, independent of runtime identity and ownership. + +use freshell_codex::{ + normalize_freshcodex_effort, normalize_freshcodex_model, to_codex_reasoning_effort, +}; +use freshell_protocol::FreshAgentSendSettings; +use serde::Serialize; +use serde_json::{json, Value}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum CodexWorkMode { + Default, + Plan, +} + +impl CodexWorkMode { + pub fn parse(value: &str) -> Option { + match value { + "default" => Some(Self::Default), + "plan" => Some(Self::Plan), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Default => "default", + Self::Plan => "plan", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexEffectiveSettings { + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, +} + +#[derive(Debug)] +pub struct SettingsError { + pub code: &'static str, + pub message: String, +} + +impl CodexEffectiveSettings { + /// An absent patch field preserves the exact selection, including native + /// default effort and opaque provider model/effort values. + pub fn resolve(base: &Self, patch: &FreshAgentSendSettings) -> Result { + let model = normalize_freshcodex_model(patch.model.as_deref().or(Some(&base.model))); + let effort = normalize_freshcodex_effort( + Some(&model), + patch + .effort + .as_ref() + .map_or(base.effort.as_deref(), |value| value.as_deref()), + ); + let mut sandbox = patch + .sandbox + .map(super::sandbox_wire_value) + .or_else(|| base.sandbox.clone()); + let mut permission_mode = patch + .permission_mode + .clone() + .or_else(|| base.permission_mode.clone()); + super::normalize_codex_permission(&mut permission_mode, &mut sandbox).map_err( + |message| SettingsError { + code: "INVALID_PERMISSION_MODE", + message: message.into(), + }, + )?; + to_codex_reasoning_effort(effort.as_deref()).map_err(|error| SettingsError { + code: "INVALID_EFFORT", + message: error.to_string(), + })?; + let cwd = patch.cwd.clone().or_else(|| base.cwd.clone()); + if cwd.as_deref().is_some_and(|cwd| cwd.trim().is_empty()) { + return Err(SettingsError { + code: "INVALID_CWD", + message: "Choose a nonempty working directory.".into(), + }); + } + let collaboration_mode = match patch.collaboration_mode.as_deref() { + Some(value) => Some(CodexWorkMode::parse(value).ok_or_else(|| SettingsError { + code: "INVALID_COLLABORATION_MODE", + message: "Choose Build or Plan work mode.".into(), + })?), + None => base.collaboration_mode, + }; + Ok(Self { + model, + effort, + cwd, + sandbox, + permission_mode, + collaboration_mode, + }) + } + + /// The caller supplies the same wire-normalized effort used by turn/start. + /// Default is explicit: omission could retain a previous native Plan mode. + pub fn native_collaboration_mode(&self) -> Option { + Some( + json!({"mode":self.collaboration_mode.unwrap_or(CodexWorkMode::Default).as_str(),"settings":{ + "model":self.model,"reasoning_effort":self.effort,"developer_instructions":null, + }}), + ) + } + + pub fn as_json(&self) -> Value { + let mut value = serde_json::to_value(self).expect("Codex settings serialize"); + value["effort"] = json!(self.effort); + value["collaborationMode"] = + json!(self.collaboration_mode.unwrap_or(CodexWorkMode::Default)); + value + } + + /// Adopt overrides actually supplied to thread/start or thread/resume. + /// Effort and work mode are next-turn choices: those requests do not send + /// them. Their native echoes are retained separately as thread observations. + pub fn adopt_native(&mut self, native: &Value) { + if let Some(model) = native + .get("model") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + { + self.model = model.into(); + } + if let Some(cwd) = native.get("cwd").and_then(Value::as_str) { + self.cwd = Some(cwd.into()); + } + if let Some(policy) = native.get("approvalPolicy").and_then(Value::as_str) { + self.permission_mode = Some(policy.into()); + } + if let Some(sandbox) = native.get("sandbox") { + let mode = sandbox + .as_str() + .or_else(|| sandbox.get("type").and_then(Value::as_str)); + self.sandbox = match mode { + Some("readOnly" | "read-only") => Some("read-only".into()), + Some("workspaceWrite" | "workspace-write") => Some("workspace-write".into()), + Some("dangerFullAccess" | "danger-full-access") => { + Some("danger-full-access".into()) + } + _ => self.sandbox.clone(), + }; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plan_mode_uses_selected_model_and_effort_without_changing_sandbox() { + let settings = CodexEffectiveSettings { + model: "provider-current".into(), + effort: Some("xhigh".into()), + cwd: Some("/work".into()), + sandbox: Some("read-only".into()), + permission_mode: Some("on-request".into()), + collaboration_mode: Some(CodexWorkMode::Plan), + }; + assert_eq!( + settings.native_collaboration_mode(), + Some( + json!({"mode":"plan","settings":{"model":"provider-current","reasoning_effort":"xhigh","developer_instructions":null}}) + ) + ); + assert_eq!(settings.sandbox.as_deref(), Some("read-only")); + } +} diff --git a/crates/freshell-freshagent/src/codex/turn_state.rs b/crates/freshell-freshagent/src/codex/turn_state.rs new file mode 100644 index 0000000000..d0f114433a --- /dev/null +++ b/crates/freshell-freshagent/src/codex/turn_state.rs @@ -0,0 +1,554 @@ +//! Session-local dispatch ownership. RPC replies describe delivery; only native +//! terminal evidence (or confirmed owned exit) describes the end of work. + +use super::{CodexSettlementSource, CodexTurnEvent}; +use std::collections::VecDeque; +use std::sync::Arc; +use tokio::sync::Notify; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct DispatchToken(u64); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum StopDisposition { + None, + Latched, + Interrupt { + dispatch: DispatchToken, + turn_id: String, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum NativeSettlement { + Completed, + Interrupted, + Failed, + OwnedExit, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Delivery { + Pending, + Accepted, + Ambiguous, + Refused, +} + +#[derive(Clone, Debug)] +pub(crate) struct DispatchFact { + pub token: DispatchToken, + pub request_id: String, + pub turn_id: Option, + pub delivery: Delivery, + pub settlement: Option, +} + +#[derive(Debug)] +struct StopOperation { + dispatch: DispatchToken, + turn_id: Option, + issued: bool, +} + +#[derive(Debug, Default)] +pub(crate) struct CodexTurnState { + next_dispatch: u64, + facts: VecDeque, + active: Option, + stop: Option, + user_stop: Option, + owned_exit: bool, + settlement_stream: Option, + pub changed: Arc, + #[cfg(test)] + pub waiting_for_stop: Arc, +} + +#[derive(Debug)] +struct SettlementStream { + session_id: String, + incarnation_id: String, + sender: tokio::sync::broadcast::Sender, +} + +impl CodexTurnState { + pub fn bind_settlement_stream( + &mut self, + session_id: String, + incarnation_id: String, + sender: tokio::sync::broadcast::Sender, + ) { + self.settlement_stream = Some(SettlementStream { + session_id, + incarnation_id, + sender, + }); + } + pub fn begin_dispatch(&mut self, request_id: &str) -> DispatchToken { + self.next_dispatch = self + .next_dispatch + .checked_add(1) + .expect("dispatch counter exhausted"); + let token = DispatchToken(self.next_dispatch); + // Keep all unsettled receipts; bound completed history without throwing + // away the current turn's individual same-ID steer deliveries. + while self.facts.len() >= 64 + && self + .facts + .front() + .is_some_and(|f| f.settlement.is_some() || f.delivery == Delivery::Refused) + { + self.facts.pop_front(); + } + self.facts.push_back(DispatchFact { + token, + request_id: request_id.into(), + turn_id: self.active.clone(), + delivery: Delivery::Pending, + settlement: None, + }); + token + } + + pub fn accept(&mut self, token: DispatchToken, turn_id: &str) -> StopDisposition { + let Some(fact) = self.facts.iter_mut().find(|f| f.token == token) else { + return StopDisposition::None; + }; + if fact.delivery == Delivery::Refused { + return StopDisposition::None; + } + fact.delivery = Delivery::Accepted; + fact.turn_id = Some(turn_id.into()); + if fact.settlement.is_none() { + self.active = Some(turn_id.into()); + } + self.issue_latched_stop(token, turn_id) + } + + pub fn reject(&mut self, token: DispatchToken) -> bool { + let Some(fact) = self.facts.iter_mut().find(|f| f.token == token) else { + return false; + }; + // An observed native start is admission evidence even if a contrary + // controller response follows it. + if fact.delivery == Delivery::Accepted { + return false; + } + fact.delivery = Delivery::Refused; + fact.settlement = None; + if self + .stop + .as_ref() + .is_some_and(|s| s.dispatch == token && !s.issued) + { + self.stop = None; + self.changed.notify_waiters(); + } + true + } + + pub fn mark_ambiguous(&mut self, token: DispatchToken) { + if let Some(fact) = self.facts.iter_mut().find(|f| f.token == token) { + if fact.delivery == Delivery::Pending { + fact.delivery = Delivery::Ambiguous; + } + } + } + + pub fn request_stop(&mut self) -> StopDisposition { + if self.stop.is_some() { + return StopDisposition::Latched; + } + let Some(fact) = self + .facts + .iter() + .rev() + .find(|f| f.settlement.is_none() && f.delivery != Delivery::Refused) + else { + return StopDisposition::None; + }; + let dispatch = fact.token; + let target = self.active.clone().or_else(|| fact.turn_id.clone()); + if target + .as_ref() + .is_some_and(|id| self.user_stop.as_ref() == Some(id)) + { + return StopDisposition::None; + } + self.stop = Some(StopOperation { + dispatch, + turn_id: target.clone(), + issued: false, + }); + match target { + Some(id) => self.issue_latched_stop(dispatch, &id), + None => StopDisposition::Latched, + } + } + + fn issue_latched_stop(&mut self, token: DispatchToken, turn_id: &str) -> StopDisposition { + let Some(stop) = self + .stop + .as_mut() + .filter(|s| s.dispatch == token && !s.issued) + else { + return StopDisposition::None; + }; + if !self + .facts + .iter() + .any(|f| f.token == token && f.settlement.is_none() && f.delivery != Delivery::Refused) + { + self.stop = None; + self.changed.notify_waiters(); + return StopDisposition::None; + } + stop.issued = true; + stop.turn_id = Some(turn_id.into()); + self.user_stop = Some(turn_id.into()); + StopDisposition::Interrupt { + dispatch: token, + turn_id: turn_id.into(), + } + } + + pub fn interrupt_ack(&mut self, token: DispatchToken, turn_id: &str) { + if self + .stop + .as_ref() + .is_some_and(|s| s.dispatch == token && s.turn_id.as_deref() == Some(turn_id)) + { + self.stop = None; + self.changed.notify_waiters(); + } + } + + /// Only a proven pre-submission refusal retracts a user's Stop intent. + pub fn interrupt_refused(&mut self, token: DispatchToken, turn_id: &str) -> bool { + if !self + .stop + .as_ref() + .is_some_and(|s| s.dispatch == token && s.turn_id.as_deref() == Some(turn_id)) + { + return false; + } + self.interrupt_ack(token, turn_id); + if self.user_stop.as_deref() == Some(turn_id) { + self.user_stop = None; + } + true + } + + pub fn settle_native(&mut self, turn_id: &str, evidence: NativeSettlement) -> bool { + self.settle( + turn_id, + evidence, + if evidence == NativeSettlement::OwnedExit { + CodexSettlementSource::ConfirmedOwnedExit + } else { + CodexSettlementSource::NativeNotification + }, + ) + } + + pub fn settle_native_read(&mut self, turn_id: &str, evidence: NativeSettlement) -> bool { + self.settle(turn_id, evidence, CodexSettlementSource::NativeThreadRead) + } + + fn settle( + &mut self, + turn_id: &str, + evidence: NativeSettlement, + source: CodexSettlementSource, + ) -> bool { + let mut settled = false; + let mut receipts = std::collections::BTreeMap::, Vec>::new(); + for fact in &mut self.facts { + if fact.settlement.is_none() + && fact.delivery != Delivery::Refused + && (evidence == NativeSettlement::OwnedExit + || fact.turn_id.as_deref() == Some(turn_id)) + { + fact.settlement = Some(evidence); + receipts + .entry(fact.turn_id.clone()) + .or_default() + .push(fact.request_id.clone()); + settled = true; + } + } + if evidence == NativeSettlement::OwnedExit || self.active.as_deref() == Some(turn_id) { + self.active = None; + } + // A completion does not prove that an outstanding native interrupt has + // finished its unqualified core submission. Owned exit does. + if evidence == NativeSettlement::OwnedExit { + self.owned_exit = true; + self.stop = None; + self.user_stop = None; + self.changed.notify_waiters(); + } + if let Some(stream) = &self.settlement_stream { + for (turn_id, request_ids) in receipts { + let _ = stream.sender.send(CodexTurnEvent::Settled { + session_id: stream.session_id.clone(), + incarnation_id: stream.incarnation_id.clone(), + turn_id, + request_ids, + outcome: evidence, + source, + }); + } + } + settled + } + + pub fn observe_started(&mut self, turn_id: &str) -> StopDisposition { + if self + .facts + .iter() + .any(|f| f.turn_id.as_deref() == Some(turn_id) && f.settlement.is_some()) + { + return StopDisposition::None; + } + if self.active.as_deref().is_some_and(|id| id != turn_id) { + return StopDisposition::None; + } + let token = self + .facts + .iter() + .rev() + .find(|f| { + f.turn_id.is_none() && f.settlement.is_none() && f.delivery != Delivery::Refused + }) + .map(|f| f.token) + .or_else(|| { + self.facts + .iter() + .find(|f| { + f.turn_id.as_deref() == Some(turn_id) + && f.settlement.is_none() + && f.delivery == Delivery::Accepted + }) + .map(|f| f.token) + }) + .unwrap_or_else(|| self.begin_dispatch("")); + self.accept(token, turn_id) + } + + pub fn has_owed_work(&self) -> bool { + self.facts + .iter() + .any(|f| f.settlement.is_none() && f.delivery != Delivery::Refused) + } + pub fn has_unknown_delivery(&self) -> bool { + self.facts.iter().any(|f| { + f.settlement.is_none() + && f.turn_id.is_none() + && matches!(f.delivery, Delivery::Pending | Delivery::Ambiguous) + }) + } + pub fn has_stopping_work(&self) -> bool { + self.user_stop.as_deref().is_some_and(|id| { + self.facts.iter().any(|f| { + f.turn_id.as_deref() == Some(id) + && f.settlement.is_none() + && f.delivery != Delivery::Refused + }) + }) + } + pub fn observe_idle(&mut self) { + self.active = None; + } + pub fn active_turn_id(&self) -> Option<&str> { + self.active.as_deref() + } + pub fn has_unresolved_stop(&self) -> bool { + self.stop.is_some() + } + pub fn has_owned_exit(&self) -> bool { + self.owned_exit + } + pub fn user_stop_turn_id(&self) -> Option<&str> { + self.user_stop.as_deref() + } + pub fn facts(&self) -> &VecDeque { + &self.facts + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_acknowledged_stop_stays_deduplicated_through_the_native_idle_gap() { + let mut s = CodexTurnState::default(); + let old = s.begin_dispatch("old"); + s.accept(old, "old"); + assert_eq!( + s.request_stop(), + StopDisposition::Interrupt { + dispatch: old, + turn_id: "old".into() + } + ); + s.interrupt_ack(old, "old"); + s.observe_idle(); + assert_eq!( + s.request_stop(), + StopDisposition::None, + "thread idle must not re-arm the same acknowledged Stop" + ); + let new = s.begin_dispatch("new"); + s.accept(new, "new"); + assert_eq!( + s.request_stop(), + StopDisposition::Interrupt { + dispatch: new, + turn_id: "new".into() + } + ); + } + + #[test] + fn native_completion_during_a_pending_steer_cannot_assign_an_outcome_to_a_later_refusal() { + let mut s = CodexTurnState::default(); + let old = s.begin_dispatch("old"); + s.accept(old, "old"); + let steer = s.begin_dispatch("steer"); + s.settle_native("old", NativeSettlement::Completed); + assert!(s.reject(steer)); + assert_eq!(s.facts().back().unwrap().delivery, Delivery::Refused); + assert_eq!(s.facts().back().unwrap().settlement, None); + assert!(!s.has_owed_work()); + } + + #[test] + fn rejected_initial_dispatch_releases_its_latched_stop_without_owing_work() { + let mut s = CodexTurnState::default(); + let token = s.begin_dispatch("refused"); + assert_eq!(s.request_stop(), StopDisposition::Latched); + assert!(s.reject(token)); + assert!(!s.has_owed_work()); + assert!(!s.has_unresolved_stop()); + assert_eq!(s.accept(token, "never-admitted"), StopDisposition::None); + assert_eq!(s.active_turn_id(), None); + } + + #[test] + fn a_duplicate_start_cannot_accept_a_rejected_or_ambiguous_same_turn_steer() { + let mut s = CodexTurnState::default(); + let first = s.begin_dispatch("start"); + s.accept(first, "old"); + let steer = s.begin_dispatch("steer"); + s.observe_started("old"); + assert!(s.reject(steer)); + let ambiguous = s.begin_dispatch("unknown-steer"); + s.mark_ambiguous(ambiguous); + s.observe_started("old"); + assert_eq!(s.facts().back().unwrap().delivery, Delivery::Ambiguous); + assert!(s.has_owed_work()); + s.settle_native("old", NativeSettlement::Completed); + assert!(!s.has_owed_work()); + assert_eq!( + s.facts()[1].settlement, + None, + "a refusal never acquires a native outcome" + ); + } + + #[test] + fn same_id_steer_receipts_have_one_terminal_settlement_and_refusal_keeps_the_turn() { + let mut s = CodexTurnState::default(); + let first = s.begin_dispatch("first"); + s.accept(first, "old"); + let refused = s.begin_dispatch("refused"); + assert!(s.reject(refused)); + assert!(s.has_owed_work()); + assert_eq!(s.active_turn_id(), Some("old")); + let steer = s.begin_dispatch("steer"); + s.accept(steer, "old"); + assert_eq!( + s.facts() + .iter() + .filter(|f| f.delivery == Delivery::Accepted) + .count(), + 2 + ); + assert!(s.settle_native("old", NativeSettlement::Completed)); + assert!(!s.has_owed_work()); + assert!(!s.settle_native("old", NativeSettlement::Completed)); + } + + #[test] + fn stop_latches_before_acceptance_and_ack_does_not_settle() { + let mut s = CodexTurnState::default(); + let dispatch = s.begin_dispatch("pending"); + assert_eq!(s.request_stop(), StopDisposition::Latched); + assert_eq!( + s.accept(dispatch, "old"), + StopDisposition::Interrupt { + dispatch, + turn_id: "old".into() + } + ); + assert_eq!(s.accept(dispatch, "old"), StopDisposition::None); + s.interrupt_ack(dispatch, "old"); + assert!(s.has_owed_work()); + assert!(!s.has_unresolved_stop()); + assert!(s.settle_native("old", NativeSettlement::Interrupted)); + assert!(!s.has_owed_work()); + } + + #[test] + fn natural_completion_and_ambiguous_stop_do_not_open_the_issuance_gate() { + let mut s = CodexTurnState::default(); + let old = s.begin_dispatch("old"); + s.accept(old, "old"); + s.request_stop(); + s.settle_native("old", NativeSettlement::Completed); + assert!(!s.has_owed_work()); + assert!(s.has_unresolved_stop()); + s.mark_ambiguous(old); + assert!(s.has_unresolved_stop()); + s.interrupt_ack(old, "old"); + let new = s.begin_dispatch("new"); + s.accept(new, "new"); + s.interrupt_ack(old, "old"); + s.settle_native("old", NativeSettlement::Interrupted); + assert!(s.has_owed_work()); + assert_eq!(s.active_turn_id(), Some("new")); + } + + #[test] + fn ambiguous_dispatch_is_not_replayed_or_settled_by_an_empty_startup_ack() { + let mut s = CodexTurnState::default(); + let dispatch = s.begin_dispatch("receipt"); + s.mark_ambiguous(dispatch); + assert_eq!(s.request_stop(), StopDisposition::Latched); + s.interrupt_ack(dispatch, ""); + assert!(s.has_owed_work()); + assert!(s.has_unresolved_stop()); + s.observe_started("native"); + s.settle_native("native", NativeSettlement::Failed); + assert!(!s.has_owed_work()); + assert!(s.has_unresolved_stop()); + s.settle_native("", NativeSettlement::OwnedExit); + assert!(!s.has_unresolved_stop()); + assert_eq!(s.facts()[0].request_id, "receipt"); + } + + #[test] + fn pre_acceptance_events_do_not_resurrect_settled_work() { + let mut s = CodexTurnState::default(); + let token = s.begin_dispatch("fast"); + s.observe_started("fast"); + s.settle_native("fast", NativeSettlement::Completed); + s.accept(token, "fast"); + s.observe_started("fast"); + assert!(!s.has_owed_work()); + assert_eq!(s.active_turn_id(), None); + } +} diff --git a/crates/freshell-freshagent/src/codex_rest_lane.rs b/crates/freshell-freshagent/src/codex_rest_lane.rs new file mode 100644 index 0000000000..bc2b7d35a3 --- /dev/null +++ b/crates/freshell-freshagent/src/codex_rest_lane.rs @@ -0,0 +1,324 @@ +//! REST automation of the existing, ownership-bearing Codex runtime. + +use crate::codex::turn_state::NativeSettlement; +use crate::codex::{ + CodexCreateOutcome, CodexSendOutcome, CodexSnapshotError, CodexTurnEvent, FreshCodexState, +}; +use crate::rest_lane::*; +use freshell_protocol::{ + AgentProvider, FreshAgentCreate, FreshAgentSend, SessionLocator, SessionType, +}; +use std::sync::Arc; +use std::time::Duration; + +pub struct CodexRestLane { + state: FreshCodexState, + enabled: Arc bool + Send + Sync>, +} + +impl CodexRestLane { + pub fn new(state: FreshCodexState, enabled: Arc bool + Send + Sync>) -> Self { + Self { state, enabled } + } +} + +fn codex_identity(provider: &str, session_type: &str) -> Result<(), RestLaneError> { + if provider == "codex" && session_type == "freshcodex" { + return Ok(()); + } + Err(RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_REQUEST", + "the local Codex lane supports freshcodex sessions", + )) +} + +fn failure( + code: String, + message: String, + retryable: Option, + owner: Option, +) -> RestLaneError { + let kind = match code.as_str() { + "SESSION_RESERVED" | "STALE_CLAIM" | "CODEX_DELIVERY_AMBIGUOUS" => { + RestLaneErrorKind::Conflict + } + "SESSION_NOT_FOUND" => RestLaneErrorKind::NotFound, + code if code.starts_with("INVALID_") => RestLaneErrorKind::BadRequest, + _ => RestLaneErrorKind::Unavailable, + }; + let mut error = RestLaneError::new(kind, code, message); + error.retryable = retryable; + error.owner = owner; + error +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for CodexRestLane { + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + codex_identity(&request.provider, &request.session_type)?; + if let Some(ownership_id) = created.cleanup_token.as_deref() { + self.state + .abandon_created_runtime(&created.session_id, ownership_id) + .await?; + } + Ok(()) + } + + async fn snapshot( + &self, + request: RestLaneSnapshot, + ) -> Result, RestLaneSnapshotError> { + codex_identity(&request.provider, &request.session_type) + .map_err(|_| RestLaneSnapshotError::OwnershipUnavailable)?; + self.state.get_snapshot(&request.session_id, None).await.map(Some).map_err(|error| { + tracing::warn!(event="freshagent.codex.rest_lane.snapshot_failed", session_id=%request.session_id, error=%error); + RestLaneSnapshotError::ManagedUnavailable + }) + } + + async fn capture( + &self, + request: RestLaneCapture, + ) -> Result { + codex_identity(&request.provider, &request.session_type) + .map_err(|_| RestLaneCaptureError::Unsupported)?; + // A headless turn need not publish text-item events. The existing exact + // snapshot path hydrates native saved history and merges retained live + // fragments, including its finite unavailable/interrupted states. + let snapshot = self.state.get_snapshot(&request.session_id, None).await.map_err(|error| { + tracing::warn!(event="freshagent.codex.rest_lane.capture_failed", session_id=%request.session_id, error=%error); + match error { CodexSnapshotError::NotFound => RestLaneCaptureError::NotFound, _ => RestLaneCaptureError::Unavailable } + })?; + if snapshot["extensions"]["codex"]["ownerKind"] == "vacant" + && snapshot["turns"].as_array().is_some_and(Vec::is_empty) + { + return Err(RestLaneCaptureError::NotFound); + } + let (text, truncated) = freshell_agent_runtime::snapshot_text::render_snapshot_text( + &snapshot, + request.max_bytes.min(256 * 1024), + ); + tracing::info!(event="freshagent.codex.rest_lane.capture", session_id=%request.session_id, bytes=text.len(), truncated); + Ok(RestLaneCaptureResult { + native_session_id: snapshot["threadId"] + .as_str() + .unwrap_or(&request.session_id) + .into(), + session_id: request.session_id, + text, + truncated, + }) + } + + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + codex_identity(&request.provider, &request.session_type)?; + if !(self.enabled)() { + return Err(RestLaneError::new( + RestLaneErrorKind::Forbidden, + "FRESH_AGENT_DISABLED", + "fresh agents are disabled", + )); + } + let outcome = self + .state + .handle_create( + FreshAgentCreate { + provider: Some(AgentProvider::Codex), + session_type: SessionType::Freshcodex, + request_id: request.request_id, + cwd: request.cwd, + model: request.model, + effort: request.effort, + collaboration_mode: request.collaboration_mode, + model_selection: request.model_selection, + sandbox: request.sandbox, + permission_mode: request.permission_mode, + plugins: request.plugins, + session_ref: request.native_session_id.map(|session_id| SessionLocator { + provider: "codex".into(), + session_id, + }), + tab_id: request.tab_id, + pane_id: request.pane_id, + naming_handle: request.naming_handle, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, + legacy_restore_context: None, + resume_session_id: None, + }, + None, + ) + .await; + match outcome { + CodexCreateOutcome::Created { + session_id, + session_ref, + cleanup_ownership_id, + name_ref, + } => { + let effective_settings = self + .state + .created_rest_settings(&session_id, cleanup_ownership_id.as_deref()) + .await; + if effective_settings.is_none() { + if let Some(owner) = cleanup_ownership_id.as_deref() { + self.state + .abandon_created_runtime(&session_id, owner) + .await?; + } + return Err(failure( + "CODEX_CREATE_CHANGED".into(), + "the owning Codex runtime changed before its pane could be published" + .into(), + Some(true), + None, + )); + } + Ok(RestLaneCreated { + session_id, + session_ref, + cleanup_token: cleanup_ownership_id, + effective_settings, + name_ref, + }) + } + CodexCreateOutcome::Failed { + code, + message, + retryable, + owner, + } => Err(failure(code, message, retryable, owner)), + } + } + + async fn send_agent(&self, request: RestLaneSend) -> Result { + codex_identity(&request.provider, &request.session_type)?; + if request + .text + .bytes() + .all(|byte| matches!(byte, b'\r' | b'\n')) + { + return Ok(RestLaneSendResult { + session_id: request.session_id, + completed: true, + noop: true, + ..Default::default() + }); + } + let text = request + .text + .strip_suffix(['\r', '\n']) + .unwrap_or(&request.text) + .to_string(); + // Fast native turns can settle before the dispatch future returns. + let mut events = self.state.subscribe_turn_events(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(request.timeout_ms); + match self + .state + .handle_send(FreshAgentSend { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: request.session_id.clone(), + request_id: Some(request.request_id.clone()), + text, + cwd: None, + images: None, + settings: None, + checkpoint_intent: None, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, + }) + .await + { + CodexSendOutcome::Accepted { + session_id, + submitted_turn_id, + incarnation_id, + .. + } => { + let settled = tokio::time::timeout_at(deadline, async { + loop { + match events.recv().await { + Ok(CodexTurnEvent::Settled { session_id: settled_session, turn_id, incarnation_id: settled_incarnation, request_ids, outcome, source }) + if settled_session == session_id && turn_id.as_deref() == Some(&submitted_turn_id) + && settled_incarnation == incarnation_id && request_ids.iter().any(|id| id == &request.request_id) => { + tracing::info!(event="freshagent.codex.rest_lane.settled", session_id=%session_id, native_turn_id=%submitted_turn_id, incarnation_id=%incarnation_id, request_id=%request.request_id, outcome=?outcome, source=?source); + break Ok(outcome); + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!(event="freshagent.codex.rest_lane.events_lagged", session_id=%session_id, request_id=%request.request_id, skipped), + Err(tokio::sync::broadcast::error::RecvError::Closed) => break Err(failure("FRESH_AGENT_EVENTS_UNAVAILABLE".into(), "Codex turn events are unavailable".into(), None, None)), + } + } + }).await; + let completed = match settled { + Ok(Ok(NativeSettlement::Completed)) => true, + Err(_) => false, + Ok(Ok(outcome)) => { + let (code, message) = match outcome { + NativeSettlement::Interrupted => ( + "CODEX_TURN_INTERRUPTED", + "the submitted Codex turn was interrupted", + ), + NativeSettlement::Failed => { + ("CODEX_TURN_FAILED", "the submitted Codex turn failed") + } + NativeSettlement::OwnedExit => ( + "FRESH_AGENT_SESSION_ENDED", + "the owned Codex runtime exited before the turn completed", + ), + NativeSettlement::Completed => unreachable!(), + }; + let mut error = failure(code.into(), message.into(), Some(false), None); + error.kind = RestLaneErrorKind::Conflict; + error.correlation = Some(Box::new(RestLaneErrorCorrelation { + session_id: Some(session_id), + request_id: Some(request.request_id), + incarnation_id: Some(incarnation_id), + submitted_turn_id: Some(submitted_turn_id), + })); + return Err(error); + } + Ok(Err(error)) => return Err(error), + }; + tracing::info!(event="freshagent.codex.rest_lane.send", session_id=%session_id, native_turn_id=%submitted_turn_id, incarnation_id=%incarnation_id, request_id=%request.request_id, completed); + Ok(RestLaneSendResult { + session_id, + submitted_turn_id: Some(submitted_turn_id), + completed, + noop: false, + }) + } + CodexSendOutcome::Refused { + code, + message, + retryable, + owner, + } => Err(failure(code, message, retryable, owner)), + CodexSendOutcome::Ambiguous { + session_id, + request_id, + incarnation_id, + code, + message, + } => { + let mut error = failure(code, message, Some(false), None); + error.correlation = Some(Box::new(RestLaneErrorCorrelation { + session_id: Some(session_id), + request_id: Some(request_id), + incarnation_id: Some(incarnation_id), + ..Default::default() + })); + Err(error) + } + } + } +} diff --git a/crates/freshell-freshagent/src/codex_rest_lane_tests.rs b/crates/freshell-freshagent/src/codex_rest_lane_tests.rs new file mode 100644 index 0000000000..082d1a53d9 --- /dev/null +++ b/crates/freshell-freshagent/src/codex_rest_lane_tests.rs @@ -0,0 +1,998 @@ +use super::*; +use crate::codex_rest_lane::CodexRestLane; +use crate::rest_lane::*; +use axum::http::StatusCode; +use freshell_protocol::{AgentProvider, SessionType}; + +fn http_pane(state: FreshCodexState, id: &str) -> (axum::Router, String) { + let api = crate::FreshAgentState::new( + Arc::new("tok".into()), + Arc::new(tokio::sync::broadcast::channel(64).0), + ) + .with_terminal_registry(freshell_terminal::TerminalRegistry::new()); + let (tab, pane) = api.layout.create_tab(None); + api.layout.attach_pane_content(&tab, &pane, json!({"kind":"fresh-agent","provider":"codex","sessionType":"freshcodex","sessionRef":{"provider":"codex","sessionId":id}})); + api.set_codex_rest_lane(Arc::new(CodexRestLane::new(state, Arc::new(|| true)))) + .unwrap(); + (crate::router(api), pane) +} + +async fn post_http(router: axum::Router, uri: &str, body: Value) -> (StatusCode, Value) { + use tower::util::ServiceExt; + let response = router + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(uri) + .header("x-auth-token", "tok") + .header("content-type", "application/json") + .body(axum::body::Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + (status, serde_json::from_slice(&bytes).unwrap()) +} + +async fn accepted(frames: &mut tokio::sync::broadcast::Receiver, request: &str) { + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let value: Value = serde_json::from_str(&frames.recv().await.unwrap()).unwrap(); + if value["type"] == "freshAgent.send.accepted" && value["requestId"] == request { + break; + } + } + }) + .await + .unwrap(); +} + +async fn capture_from_peer( + lane: &CodexRestLane, + request: RestLaneCapture, + peer: &freshell_codex::ChannelPeer, + raw: &Value, +) -> Result { + let mut capturing = Box::pin(lane.capture(request)); + tokio::select! { + result = &mut capturing => result, + (id, method, _) = peer.expect_request() => { + assert_eq!(method, "thread/read"); + peer.respond(&id, raw.clone()); + capturing.await + } + } +} + +async fn channel_session(id: &str) -> (FreshCodexState, freshell_codex::ChannelPeer) { + let (state, _) = super::tests::state_with_bus(); + let (transport, peer) = freshell_codex::new_channel_transport(); + let (client, notifs) = + CodexAppServerClient::connect_with_timeout(transport, Duration::from_millis(200)); + super::tests::insert_fake_session_with_real_consumer( + &state, + id, + Arc::new(client), + Arc::new(StdMutex::new(None)), + notifs, + super::tests::spawn_sleeper(), + &format!("task009-owner-{id}"), + ) + .await; + (state, peer) +} + +fn send(id: &str, request: &str) -> FreshAgentSend { + FreshAgentSend { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: id.into(), + request_id: Some(request.into()), + text: "original recoverable prompt".into(), + cwd: None, + images: None, + settings: None, + checkpoint_intent: None, + observed_epoch: None, + observed_generation: None, + } +} + +async fn initialize(peer: &freshell_codex::ChannelPeer) { + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "initialize"); + peer.respond(&id, json!({"userAgent":"fixture","codexHome":"/fixture","platformFamily":"unix","platformOs":"linux"})); + assert_eq!(peer.expect_notification().await.0, "initialized"); +} + +async fn typed_send_receipt(response: &str) { + let session = format!("task009-send-{response}"); + let (state, peer) = channel_session(&session).await; + let submitting = { + let state = state.clone(); + let session = session.clone(); + tokio::spawn(async move { state.handle_send(send(&session, "request-9")).await }) + }; + initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + assert_eq!(params["input"][0]["text"], "original recoverable prompt"); + match response { + "accepted" => peer.respond(&id, json!({"turn":{"id":"native-9"}})), + "refused" => peer.respond_error(&id, -32602, "not admitted"), + "internal" => peer.respond_error(&id, -32603, "possibly admitted"), + _ => {} + } + let result = submitting.await.unwrap(); + let facts = state + .sessions + .lock() + .await + .get(&session) + .unwrap() + .turn_state + .lock() + .unwrap() + .facts() + .clone(); + drop(peer); + state.shutdown().await; + match (response, result) { + ( + "accepted", + CodexSendOutcome::Accepted { + session_id, + submitted_turn_id, + incarnation_id, + .. + }, + ) => { + assert_eq!( + ( + session_id.as_str(), + submitted_turn_id.as_str(), + incarnation_id.as_str() + ), + ( + session.as_str(), + "native-9", + format!("task009-owner-{session}").as_str() + ) + ); + assert_eq!(facts[0].settlement, None, "acceptance is not settlement"); + } + ("refused", CodexSendOutcome::Refused { code, .. }) => { + assert_eq!(code, "CODEX_TURN_START_FAILED"); + assert_eq!(facts[0].delivery, turn_state::Delivery::Refused); + } + ( + "internal" | "timeout", + CodexSendOutcome::Ambiguous { + session_id, + request_id, + incarnation_id, + code, + .. + }, + ) => { + assert_eq!( + ( + session_id.as_str(), + request_id.as_str(), + incarnation_id.as_str(), + code.as_str() + ), + ( + session.as_str(), + "request-9", + format!("task009-owner-{session}").as_str(), + "CODEX_DELIVERY_AMBIGUOUS" + ) + ); + assert_eq!(facts[0].settlement, None); + assert_eq!(facts[0].delivery, turn_state::Delivery::Ambiguous); + } + (_, other) => { + panic!("{response} must return its actual native delivery receipt: {other:?}") + } + } +} + +#[tokio::test] +async fn task009_rest_lane_typed_acceptance_is_not_settlement() { + typed_send_receipt("accepted").await; +} +#[tokio::test] +async fn task009_rest_lane_typed_preadmission_refusal() { + typed_send_receipt("refused").await; +} +#[tokio::test] +async fn task009_rest_lane_typed_internal_error_stays_ambiguous() { + typed_send_receipt("internal").await; +} +#[tokio::test] +async fn task009_rest_lane_typed_timeout_stays_ambiguous() { + typed_send_receipt("timeout").await; +} + +#[tokio::test] +async fn task009_rest_lane_private_settlement_ignores_idle_and_stop_ack_and_names_actual_evidence() +{ + let (state, peer) = channel_session("task009-events").await; + let mut events = state.subscribe_turn_events(); + let submitting = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_send(send("task009-events", "request-events")) + .await + }) + }; + initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({"turn":{"id":"native-events"}})); + let _ = submitting.await.unwrap(); + let interrupt = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_interrupt(FreshAgentInterrupt { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: "task009-events".into(), + request_id: None, + cwd: None, + }) + .await + }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + peer.respond(&id, json!({})); + interrupt.await.unwrap(); + peer.emit_notification( + "thread/status/changed", + json!({"threadId":"task009-events","status":{"type":"idle"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"foreign","turn":{"id":"native-events","status":"completed"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"task009-events","turn":{"id":"unrelated","status":"completed"}}), + ); + let premature = tokio::time::timeout(Duration::from_millis(50), events.recv()).await; + peer.emit_notification( + "turn/completed", + json!({"threadId":"task009-events","turn":{"id":"native-events","status":"interrupted"}}), + ); + let settled = tokio::time::timeout(Duration::from_secs(1), events.recv()).await; + drop(peer); + state.shutdown().await; + assert!( + premature.is_err(), + "ACK/status/foreign terminal cannot settle: {premature:?}" + ); + assert!( + matches!(settled, Ok(Ok(CodexTurnEvent::Settled { + session_id, turn_id, incarnation_id, request_ids, + outcome: NativeSettlement::Interrupted, source: CodexSettlementSource::NativeNotification, + })) if session_id == "task009-events" && turn_id.as_deref() == Some("native-events") && incarnation_id == "task009-owner-task009-events" && request_ids == ["request-events"]), + "matching native terminal must publish its typed evidence" + ); +} + +#[tokio::test] +async fn task009_rest_lane_typed_create_and_adoption_distinguish_cleanup_authority() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-created","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let request: FreshAgentCreate = serde_json::from_value(json!({ + "requestId":"task009-create", "provider":"codex", "sessionType":"freshcodex", + "model":"provider-current", "effort":"xhigh", "collaborationMode":"plan", + "sandbox":"workspace-write", "permissionMode":"on-request", + })) + .unwrap(); + let created = state.handle_create(request.clone(), None).await; + let mut resume = request; + resume.request_id = "task009-adopt".into(); + resume.session_ref = Some(SessionLocator { + provider: "codex".into(), + session_id: "task009-created".into(), + }); + let adopted = state.handle_create(resume, None).await; + let writers = state.sessions.lock().await.len(); + state.shutdown().await; + assert_eq!(writers, 1); + assert!( + matches!(created, CodexCreateOutcome::Created { session_id, session_ref: Some(reference), cleanup_ownership_id: Some(owner), .. } if session_id == "task009-created" && reference.session_id == session_id && !owner.is_empty()) + ); + assert!( + matches!(adopted, CodexCreateOutcome::Created { session_id, cleanup_ownership_id: None, .. } if session_id == "task009-created") + ); +} + +#[tokio::test] +async fn task009_rest_lane_http_same_id_steers_wait_for_one_actual_terminal() { + let (state, peer) = channel_session("task009-http-steer").await; + let (router, pane) = http_pane(state.clone(), "task009-http-steer"); + let mut frames = state.broadcast_tx.subscribe(); + let first_router = router.clone(); + let first_uri = format!("/api/panes/{pane}/send-keys"); + let mut first = tokio::spawn(async move { + post_http( + first_router, + &first_uri, + json!({"data":"first\r","requestId":"first","timeout":3}), + ) + .await + }); + initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + assert_eq!(params["input"][0]["text"], "first"); + peer.respond(&id, json!({"turn":{"id":"shared-native-turn"}})); + accepted(&mut frames, "first").await; + let first_early = tokio::time::timeout(Duration::from_millis(30), &mut first) + .await + .ok() + .map(Result::unwrap); + let second_uri = format!("/api/panes/{pane}/send-keys"); + let mut second = tokio::spawn(async move { + post_http( + router, + &second_uri, + json!({"data":"steer\n","requestId":"second","timeout":3}), + ) + .await + }); + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/steer"); + assert_eq!(params["expectedTurnId"], "shared-native-turn"); + peer.respond(&id, json!({"turnId":"shared-native-turn"})); + accepted(&mut frames, "second").await; + let second_early = tokio::time::timeout(Duration::from_millis(30), &mut second) + .await + .ok() + .map(Result::unwrap); + let early = (first_early.is_some(), second_early.is_some()); + peer.emit_notification( + "thread/status/changed", + json!({"threadId":"task009-http-steer","status":{"type":"idle"}}), + ); + peer.emit_notification( + "turn/completed", + json!({"threadId":"task009-http-steer","turn":{"id":"unrelated","status":"completed"}}), + ); + peer.emit_notification("turn/completed", json!({"threadId":"task009-http-steer","turn":{"id":"shared-native-turn","status":"completed"}})); + let one = match first_early { + Some(value) => value, + None => first.await.unwrap(), + }; + let two = match second_early { + Some(value) => value, + None => second.await.unwrap(), + }; + drop(peer); + state.shutdown().await; + assert_eq!( + early, + (false, false), + "HTTP must remain pending after native admission" + ); + for (status, body) in [one, two] { + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["status"], "ok", "{body}"); + assert_eq!(body["data"]["status"], "idle", "{body}"); + assert_eq!(body["data"]["submittedTurnId"], "shared-native-turn"); + } +} + +#[tokio::test] +async fn task009_rest_lane_http_uncertainty_keeps_native_request_receipt_and_never_replays() { + let (state, peer) = channel_session("task009-http-uncertain").await; + let (router, pane) = http_pane(state.clone(), "task009-http-uncertain"); + let uri = format!("/api/panes/{pane}/send-keys"); + let submitting = tokio::spawn(async move { + post_http( + router, + &uri, + json!({"data":"uncertain prompt\r","requestId":"uncertain-9"}), + ) + .await + }); + initialize(&peer).await; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond_error(&id, -32603, "delivery cannot be proven"); + let (status, body) = submitting.await.unwrap(); + let no_replay = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(); + drop(peer); + state.shutdown().await; + assert!(no_replay); + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["code"], "CODEX_DELIVERY_AMBIGUOUS"); + assert_eq!(body["retryable"], false); + assert_eq!(body["requestId"], "uncertain-9"); + assert_eq!(body["sessionId"], "task009-http-uncertain"); + assert!(body["incarnationId"] + .as_str() + .is_some_and(|id| !id.is_empty())); + assert!( + body.get("submittedTurnId").is_none(), + "unknown delivery cannot invent a native turn id" + ); +} + +#[tokio::test] +async fn task009_rest_lane_capture_preserves_native_output_and_bounds_utf8_separately() { + let (state, peer) = channel_session("task009-capture").await; + let mut events = state.subscribe_turn_events(); + let submitting = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_send(send("task009-capture", "capture-control")) + .await + }) + }; + initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({"turn":{"id":"captured-turn"}})); + submitting.await.unwrap(); + let reading = { + let state = state.clone(); + tokio::spawn(async move { state.get_snapshot("task009-capture", None).await }) + }; + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "thread/read"); + let raw = json!({"thread":{"id":"task009-capture","status":{"type":"idle"},"turns":[{"id":"captured-turn","status":"completed","items":[{"type":"userMessage","id":"u","content":[{"type":"text","text":"éé"}]},{"type":"agentMessage","id":"a","text":"actual retained answer"}]}]}}); + peer.respond(&id, raw.clone()); + let ready = reading.await.unwrap().unwrap(); + let evidence = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .unwrap() + .unwrap(); + let lane = CodexRestLane::new(state.clone(), Arc::new(|| true)); + let request = RestLaneCapture { + session_id: "task009-capture".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + max_bytes: 7, + }; + let short = capture_from_peer(&lane, request.clone(), &peer, &raw).await; + let full = capture_from_peer( + &lane, + RestLaneCapture { + max_bytes: 4096, + ..request + }, + &peer, + &raw, + ) + .await; + drop(peer); + state.shutdown().await; + assert_eq!( + ready["status"], "idle", + "native terminal read established fixture readiness" + ); + assert!(matches!( + evidence, + CodexTurnEvent::Settled { + outcome: NativeSettlement::Completed, + source: CodexSettlementSource::NativeThreadRead, + .. + } + )); + let short = short.unwrap(); + let full = full.unwrap(); + assert_eq!(short.text, "user: "); + assert!(short.truncated); + assert!(!full.truncated); + assert!(full.text.contains("user: éé")); + assert!(full.text.contains("assistant: actual retained answer")); +} + +#[tokio::test] +async fn task009_rest_lane_socket_loss_does_not_settle_until_the_owned_process_exits() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; + let (state, peer) = channel_session("task009-owned-exit").await; + let (router, pane) = http_pane(state.clone(), "task009-owned-exit"); + let mut events = state.subscribe_turn_events(); + let mut frames = state.broadcast_tx.subscribe(); + let uri = format!("/api/panes/{pane}/send-keys"); + let mut submitting = tokio::spawn(async move { + post_http( + router, + &uri, + json!({"data":"owned exit","requestId":"owned-exit","timeout":3}), + ) + .await + }); + initialize(&peer).await; + let (id, _, _) = peer.expect_request().await; + peer.respond(&id, json!({"turn":{"id":"owned-exit-native"}})); + accepted(&mut frames, "owned-exit").await; + drop(peer); + let early = tokio::time::timeout(Duration::from_millis(50), &mut submitting) + .await + .ok() + .map(Result::unwrap); + let before_exit = early.is_none(); + let no_false_event = events.try_recv().is_err(); + state.shutdown().await; + let (status, body) = match early { + Some(result) => result, + None => submitting.await.unwrap(), + }; + let evidence = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .unwrap() + .unwrap(); + assert!( + before_exit && no_false_event, + "transport loss is not native termination" + ); + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["code"], "FRESH_AGENT_SESSION_ENDED"); + assert_eq!(body["submittedTurnId"], "owned-exit-native"); + assert!(matches!( + evidence, + CodexTurnEvent::Settled { + outcome: NativeSettlement::OwnedExit, + source: CodexSettlementSource::ConfirmedOwnedExit, + .. + } + )); +} + +#[tokio::test] +async fn task009_rest_lane_old_cleanup_receipt_cannot_stop_a_resumed_incarnation() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-replaced","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let lane = Arc::new(CodexRestLane::new(state.clone(), Arc::new(|| true))); + let request = RestLaneCreate { + request_id: "original-incarnation".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + ..Default::default() + }; + let original = lane.clone().create_agent(request.clone()).await.unwrap(); + state + .handle_recovery_stop(FreshAgentRecoveryStop { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: original.session_id.clone(), + request_id: "owned-stop".into(), + observed_epoch: None, + observed_generation: None, + }) + .await; + let resume = RestLaneCreate { + request_id: "replacement-incarnation".into(), + native_session_id: Some(original.session_id.clone()), + ..request.clone() + }; + let replacement = lane.clone().create_agent(resume.clone()).await.unwrap(); + lane.abandon_create(&request, &original).await.unwrap(); + let survives = state.has_live_session(&replacement.session_id).await; + let sent = lane + .send_agent(RestLaneSend { + provider: "codex".into(), + session_type: "freshcodex".into(), + session_id: replacement.session_id.clone(), + request_id: "replacement-send".into(), + text: "replacement is still the writer\r".into(), + timeout_ms: 2000, + observed_epoch: None, + observed_generation: None, + }) + .await; + let cleanup = lane.abandon_create(&resume, &replacement).await; + let stopped = !state.has_live_session(&replacement.session_id).await; + state.shutdown().await; + assert_ne!(original.cleanup_token, replacement.cleanup_token); + assert!(survives); + assert!(sent.unwrap().completed); + assert!(cleanup.is_ok() && stopped); +} + +#[tokio::test] +async fn task009_rest_lane_http_deadline_returns_only_native_admission_as_approximate() { + let (state, peer) = channel_session("task009-deadline").await; + let (router, pane) = http_pane(state.clone(), "task009-deadline"); + let uri = format!("/api/panes/{pane}/send-keys"); + let submitting = tokio::spawn(async move { + post_http( + router, + &uri, + json!({"data":"not yet terminal\r\n","requestId":"deadline-request","timeout":0}), + ) + .await + }); + initialize(&peer).await; + let (id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond(&id, json!({"turn":{"id":"deadline-native"}})); + let (status, body) = submitting.await.unwrap(); + drop(peer); + state.shutdown().await; + assert_eq!( + params["input"][0]["text"], "not yet terminal\r", + "strip exactly one terminator" + ); + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "approx"); + assert_eq!(body["data"]["status"], "approx"); + assert_eq!(body["data"]["submittedTurnId"], "deadline-native"); +} + +#[tokio::test] +async fn task009_rest_lane_enter_only_is_a_noop_without_creating_a_runtime() { + let (state, _) = super::tests::state_with_bus(); + let lane = CodexRestLane::new(state.clone(), Arc::new(|| true)); + for text in ["\r", "\n", "\r\n", "\n\r\n"] { + let sent = lane + .send_agent(RestLaneSend { + provider: "codex".into(), + session_type: "freshcodex".into(), + session_id: "never-started".into(), + request_id: "enter-only".into(), + text: text.into(), + timeout_ms: 0, + observed_epoch: None, + observed_generation: None, + }) + .await + .unwrap(); + assert!(sent.noop && sent.completed); + assert!(sent.submitted_turn_id.is_none()); + } + assert!(state.sessions.lock().await.is_empty()); + state.shutdown().await; +} + +#[tokio::test] +async fn task009_rest_lane_headless_capture_hydrates_native_history_without_a_browser() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-headless-history","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let lane = Arc::new(CodexRestLane::new(state.clone(), Arc::new(|| true))); + let created = lane + .clone() + .create_agent(RestLaneCreate { + request_id: "headless-history".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + ..Default::default() + }) + .await + .unwrap(); + let sent = lane + .send_agent(RestLaneSend { + request_id: "headless-history-send".into(), + session_id: created.session_id.clone(), + provider: "codex".into(), + session_type: "freshcodex".into(), + text: "headless saved history\r".into(), + timeout_ms: 2000, + observed_epoch: None, + observed_generation: None, + }) + .await + .unwrap(); + let captured = lane + .capture(RestLaneCapture { + session_id: created.session_id.clone(), + provider: "codex".into(), + session_type: "freshcodex".into(), + max_bytes: 4096, + }) + .await; + // Readiness proof follows capture: no UI or earlier GET can mask a cold + // headless capture by hydrating the retained projection first. + let native = state.get_snapshot(&created.session_id, None).await.unwrap(); + state.shutdown().await; + assert!(sent.completed); + assert_eq!(native["turns"].as_array().unwrap().len(), 2); + let captured = captured.unwrap(); + assert!( + captured.text.contains("user: headless saved history"), + "{}", + captured.text + ); + assert!( + captured.text.contains("assistant: Fixture turn"), + "{}", + captured.text + ); +} + +#[tokio::test] +async fn task009_rest_lane_abandon_only_stops_the_runtime_its_create_established() { + let _environment = super::tests::ENV_LOCK.lock().await; + super::tests::configure_fake_codex_cmd( + r#"{"threadStartThreadId":"task009-cleanup","recordTurns":true}"#, + ); + let (state, _) = super::tests::state_with_bus(); + let lane = Arc::new(CodexRestLane::new(state.clone(), Arc::new(|| true))); + let request = RestLaneCreate { + request_id: "cleanup-create".into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + ..Default::default() + }; + let created = lane.clone().create_agent(request.clone()).await.unwrap(); + let resume = RestLaneCreate { + request_id: "cleanup-adopt".into(), + native_session_id: Some(created.session_id.clone()), + ..request.clone() + }; + let adopted = lane.clone().create_agent(resume.clone()).await.unwrap(); + lane.abandon_create(&resume, &adopted).await.unwrap(); + let adopted_survives = state.has_live_session(&created.session_id).await; + let stale = RestLaneCreated { + cleanup_token: Some("foreign-incarnation".into()), + ..created.clone() + }; + let stale_cleanup = lane.abandon_create(&request, &stale).await; + let stale_survives = state.has_live_session(&created.session_id).await; + let owned_cleanup = lane.abandon_create(&request, &created).await; + let owned_survives = state.has_live_session(&created.session_id).await; + state.shutdown().await; + assert!(adopted.cleanup_token.is_none()); + assert!( + stale_cleanup.is_ok() && owned_cleanup.is_ok(), + "stale={stale_cleanup:?}, owned={owned_cleanup:?}" + ); + assert!( + adopted_survives && stale_survives, + "adoption or a foreign receipt cannot kill this writer" + ); + assert!( + !owned_survives, + "abandoned create must confirm its owned runtime stopped" + ); +} + +#[tokio::test] +async fn task009_rest_lane_http_startup_stop_waits_for_native_terminal_and_blocks_later_start() { + let (state, peer) = channel_session("task009-startup-stop").await; + let (router, pane) = http_pane(state.clone(), "task009-startup-stop"); + let first_uri = format!("/api/panes/{pane}/send-keys"); + let first_router = router.clone(); + let mut first = tokio::spawn(async move { + post_http( + first_router, + &first_uri, + json!({"data":"startup","requestId":"startup","timeout":3}), + ) + .await + }); + initialize(&peer).await; + let (start_id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + state + .handle_interrupt(FreshAgentInterrupt { + provider: AgentProvider::Codex, + session_type: SessionType::Freshcodex, + session_id: "task009-startup-stop".into(), + request_id: None, + cwd: None, + }) + .await; + let before_startup = tokio::time::timeout(Duration::from_millis(30), &mut first) + .await + .is_err(); + let mut events = state.subscribe_turn_events(); + peer.respond(&start_id, json!({"turn":{"id":"startup-native"}})); + let (stop_id, method, params) = peer.expect_request().await; + assert_eq!(method, "turn/interrupt"); + assert_eq!(params["turnId"], "startup-native"); + peer.emit_notification("turn/completed", json!({"threadId":"task009-startup-stop","turn":{"id":"startup-native","status":"interrupted"}})); + let terminal = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .unwrap() + .unwrap(); + let turns = state + .sessions + .lock() + .await + .get("task009-startup-stop") + .unwrap() + .turn_state + .clone(); + let waiting = turns.lock().unwrap().waiting_for_stop.clone(); + let later_uri = format!("/api/panes/{pane}/send-keys"); + let later = tokio::spawn(async move { + post_http( + router, + &later_uri, + json!({"data":"later","requestId":"later","timeout":3}), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(2), waiting.notified()) + .await + .unwrap(); + let no_later_start = tokio::time::timeout(Duration::from_millis(30), peer.next_raw_frame()) + .await + .is_err(); + peer.respond(&stop_id, json!({})); + let (id, method, _) = peer.expect_request().await; + assert_eq!(method, "turn/start"); + peer.respond(&id, json!({"turn":{"id":"later-native"}})); + peer.emit_notification("turn/completed", json!({"threadId":"task009-startup-stop","turn":{"id":"later-native","status":"completed"}})); + let first = first.await.unwrap(); + let later = later.await.unwrap(); + drop(peer); + state.shutdown().await; + assert!( + before_startup && no_later_start, + "startup result and unresolved Stop gate native issuance" + ); + assert!(matches!( + terminal, + CodexTurnEvent::Settled { + outcome: NativeSettlement::Interrupted, + source: CodexSettlementSource::NativeNotification, + .. + } + )); + assert_eq!(first.0, StatusCode::CONFLICT, "{:?}", first.1); + assert_eq!(first.1["code"], "CODEX_TURN_INTERRUPTED"); + assert_eq!(later.1["data"]["status"], "idle", "{:?}", later.1); +} + +#[tokio::test] +async fn task009_rest_lane_http_create_refusal_names_the_actual_owner() { + use freshell_ownership::{ + BeginOutcome, CommitOutcome, OwnerIdentity, RuntimeOwnerKind, RuntimeOwnershipRegistry, + }; + let registry = Arc::new(RuntimeOwnershipRegistry::with_epoch(91)); + let BeginOutcome::Granted { generation } = registry.begin_start( + "codex", + "owned-codex", + RuntimeOwnerKind::Terminal, + "terminal-start", + None, + "fixture", + 1, + ) else { + panic!("initial owner granted"); + }; + assert_eq!( + registry.commit_live( + "codex", + "owned-codex", + "terminal-start", + generation, + OwnerIdentity { + kind: RuntimeOwnerKind::Terminal, + terminal_id: Some("owning-terminal".into()), + live_session_key: None, + pid: None, + ownership_id: None, + } + ), + CommitOutcome::Committed + ); + let (mut state, _) = super::tests::state_with_bus(); + state.set_ownership(registry); + let (router, _) = http_pane(state.clone(), "owned-codex"); + let (status, body) = post_http( + router, + "/api/tabs", + json!({"agent":"codex","sessionRef":{"provider":"codex","sessionId":"owned-codex"}}), + ) + .await; + state.shutdown().await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!( + json!([ + body["ownerKind"], + body["ownerEpoch"], + body["ownerGeneration"] + ]), + json!(["terminal", 91, generation]) + ); +} + +#[tokio::test] +async fn task009_rest_lane_headless_create_and_resume_publish_the_native_effective_settings() { + let _environment = super::tests::ENV_LOCK.lock().await; + let workspace = tempfile::tempdir().unwrap(); + let native_cwd = workspace.path().join("native-cwd"); + std::fs::create_dir(&native_cwd).unwrap(); + let request_log = workspace.path().join("native-requests.jsonl"); + super::tests::configure_fake_codex_cmd(&json!({ + "recordTurns":true,"uniqueThreadIds":true,"appendRequestLogPath":request_log, + "effectiveThreadSettings":{"model":"native-effective","cwd":native_cwd,"sandbox":"read-only","approvalPolicy":"on-request"} + }).to_string()); + let (state, _) = super::tests::state_with_bus(); + let api = crate::FreshAgentState::new( + Arc::new("tok".into()), + Arc::new(tokio::sync::broadcast::channel(64).0), + ) + .with_terminal_registry(freshell_terminal::TerminalRegistry::new()); + api.set_codex_rest_lane(Arc::new(CodexRestLane::new( + state.clone(), + Arc::new(|| true), + ))) + .unwrap(); + api.set_rest_create_settings_reader(Arc::new(|| Box::pin(async { + freshell_protocol::SettingsFreshAgent { default_plugins:vec![], enabled:true, initial_setup_done:None, + providers:json!({"freshcodex":{"modelSelection":{"kind":"exact","modelId":"provider-current"},"effort":"ultra","collaborationMode":"plan","sandbox":"workspace-write","defaultPermissionMode":"on-request"}}) + }.into() + }))).unwrap(); + let router = crate::router(api.clone()); + let (status, created) = post_http( + router.clone(), + "/api/tabs", + json!({"agent":"codex","cwd":workspace.path(),"effort":null}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let native = created["data"]["sessionRef"]["sessionId"].as_str().unwrap(); + let ready = state.get_snapshot(native, None).await.unwrap(); + let (resume_status, resumed) = post_http( + router, + "/api/tabs", + json!({"agent":"codex","sessionRef":{"provider":"codex","sessionId":native}}), + ) + .await; + let contents = [&created, &resumed].map(|body| { + api.layout + .get_pane_snapshot(body["data"]["paneId"].as_str().unwrap()) + .unwrap() + .pane_content + .unwrap() + }); + let native_requests = std::fs::read_to_string(request_log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + state.shutdown().await; + assert_eq!( + ready["settings"]["model"], "native-effective", + "the real owning runtime established native-effective settings before layout assertions" + ); + assert_eq!(resume_status, StatusCode::OK, "{resumed}"); + assert_eq!( + native_requests + .iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 1 + ); + assert_eq!( + native_requests + .iter() + .filter(|row| row["method"] == "thread/resume") + .count(), + 0, + "live canonical resume adopts the existing writer" + ); + for content in contents { + assert_eq!( + json!({"model":content["model"],"cwd":content["initialCwd"],"sandbox":content["sandbox"],"permissionMode":content["permissionMode"],"collaborationMode":content["collaborationMode"],"effortIsNativeDefault":content["effortIsNativeDefault"]}), + json!({"model":"native-effective","cwd":native_cwd,"sandbox":"read-only","permissionMode":"on-request","collaborationMode":"plan","effortIsNativeDefault":true}) + ); + } +} diff --git a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs index 07ae9c7e37..408605ee6a 100644 --- a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs +++ b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs @@ -95,6 +95,7 @@ async fn create_tracked_session_with_resume( ) -> String { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -227,6 +228,7 @@ async fn failed_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -629,6 +631,7 @@ async fn create_bail_after_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs index 96c694da8d..7ec87576d9 100644 --- a/crates/freshell-freshagent/src/identity_sink.rs +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -19,6 +19,7 @@ use crate::rollback_record::ROLLBACK_RECORD_VERSION; /// provider-native resume command needs. #[derive(Debug, Clone, Default, PartialEq)] pub struct FreshAgentSettings { + pub collaboration_mode: Option, pub model: Option, pub sandbox: Option, pub permission_mode: Option, @@ -2260,6 +2261,7 @@ mod tests { authoritative: false, settings: FreshAgentSettings { + collaboration_mode: None, model: Some("m".into()), sandbox: None, permission_mode: None, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index f53a635b9d..77f75561f8 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -59,6 +59,7 @@ pub mod claude_spawn; pub(crate) mod claude_test_holds; pub(crate) mod claude_turns; pub mod codex; +pub mod codex_rest_lane; pub(crate) mod codex_sidecar_tracking; pub(crate) mod delegation; pub mod identity_sink; @@ -2095,6 +2096,7 @@ pub struct FreshAgentState { /// requests never touch this web process's legacy provider manager. hosted_rest: Arc>, local_claude_rest: Arc>, + local_codex_rest: Arc>, rest_create_settings: Arc>, /// Monotonic `sessions.changed` revision. sessions_revision: Arc, @@ -2254,12 +2256,13 @@ pub struct FreshAgentState { pub(crate) terminal_name_binder: Option>, /// The `GET`/`POST /api/fresh-agent/model-capabilities/*` registry /// ([`model_capabilities`]): cwd-keyed TTL cache + single-flight coalescing - /// over the transient opencode catalog probe, plus the static codex/claude - /// catalogs. Constructed with the REAL transport probe in + /// over the transient OpenCode / Claude SDK catalog probes and bound + /// Codex runtime sources. Constructed with the REAL transport probe in /// [`Self::new`] (there is no wiring ceremony — unlike the Option-until- /// wired fields above, production construction is unconditional); tests /// install a scripted probe via [`Self::with_model_capability_probe`]. pub(crate) model_capabilities: Arc, + pub(crate) codex_catalog: Arc>, /// Task-3 child-session join (freshopencode TUI parity): child session id /// → the parent session id whose live watcher exists (Stage-2 ledger /// LB-2/LB-8). A watcher self-removes its entry on exit, so every parent @@ -2441,6 +2444,7 @@ impl FreshAgentState { opencode: Arc::new(tokio::sync::Mutex::new(None)), hosted_rest: Arc::new(std::sync::OnceLock::new()), local_claude_rest: Arc::new(std::sync::OnceLock::new()), + local_codex_rest: Arc::new(std::sync::OnceLock::new()), rest_create_settings: Arc::new(std::sync::OnceLock::new()), sessions_revision: Arc::new(AtomicI64::new(0)), terminal_registry: None, @@ -2463,6 +2467,7 @@ impl FreshAgentState { sidecar_liveness: None, layout: layout_store::LayoutStore::default(), terminal_created_hook: None, + codex_catalog: Arc::new(std::sync::OnceLock::new()), terminal_name_binder: None, model_capabilities: Arc::new(model_capabilities::ModelCapabilityRegistry::new( Arc::new(model_capabilities::OpencodeCatalogProbe::default()), @@ -2589,15 +2594,22 @@ impl FreshAgentState { } fn rest_lane_for(&self, provider: &str) -> Option { - self.hosted_rest_gateway().or_else(|| { - if provider == "claude" { - self.local_claude_rest.get().cloned() - } else { - None - } + self.hosted_rest_gateway().or_else(|| match provider { + "claude" => self.local_claude_rest.get().cloned(), + "codex" => self.local_codex_rest.get().cloned(), + _ => None, }) } + pub fn set_codex_rest_lane( + &self, + lane: rest_lane::SharedFreshAgentRestLane, + ) -> Result<(), &'static str> { + self.local_codex_rest + .set(lane) + .map_err(|_| "local Codex REST lane is already installed") + } + /// Install a scripted model-catalog probe (tests) — the registry's /// `opencodeCatalogProvider` injection seam /// (`model-capability-registry.ts:196-202`). Rebuilds the registry around @@ -2620,6 +2632,12 @@ impl FreshAgentState { self.model_capabilities.clone() } + pub fn set_codex_catalog_state(&self, state: FreshCodexState) -> Result<(), &'static str> { + self.codex_catalog + .set(state) + .map_err(|_| "Codex catalog state already installed") + } + /// Wire the P1.13 identity-event sink (set-once; later calls are no-ops). pub fn set_identity_sink(&self, sink: SharedPaneIdentitySink) { let _ = self.identity_sink.set(sink); @@ -4724,7 +4742,7 @@ pub fn router(state: FreshAgentState) -> Router { .route("/api/panes/{id}", patch(rename_pane)) .route("/api/panes/{id}/send-keys", post(send_keys)) .route("/api/panes/{id}/capture", get(capture)) - .route("/api/panes/{id}/wait-for", get(terminal_tabs::wait_for)) + .route("/api/panes/{id}/wait-for", get(wait_for)) .with_state(state.clone()) .merge(model_capabilities::router(state.clone())) .merge(pane_ops::router(state)) @@ -4791,6 +4809,18 @@ fn rest_lane_failure(error: rest_lane::RestLaneError) -> Response { RestLaneErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR, }; let mut body = json!({ "status": "error", "code": error.code, "message": error.message }); + if let Some(correlation) = error.correlation { + for (key, value) in [ + ("sessionId", correlation.session_id), + ("requestId", correlation.request_id), + ("incarnationId", correlation.incarnation_id), + ("submittedTurnId", correlation.submitted_turn_id), + ] { + if let Some(value) = value { + body[key] = json!(value); + } + } + } if let Some(retryable) = error.retryable { body["retryable"] = json!(retryable); } @@ -5129,7 +5159,7 @@ async fn create_lane_agent_tab( session_type: request.session_type.clone(), cwd: request.cwd.clone(), model: request.model.clone(), - effort: request.effort.clone(), + effort: request.effort.clone().flatten(), durable_id: Some( created .session_ref @@ -5695,6 +5725,7 @@ async fn resume_session_ref_tab( // carries no handoff continuation context). authoritative: false, settings: identity_sink::FreshAgentSettings { + collaboration_mode: None, model: model.clone(), sandbox: None, permission_mode: None, @@ -6744,6 +6775,7 @@ async fn send_keys( // b8ke focused ep5 r2 F1: a REST-lane write. authoritative: false, settings: identity_sink::FreshAgentSettings { + collaboration_mode: None, model: pane.model.clone(), sandbox: None, permission_mode: None, @@ -7193,6 +7225,59 @@ fn text_plain(body: String) -> Response { .into_response() } +async fn wait_for( + State(state): State, + Path(pane_id): Path, + headers: HeaderMap, + Query(params): Query>, +) -> Response { + if !authorized(&headers, &state.auth_token) { + return fail_json(StatusCode::UNAUTHORIZED, "unauthorized".into()); + } + if pane_entry_for_request(&state, &pane_id).is_none() { + return terminal_tabs::wait_for(State(state), Path(pane_id), headers, Query(params)).await; + } + let (pattern, deadline) = match terminal_tabs::wait_pattern(¶ms) { + Ok(parsed) => parsed, + Err(response) => return response, + }; + loop { + let response = capture( + State(state.clone()), + Path(pane_id.clone()), + headers.clone(), + Query(Default::default()), + ) + .await; + if response.status() != StatusCode::OK { + return response; + } + let bytes = match axum::body::to_bytes(response.into_body(), usize::MAX).await { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!(event="agent_api.wait_for.capture_failed", pane_id, error=%error); + return fail_json( + StatusCode::SERVICE_UNAVAILABLE, + "fresh-agent transcript is temporarily unavailable".into(), + ); + } + }; + if pattern + .is_match(&String::from_utf8_lossy(&bytes)) + .unwrap_or(false) + { + return ok_json( + json!({"matched":true,"reason":"pattern"}), + "pattern matched", + ); + } + if std::time::Instant::now() >= deadline { + return approx_json(json!({"matched":false}), "timeout"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + /// Render an opencode message page to plain text: collect every `{type:'text', text}` /// part's text (the transcript's assistant + user turns), joined by newlines. Robust to /// the exact message/part envelope (walks the tree). Falls back to the raw JSON so the diff --git a/crates/freshell-freshagent/src/model_capabilities.rs b/crates/freshell-freshagent/src/model_capabilities.rs index 8a558276ef..eb4829a836 100644 --- a/crates/freshell-freshagent/src/model_capabilities.rs +++ b/crates/freshell-freshagent/src/model_capabilities.rs @@ -337,6 +337,8 @@ fn normalize_claude_catalog(raw: Value) -> CatalogOut { } if !models.iter().any(|model: &ModelCapability| model.id == id) { models.push(ModelCapability { + default_effort: None, + input_modalities: None, id: id.into(), display_name: name.into(), provider: "claude", @@ -422,18 +424,15 @@ impl ModelCapabilityRegistry { /// `getCapabilities` (`model-capability-registry.ts:211-234`): cache hit within /// TTL → `cached`; otherwise single-flight refresh → `fresh`; probe failure → - /// the `unavailable` envelope at 503 (cache untouched). Codex uses its - /// built-in catalog; Claude/Kilroy share their SDK catalog cache. + /// the `unavailable` envelope at 503 (cache untouched). Unbound Codex + /// callers receive RUNTIME_PENDING; bound reads use `codex` below. + /// Claude/Kilroy share their SDK catalog cache. pub async fn get(&self, session_type: SessionType, cwd: Option) -> (StatusCode, Value) { if session_type == SessionType::FreshCodex { + let error = runtime_pending(); return ( - StatusCode::OK, - success_body( - session_type, - "fresh", - (self.now)(), - static_models(session_type), - ), + StatusCode::SERVICE_UNAVAILABLE, + failure_body(session_type, &error), ); } let key = catalog_cache_key(session_type, cwd.as_deref()); @@ -465,14 +464,10 @@ impl ModelCapabilityRegistry { cwd: Option, ) -> (StatusCode, Value) { if session_type == SessionType::FreshCodex { + let error = runtime_pending(); return ( - StatusCode::OK, - success_body( - session_type, - "fresh", - (self.now)(), - static_models(session_type), - ), + StatusCode::SERVICE_UNAVAILABLE, + failure_body(session_type, &error), ); } self.finish_catalog( @@ -486,21 +481,21 @@ impl ModelCapabilityRegistry { /// Typed internal read for server-side consumers (the session-directory /// context meter's model-limit snapshot): the same TTL/single-flight /// path as [`Self::get`], returning rows instead of the HTTP envelope. - /// Codex resolves from its static table; a probe failure returns `Err` - /// and leaves the last successful cache entry in place. + /// Codex requires a bound runtime source rather than this unscoped read. + /// A probe failure leaves the last successful cache entry in place. pub async fn models( &self, session_type: SessionType, cwd: Option, ) -> Result, CapabilityError> { if session_type == SessionType::FreshCodex { - return Ok(static_models(session_type)); + return Err(runtime_pending()); } let key = catalog_cache_key(session_type, cwd.as_deref()); if let Some(cached) = self.cached_within_ttl(&key).await { return Ok(cached.models); } - self.refreshed_catalog(session_type, key, cwd) + self.refreshed_catalog(session_type, key, cwd, None) .await .map(|catalog| catalog.models) } @@ -511,7 +506,7 @@ impl ModelCapabilityRegistry { key: String, cwd: Option, ) -> (StatusCode, Value) { - match self.refreshed_catalog(session_type, key, cwd).await { + match self.refreshed_catalog(session_type, key, cwd, None).await { Ok(catalog) => ( StatusCode::OK, success_body(session_type, "fresh", catalog.fetched_at_ms, catalog.models), @@ -531,17 +526,20 @@ impl ModelCapabilityRegistry { session_type: SessionType, key: String, cwd: Option, + probe_override: Option>, ) -> Result { let shared = { let mut state = self.state.lock().await; match state.inflight.get(&key) { Some(existing) => existing.clone(), None => { - let probe = if session_type == SessionType::FreshOpencode { - self.probe.clone() - } else { - self.claude_probe.clone() - }; + let probe = probe_override.clone().unwrap_or_else(|| { + if session_type == SessionType::FreshOpencode { + self.probe.clone() + } else { + self.claude_probe.clone() + } + }); let now = self.now.clone(); let state_arc = self.state.clone(); let key_for_cleanup = key.clone(); @@ -582,56 +580,6 @@ fn catalog_cache_key(session_type: SessionType, cwd: Option<&str>) -> String { format!("opencode:{}", cwd.unwrap_or("")) } -/// `createStaticSuccess` (`:354-378`): the shared -/// `FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE` tables -/// (`shared/fresh-agent-models.ts:21-84`) as capability rows. -fn static_models(session_type: SessionType) -> Vec { - type Row = (&'static str, &'static str, &'static [&'static str]); - let rows: &[Row] = match session_type { - SessionType::FreshCodex => &[ - ( - "gpt-6-astra", - "GPT-6 Astra", - &["low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.6-sol", - "GPT-5.6 Sol", - &["none", "low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.6-terra", - "GPT-5.6 Terra", - &["none", "low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.6-luna", - "GPT-5.6 Luna", - &["none", "low", "medium", "high", "xhigh", "max"], - ), - ( - "gpt-5.3-codex-spark", - "GPT-5.3 Codex Spark", - &["none", "minimal", "low", "medium", "high", "max"], - ), - ], - SessionType::FreshClaude | SessionType::Kilroy | SessionType::FreshOpencode => &[], - }; - rows.iter() - .map(|(id, display_name, levels)| ModelCapability { - id: id.to_string(), - display_name: display_name.to_string(), - provider: session_type.runtime_provider(), - source: None, - // `:359-366`: effort support derives from the levels list, not the flag. - supports_effort: !levels.is_empty(), - supported_effort_levels: levels.iter().map(|s| s.to_string()).collect(), - supports_adaptive_thinking: !levels.is_empty(), - limit: None, - }) - .collect() -} - /// `FreshAgentModelCapabilitiesSchema.extend({ok:true})` — the six keys, exactly. fn success_body( session_type: SessionType, @@ -666,6 +614,191 @@ fn failure_body(session_type: SessionType, err: &CapabilityError) -> Value { }) } +/// A pane's conversation identity is an observation, never a supplied soul authority. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CodexCatalogContext { + pub session_id: String, + pub session_ref: Option, + pub cwd: Option, + pub observed_epoch: Option, + pub observed_generation: Option, +} +impl CodexCatalogContext { + pub fn native_reference(&self) -> Result<&str, CapabilityError> { + if let Some(reference) = &self.session_ref { + if reference.provider != "codex" || reference.session_id.trim().is_empty() { + return Err(runtime_pending()); + } + Ok(&reference.session_id) + } else if !self.session_id.trim().is_empty() { + Ok(&self.session_id) + } else { + Err(runtime_pending()) + } + } +} + +#[derive(Clone)] +pub struct CodexCatalogSource { + pub key: String, + pub probe: Arc, +} + +pub fn runtime_pending() -> CapabilityError { + CapabilityError { + code: "RUNTIME_PENDING".into(), + message: "Waiting for the conversation's Codex runtime".into(), + retryable: true, + } +} +pub fn source_changed() -> CapabilityError { + CapabilityError { + code: "CAPABILITY_SOURCE_CHANGED".into(), + message: "The Codex runtime changed during model discovery. Try again.".into(), + retryable: true, + } +} +pub fn codex_probe_error(error: freshell_codex::CodexAppServerError) -> CapabilityError { + let code = match error { + freshell_codex::CodexAppServerError::Timeout { .. } => "CAPABILITY_PROBE_TIMEOUT", + freshell_codex::CodexAppServerError::InvalidResponse { .. } => "CAPABILITY_PAYLOAD_INVALID", + _ => "CAPABILITY_PROBE_FAILED", + }; + CapabilityError { + code: code.into(), + message: error.to_string(), + retryable: true, + } +} +pub fn codex_capabilities( + models: Vec, +) -> Vec { + models + .into_iter() + .map(|model| ModelCapability { + id: model.id, + display_name: model.display_name, + provider: "codex", + source: None, + supports_effort: !model.supported_effort_levels.is_empty(), + supported_effort_levels: model.supported_effort_levels, + default_effort: model.default_effort, + input_modalities: model.input_modalities, + supports_adaptive_thinking: false, + limit: None, + }) + .collect() +} + +impl ModelCapabilityRegistry { + pub async fn codex( + &self, + source: CodexCatalogSource, + cwd: Option, + refresh: bool, + ) -> (StatusCode, Value) { + let key = serde_json::to_string(&("codex", source.key, cwd.as_deref().map(str::trim))) + .expect("catalog key"); + if !refresh { + if let Some(cached) = self.cached_within_ttl(&key).await { + return ( + StatusCode::OK, + success_body( + SessionType::FreshCodex, + "cached", + cached.fetched_at_ms, + cached.models, + ), + ); + } + } + match self + .refreshed_catalog(SessionType::FreshCodex, key, cwd, Some(source.probe)) + .await + { + Ok(catalog) => ( + StatusCode::OK, + success_body( + SessionType::FreshCodex, + "fresh", + catalog.fetched_at_ms, + catalog.models, + ), + ), + Err(error) => ( + StatusCode::SERVICE_UNAVAILABLE, + failure_body(SessionType::FreshCodex, &error), + ), + } + } +} + +async fn bound_codex_source( + state: &FreshAgentState, + context: &CodexCatalogContext, +) -> Result { + if let Some(hosted) = state.hosted_rest_gateway() { + return hosted.model_catalog_source(context.clone()).await; + } + let local = state.codex_catalog.get().ok_or_else(runtime_pending)?; + local.model_catalog_source(context.clone()).await +} +async fn capabilities_for_request( + state: &FreshAgentState, + st: SessionType, + query: &HashMap, + refresh: bool, +) -> (StatusCode, Value) { + if st != SessionType::FreshCodex { + return if refresh { + state + .model_capabilities + .refresh(st, resolve_context(st, query)) + .await + } else { + state + .model_capabilities + .get(st, resolve_context(st, query)) + .await + }; + } + let work = async { + let context = CodexCatalogContext { + session_id: query.get("sessionId").cloned().unwrap_or_default(), + session_ref: query + .get("sessionRef") + .map(|s| serde_json::from_str(s).map_err(|_| source_changed())) + .transpose()?, + cwd: query.get("cwd").cloned(), + observed_epoch: query + .get("observedEpoch") + .map(|s| s.parse::().map_err(|_| source_changed())) + .transpose()?, + observed_generation: query + .get("observedGeneration") + .map(|s| s.parse::().map_err(|_| source_changed())) + .transpose()?, + }; + context.native_reference()?; + if context.observed_epoch.is_some() != context.observed_generation.is_some() { + return Err(source_changed()); + } + let source = bound_codex_source(state, &context).await?; + let key = source.key.clone(); + let response = state + .model_capabilities + .codex(source, context.cwd.clone(), refresh) + .await; + if response.0 == StatusCode::OK && bound_codex_source(state, &context).await?.key != key { + return Err(source_changed()); + } + Ok(response) + } + .await; + work.unwrap_or_else(|error| (StatusCode::SERVICE_UNAVAILABLE, failure_body(st, &error))) +} + // ── HTTP surface (createFreshAgentModelCapabilitiesRouter) ───────────────────── /// The two routes. Merged into [`crate::router`] so `freshell-server`'s `main.rs` @@ -700,10 +833,7 @@ async fn get_capabilities( ) .into_response(); }; - let (status, body) = state - .model_capabilities - .get(st, resolve_context(st, &query)) - .await; + let (status, body) = capabilities_for_request(&state, st, &query, false).await; (status, Json(body)).into_response() } @@ -724,10 +854,7 @@ async fn refresh_capabilities( ) .into_response(); }; - let (status, body) = state - .model_capabilities - .refresh(st, resolve_context(st, &query)) - .await; + let (status, body) = capabilities_for_request(&state, st, &query, true).await; (status, Json(body)).into_response() } diff --git a/crates/freshell-freshagent/src/model_capabilities_tests.rs b/crates/freshell-freshagent/src/model_capabilities_tests.rs index 7c87dc4433..8e11cea5fb 100644 --- a/crates/freshell-freshagent/src/model_capabilities_tests.rs +++ b/crates/freshell-freshagent/src/model_capabilities_tests.rs @@ -72,6 +72,8 @@ impl ModelCatalogProbe for RecordingProbe { fn cap(id: &str, display: &str, source_id: Option<&str>) -> ModelCapability { ModelCapability { + default_effort: None, + input_modalities: None, id: id.to_string(), display_name: display.to_string(), provider: "opencode", @@ -333,70 +335,6 @@ async fn blank_cwd_maps_to_default_cache_key() { assert_eq!(probe.calls().await, vec![None]); } -/// Codex keeps its built-in catalog without starting a Claude or OpenCode probe. -#[tokio::test] -async fn codex_serves_static_catalog() { - let probe = Arc::new(RecordingProbe::new()); - let (registry, _now) = registry_with(probe.clone()); - - let (status, codex) = registry.get(SessionType::FreshCodex, None).await; - assert_eq!(status, StatusCode::OK); - assert_eq!( - codex, - json!({ - "ok": true, - "sessionType": "freshcodex", - "runtimeProvider": "codex", - "status": "fresh", - "fetchedAt": 1_000, - "models": [ - { - "id": "gpt-6-astra", - "displayName": "GPT-6 Astra", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.6-sol", - "displayName": "GPT-5.6 Sol", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.6-terra", - "displayName": "GPT-5.6 Terra", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.6-luna", - "displayName": "GPT-5.6 Luna", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "low", "medium", "high", "xhigh", "max"], - "supportsAdaptiveThinking": true, - }, - { - "id": "gpt-5.3-codex-spark", - "displayName": "GPT-5.3 Codex Spark", - "provider": "codex", - "supportsEffort": true, - "supportedEffortLevels": ["none", "minimal", "low", "medium", "high", "max"], - "supportsAdaptiveThinking": true, - }, - ], - }) - ); - - assert_eq!(probe.call_count().await, 0, "static paths never probe"); -} - #[test] fn claude_catalog_keeps_live_effort_choices_and_deduplicates_models() { let models = normalize_claude_catalog(json!([ @@ -724,6 +662,95 @@ async fn post(app: Router, uri: &str, auth: bool) -> (StatusCode, Value) { const CAP: &str = "/api/fresh-agent/model-capabilities"; +#[tokio::test] +async fn codex_unbound_registry_is_pending_and_never_probes_another_provider() { + let probe = Arc::new(RecordingProbe::new()); + let (registry, _) = registry_with(probe.clone()); + for (status, body) in [ + registry.get(SessionType::FreshCodex, None).await, + registry.refresh(SessionType::FreshCodex, None).await, + ] { + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["error"]["code"], "RUNTIME_PENDING"); + assert_eq!(body["models"], json!([])); + } + assert_eq!( + registry + .models(SessionType::FreshCodex, None) + .await + .unwrap_err() + .code, + "RUNTIME_PENDING" + ); + assert_eq!(probe.call_count().await, 0); +} + +#[tokio::test] +async fn codex_model_catalog_cache_is_source_scoped_and_failed_refresh_preserves_same_source() { + let gateway = Arc::new(RecordingProbe::new()); + let local = Arc::new(RecordingProbe::new()); + let managed = Arc::new(RecordingProbe::new()); + let (registry, _) = registry_with(gateway.clone()); + local + .push_ok(vec![cap("local-current", "Local Current", None)]) + .await; + managed + .push_ok(vec![cap("managed-current", "Managed Current", None)]) + .await; + let local_source = CodexCatalogSource { + key: "native-client-a:epoch1:generation1".into(), + probe: local.clone(), + }; + let managed_source = CodexCatalogSource { + key: "host-boot-b:incarnation2:generation3:volume-owned".into(), + probe: managed.clone(), + }; + let (_, first) = registry + .codex(local_source.clone(), Some("/same-cwd".into()), false) + .await; + let (_, other) = registry + .codex(managed_source, Some("/same-cwd".into()), false) + .await; + assert_eq!(first["models"][0]["id"], "local-current"); + assert_eq!(other["models"][0]["id"], "managed-current"); + local + .push_err(CapabilityError { + code: "CAPABILITY_PROBE_TIMEOUT".into(), + message: "deadline".into(), + retryable: true, + }) + .await; + let (status, failed) = registry + .codex(local_source.clone(), Some("/same-cwd".into()), true) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(failed["error"]["code"], "CAPABILITY_PROBE_TIMEOUT"); + let (_, cached) = registry + .codex(local_source, Some("/same-cwd".into()), false) + .await; + assert_eq!(cached["status"], "cached"); + assert_eq!(cached["models"][0]["id"], "local-current"); + assert_eq!(local.call_count().await, 2); + assert_eq!(managed.call_count().await, 1); + assert_eq!(gateway.call_count().await, 0); +} + +#[tokio::test] +async fn codex_model_catalog_without_bound_runtime_is_pending_and_does_not_probe_gateway() { + let probe = Arc::new(RecordingProbe::new()); + let app = app_with_probe(probe.clone()); + for uri in [ + format!("{CAP}/freshcodex"), + format!("{CAP}/freshcodex?sessionId=not-created&cwd=/other-home"), + ] { + let (status, body) = get(app.clone(), &uri, true).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["error"]["code"], "RUNTIME_PENDING"); + assert_eq!(body["models"], json!([])); + } + assert_eq!(probe.call_count().await, 0); +} + /// `model-capabilities-router.ts:34-37 + :50-53`. #[tokio::test] async fn invalid_session_type_is_400_for_get_and_refresh() { @@ -820,20 +847,6 @@ async fn probe_failure_maps_to_503_unavailable_envelope() { ); } -/// Static providers work over HTTP too and never probe. -#[tokio::test] -async fn get_freshcodex_serves_static_catalog_over_http() { - let probe = Arc::new(RecordingProbe::new()); - let app = app_with_probe(probe.clone()); - - let (status, body) = get(app, &format!("{CAP}/freshcodex?cwd=/ignored"), true).await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["runtimeProvider"], json!("codex")); - assert_eq!(body["models"].as_array().unwrap().len(), 5); - assert_eq!(body["models"][0]["id"], json!("gpt-6-astra")); - assert_eq!(probe.call_count().await, 0); -} - /// POST refresh re-probes even within the TTL window. #[tokio::test] async fn refresh_reprobes_and_reports_fresh() { diff --git a/crates/freshell-freshagent/src/naming.rs b/crates/freshell-freshagent/src/naming.rs index 5db8812aab..7ae756fd1b 100644 --- a/crates/freshell-freshagent/src/naming.rs +++ b/crates/freshell-freshagent/src/naming.rs @@ -11,6 +11,7 @@ //! rename answer ALWAYS reports the actual accepted winner (including an //! unchanged winner when an automatic suggestion loses). +use serde::{Deserialize, Serialize}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -132,7 +133,7 @@ pub struct RenameNameInput { /// Why activity arrived. `AcceptedUserMessage`/`IndexUserMessage` may carry a /// first user message (fallback upgrade input); `Opened`/`Resumed` arm an /// unattempted generation series only when a first message exists. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum NameActivityReason { AcceptedUserMessage, IndexUserMessage, @@ -143,7 +144,7 @@ pub enum NameActivityReason { /// One activity event. `event_id` is the existing provider/event message id /// (duplicate delivery folds through the series' input fingerprint, never a /// new event store). -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NameActivity { pub target: SessionNameRef, pub mode: String, @@ -309,7 +310,7 @@ pub fn named_provider_for(mode: Option<&str>, session_type: Option<&str>) -> Opt /// Task 2 interfaces) — carried SEPARATELY from the ledger's `supersedes` /// edge, which cannot distinguish a rollback fork from a crash-recovery /// mint-new from a deliberate new conversation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum NameTransitionReason { /// First verified durable materialization of a pre-durable handle /// (requires verified persistence — never a bare start ack). @@ -338,13 +339,34 @@ pub enum NameTransitionReason { /// classification plus the native routing evidence the store retains with /// the record. `pending` is the pre-durable handle this transition resolves /// (`None` on resume/switch of established identities). -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NameTransition { pub reason: NameTransitionReason, pub acquisition: NativeAcquisition, pub pending: Option, } +pub const HOSTED_CODEX_NAMING_EVENT: &str = "freshAgent.host.naming"; + +/// Provider facts transported to the gateway's existing name authority. +/// These never contain accepted name records or native control answers. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub enum HostedCodexNamingFact { + Activity(NameActivity), + /// Exact persisted metadata for a current session without a pending + /// handle. This admits that session's own name; it never transfers one. + Acquisition { + target: SessionNameRef, + acquisition: NativeAcquisition, + cwd: Option, + }, + Transition { + target: SessionNameRef, + transition: NameTransition, + }, +} + impl NameTransition { /// A verified transition resolving `pending` onto its durable identity. pub fn binding( diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 286d150384..62b0333b6d 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -1510,7 +1510,10 @@ impl FreshOpencodeState { } let model = normalize_opencode_model(msg.model.as_deref()); - let effort = normalize_opencode_effort(model.as_deref(), msg.effort.as_deref()); + let effort = normalize_opencode_effort( + model.as_deref(), + msg.effort.as_ref().and_then(|value| value.as_deref()), + ); let placeholder = format!("freshopencode-{request_id}"); let mut session = OpencodeSession::new(placeholder.clone(), msg.cwd.clone(), model, effort); @@ -1813,6 +1816,7 @@ impl FreshOpencodeState { ( session.provenance.clone(), crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: session.model.clone(), sandbox: None, permission_mode: None, @@ -1836,7 +1840,7 @@ impl FreshOpencodeState { } let raw_model = msg.model.clone().or_else(|| session.model.clone()); let model = normalize_opencode_model(raw_model.as_deref()); - let raw_effort = msg.effort.clone().or_else(|| session.effort.clone()); + let raw_effort = msg.effort.clone().unwrap_or_else(|| session.effort.clone()); let effort = normalize_opencode_effort(model.as_deref(), raw_effort.as_deref()); session.model = model; session.effort = effort; @@ -1979,6 +1983,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model, sandbox: None, permission_mode: None, @@ -2309,7 +2314,10 @@ impl FreshOpencodeState { // stored model/effort/cwd are reused verbatim. let (model, effort, cwd) = if let Some(settings) = msg.settings.as_ref() { let model = normalize_opencode_model(settings.model.as_deref()); - let effort = normalize_opencode_effort(model.as_deref(), settings.effort.as_deref()); + let effort = normalize_opencode_effort( + model.as_deref(), + settings.effort.as_ref().and_then(|value| value.as_deref()), + ); let cwd = settings .cwd .clone() @@ -2597,6 +2605,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: session.model.clone(), sandbox: None, permission_mode: None, @@ -2729,6 +2738,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: session.model.clone(), sandbox: None, permission_mode: None, @@ -3172,7 +3182,13 @@ impl FreshOpencodeState { } let model = normalize_opencode_model(msg.settings.model.as_deref()); - let effort = normalize_opencode_effort(model.as_deref(), msg.settings.effort.as_deref()); + let effort = normalize_opencode_effort( + model.as_deref(), + msg.settings + .effort + .as_ref() + .and_then(|value| value.as_deref()), + ); let changed = session.model != model || session.effort != effort; if !changed { // Idempotent configure: nothing to converge. @@ -3206,6 +3222,7 @@ impl FreshOpencodeState { observed_generation: None, authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: model.clone(), sandbox: None, permission_mode: None, @@ -6051,6 +6068,7 @@ impl FreshOpencodeState { // b8ke focused ep5 r2 F1: a lane write. authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model, sandbox: None, permission_mode: None, @@ -7639,6 +7657,7 @@ impl FreshOpencodeState { // session gains provenance WITHOUT a laundered defaults snapshot). let settings = if recovered.is_some() { crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: rec.model.clone(), sandbox: None, permission_mode: None, @@ -9797,6 +9816,7 @@ mod tests { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, observed_epoch: None, @@ -9912,6 +9932,7 @@ mod tests { let placeholder = "freshopencode-req-ign"; let mut msg = send_msg(placeholder, "ignored knobs should log"); msg.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, effort: None, model: None, @@ -13872,7 +13893,7 @@ mod tests { let mut create = create_msg("r1"); create.cwd = Some("/w".to_string()); create.model = Some("big-model".to_string()); - create.effort = Some("high".to_string()); + create.effort = Some(Some("high".to_string())); state.handle_create(create, None).await; state .handle_send(send_msg("freshopencode-r1", "hello")) @@ -14218,15 +14239,16 @@ mod tests { let mut create = create_msg("r2"); create.cwd = Some("/w".to_string()); create.model = Some("big-model".to_string()); - create.effort = Some("high".to_string()); + create.effort = Some(Some("high".to_string())); state.handle_create(create, None).await; let placeholder = "freshopencode-r2"; state.handle_send(send_msg(placeholder, "first")).await; let mut second = send_msg(placeholder, "second"); second.settings = Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, - effort: Some("low".to_string()), + effort: Some(Some("low".to_string())), model: Some("small-model".to_string()), permission_mode: None, sandbox: None, @@ -14344,6 +14366,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -14387,6 +14410,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -14852,6 +14876,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -14927,6 +14952,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -15456,6 +15482,7 @@ mod tests { "opencode", DURABLE_ID, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -15664,6 +15691,7 @@ mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -15767,6 +15795,7 @@ mod tests { authoritative: false, settings: crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".into()), sandbox: None, permission_mode: None, @@ -22838,11 +22867,12 @@ mod tests { cwd: None, request_id: Some("req-configure".to_string()), settings: freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }, } } @@ -22862,11 +22892,12 @@ mod tests { images: None, request_id: Some(format!("req-{text}")), settings: Some(freshell_protocol::FreshAgentSendSettings { + collaboration_mode: None, cwd: None, model: model.map(|m| m.to_string()), permission_mode: None, sandbox: None, - effort: effort.map(|e| e.to_string()), + effort: effort.map(|e| e.to_string()).map(Some), }), observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 6a3b324a1b..f7215ecc2f 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -241,7 +241,7 @@ pub(crate) async fn split_pane( session_type: session_type.into(), cwd: request.cwd.clone(), model: request.model.clone(), - effort: request.effort.clone(), + effort: request.effort.clone().flatten(), durable_id: Some( created .session_ref diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index 96d9305477..a1f045b711 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -1132,6 +1132,7 @@ async fn rest_resume_durable_ses_is_born_durable_with_ledger_settings_and_route( sink.settings.lock().unwrap().insert( ("opencode".to_string(), "ses_resumed_1".to_string()), FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".to_string()), sandbox: None, permission_mode: None, @@ -1681,6 +1682,7 @@ async fn rest_resume_body_model_and_effort_beat_the_ledger_record() { sink.settings.lock().unwrap().insert( ("opencode".to_string(), "ses_override_1".to_string()), FreshAgentSettings { + collaboration_mode: None, model: Some("big-model".to_string()), sandbox: None, permission_mode: None, diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index 73df0e26fe..8227ac7833 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -6,11 +6,23 @@ use async_trait::async_trait; use serde_json::Value; pub type RestCreateSettingsReader = std::sync::Arc< - dyn Fn() -> futures_util::future::BoxFuture<'static, freshell_protocol::SettingsFreshAgent> - + Send - + Sync, + dyn Fn() -> futures_util::future::BoxFuture<'static, RestCreateSettings> + Send + Sync, >; +pub struct RestCreateSettings { + pub fresh_agent: freshell_protocol::SettingsFreshAgent, + pub coding_cli: Option, +} + +impl From for RestCreateSettings { + fn from(fresh_agent: freshell_protocol::SettingsFreshAgent) -> Self { + Self { + fresh_agent, + coding_cli: None, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RestLaneErrorKind { BadRequest, @@ -29,6 +41,15 @@ pub struct RestLaneError { pub retryable: Option, pub retry_after_ms: Option, pub owner: Option, + pub correlation: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RestLaneErrorCorrelation { + pub session_id: Option, + pub request_id: Option, + pub incarnation_id: Option, + pub submitted_turn_id: Option, } impl RestLaneError { @@ -44,6 +65,7 @@ impl RestLaneError { retryable: None, retry_after_ms: None, owner: None, + correlation: None, } } } @@ -125,7 +147,8 @@ pub struct RestLaneCreate { pub session_type: String, pub cwd: Option, pub model: Option, - pub effort: Option, + pub effort: Option>, + pub collaboration_mode: Option, pub native_session_id: Option, pub plugins: Option>, pub model_selection: Option>, @@ -145,9 +168,15 @@ pub struct RestLaneCreated { /// table. It is not substituted for the provider's native identity. pub session_id: String, pub session_ref: Option, + /// Naming identity acknowledged by the owning create/resume operation. + /// A resumed conversation can already have a durable canonical record. + pub name_ref: Option, /// Provider-owned identity of a runtime newly started by this create. /// An adopted existing writer has no cleanup token. pub cleanup_token: Option, + /// Provider-resolved next-turn choices captured from the same writer. + /// Native thread echoes can differ from the requested launch defaults. + pub effective_settings: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -200,6 +229,14 @@ pub struct RestLaneSnapshot { pub session_type: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneChildRead { + pub parent_session_id: String, + pub child_thread_id: String, + pub observed_epoch: Option, + pub observed_generation: Option, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum RestLaneSnapshotError { OwnershipUnavailable, @@ -208,6 +245,21 @@ pub enum RestLaneSnapshotError { #[async_trait] pub trait FreshAgentRestLane: Send + Sync { + async fn model_catalog_source( + &self, + _context: crate::model_capabilities::CodexCatalogContext, + ) -> Result< + crate::model_capabilities::CodexCatalogSource, + crate::model_capabilities::CapabilityError, + > { + Err(crate::model_capabilities::runtime_pending()) + } + async fn read_child( + &self, + _request: RestLaneChildRead, + ) -> Result, crate::codex::children::ChildReadError> { + Err(crate::codex::children::ChildReadError::SourceUnavailable) + } /// None leaves genuinely unmanaged threads on their existing read path. /// A hosted read failure must never fall back to saved history as live truth. async fn snapshot( diff --git a/crates/freshell-freshagent/src/rest_lane_create.rs b/crates/freshell-freshagent/src/rest_lane_create.rs index cf93f22c9a..bb34bb5101 100644 --- a/crates/freshell-freshagent/src/rest_lane_create.rs +++ b/crates/freshell-freshagent/src/rest_lane_create.rs @@ -66,8 +66,18 @@ impl FreshAgentState { .map(str::to_string), effort: body .get("effort") - .and_then(Value::as_str) - .map(str::to_string), + .map(|value| { + serde_json::from_value(value.clone()) + .map_err(|_| invalid("effort must be a string or null".into())) + }) + .transpose()?, + collaboration_mode: body + .get("collaborationMode") + .map(|value| { + serde_json::from_value(value.clone()) + .map_err(|_| invalid("collaborationMode must be a string".into())) + }) + .transpose()?, native_session_id, plugins: inputs.plugins, model_selection: inputs.model_selection, @@ -77,29 +87,33 @@ impl FreshAgentState { observed_generation, ..Default::default() }; - if provider == "claude" { + if matches!(provider, "claude" | "codex") { let settings = if let Some(reader) = self.rest_create_settings.get() { reader().await } else { - SettingsFreshAgent { + rest_lane::RestCreateSettings::from(SettingsFreshAgent { default_plugins: vec![], enabled: false, providers: json!({}), initial_setup_done: None, - } + }) }; - let resolved = crate::claude_create_defaults::resolve_claude_rest_create( - session_type, - body, - &settings, - ) - .map_err(invalid)?; - request.model = Some(resolved.model); - request.model_selection = Some(resolved.model_selection); - request.model_effort_levels = resolved.model_effort_levels; - request.effort = resolved.effort; - request.permission_mode = Some(resolved.permission_mode); - request.plugins = Some(resolved.plugins); + if provider == "codex" { + resolve_codex_rest_create(&mut request, &settings)?; + } else { + let resolved = crate::claude_create_defaults::resolve_claude_rest_create( + session_type, + body, + &settings.fresh_agent, + ) + .map_err(invalid)?; + request.model = Some(resolved.model); + request.model_selection = Some(resolved.model_selection); + request.model_effort_levels = resolved.model_effort_levels; + request.effort = resolved.effort.map(Some); + request.permission_mode = Some(resolved.permission_mode); + request.plugins = Some(resolved.plugins); + } } if naming::is_unified_agent_mode(None, Some(session_type)) { request.naming_handle = Some( @@ -194,7 +208,7 @@ pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) for (key, value) in [ ("initialCwd", request.cwd.as_ref()), ("model", request.model.as_ref()), - ("effort", request.effort.as_ref()), + ("effort", request.effort.as_ref().and_then(Option::as_ref)), ("permissionMode", request.permission_mode.as_ref()), ("namingHandle", request.naming_handle.as_ref()), ] { @@ -202,6 +216,14 @@ pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) content[key] = json!(value); } } + if request.provider == "codex" { + if request.effort.is_some() { + content["effortIsNativeDefault"] = json!(request.effort == Some(None)); + } + if let Some(mode) = &request.collaboration_mode { + content["collaborationMode"] = json!(mode); + } + } if let Some(value) = &request.plugins { content["plugins"] = json!(value); } @@ -217,5 +239,163 @@ pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) if let Some(value) = &created.session_ref { content["sessionRef"] = json!(value); } + if let Some(value) = &created.name_ref { + content["nameRef"] = json!(value); + } + if let Some(settings) = &created.effective_settings { + for (from, to) in [ + ("model", "model"), + ("cwd", "initialCwd"), + ("sandbox", "sandbox"), + ("permissionMode", "permissionMode"), + ("collaborationMode", "collaborationMode"), + ] { + if let Some(value) = settings.get(from) { + if value.is_null() { + content.as_object_mut().expect("pane object").remove(to); + } else { + content[to] = value.clone(); + } + } + } + if let Some(effort) = settings.get("effort") { + if effort.is_null() { + content + .as_object_mut() + .expect("pane object") + .remove("effort"); + } else { + content["effort"] = effort.clone(); + } + if request.provider == "codex" { + content["effortIsNativeDefault"] = json!(effort.is_null()); + } + } + } content } + +fn resolve_codex_rest_create( + request: &mut RestLaneCreate, + settings: &rest_lane::RestCreateSettings, +) -> Result<(), RestLaneError> { + use crate::codex::settings::CodexEffectiveSettings; + if request + .effort + .as_ref() + .and_then(Option::as_deref) + .is_some_and(|effort| effort.trim().is_empty()) + { + return Err(RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_EFFORT", + "effort must be a nonempty string or null for native Default", + )); + } + let saved = &settings.fresh_agent.providers["freshcodex"]; + let cli = settings + .coding_cli + .as_ref() + .and_then(|settings| settings.providers.get("codex")); + // A canonical resume inherits its conversation's saved choices in the runtime. + // Provider defaults apply only when creating a new conversation. + if request.native_session_id.is_none() { + let selected_model = match &request.model_selection { + Some(selection) => selection + .as_ref() + .map(|selection| selection.model_id.clone()), + None => saved + .pointer("/modelSelection/modelId") + .and_then(Value::as_str) + .map(str::to_owned), + }; + request.model = request + .model + .take() + .or(selected_model) + .or_else(|| cli.and_then(|settings| settings.model.clone())); + request.effort = request.effort.take().or_else(|| { + Some( + saved + .get("effort") + .and_then(Value::as_str) + .map(str::to_owned), + ) + }); + request.cwd = request + .cwd + .take() + .or_else(|| saved.get("cwd").and_then(Value::as_str).map(str::to_owned)); + request.permission_mode = request + .permission_mode + .take() + .or_else(|| { + saved + .get("defaultPermissionMode") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| cli.and_then(|settings| settings.permission_mode.clone())) + .or_else(|| Some("on-request".into())); + request.collaboration_mode = request + .collaboration_mode + .take() + .or_else(|| { + saved + .get("collaborationMode") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| Some("default".into())); + if request.sandbox.is_none() { + let sandbox = saved + .get("sandbox") + .and_then(Value::as_str) + .or_else(|| cli.and_then(|settings| settings.sandbox.as_deref())); + request.sandbox = sandbox + .map(|value| serde_json::from_value(json!(value))) + .transpose() + .map_err(|_| { + RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_SANDBOX", + "saved Codex file access is invalid", + ) + })?; + } + } + let effective = CodexEffectiveSettings::resolve( + &CodexEffectiveSettings { + model: request.model.clone().unwrap_or_default(), + effort: request.effort.clone().flatten(), + cwd: request.cwd.clone(), + sandbox: None, + permission_mode: None, + collaboration_mode: None, + }, + &freshell_protocol::FreshAgentSendSettings { + sandbox: request.sandbox, + permission_mode: request.permission_mode.clone(), + collaboration_mode: request.collaboration_mode.clone(), + ..Default::default() + }, + ) + .map_err(|error| { + RestLaneError::new(RestLaneErrorKind::BadRequest, error.code, error.message) + })?; + // Keep resume omissions absent; otherwise full defaults would override saved intent. + if request.native_session_id.is_none() || request.model.is_some() { + request.model = Some(effective.model); + } + if request.effort.is_some() { + request.effort = Some(effective.effort); + } + request.permission_mode = effective.permission_mode; + request.sandbox = effective + .sandbox + .map(|sandbox| serde_json::from_value(json!(sandbox)).expect("validated Codex sandbox")); + request.collaboration_mode = effective + .collaboration_mode + .map(|mode| mode.as_str().into()); + Ok(()) +} diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index 63095c71d0..fc20ea49de 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -24,6 +24,7 @@ impl FreshAgentRestLane for RefusingLane { owner_generation: 7, owner_epoch: 3, }), + correlation: None, }) } @@ -177,7 +178,7 @@ async fn claude_defaults_are_read_once_and_used_for_the_runtime_and_pane() { "freshclaude": { "modelSelection": { "kind": "exact", "modelId": "catalog-a" }, "modelEffortLevels": { "modelId": "catalog-a", "levels": [] }, "effort": "high", "defaultPermissionMode": "plan" }, "kilroy": { "modelSelection": { "kind": "exact", "modelId": "catalog-b" }, "effort": "low", "defaultPermissionMode": "bypassPermissions" }, }), - } }) + }.into() }) })).unwrap(); let router = app(state.clone()); for (agent, model, mode, effort) in [ @@ -196,7 +197,7 @@ async fn claude_defaults_are_read_once_and_used_for_the_runtime_and_pane() { .unwrap(); assert_eq!(request.model.as_deref(), Some(model)); assert_eq!(request.permission_mode.as_deref(), Some(mode)); - assert_eq!(request.effort.as_deref(), effort); + assert_eq!(request.effort.as_ref().and_then(Option::as_deref), effort); assert_eq!(request.plugins, Some(vec!["/saved-plugin".into()])); assert_eq!(pane["model"], model); assert_eq!(pane["permissionMode"], mode); @@ -238,6 +239,7 @@ async fn an_explicit_model_override_clears_a_different_saved_exact_selection() { "modelEffortLevels": { "modelId": "saved-model", "levels": [] }, } }), } + .into() }) })) .unwrap(); @@ -319,6 +321,25 @@ async fn hosted_gateway_takes_precedence_over_the_local_claude_lane() { assert!(local.creates.lock().unwrap().is_empty()); } +#[tokio::test] +async fn hosted_gateway_takes_precedence_over_the_local_codex_lane() { + let state = state_with_registry(); + let local = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + let hosted = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_codex_rest_lane(local.clone()).unwrap(); + state.set_hosted_rest_gateway(hosted.clone()).unwrap(); + let (status, body) = post(app(state), "/api/tabs", json!({"agent":"codex"}), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(hosted.creates.lock().unwrap().len(), 1); + assert!(local.creates.lock().unwrap().is_empty()); +} + #[derive(Default)] struct ControlLane { sends: std::sync::Mutex>, @@ -327,6 +348,181 @@ struct ControlLane { concurrent_replacement: Option<(crate::layout_store::LayoutStore, String, String, Value)>, } +#[tokio::test] +async fn codex_rest_lane_wait_for_reads_the_current_fresh_pane_capture() { + let state = state_with_registry(); + let (tab, pane) = state.layout.create_tab(None); + state.layout.attach_pane_content( + &tab, + &pane, + json!({ + "kind":"fresh-agent", "provider":"codex", "sessionType":"freshcodex", + "sessionRef":{"provider":"codex","sessionId":"durable-codex"}, + }), + ); + let lane = Arc::new(ControlLane::default()); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + let router = app(state); + let matched = get( + router.clone(), + &format!("/api/panes/{pane}/wait-for?pattern=saved%20conversation&T=0"), + true, + ) + .await; + let absent = get( + router, + &format!("/api/panes/{pane}/wait-for?pattern=absent&T=0"), + true, + ) + .await; + assert_eq!( + (matched.0, absent.0), + (StatusCode::OK, StatusCode::OK), + "{matched:?} {absent:?}" + ); + assert_eq!(matched.1["data"]["matched"], true); + assert_eq!(absent.1["status"], "approx"); + assert_eq!(absent.1["data"]["matched"], false); + assert!(lane + .captures + .lock() + .unwrap() + .iter() + .all(|request| request.session_id == "durable-codex")); +} + +#[tokio::test] +async fn codex_rest_lane_create_resolves_saved_defaults_and_explicit_native_default_once() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = reads.clone(); + state + .set_rest_create_settings_reader(Arc::new(move || { + count.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::pin(async { + freshell_protocol::SettingsFreshAgent { + default_plugins: vec![], + enabled: true, + initial_setup_done: None, + providers: json!({"freshcodex":{ + "modelSelection":{"kind":"exact","modelId":"provider-current"}, + "effort":"ultra", "cwd":"/work", "sandbox":"workspace-write", + "defaultPermissionMode":"on-request", "collaborationMode":"plan" + }}), + } + .into() + }) + })) + .unwrap(); + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({"agent":"codex", "effort":null}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let pane = state + .layout + .get_pane_snapshot(body["data"]["paneId"].as_str().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!( + json!({ + "model":pane["model"], "effortIsNativeDefault":pane["effortIsNativeDefault"], + "cwd":pane["initialCwd"], "sandbox":pane["sandbox"], + "permissionMode":pane["permissionMode"], "collaborationMode":pane["collaborationMode"], + }), + json!({"model":"provider-current","effortIsNativeDefault":true,"cwd":"/work","sandbox":"workspace-write","permissionMode":"on-request","collaborationMode":"plan"}) + ); + assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn codex_rest_lane_explicit_model_selection_clear_uses_the_cli_default() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_codex_rest_lane(lane.clone()).unwrap(); + state.set_rest_create_settings_reader(Arc::new(|| Box::pin(async { + RestCreateSettings { + fresh_agent: freshell_protocol::SettingsFreshAgent { + default_plugins: vec![], enabled:true, initial_setup_done:None, + providers:json!({"freshcodex":{"modelSelection":{"kind":"exact","modelId":"saved-picker-model"}}}), + }, + coding_cli:Some(serde_json::from_value(json!({ + "enabledProviders":["codex"], "mcpServer":false, + "providers":{"codex":{"model":"cli-default-model"}}, + })).unwrap()), + } + }))).unwrap(); + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({"agent":"codex","modelSelection":null}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!( + request.model.as_deref(), + Some("cli-default-model"), + "explicit null must not restore a saved picker selection" + ); + assert_eq!(request.model_selection, Some(None)); + let content = state + .layout + .get_pane_snapshot(body["data"]["paneId"].as_str().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(content["model"], "cli-default-model"); + assert_eq!(content["modelSelection"], Value::Null); +} + +#[tokio::test] +async fn codex_rest_lane_blank_effort_is_not_an_explicit_native_default() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_codex_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let mut outcomes = vec![]; + for effort in ["", " "] { + outcomes.push( + post( + router.clone(), + "/api/tabs", + json!({"agent":"codex","effort":effort}), + true, + ) + .await, + ); + } + assert!( + outcomes + .iter() + .all(|(status, body)| *status == StatusCode::BAD_REQUEST + && body["code"] == "INVALID_EFFORT"), + "{outcomes:?}" + ); + assert!( + lane.creates.lock().unwrap().is_empty(), + "a blank string must not clear a choice or create a runtime" + ); +} + #[async_trait::async_trait] impl FreshAgentRestLane for ControlLane { async fn create_agent( diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 98460787a5..391e47ec3f 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -701,6 +701,7 @@ async fn unsupported_atomic_handoff_refuses_before_mutation_and_clear_bypasses_p fn create_msg(sid: &str) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, request_id: format!("handoff-create-{}", uuid::Uuid::new_v4()), @@ -6240,6 +6241,7 @@ async fn the_claude_handoff_target_binding_carries_the_handoff_generation() { "claude", &sid, crate::identity_sink::FreshAgentSettings { + collaboration_mode: None, model: Some("opus-x".into()), sandbox: None, permission_mode: Some("plan".into()), @@ -6918,6 +6920,7 @@ async fn an_abort_cleanup_broadcasts_the_restored_prior_owner() { fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { FreshAgentCreate { + collaboration_mode: None, pane_id: None, naming_handle: None, request_id: format!("handoff-create-fresh-{}", uuid::Uuid::new_v4()), diff --git a/crates/freshell-freshagent/src/session_lease.rs b/crates/freshell-freshagent/src/session_lease.rs index d0286fcfc7..931d510923 100644 --- a/crates/freshell-freshagent/src/session_lease.rs +++ b/crates/freshell-freshagent/src/session_lease.rs @@ -20,6 +20,29 @@ use std::collections::HashMap; use std::sync::Mutex; +/// Opaque and reparented fixtures share this binary's `/proc` view with native +/// provider tests. Reuse their existing environment guards in the same order +/// as cross-provider handoff tests, from before spawn through physical cleanup. +#[cfg(test)] +pub(crate) mod process_fixture { + pub(crate) type Guard = ( + crate::codex::tests::IsolatedCodexEnvGuard<'static>, + tokio::sync::MutexGuard<'static, ()>, + ); + + pub(crate) async fn lock() -> Guard { + let claude = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; + let codex = crate::codex::tests::ENV_LOCK.lock().await; + (codex, claude) + } + + pub(crate) fn blocking_lock() -> Guard { + let claude = crate::claude::tests::CLAUDE_ENV_LOCK.blocking_lock(); + let codex = crate::codex::tests::ENV_LOCK.blocking_lock(); + (codex, claude) + } +} + /// Lease TTL: how long a create/resume may hold the sessionRef before contenders may /// demand the kill-before-release path. Env `FRESHELL_FRESH_AGENT_LEASE_TTL_MS` overrides. pub const FRESH_AGENT_SESSION_LEASE_TTL_MS: u64 = 20_000; @@ -70,43 +93,8 @@ pub fn now_epoch_ms() -> u64 { /// `force_release` ONLY then. Non-Linux: no `/proc` — returns `false` (hold closed). #[cfg(target_os = "linux")] pub async fn kill_and_confirm_tree_dead(pid: u32, ownership_env: &str, ownership_id: &str) -> bool { - // 1. Capture the tagged tree BEFORE any kill (see YAMA note above). - // The direct child's recorded start time — captured BEFORE any signal - // so the grace wait and the escalation both revalidate the incarnation - // (b8ke focused round-4 R4-8: a pid recycled mid-wait is never signaled). - let child_start = match proc_starttime_observation(pid as i32) { - Ok(start) => start, - Err(_) => return false, - }; - let scan = scan_tagged_pids(ownership_env, ownership_id, Some(pid as i32)); - if !scan.complete { - return false; - } - let mut tree = Vec::new(); - for p in scan.pids { - match proc_starttime_observation(p) { - Ok(Some(st)) => tree.push((p, st)), - Ok(None) => {} - Err(_) => return false, - } - } - if !tree.iter().any(|(p, _)| *p == pid as i32) { - if let Some(st) = child_start { - tree.push((pid as i32, st)); - } - } - - // 2. Graceful SIGTERM to the recorded child, poll, SIGKILL fallback — - // both signals through the pinned incarnation (R5-4). - if kill_recorded_child_unconfirmed(pid, child_start).await { - return false; - } - - // 3. Sweep the captured tree until confirmed empty (bounded; SIGKILL - // escalation after 20 SIGTERM rounds). Re-scan folds in still-readable - // tagged newcomers (covers the YAMA=0 case and children spawned after - // the initial capture). - sweep_captured_tree_until_dead(tree, ownership_env, ownership_id, Some(pid as i32)).await + let recorded = record_condemned_runtime_identity(pid, ownership_env, ownership_id); + kill_and_confirm_recorded_tree_dead(&recorded, ownership_env).await } /// The sweep behind [`kill_and_confirm_tree_dead`]'s step 3: poll the @@ -122,6 +110,7 @@ async fn sweep_captured_tree_until_dead( ownership_env: &str, ownership_id: &str, owned_root_pid: Option, + expected_start: Option, ) -> bool { for round in 0..24u8 { let mut observation_complete = true; @@ -136,7 +125,14 @@ async fn sweep_captured_tree_until_dead( if !observation_complete { return false; } - let scan = scan_tagged_pids(ownership_env, ownership_id, owned_root_pid); + let scan = scan_tagged_pids_observing( + ownership_env, + ownership_id, + owned_root_pid, + expected_start, + &tree, + &mut |_, _| {}, + ); if !scan.complete { return false; } @@ -183,7 +179,8 @@ async fn sweep_captured_tree_until_dead( /// never signaled) and sweeps the RECORDED tree, so reparented descendants /// whose tags became unreadable after the child died are still confirmed /// dead-by-starttime — never an empty-tree false confirmation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] pub struct CondemnedRuntimeIdentity { /// The direct sidecar child's pid at kill time. pub pid: u32, @@ -219,17 +216,81 @@ pub fn record_condemned_runtime_identity( ownership_env: &str, ownership_id: &str, ) -> CondemnedRuntimeIdentity { + record_condemned_runtime_identity_observing(pid, ownership_env, ownership_id, &mut |_, _| {}) +} + +#[cfg(target_os = "linux")] +fn record_condemned_runtime_identity_observing( + pid: u32, + ownership_env: &str, + ownership_id: &str, + before_read: &mut impl FnMut(&str, i32), +) -> CondemnedRuntimeIdentity { + capture_runtime_identity(pid, ownership_env, ownership_id, None, before_read) +} + +/// Enrich a recorded identity without replacing its members, root incarnation, +/// or unavailable coverage. The old root start also bounds later opaque orphans. +#[cfg(target_os = "linux")] +pub fn refresh_condemned_runtime_identity( + recorded: &CondemnedRuntimeIdentity, + ownership_env: &str, +) -> CondemnedRuntimeIdentity { + capture_runtime_identity( + recorded.pid, + ownership_env, + &recorded.ownership_id, + Some(recorded), + &mut |_, _| {}, + ) +} + +#[cfg(target_os = "linux")] +fn capture_runtime_identity( + pid: u32, + ownership_env: &str, + ownership_id: &str, + previous: Option<&CondemnedRuntimeIdentity>, + before_read: &mut impl FnMut(&str, i32), +) -> CondemnedRuntimeIdentity { + before_read("root_stat", pid as i32); let child_observation = proc_starttime_observation(pid as i32); - let mut capture_complete = child_observation.is_ok(); - let child_start = child_observation.ok().flatten(); - let scan = scan_tagged_pids(ownership_env, ownership_id, Some(pid as i32)); + let mut capture_complete = + child_observation.is_ok() && previous.is_none_or(|recorded| recorded.capture_complete); + if let Err(error) = &child_observation { + capture_failure(pid as i32, None, ownership_id, error); + } + let observed_start = child_observation.ok().flatten(); + let child_start = previous.map_or(observed_start, |recorded| recorded.start_time); + let mut tree = previous.map_or_else(Vec::new, |recorded| recorded.tree.clone()); + if let Some(start) = child_start { + capture_complete &= capture_linked_descendants(pid as i32, start, ownership_id, &mut tree); + } + // Reuse exact descendant membership from this walk as well as the old + // record. A fresh root is added only after the scan: its unavailable + // ownership evidence must still make the capture incomplete. + let scan = scan_tagged_pids_observing( + ownership_env, + ownership_id, + Some(pid as i32), + child_start, + &tree, + before_read, + ); capture_complete &= scan.complete; - let mut tree = Vec::new(); for p in scan.pids { + before_read("member_stat", p); match proc_starttime_observation(p) { - Ok(Some(st)) => tree.push((p, st)), + Ok(Some(st)) => { + if !tree.contains(&(p, st)) { + tree.push((p, st)); + } + } Ok(None) => {} - Err(_) => capture_complete = false, + Err(error) => { + capture_failure(p, child_start, ownership_id, &error); + capture_complete = false; + } } } if !tree.iter().any(|(p, _)| *p == pid as i32) { @@ -294,6 +355,10 @@ pub async fn kill_and_confirm_recorded_tree_dead( ); return false; } + let recorded = refresh_condemned_runtime_identity(recorded, ownership_env); + if !recorded.capture_complete { + return false; + } // The recorded start time is the identity license: the direct child's // signals ride the pidfd-pinned incarnation only (R5-4). No recorded // start time (the identity says the child was already gone at capture @@ -318,6 +383,7 @@ pub async fn kill_and_confirm_recorded_tree_dead( ownership_env, &recorded.ownership_id, Some(recorded.pid as i32), + recorded.start_time, ) .await; swept @@ -612,11 +678,11 @@ fn proc_entry_gone(error: &std::io::Error) -> bool { /// Read a process start time while preserving unavailable observations as a /// distinct result from a process that is gone. #[cfg(target_os = "linux")] -fn proc_starttime_observation(pid: i32) -> Result, ()> { +fn proc_starttime_observation(pid: i32) -> Result, ProcObservationError> { match std::fs::File::open(format!("/proc/{pid}/stat")) { Ok(stat) => proc_starttime_from_open_stat(stat), Err(error) if proc_entry_gone(&error) => Ok(None), - Err(_) => Err(()), + Err(error) => Err(ProcObservationError::io("stat_open", &error)), } } @@ -624,24 +690,32 @@ fn proc_starttime_observation(pid: i32) -> Result, ()> { /// already-open `/proc//stat` (split out so the open→read window — /// the process reaped in between — is testable deterministically). #[cfg(target_os = "linux")] -fn proc_starttime_from_open_stat(mut stat_file: std::fs::File) -> Result, ()> { +fn proc_starttime_from_open_stat( + mut stat_file: std::fs::File, +) -> Result, ProcObservationError> { use std::io::Read; let mut stat = String::new(); match stat_file.read_to_string(&mut stat) { Ok(_) => {} Err(error) if proc_entry_gone(&error) => return Ok(None), - Err(_) => return Err(()), + Err(error) => return Err(ProcObservationError::io("stat_read", &error)), } // comm (field 2) may contain spaces/parens: split at the LAST ')' — the remainder // starts at field 3 (state), so starttime (field 22) is index 19 there. - let rest = stat.rsplit(')').next().ok_or(())?; + let invalid = || ProcObservationError::invalid("stat_parse"); + let rest = stat.rsplit(')').next().ok_or_else(invalid)?; let fields: Vec<&str> = rest.split_whitespace().collect(); match fields.first() { Some(&"Z") | Some(&"X") => return Ok(None), - None => return Err(()), + None => return Err(invalid()), Some(_) => {} } - fields.get(19).ok_or(())?.parse().map(Some).map_err(|_| ()) + fields + .get(19) + .ok_or_else(invalid)? + .parse() + .map(Some) + .map_err(|_| invalid()) } /// Classify the current occupant against the recorded process incarnation. @@ -680,32 +754,185 @@ struct TaggedProcessScan { } #[cfg(target_os = "linux")] -fn scan_tagged_pids( +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProcObservationError { + stage: &'static str, + kind: std::io::ErrorKind, + errno: Option, +} + +#[cfg(target_os = "linux")] +impl ProcObservationError { + fn io(stage: &'static str, error: &std::io::Error) -> Self { + Self { + stage, + kind: error.kind(), + errno: error.raw_os_error(), + } + } + + fn invalid(stage: &'static str) -> Self { + Self { + stage, + kind: std::io::ErrorKind::InvalidData, + errno: None, + } + } +} + +#[cfg(target_os = "linux")] +fn capture_failure( + pid: i32, + expected_start: Option, + ownership_id: &str, + error: &ProcObservationError, +) { + // Debug/test harnesses do not necessarily install a tracing subscriber. + // Keep failed observations diagnosable there with the same bounded fields. + #[cfg(debug_assertions)] + eprintln!( + "{}", + serde_json::json!({"severity":"warn","event":"session_lease.capture_incomplete","stage":error.stage,"pid":pid,"expectedStart":expected_start,"ownershipId":ownership_id,"errorKind":format!("{:?}",error.kind),"errno":error.errno,"captureComplete":false}) + ); + tracing::warn!(stage=error.stage, pid, ?expected_start, ownership_id, + error_kind = ?error.kind, errno = ?error.errno, + capture_complete = false, "session_lease.capture_incomplete"); +} + +/// A live parent link proves membership even when the host and provider have +/// different UIDs. Every parent incarnation is checked around the observation; +/// each child's own start and parent come from the same stat read. +#[cfg(target_os = "linux")] +fn capture_linked_descendants( + root: i32, + start: u64, + ownership_id: &str, + tree: &mut Vec<(i32, u64)>, +) -> bool { + let mut pending = vec![(root, start)]; + let mut visited = std::collections::HashSet::new(); + let mut complete = true; + while let Some((pid, expected)) = pending.pop() { + if !visited.insert((pid, expected)) { + continue; + } + match proc_starttime_observation(pid) { + Ok(Some(actual)) if actual == expected => {} + Ok(_) => continue, + Err(error) => { + capture_failure(pid, Some(start), ownership_id, &error); + complete = false; + continue; + } + } + let observation = (|| -> Result, ProcObservationError> { + let tasks = std::fs::read_dir(format!("/proc/{pid}/task")) + .map_err(|error| ProcObservationError::io("owned_tasks", &error))?; + let mut children = Vec::new(); + for task in tasks { + let task = task.map_err(|error| ProcObservationError::io("owned_task", &error))?; + let row = std::fs::read_to_string(task.path().join("children")) + .map_err(|error| ProcObservationError::io("owned_children", &error))?; + for child in row.split_whitespace() { + let child: i32 = child + .parse() + .map_err(|_| ProcObservationError::invalid("owned_child_pid"))?; + let stat = match std::fs::read_to_string(format!("/proc/{child}/stat")) { + Ok(stat) => stat, + Err(error) if proc_entry_gone(&error) => continue, + Err(error) => { + return Err(ProcObservationError::io("owned_child_stat", &error)) + } + }; + let fields: Vec<_> = stat + .rsplit(')') + .next() + .unwrap_or_default() + .split_whitespace() + .collect(); + if matches!(fields.first(), Some(&"Z") | Some(&"X")) { + continue; + } + let parent: i32 = fields + .get(1) + .ok_or_else(|| ProcObservationError::invalid("owned_child_stat"))? + .parse() + .map_err(|_| ProcObservationError::invalid("owned_child_stat"))?; + let child_start: u64 = fields + .get(19) + .ok_or_else(|| ProcObservationError::invalid("owned_child_stat"))? + .parse() + .map_err(|_| ProcObservationError::invalid("owned_child_stat"))?; + if child <= 0 || parent != pid { + return Err(ProcObservationError::invalid("owned_child_link")); + } + children.push((child, child_start)); + } + } + if proc_starttime_observation(pid)? != Some(expected) { + return Err(ProcObservationError::invalid("owned_parent_changed")); + } + Ok(children) + })(); + match observation { + Ok(children) => { + for child in children { + if !tree.contains(&child) { + tree.push(child); + } + pending.push(child); + } + } + Err(error) => { + capture_failure(pid, Some(start), ownership_id, &error); + complete = false; + } + } + } + complete +} + +#[cfg(target_os = "linux")] +fn scan_tagged_pids_observing( ownership_env: &str, ownership_id: &str, owned_root_pid: Option, + expected_start: Option, + known_tree: &[(i32, u64)], + before_read: &mut impl FnMut(&str, i32), ) -> TaggedProcessScan { let needle = format!("{ownership_env}={ownership_id}"); let mut out = Vec::new(); let entries = match std::fs::read_dir("/proc") { Ok(entries) => entries, - Err(_) => { + Err(error) => { + capture_failure( + owned_root_pid.unwrap_or(0), + expected_start, + ownership_id, + &ProcObservationError::io("proc_directory", &error), + ); return TaggedProcessScan { pids: out, complete: false, - } + }; } }; let mut complete = true; - // `/proc//environ` is intentionally unreadable for processes owned by - // other users. Those processes cannot be part of this server's normally - // inherited sidecar tree, so filter them by the readable ownership record - // before treating an environ read failure as unknown evidence. + before_read("proc_directory", owned_root_pid.unwrap_or(0)); + // Cross-UID membership is captured from anchored links above. A different + // UID alone cannot authorize a signal or prove a descendant unrelated. let current_uid = unsafe { libc::geteuid() }; for entry in entries { let entry = match entry { Ok(entry) => entry, - Err(_) => { + Err(error) => { + capture_failure( + owned_root_pid.unwrap_or(0), + expected_start, + ownership_id, + &ProcObservationError::io("proc_directory_entry", &error), + ); // An unreadable directory entry leaves the process identity // unknown, so this capture cannot prove the tree is gone. complete = false; @@ -714,18 +941,38 @@ fn scan_tagged_pids( }; let name = entry.file_name(); let Some(name) = name.to_str() else { + capture_failure( + owned_root_pid.unwrap_or(0), + expected_start, + ownership_id, + &ProcObservationError::invalid("proc_directory_name"), + ); complete = false; continue; }; let Ok(pid) = name.parse::() else { continue; }; + if Some(pid) == owned_root_pid + && expected_start.is_some() + && matches!(proc_starttime_observation(pid), Ok(Some(start)) if Some(start) != expected_start) + { + continue; + } let status_path = std::path::Path::new("/proc").join(name).join("status"); + before_read("status", pid); let status = match std::fs::read_to_string(status_path) { Ok(status) => status, Err(error) if proc_entry_gone(&error) => continue, - Err(_) => { - if process_is_known_owned_process(pid, owned_root_pid) { + Err(error) => { + if unreadable_process_may_be_owned(pid, owned_root_pid, expected_start, known_tree) + { + capture_failure( + pid, + expected_start, + ownership_id, + &ProcObservationError::io("owned_status", &error), + ); complete = false; } continue; @@ -736,7 +983,13 @@ fn scan_tagged_pids( .find_map(|line| line.strip_prefix("Uid:")?.split_whitespace().next()) .and_then(|uid| uid.parse::().ok()) else { - if process_is_known_owned_process(pid, owned_root_pid) { + if unreadable_process_may_be_owned(pid, owned_root_pid, expected_start, known_tree) { + capture_failure( + pid, + expected_start, + ownership_id, + &ProcObservationError::invalid("owned_uid"), + ); complete = false; } continue; @@ -745,17 +998,25 @@ fn scan_tagged_pids( continue; } let environ = std::path::Path::new("/proc").join(name).join("environ"); + before_read("environ", pid); let bytes = match std::fs::read(environ) { Ok(bytes) => bytes, // A process disappearing between the directory and environ read // is an expected race, not unknown surviving-writer evidence. Err(error) if proc_entry_gone(&error) => continue, - Err(_) => { + Err(error) => { // Same-user processes can still make environ unreadable (for // example a non-dumpable user service). An unrelated process // cannot be evidence about this sidecar; an unreadable // process in the known sidecar ancestry does. - if process_is_known_owned_process(pid, owned_root_pid) { + if unreadable_process_may_be_owned(pid, owned_root_pid, expected_start, known_tree) + { + capture_failure( + pid, + expected_start, + ownership_id, + &ProcObservationError::io("owned_environ", &error), + ); complete = false; } continue; @@ -772,7 +1033,11 @@ fn scan_tagged_pids( } #[cfg(target_os = "linux")] -fn process_is_known_owned_process(pid: i32, owned_root_pid: Option) -> bool { +fn process_is_known_owned_process( + pid: i32, + owned_root_pid: Option, + expected_start: Option, +) -> bool { owned_root_pid.is_some_and(|root| { // The direct root is expected to be a zombie briefly after the // watcher reaps it. Its unreadable `/proc//environ` is therefore @@ -780,10 +1045,96 @@ fn process_is_known_owned_process(pid: i32, owned_root_pid: Option) -> bool if pid == root && matches!(proc_starttime_observation(pid), Ok(None)) { return false; } - process_is_descendant_of(pid, root).unwrap_or(false) + expected_start.is_some() + && proc_starttime_observation(root).ok().flatten() == expected_start + && process_is_descendant_of(pid, root).unwrap_or(false) + && proc_starttime_observation(root).ok().flatten() == expected_start }) } +#[cfg(target_os = "linux")] +fn unreadable_process_may_be_owned( + pid: i32, + owned_root_pid: Option, + expected_start: Option, + known_tree: &[(i32, u64)], +) -> bool { + unreadable_process_may_be_owned_observing( + pid, + owned_root_pid, + expected_start, + known_tree, + &mut || {}, + ) +} + +#[cfg(target_os = "linux")] +fn unreadable_process_may_be_owned_observing( + pid: i32, + owned_root_pid: Option, + expected_start: Option, + known_tree: &[(i32, u64)], + after_stat: &mut impl FnMut(), +) -> bool { + let observed = match proc_starttime_observation(pid) { + Ok(None) => return false, + Ok(Some(start)) => start, + Err(_) => return expected_start.is_some(), + }; + after_stat(); + // This exact original incarnation already has positive ownership evidence. + if known_tree.contains(&(pid, observed)) { + return false; + } + if process_is_known_owned_process(pid, owned_root_pid, expected_start) { + return true; + } + let Some(root_start) = expected_start else { + return false; + }; + if observed < root_start { + return false; + } + // A direct child of this non-subreaper cannot have reparented here from + // the condemned root. PID1/subreapers must retain the uncertainty instead. + let parent_pid = |candidate| { + std::fs::read_to_string(format!("/proc/{candidate}/status")) + .ok() + .and_then(|status| { + status + .lines() + .find_map(|line| line.strip_prefix("PPid:")?.trim().parse::().ok()) + }) + }; + let mut subreaper: libc::c_int = 0; + let is_subreaper = + unsafe { libc::prctl(libc::PR_GET_CHILD_SUBREAPER, &mut subreaper, 0, 0, 0) }; + if std::process::id() != 1 + && Some(pid) != owned_root_pid + && is_subreaper == 0 + && subreaper == 0 + && parent_pid(pid) == Some(std::process::id()) + { + return false; + } + // The candidate can exit between its first stat and the ancestry/status + // reads. Positive disappearance is not an unavailable surviving writer; + // a live different incarnation or a failed read still retains uncertainty. + if matches!(proc_starttime_observation(pid), Ok(None)) { + return false; + } + // A later opaque orphan may have been born after the snapshot and lost its + // ancestry. It is not licensed for signaling, but it prevents confirmation. + #[cfg(debug_assertions)] + eprintln!( + "{}", + serde_json::json!({"severity":"warn","event":"session_lease.opaque_ownership_unavailable","pid":pid,"rootPid":owned_root_pid,"expectedStart":expected_start,"observedStart":observed,"observerPid":std::process::id(),"parentPid":parent_pid(pid),"rootParentPid":owned_root_pid.and_then(parent_pid),"observerSubreaper":subreaper,"subreaperProbe":is_subreaper}) + ); + tracing::warn!(pid, ?owned_root_pid, ?expected_start, observed_start=observed, + "session_lease.opaque_ownership_unavailable: opaque process may be a later reparented writer"); + true +} + #[cfg(target_os = "linux")] fn process_is_descendant_of(mut pid: i32, root_pid: i32) -> Result { for _ in 0..128 { @@ -1137,6 +1488,570 @@ mod tests { const TTL: u64 = FRESH_AGENT_SESSION_LEASE_TTL_MS; + #[cfg(target_os = "linux")] + fn capture_child(non_dumpable: bool) -> (std::process::Child, String) { + use std::io::BufRead; + let tag = format!("capture-{}", uuid::Uuid::new_v4()); + let mut child = std::process::Command::new("python3") + .args(["-c", if non_dumpable { + "import ctypes,sys; assert ctypes.CDLL(None).prctl(4,0,0,0,0)==0; print('ready',flush=True); assert sys.stdin.readline()=='exit\\n'" + } else { + "import sys; print('ready',flush=True); assert sys.stdin.readline()=='exit\\n'" + }]) + .env("CAPTURE_TEST_OWNERSHIP", &tag) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn().unwrap(); + let mut ready = String::new(); + std::io::BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + assert_eq!(ready, "ready\n"); + (child, tag) + } + + /// The dumpable root owns the opaque child's readiness/release pipes and + /// reaps it on either our explicit exit or the production SIGTERM path. + #[cfg(target_os = "linux")] + struct LinkedOpaqueFixture { + root: std::process::Child, + replies: std::io::BufReader, + tag: String, + } + + #[cfg(target_os = "linux")] + impl LinkedOpaqueFixture { + fn new() -> Self { + let tag = format!("capture-{}", uuid::Uuid::new_v4()); + let mut root = std::process::Command::new("python3") + .args(["-c", r#" +import ctypes, json, os, signal, sys +libc = ctypes.CDLL(None) +child = None +release = None +def close(*_): + if child is not None: + os.write(release, b'X') + assert os.waitpid(child, 0)[1] == 0 + sys.exit(0) +signal.signal(signal.SIGTERM, close) +print(json.dumps({'pid':os.getpid(), 'uid':os.geteuid(), 'dumpable':libc.prctl(3,0,0,0,0)}), flush=True) +for command in sys.stdin: + if command == 'exit\n': + close() + if command in ('hide\n', 'show\n'): + dumpable = int(command == 'show\n') + assert libc.prctl(4,dumpable,0,0,0) == 0 + assert libc.prctl(3,0,0,0,0) == dumpable + print(json.dumps({'dumpable':dumpable}), flush=True) + continue + assert command == 'spawn\n' and child is None + reader, release = os.pipe() + ready, ack = os.pipe() + child = os.fork() + if child == 0: + signal.signal(signal.SIGTERM, signal.SIG_DFL) + assert libc.prctl(4,0,0,0,0) == 0 + assert libc.prctl(3,0,0,0,0) == 0 + os.write(ack, json.dumps({'pid':os.getpid(), 'parent':os.getppid(), 'uid':os.geteuid(), 'dumpable':0}).encode() + b'\n') + assert os.read(reader,1) == b'X' + os._exit(0) + with os.fdopen(ready) as replies: + print(replies.readline(), end='', flush=True) +close() +"#]) + .env("CAPTURE_TEST_OWNERSHIP", &tag) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn().unwrap(); + let replies = std::io::BufReader::new(root.stdout.take().unwrap()); + let mut fixture = Self { root, replies, tag }; + let ready = fixture.read_reply(); + assert_eq!(ready["pid"], fixture.root.id()); + assert_eq!(ready["uid"], unsafe { libc::geteuid() }); + assert_eq!(ready["dumpable"], 1); + fixture + } + + fn read_reply(&mut self) -> serde_json::Value { + use std::io::BufRead; + let mut line = String::new(); + self.replies.read_line(&mut line).unwrap(); + serde_json::from_str(&line).unwrap() + } + + fn command(&mut self, command: &[u8]) -> serde_json::Value { + use std::io::Write; + self.root + .stdin + .as_mut() + .unwrap() + .write_all(command) + .unwrap(); + self.read_reply() + } + + fn spawn_opaque_member(&mut self) -> (i32, u64) { + let ready = self.command(b"spawn\n"); + let pid = ready["pid"].as_i64().unwrap() as i32; + assert_eq!(ready["parent"], self.root.id()); + assert_eq!(ready["uid"], unsafe { libc::geteuid() }); + assert_eq!(ready["dumpable"], 0); + assert_eq!( + std::fs::read(format!("/proc/{pid}/environ")) + .unwrap_err() + .kind(), + std::io::ErrorKind::PermissionDenied + ); + let start = proc_starttime(pid).unwrap(); + assert_eq!( + process_is_descendant_of(pid, self.root.id() as i32), + Ok(true) + ); + (pid, start) + } + + fn set_root_dumpable(&mut self, dumpable: bool) { + let ready = self.command(if dumpable { b"show\n" } else { b"hide\n" }); + assert_eq!(ready["dumpable"], u8::from(dumpable)); + } + + fn close(&mut self) { + use std::io::Write; + if self.root.try_wait().unwrap().is_none() { + let _ = self.root.stdin.as_mut().unwrap().write_all(b"exit\n"); + } + assert!(self.root.wait().unwrap().success()); + } + } + + #[cfg(target_os = "linux")] + impl Drop for LinkedOpaqueFixture { + fn drop(&mut self) { + self.close(); + } + } + + #[cfg(target_os = "linux")] + async fn assert_linked_opaque_capture(late_member: bool) { + let mut fixture = LinkedOpaqueFixture::new(); + let pid = fixture.root.id(); + let root_start = proc_starttime(pid as i32).unwrap(); + let original = late_member.then(|| { + record_condemned_runtime_identity(pid, "CAPTURE_TEST_OWNERSHIP", &fixture.tag) + }); + let member = fixture.spawn_opaque_member(); + let mut linked = Vec::new(); + let link_complete = + capture_linked_descendants(pid as i32, root_start, &fixture.tag, &mut linked); + let without_membership = scan_tagged_pids_observing( + "CAPTURE_TEST_OWNERSHIP", + &fixture.tag, + Some(pid as i32), + Some(root_start), + &[], + &mut |_, _| {}, + ); + let with_membership = scan_tagged_pids_observing( + "CAPTURE_TEST_OWNERSHIP", + &fixture.tag, + Some(pid as i32), + Some(root_start), + &linked, + &mut |_, _| {}, + ); + let recorded = original.unwrap_or_else(|| { + record_condemned_runtime_identity(pid, "CAPTURE_TEST_OWNERSHIP", &fixture.tag) + }); + let original_bytes = serde_json::to_vec(&recorded).unwrap(); + let refreshed = refresh_condemned_runtime_identity(&recorded, "CAPTURE_TEST_OWNERSHIP"); + let confirmed = + kill_and_confirm_recorded_tree_dead(&recorded, "CAPTURE_TEST_OWNERSHIP").await; + let root_survived_cleanup = proc_starttime(pid as i32) == Some(root_start); + let member_survived_cleanup = proc_starttime(member.0) == Some(member.1); + // Close and reap before the intended Red assertions, even when the + // production consumer correctly refuses genuinely missing coverage. + fixture.close(); + let root_reaped = !std::path::Path::new(&format!("/proc/{pid}")).exists(); + let member_reaped = !std::path::Path::new(&format!("/proc/{}", member.0)).exists(); + eprintln!( + "{}", + serde_json::json!({"severity":"info","event":"session_lease.linked_opaque_fixture","lateMember":late_member,"rootPid":pid,"rootStart":root_start,"member":member,"uid":unsafe {libc::geteuid()},"dumpable":0,"environPermissionDenied":true,"linkComplete":link_complete,"linked":linked,"scanWithoutMembership":without_membership.complete,"scanWithMembership":with_membership.complete,"originalComplete":recorded.capture_complete,"refreshedComplete":refreshed.capture_complete,"confirmed":confirmed,"rootSurvivedCleanup":root_survived_cleanup,"memberSurvivedCleanup":member_survived_cleanup,"rootReaped":root_reaped,"memberReaped":member_reaped}) + ); + assert!(root_reaped && member_reaped); + assert!( + link_complete, + "missing descendant coverage is not an ownership-only defect" + ); + assert!(linked.contains(&member)); + assert!(!without_membership.complete); + assert!( + with_membership.complete, + "positive exact membership resolves only the duplicate ownership lookup" + ); + assert!( + with_membership.pids.contains(&(pid as i32)), + "the original root's ownership tag remains readable and matching" + ); + assert_eq!(serde_json::to_vec(&recorded).unwrap(), original_bytes); + assert_eq!(recorded.start_time, Some(root_start)); + assert!(recorded + .tree + .iter() + .all(|member| refreshed.tree.contains(member))); + if late_member { + assert!( + recorded.capture_complete, + "the original root-only record was complete" + ); + assert!(!recorded.tree.contains(&member)); + } else { + assert!(recorded.tree.contains(&member)); + assert!( + recorded.capture_complete, + "complete positive links must suffice for an opaque descendant" + ); + } + assert!(refreshed.tree.contains(&member)); + assert!( + refreshed.capture_complete, + "refresh must reuse complete positive links for the new member" + ); + assert!( + confirmed, + "the unchanged original record must drive physical cleanup" + ); + assert!( + !root_survived_cleanup && !member_survived_cleanup, + "confirmation must stop the actual root and writer" + ); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linked_opaque_member_is_complete_at_initial_capture() { + let _process_fixture = process_fixture::lock().await; + assert_linked_opaque_capture(false).await; + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linked_opaque_member_first_seen_at_refresh_is_killed_from_original_record() { + let _process_fixture = process_fixture::lock().await; + assert_linked_opaque_capture(true).await; + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn linked_opaque_member_cannot_confirm_an_unavailable_root_or_incomplete_original() { + let _process_fixture = process_fixture::lock().await; + let mut fixture = LinkedOpaqueFixture::new(); + let member = fixture.spawn_opaque_member(); + fixture.set_root_dumpable(false); + assert_eq!( + std::fs::read(format!("/proc/{}/environ", fixture.root.id())) + .unwrap_err() + .kind(), + std::io::ErrorKind::PermissionDenied + ); + let original = record_condemned_runtime_identity( + fixture.root.id(), + "CAPTURE_TEST_OWNERSHIP", + &fixture.tag, + ); + let original_bytes = serde_json::to_vec(&original).unwrap(); + let unavailable_confirmed = + kill_and_confirm_recorded_tree_dead(&original, "CAPTURE_TEST_OWNERSHIP").await; + fixture.set_root_dumpable(true); + let fresh = record_condemned_runtime_identity( + fixture.root.id(), + "CAPTURE_TEST_OWNERSHIP", + &fixture.tag, + ); + let refreshed = refresh_condemned_runtime_identity(&original, "CAPTURE_TEST_OWNERSHIP"); + let incomplete_confirmed = + kill_and_confirm_recorded_tree_dead(&original, "CAPTURE_TEST_OWNERSHIP").await; + let root_survived = proc_starttime(fixture.root.id() as i32) == original.start_time; + let member_survived = proc_starttime(member.0) == Some(member.1); + fixture.close(); + assert!(original.tree.contains(&member)); + assert!(!original.capture_complete); + assert!(!unavailable_confirmed); + assert!( + fresh.capture_complete, + "restored root evidence permits only a new complete capture" + ); + assert!( + !refreshed.capture_complete, + "refresh cannot upgrade original unavailable coverage" + ); + assert!(original + .tree + .iter() + .all(|member| refreshed.tree.contains(member))); + assert!(!incomplete_confirmed); + assert!( + root_survived && member_survived, + "incomplete original evidence cannot authorize a signal" + ); + assert_eq!(serde_json::to_vec(&original).unwrap(), original_bytes); + assert!(!std::path::Path::new(&format!("/proc/{}", member.0)).exists()); + } + + #[cfg(target_os = "linux")] + async fn assert_capture_across_normal_exit(stage: &str, reap_before_read: bool) { + use std::io::Write; + let (mut child, tag) = capture_child(false); + let pid = child.id(); + let start = proc_starttime(pid as i32).unwrap(); + let zombie_identity = || { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap(); + let fields: Vec<_> = stat + .rsplit(')') + .next() + .unwrap() + .split_whitespace() + .collect(); + (fields[0].to_owned(), fields[19].parse::().unwrap()) + }; + let mut exited = false; + let recorded = record_condemned_runtime_identity_observing( + pid, + "CAPTURE_TEST_OWNERSHIP", + &tag, + &mut |observed, candidate| { + if observed == stage && candidate == pid as i32 { + assert!(!exited); + child.stdin.as_mut().unwrap().write_all(b"exit\n").unwrap(); + if reap_before_read { + assert!(child.wait().unwrap().success()); + } else { + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let (state, observed_start) = zombie_identity(); + assert_eq!(observed_start, start); + if state == "Z" { + break; + } + assert!( + std::time::Instant::now() < deadline, + "normal exit did not reach zombie state" + ); + std::thread::yield_now(); + } + } + exited = true; + } + }, + ); + let confirmed = + kill_and_confirm_recorded_tree_dead(&recorded, "CAPTURE_TEST_OWNERSHIP").await; + if !reap_before_read { + // No wait/try_wait has run: confirmation observes exactly the + // original unreaped incarnation, then the owner finally reaps it. + assert_eq!(zombie_identity(), ("Z".into(), start)); + assert!(child.wait().unwrap().success()); + } + assert!(exited, "the {stage} barrier was actually exercised"); + assert_eq!(recorded.start_time, Some(start)); + assert_eq!(recorded.ownership_id, tag); + assert_eq!(recorded.tree, vec![(pid as i32, start)]); + assert!( + recorded.capture_complete, + "the {stage} exit lost coverage: {recorded:?}" + ); + assert!(confirmed); + } + + /// Each observation has its own barrier. These are controls: a green + /// reap window is not a missing-behavior Red. + #[cfg(target_os = "linux")] + #[tokio::test] + async fn capture_preserves_identity_when_exit_precedes_each_tree_read() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; + for stage in ["status", "environ", "member_stat"] { + assert_capture_across_normal_exit(stage, true).await; + } + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn capture_preserves_identity_during_unreaped_exit_at_each_tree_read() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; + // proc_directory releases the normal exit after opening the iterator, + // before enumeration; member_stat releases it after tagged discovery. + for stage in ["proc_directory", "status", "environ", "member_stat"] { + assert_capture_across_normal_exit(stage, false).await; + } + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn capture_keeps_a_real_unavailable_live_root_unconfirmed() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; + use std::io::Write; + let (mut child, tag) = capture_child(true); + let pid = child.id(); + let start = proc_starttime(pid as i32).unwrap(); + assert_eq!( + std::fs::read(format!("/proc/{pid}/environ")) + .unwrap_err() + .kind(), + std::io::ErrorKind::PermissionDenied + ); + let recorded = record_condemned_runtime_identity(pid, "CAPTURE_TEST_OWNERSHIP", &tag); + let confirmed = + kill_and_confirm_recorded_tree_dead(&recorded, "CAPTURE_TEST_OWNERSHIP").await; + let signaled = child.try_wait().unwrap().is_some(); + child.stdin.as_mut().unwrap().write_all(b"exit\n").unwrap(); + assert!(child.wait().unwrap().success()); + assert_eq!(recorded.start_time, Some(start)); + assert_eq!(recorded.tree, vec![(pid as i32, start)]); + assert!(!recorded.capture_complete); + assert!(!confirmed); + assert!( + !signaled, + "unavailable original evidence cannot authorize a signal" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn capture_retains_linked_descendants_without_readable_ownership_tags() { + let _process_fixture = crate::session_lease::process_fixture::blocking_lock(); + use std::io::{BufRead, Write}; + let tag = format!("capture-{}", uuid::Uuid::new_v4()); + let mut root = std::process::Command::new("python3") + .args(["-c", r#" +import os, subprocess, sys +child = subprocess.Popen([sys.executable, '-c', "import sys; print('ready',flush=True); assert sys.stdin.readline()=='exit\\n'"], env={}, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) +assert child.stdout.readline() == 'ready\n' +print(child.pid, flush=True) +assert sys.stdin.readline() == 'exit\n' +child.stdin.write('exit\n'); child.stdin.flush() +assert child.wait() == 0 +"#]) + .env("CAPTURE_TEST_OWNERSHIP", &tag) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn().unwrap(); + let mut ready = String::new(); + std::io::BufReader::new(root.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + let descendant: i32 = ready.trim().parse().unwrap(); + let start = proc_starttime(descendant).unwrap(); + let original = record_condemned_runtime_identity(root.id(), "CAPTURE_TEST_OWNERSHIP", &tag); + let root_start = original.start_time.unwrap(); + let stale = CondemnedRuntimeIdentity { + start_time: Some(root_start + 1), + tree: vec![(root.id() as i32, root_start + 1)], + ..original.clone() + }; + let stale_observed = refresh_condemned_runtime_identity(&stale, "CAPTURE_TEST_OWNERSHIP"); + root.stdin.as_mut().unwrap().write_all(b"exit\n").unwrap(); + assert!(root.wait().unwrap().success()); + assert!(original.capture_complete); + assert_eq!( + stale_observed.tree, stale.tree, + "another root incarnation must not donate its descendants" + ); + assert!( + original.tree.contains(&(descendant, start)), + "a positively linked child must survive the UID/tag filter: {original:?}" + ); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn positively_unrelated_opaque_child_does_not_block_owned_cleanup() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; + use std::io::Write; + let (mut owned, tag) = capture_child(false); + let (mut unrelated, _) = capture_child(true); + let original = + record_condemned_runtime_identity(owned.id(), "CAPTURE_TEST_OWNERSHIP", &tag); + let confirmed = + kill_and_confirm_recorded_tree_dead(&original, "CAPTURE_TEST_OWNERSHIP").await; + let unrelated_signaled = unrelated.try_wait().unwrap().is_some(); + unrelated + .stdin + .as_mut() + .unwrap() + .write_all(b"exit\n") + .unwrap(); + assert!(unrelated.wait().unwrap().success()); + let owned_exit = owned.wait().unwrap(); + assert!(original.capture_complete); + assert!(confirmed); + assert!(!unrelated_signaled); + assert!(!owned_exit.success()); + } + + #[cfg(target_os = "linux")] + #[test] + fn opaque_candidate_reaped_during_observation_is_gone() { + let _process_fixture = crate::session_lease::process_fixture::blocking_lock(); + use std::io::{BufRead, Write}; + let (mut owned, _) = capture_child(false); + let root_start = proc_starttime(owned.id() as i32).unwrap(); + let mut other = std::process::Command::new("python3") + .args([ + "-c", + r#" +import ctypes, os, sys +reader, writer = os.pipe() +acks_r, acks_w = os.pipe() +child = os.fork() +if child == 0: + assert ctypes.CDLL(None).prctl(4,0,0,0,0) == 0 + os.write(acks_w,b'R') + assert os.read(reader,1) == b'X' + os._exit(0) +assert os.read(acks_r,1) == b'R' +print(child,flush=True) +assert sys.stdin.readline() == 'close\n' +os.write(writer,b'X') +assert os.waitpid(child,0)[1] == 0 +print('reaped',flush=True) +assert sys.stdin.readline() == 'exit\n' +"#, + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut replies = std::io::BufReader::new(other.stdout.take().unwrap()); + let mut line = String::new(); + replies.read_line(&mut line).unwrap(); + let candidate: i32 = line.trim().parse().unwrap(); + assert!(proc_starttime(candidate).unwrap() >= root_start); + assert_eq!( + std::fs::read(format!("/proc/{candidate}/environ")) + .unwrap_err() + .kind(), + std::io::ErrorKind::PermissionDenied + ); + let may_be_owned = unreadable_process_may_be_owned_observing( + candidate, + Some(owned.id() as i32), + Some(root_start), + &[], + &mut || { + other.stdin.as_mut().unwrap().write_all(b"close\n").unwrap(); + line.clear(); + replies.read_line(&mut line).unwrap(); + assert_eq!(line, "reaped\n"); + }, + ); + other.stdin.as_mut().unwrap().write_all(b"exit\n").unwrap(); + assert!(other.wait().unwrap().success()); + owned.stdin.as_mut().unwrap().write_all(b"exit\n").unwrap(); + assert!(owned.wait().unwrap().success()); + assert!(!may_be_owned, "positive reaping resolves this candidate, rather than reporting an unavailable live writer"); + } + /// A process reaped between the open and the read of its `/proc` files /// reads as GONE — procfs answers ESRCH on the already-open descriptor — /// never as unavailable evidence. The expired-lease kill sweep reads @@ -1146,6 +2061,7 @@ mod tests { #[cfg(target_os = "linux")] #[test] fn a_process_reaped_between_proc_open_and_read_reads_as_gone() { + let _process_fixture = crate::session_lease::process_fixture::blocking_lock(); use std::io::Read; let mut child = std::process::Command::new("sleep") .arg("30") @@ -1405,6 +2321,7 @@ mod tests { #[cfg(target_os = "linux")] #[tokio::test] async fn the_grace_wait_treats_a_recycled_pid_as_gone_and_never_escalates() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; // A live "unrelated replacement" process holding the recorded pid. let mut unrelated = tokio::process::Command::new("sleep") .arg("300") @@ -1442,6 +2359,7 @@ mod tests { #[cfg(target_os = "linux")] #[tokio::test] async fn the_grace_wait_reports_a_live_recorded_incarnation_still_present() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let mut child = tokio::process::Command::new("sleep") .arg("300") .kill_on_drop(true) @@ -1470,6 +2388,7 @@ mod tests { #[cfg(target_os = "linux")] #[tokio::test] async fn the_recorded_kill_path_never_signals_a_recycled_pid() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let mut unrelated = tokio::process::Command::new("sleep") .arg("300") .kill_on_drop(true) @@ -1508,6 +2427,7 @@ mod tests { #[cfg(target_os = "linux")] #[tokio::test] async fn an_unconfirmable_identity_never_signals_the_pids_occupant() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; // A live process holding the recorded pid — under the forged // identity it is an unrelated occupant (the identity claims the // condemned child was already gone at capture time). @@ -1547,6 +2467,7 @@ mod tests { #[cfg(target_os = "linux")] #[tokio::test] async fn a_recorded_incarnation_is_killed_through_the_pinned_pidfd() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let mut child = tokio::process::Command::new("sleep") .arg("300") .kill_on_drop(true) @@ -1580,6 +2501,7 @@ mod tests { #[cfg(target_os = "linux")] #[tokio::test] async fn incomplete_owned_process_evidence_keeps_the_reap_unconfirmed() { + let _process_fixture = crate::session_lease::process_fixture::lock().await; let mut occupant = tokio::process::Command::new("sleep") .arg("300") .kill_on_drop(true) diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 721a3aba18..d8144b7e3e 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -189,9 +189,98 @@ pub fn router(state: SnapshotState) -> Router { "/api/fresh-agent/threads/{sessionType}/{provider}/{threadId}", get(get_snapshot), ) + .route( + "/api/fresh-agent/threads/{sessionType}/{provider}/{threadId}/children/{childThreadId}", + get(get_child), + ) .with_state(state) } +async fn get_child( + State(state): State, + Path((session_type, provider, parent, child)): Path<(String, String, String, String)>, + Query(query): Query>, + headers: HeaderMap, +) -> Response { + use crate::codex::children::{observation, ChildReadError}; + if !authorized(&headers, &state.auth_token) { + return fail(StatusCode::UNAUTHORIZED, "unauthorized".into()); + } + if session_type != "freshcodex" || provider != "codex" { + return fail( + StatusCode::BAD_REQUEST, + "Child observation is supported for Freshcodex".into(), + ); + } + let parse = |field: &str| { + query + .get(field) + .map(|value| value.parse::()) + .transpose() + }; + let (Ok(epoch), Ok(generation)) = (parse("observedEpoch"), parse("observedGeneration")) else { + return fail(StatusCode::BAD_REQUEST, "Invalid owner fence".into()); + }; + if crate::ownership_lane::wire_fence(epoch, generation).is_err() { + return fail(StatusCode::BAD_REQUEST, "Incomplete owner fence".into()); + } + let result = if state + .local_owner(&session_type, &provider, &parent) + .await + .is_none() + { + if let Some(gateway) = state.opencode.hosted_rest_gateway() { + match gateway + .read_child(crate::rest_lane::RestLaneChildRead { + parent_session_id: parent.clone(), + child_thread_id: child.clone(), + observed_epoch: epoch, + observed_generation: generation, + }) + .await + { + Ok(Some(value)) => return Json(value).into_response(), + Ok(None) => { + state + .codex + .read_child(&parent, &child, epoch, generation) + .await + } + Err(error) => Err(error), + } + } else { + state + .codex + .read_child(&parent, &child, epoch, generation) + .await + } + } else { + state + .codex + .read_child(&parent, &child, epoch, generation) + .await + }; + match result { + Ok(value) => Json(value).into_response(), + Err(ChildReadError::UnrelatedChild) => fail_with_code( + StatusCode::NOT_FOUND, + "Child is not part of this parent conversation".into(), + "FRESH_AGENT_CHILD_NOT_FOUND", + ), + Err(ChildReadError::InvalidFence) => { + fail(StatusCode::BAD_REQUEST, "Invalid owner fence".into()) + } + Err(ChildReadError::SourceUnavailable) => Json(observation( + &parent, + &child, + "unavailable", + "source_unavailable", + true, + )) + .into_response(), + } +} + async fn get_snapshot( State(state): State, Path((session_type, provider, thread_id)): Path<(String, String, String)>, diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 05ba2b84dd..aa1099309a 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -3937,22 +3937,56 @@ pub(crate) async fn wait_for( ); }; + let (pattern, deadline) = match wait_pattern(¶ms) { + Ok(parsed) => parsed, + Err(response) => return response, + }; + + loop { + let text = registry + .directory() + .into_iter() + .find(|d| d.terminal_id == terminal_id) + .map(|d| d.snapshot) + .unwrap_or_default(); + if pattern.is_match(&text).unwrap_or(false) { + return ok_json( + json!({ "matched": true, "reason": "pattern" }), + "pattern matched", + ); + } + if std::time::Instant::now() >= deadline { + return crate::approx_json(json!({ "matched": false }), "timeout"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} + +#[allow(clippy::result_large_err)] +pub(crate) fn wait_pattern( + params: &std::collections::HashMap, +) -> Result<(fancy_regex::Regex, std::time::Instant), Response> { let raw_pattern = params.get("pattern").or_else(|| params.get("p")); let pattern = match raw_pattern { Some(p) => match fancy_regex::Regex::new(p) { Ok(re) => Some(re), - Err(_) => return fail_json(StatusCode::BAD_REQUEST, "invalid pattern".to_string()), + Err(_) => { + return Err(fail_json( + StatusCode::BAD_REQUEST, + "invalid pattern".to_string(), + )) + } }, None => None, }; if pattern.is_none() { // Slice 1 scope: `stable`/`exit`/`prompt` fallback modes are deferred. - return fail_json( + return Err(fail_json( StatusCode::BAD_REQUEST, "wait-for requires `pattern` in this Rust port slice (stable/exit/prompt \ are deferred -- see docs/plans/2026-07-18-agent-api-mcp-parity-spec.md §8)" .to_string(), - ); + )); } let pattern = pattern.expect("checked above"); @@ -3964,24 +3998,7 @@ pub(crate) async fn wait_for( .unwrap_or(30.0); let deadline = std::time::Instant::now() + std::time::Duration::from_secs_f64(timeout_secs); - loop { - let text = registry - .directory() - .into_iter() - .find(|d| d.terminal_id == terminal_id) - .map(|d| d.snapshot) - .unwrap_or_default(); - if pattern.is_match(&text).unwrap_or(false) { - return ok_json( - json!({ "matched": true, "reason": "pattern" }), - "pattern matched", - ); - } - if std::time::Instant::now() >= deadline { - return crate::approx_json(json!({ "matched": false }), "timeout"); - } - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } + Ok((pattern, deadline)) } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/crates/freshell-opencode/src/catalog.rs b/crates/freshell-opencode/src/catalog.rs index 2b065f048e..81b89f7f47 100644 --- a/crates/freshell-opencode/src/catalog.rs +++ b/crates/freshell-opencode/src/catalog.rs @@ -54,6 +54,10 @@ pub struct ModelCapability { pub supports_effort: bool, #[serde(rename = "supportedEffortLevels")] pub supported_effort_levels: Vec, + #[serde(rename = "defaultEffort", skip_serializing_if = "Option::is_none")] + pub default_effort: Option, + #[serde(rename = "inputModalities", skip_serializing_if = "Option::is_none")] + pub input_modalities: Option>, #[serde(rename = "supportsAdaptiveThinking")] pub supports_adaptive_thinking: bool, /// Raw `limit { context, input?, output }` block when the provider @@ -259,6 +263,8 @@ pub fn normalize_enabled_model_catalog(raw: &Value) -> Vec { let has_levels = !supported_effort_levels.is_empty(); let limit = read_model_limits(model.get("limit")); models.push(ModelCapability { + default_effort: None, + input_modalities: None, id: format!("{provider_id}/{model_id}"), display_name, provider: "opencode", @@ -828,6 +834,8 @@ mod tests { assert_eq!( models, vec![ModelCapability { + default_effort: None, + input_modalities: None, id: "deepseek/deepseek-v4-pro".to_string(), display_name: "DeepSeek V4 Pro".to_string(), provider: "opencode", @@ -1086,6 +1094,8 @@ mod tests { assert_eq!( models, vec![ModelCapability { + default_effort: None, + input_modalities: None, id: "keyprovider/keymodel".to_string(), display_name: "Keyed Name".to_string(), provider: "opencode", @@ -1203,6 +1213,8 @@ mod tests { #[test] fn model_limits_never_serialize_to_the_strict_wire_schema() { let cap = ModelCapability { + default_effort: None, + input_modalities: None, id: "lunaroute/glm-5.3".into(), display_name: "GLM 5.3".into(), provider: "opencode", diff --git a/crates/freshell-platform/src/clock.rs b/crates/freshell-platform/src/clock.rs index 7aae274fcb..924236b4e4 100644 --- a/crates/freshell-platform/src/clock.rs +++ b/crates/freshell-platform/src/clock.rs @@ -347,10 +347,17 @@ mod tests { #[test] fn advance_moves_live_time_forward_by_exactly_the_delta() { let (_lock, _guard) = OverrideGuard::locked(true); + let real_before = system_now_ms(); let before = snapshot(); + // Live time keeps moving during advance; exercise that movement rather + // than requiring both snapshots to land in the same millisecond. + std::thread::sleep(std::time::Duration::from_millis(2)); advance_ms(90_000).unwrap(); let after = snapshot(); - assert_eq!(after.now_ms - before.now_ms, 90_000); + let real_after = system_now_ms(); + let delta = after.now_ms - before.now_ms; + assert!(delta >= 90_000 && delta <= 90_000 + real_after - real_before); + assert_eq!(after.offset_ms - before.offset_ms, 90_000); assert_eq!(after.offset_ms, 90_000); assert_eq!(after.mode, ClockMode::Live); } diff --git a/crates/freshell-platform/src/codex_inputs.rs b/crates/freshell-platform/src/codex_inputs.rs index e5bea88ed2..4c27f6a428 100644 --- a/crates/freshell-platform/src/codex_inputs.rs +++ b/crates/freshell-platform/src/codex_inputs.rs @@ -3,6 +3,19 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NativeSourceRuntime { + #[default] + Tui, + AppServer, +} +impl NativeSourceRuntime { + fn is_tui(&self) -> bool { + *self == Self::Tui + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct CodexInputManifest { @@ -11,6 +24,8 @@ pub struct CodexInputManifest { pub cwd: PathBuf, pub codex_home: PathBuf, pub named_profile: Option, + #[serde(default, skip_serializing_if = "NativeSourceRuntime::is_tui")] + pub native_source_runtime: NativeSourceRuntime, } impl CodexInputManifest { pub fn validate(&self) -> Result<(), &'static str> { diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index 1734cfb30f..dbf3fc98ce 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -111,6 +111,8 @@ pub enum ClientMessage { FreshAgentApprovalRespond(FreshAgentApprovalRespond), #[serde(rename = "freshAgent.question.respond")] FreshAgentQuestionRespond(FreshAgentQuestionRespond), + #[serde(rename = "freshAgent.elicitation.respond")] + FreshAgentElicitationRespond(FreshAgentElicitationRespond), #[serde(rename = "freshAgent.kill")] FreshAgentKill(FreshAgentKill), #[serde(rename = "freshAgent.recovery.stop")] @@ -181,7 +183,8 @@ pub const CLIENT_MESSAGE_TYPES: [&str; 42] = [ /// Extension client→server discriminants declared beyond the generated /// inventory. The process-only Codex recovery stop is an additive extension; /// the generated frozen inventory remains unchanged. -pub const EXTENSION_CLIENT_MESSAGE_TYPES: [&str; 1] = ["freshAgent.recovery.stop"]; +pub const EXTENSION_CLIENT_MESSAGE_TYPES: [&str; 2] = + ["freshAgent.recovery.stop", "freshAgent.elicitation.respond"]; // --- hello ------------------------------------------------------------------ @@ -866,12 +869,19 @@ pub struct ModelSelection { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentCreate { + #[serde(skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, pub request_id: String, pub session_type: SessionType, #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, + /// Absent preserves saved effort; null selects the provider's default. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "double_option" + )] + pub effort: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub legacy_restore_context: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -972,13 +982,20 @@ pub struct FreshAgentImage { pub media_type: String, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentSendSettings { #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, + pub collaboration_mode: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, + pub cwd: Option, + /// Absent leaves the selection alone; null clears the Freshell override. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "double_option" + )] + pub effort: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, /// Free string. @@ -1097,6 +1114,37 @@ pub struct FreshAgentQuestionRespond { pub cwd: Option, } +/// An MCP elicitation reply is a native control response, never model text. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentElicitationRespond { + pub provider: AgentProvider, + pub session_id: String, + pub session_type: SessionType, + pub request_id: StringOrNumber, + pub action: ElicitationAction, + pub content: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ElicitationAction { + Accept, + Decline, + Cancel, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ElicitationValue { + String(String), + Number(serde_json::Number), + Boolean(bool), + Strings(Vec), +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentKill { diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index 484d0036db..e0edbc523f 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -1303,6 +1303,8 @@ pub struct FreshAgentAttentionMark { pub last_turn_complete_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub last_waiting_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_question_available_at: Option, } /// One `ready.runtimeOwners` replay record (kata b8ke reconnect-owner diff --git a/crates/freshell-protocol/tests/inventory.rs b/crates/freshell-protocol/tests/inventory.rs index 5563e1b5e4..f5770146e6 100644 --- a/crates/freshell-protocol/tests/inventory.rs +++ b/crates/freshell-protocol/tests/inventory.rs @@ -8,7 +8,7 @@ use std::collections::BTreeSet; use std::path::PathBuf; -use freshell_protocol::{all_message_types, CLIENT_MESSAGE_TYPES, SERVER_MESSAGE_TYPES}; +use freshell_protocol::{all_message_types, SERVER_MESSAGE_TYPES}; fn inventory() -> serde_json::Value { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -26,23 +26,6 @@ fn json_type_set(node: &serde_json::Value) -> BTreeSet { .collect() } -#[test] -fn client_types_match_inventory_exactly() { - let inv = inventory(); - assert_eq!( - inv["clientToServer"]["count"].as_u64(), - Some(42), - "inventory declares 42 client→server types" - ); - let expected = json_type_set(&inv["clientToServer"]["types"]); - let actual: BTreeSet = CLIENT_MESSAGE_TYPES.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual.len(), 42, "crate declares 42 client types (no dups)"); - assert_eq!( - actual, expected, - "CLIENT_MESSAGE_TYPES must equal the frozen inventory (no missing/extra)" - ); -} - #[test] fn server_types_match_inventory_exactly() { let inv = inventory(); diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index b1f9573805..dc7f55075e 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -918,3 +918,47 @@ fn fresh_agent_create_and_attach_carry_the_pane_they_run_for() { other => panic!("expected FreshAgentAttach, got {other:?}"), } } + +#[test] +fn combined_codex_controls_and_naming_fields_roundtrip_against_generated_contracts() { + for request_id in [json!(41), json!("41")] { + let wire = json!({ + "type": "freshAgent.elicitation.respond", "provider": "codex", + "sessionType": "freshcodex", "sessionId": "native-thread", + "requestId": request_id, "action": "accept", + "content": { "approved": true, "count": 3, "label": "chosen", "tags": ["one"] } + }); + assert!(matches!( + client_roundtrip(&wire.to_string(), "freshAgent.elicitation.respond"), + ClientMessage::FreshAgentElicitationRespond(_) + )); + } + let reconcile = json!({ + "type": "pane.reconcile.request", "reconcileId": "rec-combined", + "panes": [{ "paneKey": "tab:pane", "kind": "terminal", "mode": "codex", + "createRequestId": "create", "namingHandle": "pending-name" }] + }); + match client_roundtrip(&reconcile.to_string(), "pane.reconcile.request") { + ClientMessage::PaneReconcileRequest(request) => { + assert_eq!( + request.panes[0].naming_handle.as_deref(), + Some("pending-name") + ); + } + other => panic!("expected reconcile request, got {other:?}"), + } + let associated = json!({ + "type": "terminal.session.associated", "terminalId": "terminal", + "sessionRef": { "provider": "codex", "sessionId": "native-thread" }, + "nameRef": { "kind": "session", "provider": "codex", "sessionId": "native-thread" } + }); + server_roundtrip(&associated.to_string(), "terminal.session.associated"); + let ready = json!({ + "type": "ready", "timestamp": "now", + "freshAgentAttention": [{ + "provider": "codex", "sessionId": "native-thread", "sessionType": "freshcodex", + "lastQuestionAvailableAt": 1234 + }] + }); + server_roundtrip(&ready.to_string(), "ready"); +} diff --git a/crates/freshell-runtime-client/src/lib.rs b/crates/freshell-runtime-client/src/lib.rs index c80bb985da..ee8adb0204 100644 --- a/crates/freshell-runtime-client/src/lib.rs +++ b/crates/freshell-runtime-client/src/lib.rs @@ -854,6 +854,77 @@ impl RuntimeClient { } } + pub async fn fresh_agent_native_name( + &self, + mut request: freshell_runtime_protocol::FreshAgentNativeNameRequest, + ) -> Result { + request.expected_control_epoch = Some(self.current_epoch().await?); + // A stale epoch is a settled refusal before host/native dispatch. Keep + // the captured target unchanged if refreshing only that control stamp. + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentNativeName(request.clone()), + ) + .await + { + Ok(AdminResult::FreshAgentNativeName(reply)) => Ok(reply), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentNativeName(request), + ) + .await? + { + AdminResult::FreshAgentNativeName(reply) => Ok(reply), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + + pub async fn fresh_agent_model_catalog( + &self, + soul_id: SoulId, + cwd: Option, + ) -> Result { + let mut request = freshell_runtime_protocol::FreshAgentModelCatalogRequest { + soul_id, + cwd, + expected_control_epoch: Some(self.current_epoch().await?), + }; + let original = request.clone(); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentModelCatalog(request), + ) + .await + { + Ok(AdminResult::FreshAgentModelCatalog(snapshot)) => Ok(snapshot), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request = original; + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request( + RequestId::new(), + AdminCommand::FreshAgentModelCatalog(request), + ) + .await? + { + AdminResult::FreshAgentModelCatalog(snapshot) => Ok(snapshot), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + pub async fn fresh_agent_snapshot( &self, soul_id: SoulId, @@ -890,6 +961,38 @@ impl RuntimeClient { } } + pub async fn fresh_agent_read_child( + &self, + soul_id: SoulId, + child_thread_id: String, + ) -> Result { + let mut request = freshell_runtime_protocol::FreshAgentReadChildRequest { + soul_id, + child_thread_id, + expected_control_epoch: Some(self.current_epoch().await?), + }; + let original = request.clone(); + match self + .request(RequestId::new(), AdminCommand::FreshAgentReadChild(request)) + .await + { + Ok(AdminResult::FreshAgentChildSnapshot(snapshot)) => Ok(snapshot), + Err(error) if error.runtime_code() == Some(RuntimeErrorCode::StaleControlEpoch) => { + request = original; + request.expected_control_epoch = Some(self.health().await?.0); + match self + .request(RequestId::new(), AdminCommand::FreshAgentReadChild(request)) + .await? + { + AdminResult::FreshAgentChildSnapshot(snapshot) => Ok(snapshot), + _ => Err(ClientError::UnexpectedResult), + } + } + Ok(_) => Err(ClientError::UnexpectedResult), + Err(error) => Err(error), + } + } + pub async fn fresh_agent_resolve( &self, soul_id: SoulId, @@ -1145,7 +1248,10 @@ impl RuntimeClient { let mut stream = UnixStream::connect(self.socket_path.as_ref()).await?; let reply_limit = if matches!( body, - AdminCommand::FreshAgentReadHistory(_) | AdminCommand::FreshAgentReadSnapshot(_) + AdminCommand::FreshAgentReadHistory(_) + | AdminCommand::FreshAgentReadSnapshot(_) + | AdminCommand::FreshAgentReadChild(_) + | AdminCommand::FreshAgentReadEvents(_) ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { @@ -1185,6 +1291,8 @@ mod tests { enum FreshAgentReadKind { Snapshot, History, + Child, + Catalog, } impl FreshAgentReadKind { @@ -1196,6 +1304,16 @@ mod tests { match self { Self::Snapshot => client.fresh_agent_snapshot(soul).await, Self::History => client.fresh_agent_history(soul).await, + Self::Catalog => { + client + .fresh_agent_model_catalog(soul, Some("/owned/project".into())) + .await + } + Self::Child => { + client + .fresh_agent_read_child(soul, "native-child".into()) + .await + } } } @@ -1203,6 +1321,8 @@ mod tests { match self { Self::Snapshot => AdminResult::FreshAgentSnapshot(value), Self::History => AdminResult::FreshAgentHistory(value), + Self::Catalog => AdminResult::FreshAgentModelCatalog(value), + Self::Child => AdminResult::FreshAgentChildSnapshot(value), } } @@ -1214,6 +1334,14 @@ mod tests { (AdminCommand::FreshAgentReadHistory(read), Self::History) => { (&read.soul_id, read.expected_control_epoch) } + (AdminCommand::FreshAgentReadChild(read), Self::Child) => { + assert_eq!(read.child_thread_id, "native-child"); + (&read.soul_id, read.expected_control_epoch) + } + (AdminCommand::FreshAgentModelCatalog(read), Self::Catalog) => { + assert_eq!(read.cwd.as_deref(), Some("/owned/project")); + (&read.soul_id, read.expected_control_epoch) + } _ => panic!("expected the original exact read operation: {envelope:?}"), }; assert_eq!(soul, &SoulId::parse("owned-read-soul").unwrap()); @@ -1228,6 +1356,81 @@ mod tests { } } + #[tokio::test] + async fn model_catalog_retries_only_stale_epoch_with_exact_soul_and_cwd() { + let kind = FreshAgentReadKind::Catalog; + let expected = + serde_json::json!([{"id":"provider-current","supportedEffortLevels":["ultra"]}]); + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![ + Err(RuntimeError::new( + RuntimeErrorCode::StaleControlEpoch, + "old", + )), + Ok(epoch_health()), + Ok(kind.reply(expected.clone())), + ], + ) + .await; + assert_refreshed_read(kind, &requests); + assert_eq!(cached, Some(2)); + assert_eq!(actual.unwrap(), expected); + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![Ok(AdminResult::FreshAgentSnapshot(serde_json::json!({})))], + ) + .await; + assert_cached_read(kind, &requests, cached); + assert!(matches!(actual, Err(ClientError::UnexpectedResult))); + let (actual, requests, cached) = scripted_fresh_agent_read( + kind, + vec![Err(RuntimeError::new( + RuntimeErrorCode::HostUnreachable, + "down", + ))], + ) + .await; + assert_cached_read(kind, &requests, cached); + assert_eq!( + actual.unwrap_err().runtime_code(), + Some(RuntimeErrorCode::HostUnreachable) + ); + } + + #[tokio::test] + async fn model_catalog_reply_keeps_the_ordinary_control_frame_bound() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("catalog.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + assert!(matches!( + request.body, + AdminCommand::FreshAgentModelCatalog(_) + )); + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(AdminResult::FreshAgentModelCatalog(serde_json::json!([ + "x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES) + ]))), + }, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let actual = client + .fresh_agent_model_catalog(SoulId::parse("catalog-owned").unwrap(), None) + .await; + let _ = server.await.unwrap(); + assert!(actual.is_err()); + } + #[tokio::test] async fn fresh_agent_send_preserves_checkpoint_identity_on_the_control_request() { let dir = tempfile::tempdir().unwrap(); @@ -1442,9 +1645,18 @@ mod tests { assert_read_epoch_refresh_preserves_source(FreshAgentReadKind::History).await; } + #[tokio::test] + async fn fresh_agent_child_read_refresh_preserves_exact_parent_and_child_and_large_body() { + assert_read_epoch_refresh_preserves_source(FreshAgentReadKind::Child).await; + } + #[tokio::test] async fn fresh_agent_reads_stop_after_second_stale_epoch() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { let (actual, requests, cached) = scripted_fresh_agent_read( kind, vec![ @@ -1470,7 +1682,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_do_not_retry_other_runtime_errors() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { for code in [ RuntimeErrorCode::HostUnreachable, RuntimeErrorCode::OwnershipMismatch, @@ -1490,7 +1706,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_propagate_health_error_without_retrying_the_read() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { let (actual, requests, cached) = scripted_fresh_agent_read( kind, vec![ @@ -1517,7 +1737,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_reject_unexpected_initial_health_and_retry_results() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { for stage in 0..3 { let mut replies = Vec::new(); if stage > 0 { @@ -1547,7 +1771,11 @@ mod tests { #[tokio::test] async fn fresh_agent_reads_keep_healthy_live_and_history_provenance_without_refresh() { - for kind in [FreshAgentReadKind::Snapshot, FreshAgentReadKind::History] { + for kind in [ + FreshAgentReadKind::Snapshot, + FreshAgentReadKind::History, + FreshAgentReadKind::Child, + ] { let expected = source_snapshot(kind); let (actual, requests, cached) = scripted_fresh_agent_read(kind, vec![Ok(kind.reply(expected.clone()))]).await; @@ -1589,6 +1817,85 @@ mod tests { assert_eq!(actual.unwrap(), expected); } + #[tokio::test] + async fn task6_retained_event_reply_preserves_body_above_the_old_control_limit() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("retained.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let expected = serde_json::json!({"retainedFrom":1,"head":1,"resetRequired":true,"events":[],"retainedState":{"snapshot":{"turns":[{"text":"x".repeat(2*freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)}]}}}); + let sent: freshell_runtime_protocol::AgentEventBatch = + serde_json::from_value(expected.clone()).unwrap(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + assert!(matches!( + envelope.body, + AdminCommand::FreshAgentReadEvents(_) + )); + freshell_runtime_protocol::write_frame_with_limit( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(AdminResult::FreshAgentEvents(sent)), + }, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + .unwrap(); + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let actual = client + .fresh_agent_events(SoulId::parse("large-retained").unwrap(), 0, 1) + .await + .unwrap(); + server.await.unwrap(); + assert!( + serde_json::to_value(actual).unwrap() == expected, + "the actual retained event caller must round-trip the entire 2MiB body" + ); + } + + #[tokio::test] + async fn task6_retained_reply_stays_bounded_and_ordinary_control_budget_stays_small() { + use tokio::io::AsyncWriteExt; + for retained in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("bounded.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let budget = if retained { + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES + } else { + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + }; + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let _: Envelope = read_frame(&mut stream).await.unwrap(); + stream + .write_all(&((budget + 1) as u32).to_be_bytes()) + .await + .unwrap(); + }); + let client = RuntimeClient::new(&socket, "0123456789abcdef"); + *client.control_epoch.write().await = Some(1); + let body = if retained { + AdminCommand::FreshAgentReadEvents( + freshell_runtime_protocol::FreshAgentReadEventsRequest { + soul_id: SoulId::parse("bounded").unwrap(), + after_sequence: 0, + max_events: 1, + expected_control_epoch: Some(1), + }, + ) + } else { + AdminCommand::Inventory + }; + let actual = client.request(RequestId::new(), body).await; + server.await.unwrap(); + assert!(matches!(actual,Err(ClientError::Protocol(message)) if message==freshell_runtime_protocol::FrameError::TooLarge.to_string()),"oversized lengths must be rejected by the caller's exact budget before reading any body"); + } + } + #[tokio::test] async fn health_authenticates_and_caches_epoch() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs b/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs index 0f028df4a1..83def017e8 100644 --- a/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs +++ b/crates/freshell-runtime-protocol/src/fresh_agent_ops.rs @@ -1,6 +1,6 @@ //! Host-owned fresh-agent operation contracts. -use crate::{FreshAgentTurnSettings, FreshProvider, SoulId}; +use crate::{FreshAgentTurnSettings, FreshProvider, HostBootId, IncarnationId, SoulId}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -73,3 +73,55 @@ pub struct FreshAgentCapture { pub text: String, pub truncated: bool, } + +/// Metadata authority captured from the current managed runtime. A name +/// request cannot follow a replacement incarnation merely because its root +/// has the same spelling. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentNativeNameSource { + pub incarnation_id: IncarnationId, + pub host_boot_id: HostBootId, + pub execution_generation: u64, + pub intent_revision: u64, + pub native_session_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +pub enum FreshAgentNativeNameOperation { + Probe, + Read, + Write { title: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum FreshAgentNativeNameError { + Unavailable { message: String }, + Rpc { code: i64, message: String }, + Timeout { timeout_ms: u64 }, + Closed, + Transport { message: String }, + InvalidResponse { detail: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub enum FreshAgentNativeNameReply { + Available, + Title(Option), + Renamed, + Failed(FreshAgentNativeNameError), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentNativeNameRequest { + pub soul_id: SoulId, + pub source: FreshAgentNativeNameSource, + pub codex_home: String, + pub operation: FreshAgentNativeNameOperation, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} diff --git a/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs b/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs index da17183316..5d2cc94208 100644 --- a/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs +++ b/crates/freshell-runtime-protocol/src/fresh_agent_ops_tests.rs @@ -90,9 +90,10 @@ fn configure_request_round_trips_settings_and_the_epoch_fence() { let configure = FreshAgentConfigureRequest { soul_id: SoulId::parse("soul-configure").unwrap(), settings: FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("acceptEdits".into()), sandbox: None, }, @@ -117,6 +118,7 @@ fn configure_request_round_trips_settings_and_the_epoch_fence() { fn host_configure_command_keeps_the_semantic_request_id_and_settings() { let request_id = RequestId::parse("request-configure-1").unwrap(); let settings = FreshAgentTurnSettings { + collaboration_mode: None, cwd: Some("/workspace/nested".into()), model: Some("opus".into()), effort: None, @@ -149,6 +151,7 @@ fn admin_configure_envelope_keeps_the_semantic_request_id() { AdminCommand::FreshAgentConfigure(FreshAgentConfigureRequest { soul_id: SoulId::parse("soul-admin-configure").unwrap(), settings: FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), effort: None, diff --git a/crates/freshell-runtime-protocol/src/lib.rs b/crates/freshell-runtime-protocol/src/lib.rs index 2fe93202b2..2b3c4e2cc8 100644 --- a/crates/freshell-runtime-protocol/src/lib.rs +++ b/crates/freshell-runtime-protocol/src/lib.rs @@ -1022,6 +1022,35 @@ pub struct ProviderLaunchContext { pub config: Vec, } +/// Effective approval intent from the original active Codex config layers. +/// Only owned public copies omit the deprecated declaration; typed requests +/// retain this policy and its declaring source across preparation and replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CodexApprovalDefault { + pub policy: String, + pub logical_source: String, + pub canonical_source: String, + pub source_sha256: String, +} +impl CodexApprovalDefault { + pub fn validate(&self) -> bool { + matches!(self.policy.as_str(), "untrusted" | "on-request" | "never") + && [&self.logical_source, &self.canonical_source] + .iter() + .all(|source| { + source.len() <= 4096 + && std::path::Path::new(source).is_absolute() + && !source.chars().any(char::is_control) + }) + && self.source_sha256.len() == 64 + && self + .source_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case", deny_unknown_fields)] pub enum ProviderPreparation { @@ -1031,6 +1060,8 @@ pub enum ProviderPreparation { Codex { tui_args: Vec, sidecar_args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + approval_default: Option, }, Opencode { project_config: Vec, @@ -1126,7 +1157,14 @@ impl ProviderLaunchContext { ProviderPreparation::Codex { tui_args, sidecar_args, + approval_default, } => { + if approval_default + .as_ref() + .is_some_and(|policy| !policy.validate()) + { + return Err(invalid()); + } let valid_recipe = |args: &[String]| { args.len() == 6 && args[0] == "-c" @@ -1754,6 +1792,10 @@ impl FreshProvider { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentLaunchSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub naming_handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, /// Stable presentation/session key used by the external Freshell wire. pub session_id: String, pub provider: FreshProvider, @@ -1807,6 +1849,10 @@ impl FreshAgentLaunchSpec { FreshProvider::Opencode => "freshopencode", }; if self.session_id.is_empty() + || self + .collaboration_mode + .as_deref() + .is_some_and(|mode| !matches!(mode, "default" | "plan")) || self.session_type != expected_type || self.runtime_variant.is_empty() || self.provider_store_id.is_empty() @@ -1956,6 +2002,8 @@ pub struct AgentJournalEvent { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentEventBatch { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retained_state: Option, pub retained_from: u64, pub head: u64, pub reset_required: bool, @@ -2033,6 +2081,24 @@ impl TerminalLaunchSpec { "managed terminal launch exceeds argv/env/bootstrap bounds", )); } + if self.mode == "codex" { + let projected = self + .provider_launch_context + .as_ref() + .is_some_and(|context| { + matches!( + &context.preparation, + ProviderPreparation::Codex { + approval_default: Some(_), + .. + } + ) + }); + if self.provider_permission_mode.as_deref() == Some("untrusted") || projected { + return Err(RuntimeError::new(RuntimeErrorCode::InvalidRequest, + "Codex providerPermissionMode/approval_policy untrusted is incompatible with the native0.164 terminal CLI; this prepared intent requires Freshcodex typed app-server requests")); + } + } validate_managed_terminal_args(&self.mode, &self.args)?; for name in self.env.keys() { let upper = name.to_ascii_uppercase(); @@ -2478,15 +2544,33 @@ pub struct TerminalInputRequest { pub expected_control_epoch: Option, } +/// Correlated native acceptance inside the authenticated owning host. +/// This never grants identity authority to asynchronous provider events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeDispatchIdentity { + pub previous_native_session_id: String, + pub native_session_id: String, + /// Existing provider pending-name intent: retained for initial recovery, + /// absent for an established new conversation. + pub pending_naming_handle: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentTurnSettings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub effort: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "double_optional" + )] + pub effort: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -2496,7 +2580,7 @@ pub struct FreshAgentTurnSettings { impl FreshAgentTurnSettings { /// The effort a provider's live `freshAgent.configure` ends on. A /// configure states only what changed, except that the model selector's - /// Default thinking row is sent as an absent effort. Claude and Kilroy + /// Default thinking row historically used an absent effort. Claude and Kilroy /// read that absence as Default when the model changes, OpenCode always /// does, and Codex keeps the session's effort. Every durable copy of a /// hosted profile (the host actor's and the supervisor's recovery record) @@ -2509,7 +2593,7 @@ impl FreshAgentTurnSettings { current_effort: Option<&str>, ) -> Option { if let Some(effort) = &self.effort { - return Some(effort.clone()); + return effort.clone(); } let absent_means_default = match provider { FreshProvider::Claude | FreshProvider::Kilroy => self @@ -2607,6 +2691,25 @@ pub struct FreshAgentReadSnapshotRequest { pub expected_control_epoch: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentModelCatalogRequest { + pub soul_id: SoulId, + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FreshAgentReadChildRequest { + pub soul_id: SoulId, + pub child_thread_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_control_epoch: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct TerminalResizeRequest { @@ -2903,6 +3006,9 @@ pub enum AdminCommand { FreshAgentReadEvents(FreshAgentReadEventsRequest), FreshAgentReadHistory(FreshAgentReadHistoryRequest), FreshAgentReadSnapshot(FreshAgentReadSnapshotRequest), + FreshAgentModelCatalog(FreshAgentModelCatalogRequest), + FreshAgentNativeName(FreshAgentNativeNameRequest), + FreshAgentReadChild(FreshAgentReadChildRequest), RuntimeMetrics(RuntimeMetricsRequest), ProbeRecovery(RecoveryProbeRequest), Recover(RecoverRequest), @@ -3417,6 +3523,9 @@ pub enum AdminResult { FreshAgentEvents(AgentEventBatch), FreshAgentHistory(serde_json::Value), FreshAgentSnapshot(serde_json::Value), + FreshAgentModelCatalog(serde_json::Value), + FreshAgentNativeName(FreshAgentNativeNameReply), + FreshAgentChildSnapshot(serde_json::Value), RuntimeMetrics(RuntimeMetrics), RecoveryProbe(RecoveryProbe), Recovery(RecoveryResult), @@ -3535,6 +3644,20 @@ pub enum HostCommand { FreshAgentReadSnapshot { incarnation_id: IncarnationId, }, + FreshAgentModelCatalog { + incarnation_id: IncarnationId, + cwd: Option, + }, + FreshAgentNativeName { + incarnation_id: IncarnationId, + native_session_id: String, + codex_home: String, + operation: FreshAgentNativeNameOperation, + }, + FreshAgentReadChild { + incarnation_id: IncarnationId, + child_thread_id: String, + }, FreshAgentResolve { incarnation_id: IncarnationId, decision_id: String, @@ -3592,6 +3715,8 @@ pub enum HostResult { /// at the same acceptance boundary as this acknowledgement. #[serde(default, skip_serializing_if = "Option::is_none")] native_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + native_identity: Option, }, FreshAgentFork(FreshAgentForkResult), FreshAgentInterrupted, @@ -3599,6 +3724,9 @@ pub enum HostResult { FreshAgentEvents(AgentEventBatch), RuntimeMetrics(RuntimeMetrics), FreshAgentSnapshot(serde_json::Value), + FreshAgentModelCatalog(serde_json::Value), + FreshAgentNativeName(FreshAgentNativeNameReply), + FreshAgentChildSnapshot(serde_json::Value), Status { host_boot_id: HostBootId, worker_pid: Option, @@ -4281,6 +4409,23 @@ mod tests { assert_eq!(reference.source_path, "/private/keys.env"); } + #[test] + fn codex_terminal_untrusted_request_is_refused_before_dispatch() { + let mut spec: TerminalLaunchSpec = serde_json::from_value(serde_json::json!({ + "terminalId":"terminal", "streamId":"stream", "mode":"codex", "program":"codex", "args":[], "env":{}, "cwd":"/workspace", "runAsUid":65534, "runAsGid":0, "cols":80, "rows":24, "projectKey":"project", "workspacePath":"/workspace", "providerPermissionMode":"untrusted" + })).unwrap(); + let error = spec + .validate() + .expect_err("the native terminal CLI cannot preserve typed untrusted request intent"); + assert!( + error.message.contains("providerPermissionMode") && error.message.contains("untrusted") + ); + for policy in ["on-request", "never"] { + spec.provider_permission_mode = Some(policy.into()); + spec.validate().unwrap(); + } + } + #[test] fn managed_provider_options_are_bounded_and_control_free() { let mut spec = TerminalLaunchSpec { @@ -4323,6 +4468,8 @@ mod tests { fn fresh_agent_launch(provider: FreshProvider, session_type: &str) -> FreshAgentLaunchSpec { FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "presentation-one".into(), provider, session_type: session_type.into(), @@ -4394,6 +4541,36 @@ mod tests { )); } + #[test] + fn child_read_wire_retains_parent_soul_and_exact_child_without_writer_parameters() { + let request = FreshAgentReadChildRequest { + soul_id: SoulId::parse("parent-soul").unwrap(), + child_thread_id: "native-child".into(), + expected_control_epoch: Some(8), + }; + let encoded = + serde_json::to_value(AdminCommand::FreshAgentReadChild(request.clone())).unwrap(); + let decoded: AdminCommand = serde_json::from_value(encoded).unwrap(); + assert!(matches!(decoded,AdminCommand::FreshAgentReadChild(value) if value == request)); + let host = HostCommand::FreshAgentReadChild { + incarnation_id: IncarnationId::parse("parent-incarnation").unwrap(), + child_thread_id: "native-child".into(), + }; + let decoded: HostCommand = + serde_json::from_value(serde_json::to_value(host).unwrap()).unwrap(); + assert!( + matches!(decoded,HostCommand::FreshAgentReadChild {child_thread_id,..} if child_thread_id == "native-child") + ); + let observation = serde_json::json!({"parentThreadId":"parent-native","childThreadId":"native-child","status":"unavailable","reason":"ephemeral_history_unavailable","retryable":false}); + let decoded: HostResult = serde_json::from_value( + serde_json::to_value(HostResult::FreshAgentChildSnapshot(observation.clone())).unwrap(), + ) + .unwrap(); + assert!( + matches!(decoded,HostResult::FreshAgentChildSnapshot(value) if value == observation) + ); + } + #[test] fn fresh_agent_fork_is_a_same_runtime_session_transition_on_the_wire() { let request = FreshAgentForkRequest { diff --git a/crates/freshell-runtime-protocol/src/provider_inputs.rs b/crates/freshell-runtime-protocol/src/provider_inputs.rs index 53b934bbc6..75e30594be 100644 --- a/crates/freshell-runtime-protocol/src/provider_inputs.rs +++ b/crates/freshell-runtime-protocol/src/provider_inputs.rs @@ -734,6 +734,7 @@ mod tests { provider_launch_context: crate::ProviderLaunchContext { prepared_inputs: Some(prepared()), preparation: crate::ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, @@ -944,6 +945,7 @@ mod tests { let context = crate::ProviderLaunchContext { prepared_inputs: Some(prepared()), preparation: crate::ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index 0b14572919..3a9dac918d 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -6,7 +6,8 @@ use freshell_freshagent::naming::{admit_pending_projection, session_projection, SessionNaming}; use freshell_freshagent::rest_lane::{ FreshAgentRestLane, RestLaneCapture, RestLaneCaptureError, RestLaneCaptureResult, - RestLaneCreate, RestLaneCreated, RestLaneSend, RestLaneSendResult, + RestLaneCreate, RestLaneCreated, RestLaneError, RestLaneErrorKind, RestLaneSend, + RestLaneSendResult, }; use freshell_protocol::session_names::NamedProvider; use freshell_protocol::{ @@ -80,15 +81,14 @@ impl HostedFreshAgentProxy { format!("{OPT_IN_ENV}=1 requires an available managed runtime controller") })?; let fixture_modes = configured_fixture_modes()?; - // Name reliability (Task 10, Residual 4): hosted fresh agents admit a - // pending name at create and read a resumed session's name, but no - // lane reports their first message or transfers the pending name onto - // the native session, so they never get a first-message or AI name. - // Said once at boot so enabling the flag cannot hide the gap. + // Codex reports authenticated accepted-input and verified acquisition + // facts to the shared authority. The other hosted provider lanes + // still have no first-message/materialization naming feed. tracing::warn!( target: "freshell_server::session_names", flag = OPT_IN_ENV, - "session_names.hosted_fresh_agent_naming_incomplete: hosted fresh-agent \ + providers = "claude,opencode", + "session_names.hosted_fresh_agent_naming_incomplete: hosted Claude and OpenCode \ sessions keep their create-time name only (no first-message or AI naming)" ); Ok(Some(Arc::new(Self { @@ -203,14 +203,19 @@ impl HostedFreshAgentProxy { match command { HostedFreshAgentCommand::Create(message) => { let request_id = message.request_id.clone(); - if self.create(message).await.is_err() { - tracing::warn!(request_id, "fresh_agent.create.failed"); + if let Err(mut error) = self.create(message).await { + tracing::warn!(request_id, code=%error.code, "fresh_agent.create.failed"); + if error.code == "FRESH_AGENT_UNAVAILABLE" { + error.code = "FRESH_AGENT_CREATE_FAILED".into(); + error.message = "managed fresh-agent host could not be created".into(); + error.retryable = Some(true); + } self.send(ServerMessage::FreshAgentCreateFailed( FreshAgentCreateFailed { - code: "FRESH_AGENT_CREATE_FAILED".into(), - message: "managed fresh-agent host could not be created".into(), + code: error.code, + message: error.message, request_id, - retryable: Some(true), + retryable: error.retryable, owner_kind: None, owner_generation: None, owner_epoch: None, @@ -363,6 +368,20 @@ impl HostedFreshAgentProxy { ) .await; } + HostedFreshAgentCommand::Elicitation(message) => { + if message.provider != AgentProvider::Codex { + self.error_event_code( + &message.provider, + &message.session_id, + message.session_type, + "UNSUPPORTED_CAPABILITY", + "MCP elicitation is not supported for this provider.", + ); + return; + } + self.resolve_decision(message.provider, message.session_id, message.session_type, decision_id(message.request_id), + serde_json::json!({"elicitation":{"action":message.action,"content":message.content}})).await; + } HostedFreshAgentCommand::Question(message) => { self.resolve_decision( message.provider, @@ -501,12 +520,12 @@ impl HostedFreshAgentProxy { async fn create( self: &Arc, message: freshell_protocol::FreshAgentCreate, - ) -> Result, ()> { + ) -> Result, RestLaneError> { let provider = fresh_provider(&message.provider, message.session_type).ok_or(())?; let public_provider = fresh_provider_wire(&provider); if let Some(session_ref) = message.session_ref.as_ref() { if session_ref.provider != public_provider { - return Err(()); + return Err(().into()); } let inventory = self.client.inventory().await.map_err(|_| ())?; let view = inventory @@ -632,6 +651,8 @@ impl HostedFreshAgentProxy { let original = |name: &str| std::env::var(name).ok(); let context = crate::managed_provider_references::codex::prepare_managed( crate::managed_provider_references::codex::CodexPreparation { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, client: &self.client, soul: &soul, creation_seed: &message.request_id, @@ -645,7 +666,15 @@ impl HostedFreshAgentProxy { ) .await .map_err(|error| { - tracing::warn!(event="fresh_agent.create.provider_inputs_failed",%error); + tracing::warn!(event="fresh_agent.create.provider_inputs_failed", provider="codex", request_id=%message.request_id, code=?error.code, message=%error.message); + let configuration = error.code == RuntimeErrorCode::InvalidRequest; + let mut refusal = RestLaneError::new( + if configuration { RestLaneErrorKind::BadRequest } else { RestLaneErrorKind::Unavailable }, + if configuration { "INVALID_REQUEST" } else { "CODEX_PREPARATION_UNAVAILABLE" }, + error.message, + ); + refusal.retryable = Some(!configuration); + refusal })?; (context.mcp_capability.clone().ok_or(())?, Some(context)) } else if matches!(provider, FreshProvider::Claude | FreshProvider::Kilroy) { @@ -704,6 +733,10 @@ impl HostedFreshAgentProxy { }; provider_launch_context = context_for_fresh_provider(provider_launch_context); let launch = FreshAgentLaunchSpec { + naming_handle: (provider == FreshProvider::Codex) + .then(|| message.naming_handle.clone()) + .flatten(), + collaboration_mode: message.collaboration_mode.clone(), session_id: public_session_id.clone(), provider: provider.clone(), session_type: session_type.clone(), @@ -716,7 +749,7 @@ impl HostedFreshAgentProxy { run_as_uid: PROVIDER_UID, run_as_gid: PROVIDER_GID, model: message.model.clone(), - effort: message.effort.clone(), + effort: message.effort.clone().flatten(), permission_mode: message.permission_mode.clone(), sandbox: message .sandbox @@ -999,6 +1032,12 @@ impl HostedFreshAgentProxy { let proxy = Arc::clone(self); tokio::spawn(async move { let mut cursor = 0; + let mut retained_through = 0; + let mut source_before = if provider == "codex" { + proxy.current_source(&soul).await + } else { + None + }; loop { match proxy .client @@ -1006,7 +1045,47 @@ impl HostedFreshAgentProxy { .await { Ok(batch) => { - if batch.reset_required { + if batch.retained_state.is_some() || cursor < retained_through { + let after = proxy.current_source(&soul).await; + let valid = retained_batch_matches_source( + source_before.as_ref(), + after.as_ref(), + batch.retained_state.as_ref(), + ); + source_before = after; + if !valid { + tracing::warn!(provider, soul_id=%soul, "freshagent.gateway.retained_source_changed"); + cursor = 0; + retained_through = 0; + tokio::time::sleep(Duration::from_millis(50)).await; + continue; + } + } + if let Some(retained) = batch.retained_state { + let presentation = proxy + .presentation_ids + .lock() + .await + .get(&soul) + .cloned() + .unwrap_or_else(|| presentation_session_id.clone()); + proxy + .forward_retained( + retained, + &provider, + &presentation, + &session_type, + if batch.reset_required { + "freshAgent.host.journal.reset" + } else { + "freshAgent.host.backpressure" + }, + ) + .await; + // Projection and controls are covered, but request receipts + // still have to drain through every bounded journal page. + retained_through = retained_through.max(batch.head); + } else if batch.reset_required { proxy.send(ServerMessage::FreshAgentEvent(FreshAgentEvent { event: serde_json::json!({ "type":"freshAgent.host.journal.reset", @@ -1017,8 +1096,16 @@ impl HostedFreshAgentProxy { session_type: session_type.clone(), })); } + if batch.events.is_empty() { + cursor = cursor.max(batch.head); + } for event in batch.events { cursor = cursor.max(event.sequence); + if event.sequence <= retained_through + && !is_request_receipt(&event.event) + { + continue; + } // Provider-native identity is nested provider state. The public // routing alias changes only through an explicit same-soul fork // transition or durable inventory reconciliation, never merely @@ -1030,12 +1117,14 @@ impl HostedFreshAgentProxy { .get(&soul) .cloned() .unwrap_or_else(|| presentation_session_id.clone()); - proxy.forward_host_event( - event.event, - &provider, - ¤t_presentation, - &session_type, - ); + proxy + .forward_host_event( + event.event, + &provider, + ¤t_presentation, + &session_type, + ) + .await; } } Err(_) => { @@ -1060,6 +1149,92 @@ impl HostedFreshAgentProxy { true } + async fn current_source( + &self, + soul: &SoulId, + ) -> Option { + self.client + .inventory() + .await + .ok()? + .into_iter() + .rev() + .find(|view| { + &view.soul_id == soul + && view.desired_state == DesiredState::Running + && view.launch_state == freshell_runtime_protocol::LaunchState::Running + }) + } + + async fn forward_retained( + &self, + mut retained: serde_json::Value, + provider: &str, + presentation: &str, + session_type: &str, + event_type: &str, + ) { + let native = retained["snapshot"]["threadId"] + .as_str() + .unwrap_or_default() + .to_string(); + if provider == "codex" { + if let Some(facts) = retained["snapshot"]["extensions"]["codex"] + .as_object_mut() + .and_then(|extension| extension.remove("hostedNamingFacts")) + { + let facts = facts.as_array().into_iter().flatten(); + // A retained accepted input may precede its admission fact. + // Bind/admit first, then fold that same idempotent input. + for fact in facts + .clone() + .filter(|fact| fact["kind"] != "activity") + .chain(facts.filter(|fact| fact["kind"] == "activity")) + { + self.fold_hosted_codex_naming(presentation, &native, fact.clone()) + .await; + } + } + } + retained["source"]["nativeSessionId"] = retained["snapshot"]["threadId"].clone(); + if let Some(ownership) = self.ownership.get() { + let canonical = ownership.resolve_canonical(provider, presentation); + let owner = ownership.observe(provider, &canonical); + retained["source"]["ownerEpoch"] = serde_json::json!(owner.epoch); + retained["source"]["ownerGeneration"] = serde_json::json!(owner.generation); + } + freshell_agent_runtime::snapshot_projection::project_hosted_rest_snapshot( + &mut retained["snapshot"], + provider, + session_type, + ); + for mark in retained["attentionMarks"] + .as_array_mut() + .into_iter() + .flatten() + { + mark["sessionId"] = serde_json::json!(presentation); + } + self.send(ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: provider.into(), + session_type: session_type.into(), + session_id: presentation.into(), + event: serde_json::json!({"type":event_type,"retainedState":retained}), + })); + for payload in retained["pendingControls"].as_array().into_iter().flatten() { + self.forward_host_event( + AgentEvent::PermissionRequested { + decision_id: payload["requestId"].as_str().unwrap_or_default().into(), + payload: payload.clone(), + }, + provider, + presentation, + session_type, + ) + .await; + } + } + async fn replay_once( &self, soul: SoulId, @@ -1067,15 +1242,71 @@ impl HostedFreshAgentProxy { presentation_session_id: &str, session_type: &str, ) { - let Ok(batch) = self.client.fresh_agent_events(soul, 0, 4096).await else { - return; + let mut before = if provider == "codex" { + self.current_source(&soul).await + } else { + None }; - for event in batch.events { - self.forward_host_event(event.event, provider, presentation_session_id, session_type); + let mut cursor = 0; + let mut replay_through = None; + let mut retained_through = 0; + loop { + let Ok(batch) = self + .client + .fresh_agent_events(soul.clone(), cursor, 4096) + .await + else { + return; + }; + let replay_through = *replay_through.get_or_insert(batch.head); + if batch.retained_state.is_some() || cursor < retained_through { + let after = self.current_source(&soul).await; + let valid = retained_batch_matches_source( + before.as_ref(), + after.as_ref(), + batch.retained_state.as_ref(), + ); + before = after; + if !valid { + return; + } + } + if let Some(retained) = batch.retained_state { + self.forward_retained( + retained, + provider, + presentation_session_id, + session_type, + "freshAgent.host.backpressure", + ) + .await; + retained_through = retained_through.max(batch.head); + } + if batch.events.is_empty() { + return; + } + for event in batch.events { + if event.sequence > replay_through { + return; + } + cursor = cursor.max(event.sequence); + if event.sequence > retained_through || is_request_receipt(&event.event) { + self.forward_host_event( + event.event, + provider, + presentation_session_id, + session_type, + ) + .await; + } + } + if cursor >= replay_through { + return; + } } } - fn forward_host_event( + async fn forward_host_event( &self, event: AgentEvent, provider: &str, @@ -1084,6 +1315,20 @@ impl HostedFreshAgentProxy { ) { match event { AgentEvent::Provider { mut payload } => { + if payload["event"]["type"] + == freshell_freshagent::naming::HOSTED_CODEX_NAMING_EVENT + { + if provider == "codex" { + let native = payload["sessionId"].as_str().unwrap_or_default(); + self.fold_hosted_codex_naming( + presentation_session_id, + native, + payload["event"]["fact"].clone(), + ) + .await; + } + return; + } if payload.get("type").and_then(serde_json::Value::as_str) == Some("freshAgent.created") { @@ -1111,6 +1356,162 @@ impl HostedFreshAgentProxy { } } + async fn fold_hosted_codex_naming( + &self, + presentation: &str, + native_id: &str, + value: serde_json::Value, + ) { + use freshell_freshagent::naming::{HostedCodexNamingFact, NameTransitionReason}; + use freshell_protocol::native_location::{ + NativeEvidenceKind, NativeLocation, NativePersistence, + }; + use freshell_protocol::session_names::{NamedProvider, SessionNameRef}; + if native_id.is_empty() { + return; + } + let Some(soul) = self + .resolve_soul(&AgentProvider::Codex, SessionType::Freshcodex, presentation) + .await + else { + return; + }; + let current = self.current_source(&soul).await; + if current + .as_ref() + .and_then(|view| view.native_session_id.as_deref()) + != Some(native_id) + { + tracing::warn!( + provider = "codex", + session_id = presentation, + native_session_id = native_id, + "freshagent.gateway.naming_source_changed" + ); + return; + } + let fact = match serde_json::from_value::(value) { + Ok(fact) => fact, + Err(error) => { + tracing::warn!(provider="codex", native_session_id=native_id, %error, "freshagent.gateway.invalid_naming_fact"); + return; + } + }; + let sink = self.naming.get().cloned(); + match fact { + HostedCodexNamingFact::Acquisition { + target, + acquisition, + cwd, + } => { + let expected = SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: native_id.into(), + }; + let location_matches = matches!(&acquisition.location, + NativeLocation::Codex { codex_home, native_thread_id: Some(id), rollout_path: Some(path), .. } + if !codex_home.is_empty() && id == native_id && !path.is_empty()); + if target != expected + || !location_matches + || acquisition.persistence != NativePersistence::Verified + || acquisition.evidence != NativeEvidenceKind::PersistedMetadata + { + tracing::warn!( + provider = "codex", + native_session_id = native_id, + "freshagent.gateway.naming_source_mismatch" + ); + return; + } + if let Some(sink) = sink { + if let Err(error) = sink.ensure_session(target.clone(), cwd).await { + freshell_freshagent::naming::log_name_error( + "ensure_session", + &target, + &error, + ); + return; + } + let after_admission = self.current_source(&soul).await; + if !current + .as_ref() + .zip(after_admission.as_ref()) + .is_some_and(|(before, after)| rest::same_snapshot_source(before, after)) + { + tracing::warn!( + provider = "codex", + session_id = presentation, + native_session_id = native_id, + "freshagent.gateway.naming_source_changed" + ); + return; + } + if let Err(error) = sink.record_acquisition(target.clone(), acquisition).await { + freshell_freshagent::naming::log_name_error( + "record_acquisition", + &target, + &error, + ); + } + let after_acquisition = self.current_source(&soul).await; + if !current + .as_ref() + .zip(after_acquisition.as_ref()) + .is_some_and(|(before, after)| rest::same_snapshot_source(before, after)) + { + tracing::warn!( + provider = "codex", + session_id = presentation, + native_session_id = native_id, + "freshagent.gateway.naming_source_changed" + ); + } + } + } + HostedCodexNamingFact::Activity(input) => { + let valid_target = match &input.target { + SessionNameRef::Pending { id } => !id.is_empty(), + SessionNameRef::Session { + provider, + session_id, + } => *provider == NamedProvider::Codex && session_id == native_id, + }; + if valid_target && input.mode == "freshcodex" { + if let Some(sink) = sink { + if let Err(error) = sink.activity(input.clone()).await { + freshell_freshagent::naming::log_name_error( + "activity", + &input.target, + &error, + ); + } + } + } + } + HostedCodexNamingFact::Transition { target, transition } => { + let expected = SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: native_id.into(), + }; + let location_matches = matches!(&transition.acquisition.location, NativeLocation::Codex { native_thread_id:Some(id), .. } if id == native_id); + if target != expected + || !location_matches + || transition.reason != NameTransitionReason::InitialMaterialization + || transition.acquisition.persistence != NativePersistence::Verified + { + tracing::warn!( + provider = "codex", + native_session_id = native_id, + "freshagent.gateway.naming_source_mismatch" + ); + return; + } + freshell_freshagent::naming::fold_identity_transition(&sink, &transition, target) + .await; + } + } + } + fn error_event( &self, provider: &AgentProvider, @@ -1171,6 +1572,70 @@ fn named_provider(provider: &FreshProvider) -> NamedProvider { } impl HostedFreshAgentGateway for HostedFreshAgentProxy { + fn attention_marks( + &self, + ) -> Pin> + Send + '_>> + { + Box::pin(async move { + let Ok(views) = self.client.inventory().await else { + return Vec::new(); + }; + let mut marks = Vec::new(); + for before in views.into_iter().filter(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && view.desired_state == DesiredState::Running + }) { + let Ok(batch) = self + .client + .fresh_agent_events(before.soul_id.clone(), 0, 1) + .await + else { + continue; + }; + let Some(retained) = batch.retained_state else { + continue; + }; + let Some(after) = self.current_source(&before.soul_id).await else { + continue; + }; + if !same_retained_host(&before, &after) + || !retained_matches_view(&retained, &before) + || !retained_matches_view(&retained, &after) + { + continue; + } + let presentation = self + .presentation_ids + .lock() + .await + .get(&before.soul_id) + .cloned() + .or(before.fresh_agent_session_id.clone()) + .or(before.native_session_id.clone()); + let Some(presentation) = presentation else { + continue; + }; + if let Ok(current) = serde_json::from_value::< + Vec, + >(retained["attentionMarks"].clone()) + { + marks.extend( + current + .into_iter() + .filter(|mark| { + mark.provider == "codex" && mark.session_type == "freshcodex" + }) + .map(|mut mark| { + mark.session_id = presentation.clone(); + mark + }), + ); + } + } + marks + }) + } fn dispatch( self: Arc, command: HostedFreshAgentCommand, @@ -1179,6 +1644,8 @@ impl HostedFreshAgentGateway for HostedFreshAgentProxy { } } +#[path = "fresh_agent_proxy_naming.rs"] +mod native_naming; #[path = "fresh_agent_proxy_rest.rs"] mod rest; #[cfg(test)] @@ -1245,6 +1712,7 @@ fn turn_settings( ) -> Option { let mut settings = settings.map( |settings| freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: settings.collaboration_mode, cwd: settings.cwd, model: settings.model, effort: settings.effort, @@ -1366,6 +1834,74 @@ fn fresh_agent_error_payload(code: &str, message: &str) -> serde_json::Value { }) } +fn same_retained_host( + before: &freshell_runtime_protocol::RuntimeView, + after: &freshell_runtime_protocol::RuntimeView, +) -> bool { + before.soul_id == after.soul_id + && before.incarnation_id == after.incarnation_id + && before.host_boot_id == after.host_boot_id +} + +/// Retained projection/control state does not carry request acknowledgements. +/// Keep only these receipts when superseding the journal's display prefix. +fn is_request_receipt(event: &AgentEvent) -> bool { + let AgentEvent::Provider { payload } = event else { + return false; + }; + let receipt = if payload["type"] == "freshAgent.event" + && matches!( + payload["event"]["type"].as_str(), + Some( + "checkpointTurnIdentity" + | "freshAgent.rolledBack" + | "freshAgent.redone" + | "freshAgent.error" + ) + ) { + &payload["event"] + } else if matches!( + payload["type"].as_str(), + Some("freshAgent.send.accepted" | "error" | "freshAgent.checkpoint.outcome") + ) { + payload + } else { + return false; + }; + receipt["requestId"] + .as_str() + .is_some_and(|id| !id.is_empty()) +} + +fn retained_batch_matches_source( + before: Option<&freshell_runtime_protocol::RuntimeView>, + after: Option<&freshell_runtime_protocol::RuntimeView>, + retained: Option<&serde_json::Value>, +) -> bool { + before.zip(after).is_some_and(|(before, after)| { + same_retained_host(before, after) + && before.native_session_id == after.native_session_id + && retained.is_none_or(|retained| { + retained_matches_view(retained, before) && retained_matches_view(retained, after) + }) + }) +} + +fn retained_matches_view( + retained: &serde_json::Value, + view: &freshell_runtime_protocol::RuntimeView, +) -> bool { + retained["source"]["soulId"].as_str() == Some(view.soul_id.as_str()) + && retained["source"]["incarnationId"].as_str() == Some(view.incarnation_id.as_str()) + && view + .host_boot_id + .as_ref() + .is_some_and(|boot| retained["source"]["hostBootId"].as_str() == Some(boot.as_str())) + && retained["snapshot"]["provider"] == "codex" + && retained["snapshot"]["sessionType"] == "freshcodex" + && retained["snapshot"]["threadId"].as_str() == view.native_session_id.as_deref() +} + fn rewrite_presentation_id(payload: &mut serde_json::Value, presentation_id: &str) { let Some(object) = payload.as_object_mut() else { return; diff --git a/crates/freshell-server/src/fresh_agent_proxy_naming.rs b/crates/freshell-server/src/fresh_agent_proxy_naming.rs new file mode 100644 index 0000000000..27b20b02ae --- /dev/null +++ b/crates/freshell-server/src/fresh_agent_proxy_naming.rs @@ -0,0 +1,192 @@ +//! Native naming uses the current host's existing client and the shared worker. +use super::*; +use crate::session_name_native::{ + classify_codex_error, NativeCallResult, NativeClassified, NativeFuture, NativeNameAttempt, + NativeNameBackend, NativeNameReadback, NativeNameTarget, NativeProbeFuture, +}; +use freshell_protocol::native_location::NativeLocation; +use freshell_runtime_protocol::{ + FreshAgentNativeNameError as NameError, FreshAgentNativeNameOperation as Operation, + FreshAgentNativeNameReply as Reply, FreshAgentNativeNameRequest, FreshAgentNativeNameSource, +}; + +#[derive(Clone)] +struct HostedCodexNameBackend { + client: RuntimeClient, + ownership: Option>, +} + +impl HostedFreshAgentProxy { + pub(crate) fn native_name_backend(&self) -> Arc { + Arc::new(HostedCodexNameBackend { + client: self.client.clone(), + ownership: self.ownership.get().cloned(), + }) + } +} + +impl HostedCodexNameBackend { + async fn invoke( + &self, + target: NativeNameTarget, + operation: Operation, + ) -> Result { + let NativeLocation::Codex { + codex_home, + native_thread_id: Some(native_id), + .. + } = &target.location + else { + return Err(NativeClassified::Unsupported( + "no exact Codex native naming target".into(), + )); + }; + if codex_home.is_empty() || native_id.is_empty() { + return Err(NativeClassified::Unsupported( + "no initialized Codex naming root or thread".into(), + )); + } + let owner = || { + self.ownership.as_ref().map(|registry| { + let canonical = registry.resolve_canonical("codex", native_id); + let observed = registry.observe("codex", &canonical); + (observed.epoch, observed.generation, observed.state) + }) + }; + let before_owner = owner(); + let inventory = self + .client + .inventory() + .await + .map_err(|error| NativeClassified::Undelivered(error.to_string()))?; + let view = inventory + .iter() + .rev() + .find(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && view.native_session_id.as_deref() == Some(native_id) + && view.launch_state == freshell_runtime_protocol::LaunchState::Running + && view.recovery_state == freshell_runtime_protocol::RecoveryState::Live + && view.desired_state == DesiredState::Running + }) + .ok_or_else(|| { + NativeClassified::Undelivered("no live managed Codex naming authority".into()) + })?; + let source = FreshAgentNativeNameSource { + incarnation_id: view.incarnation_id.clone(), + host_boot_id: view.host_boot_id.clone().ok_or_else(|| { + NativeClassified::Undelivered("Codex host has no authenticated boot".into()) + })?, + execution_generation: view.execution_generation, + intent_revision: view.intent_revision, + native_session_id: native_id.clone(), + }; + if owner() != before_owner { + return Err(NativeClassified::Undelivered( + "Codex naming owner changed before dispatch".into(), + )); + } + let reply = self + .client + .fresh_agent_native_name(FreshAgentNativeNameRequest { + soul_id: view.soul_id.clone(), + source, + codex_home: codex_home.clone(), + operation, + expected_control_epoch: None, + }) + .await + .map_err(|error| { + NativeClassified::Ambiguous(format!( + "managed Codex naming request did not settle: {error}" + )) + })?; + let inventory = self + .client + .inventory() + .await + .map_err(|error| NativeClassified::Ambiguous(error.to_string()))?; + let current = inventory + .iter() + .rev() + .find(|row| row.soul_id == view.soul_id); + if owner() != before_owner + || !current.is_some_and(|current| rest::same_snapshot_source(view, current)) + { + return Err(NativeClassified::Ambiguous( + "Codex naming source changed during dispatch".into(), + )); + } + Ok(reply) + } +} + +fn classify_name_error(error: NameError, method: &str) -> NativeClassified { + use freshell_codex::app_server::CodexAppServerError as Error; + let method = method.to_string(); + let error = match error { + NameError::Unavailable { message } => return NativeClassified::Undelivered(message), + NameError::Rpc { code, message } => Error::Rpc { + method, + error: Box::new(freshell_codex::protocol::RpcError { + code, + message, + data: None, + }), + }, + NameError::Timeout { timeout_ms } => Error::Timeout { method, timeout_ms }, + NameError::Closed => Error::Closed { method }, + NameError::Transport { message } => Error::Transport { method, message }, + NameError::InvalidResponse { detail } => Error::InvalidResponse { method, detail }, + }; + classify_codex_error(error, "native metadata") +} + +impl NativeNameBackend for HostedCodexNameBackend { + fn read(&self, target: NativeNameTarget) -> NativeFuture { + let backend = self.clone(); + Box::pin(async move { + match backend.invoke(target, Operation::Read).await { + Ok(Reply::Title(title)) => { + NativeCallResult::Confirmed(NativeNameReadback { title }) + } + Ok(Reply::Failed(error)) => classify_name_error(error, "thread/read").into_call(), + Err(error) => error.into_call(), + _ => NativeCallResult::Ambiguous("unexpected native name read reply".into()), + } + }) + } + + fn write(&self, attempt: NativeNameAttempt) -> NativeFuture<()> { + let backend = self.clone(); + Box::pin(async move { + match backend + .invoke( + attempt.target, + Operation::Write { + title: attempt.title, + }, + ) + .await + { + Ok(Reply::Renamed) => NativeCallResult::Confirmed(()), + Ok(Reply::Failed(error)) => { + classify_name_error(error, "thread/name/set").into_call() + } + Err(error) => error.into_call(), + _ => NativeCallResult::Ambiguous("unexpected native name write reply".into()), + } + }) + } + + fn route_available(&self, target: NativeNameTarget) -> NativeProbeFuture { + let backend = self.clone(); + Box::pin(async move { + matches!( + backend.invoke(target, Operation::Probe).await, + Ok(Reply::Available) + ) + }) + } +} diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 31f86d8b9b..65d69e5ce2 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -4,6 +4,145 @@ use super::*; #[async_trait::async_trait] impl FreshAgentRestLane for HostedFreshAgentProxy { + async fn model_catalog_source( + &self, + context: freshell_freshagent::model_capabilities::CodexCatalogContext, + ) -> Result< + freshell_freshagent::model_capabilities::CodexCatalogSource, + freshell_freshagent::model_capabilities::CapabilityError, + > { + use freshell_freshagent::model_capabilities::{ + runtime_pending, source_changed, CodexCatalogSource, + }; + let requested = context.native_reference()?; + let ownership = self.ownership.get().cloned(); + let owner = ownership.as_ref().map(|registry| { + let id = registry.resolve_canonical("codex", requested); + registry.observe("codex", &id) + }); + if owner.as_ref().is_some_and(|owner| { + context.observed_epoch.is_some_and(|v| v != owner.epoch) + || context + .observed_generation + .is_some_and(|v| v != owner.generation) + }) { + return Err(source_changed()); + } + let inventory = self + .client + .inventory() + .await + .map_err(|_| runtime_pending())?; + let view = inventory + .iter() + .rev() + .find(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && (view.fresh_agent_session_id.as_deref() == Some(requested) + || view.native_session_id.as_deref() == Some(requested)) + }) + .ok_or_else(runtime_pending)?; + if view.native_session_id.is_none() + || view.launch_state != freshell_runtime_protocol::LaunchState::Running + || view.recovery_state != freshell_runtime_protocol::RecoveryState::Live + || view.desired_state != DesiredState::Running + { + return Err(runtime_pending()); + } + let key = catalog_source_key(view, owner.as_ref()); + Ok(CodexCatalogSource { + key, + probe: Arc::new(HostedCodexCatalogProbe { + client: self.client.clone(), + view: view.clone(), + context, + ownership, + owner, + }), + }) + } + async fn read_child( + &self, + request: freshell_freshagent::rest_lane::RestLaneChildRead, + ) -> Result, freshell_freshagent::codex::children::ChildReadError> + { + use freshell_freshagent::codex::children::{stamp_readonly_child, ChildReadError}; + let ownership = self.ownership.get().map(|registry| { + let id = registry.resolve_canonical("codex", &request.parent_session_id); + registry.observe("codex", &id) + }); + if ownership.as_ref().is_some_and(|owner| { + request + .observed_epoch + .is_some_and(|epoch| epoch != owner.epoch) + || request + .observed_generation + .is_some_and(|generation| generation != owner.generation) + }) { + return Err(ChildReadError::SourceUnavailable); + } + let inventory = self + .client + .inventory() + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + // The child is deliberately never looked up as a root soul. + let Some(view) = inventory.iter().rev().find(|view| { + view.provider.as_deref() == Some("codex") + && view.fresh_agent_session_type.as_deref() == Some("freshcodex") + && (view.fresh_agent_session_id.as_deref() == Some(&request.parent_session_id) + || view.native_session_id.as_deref() == Some(&request.parent_session_id)) + }) else { + return Ok(None); + }; + let parent = view + .native_session_id + .as_deref() + .ok_or(ChildReadError::SourceUnavailable)?; + let mut value = self.client.fresh_agent_read_child(view.soul_id.clone(), request.child_thread_id.clone()).await.map_err(|error| { + tracing::warn!(provider="codex", soul_id=%view.soul_id, session_id=parent, child_thread_id=%request.child_thread_id, code=?error.runtime_code(), "fresh_agent.hosted_child_unavailable"); + ChildReadError::SourceUnavailable + })?; + let inventory_after = self + .client + .inventory() + .await + .map_err(|_| ChildReadError::SourceUnavailable)?; + let latest = inventory_after + .iter() + .rev() + .find(|row| row.soul_id == view.soul_id) + .ok_or(ChildReadError::SourceUnavailable)?; + let owner_changed = + self.ownership + .get() + .zip(ownership.as_ref()) + .is_some_and(|(registry, before)| { + let id = registry.resolve_canonical("codex", &request.parent_session_id); + let after = registry.observe("codex", &id); + after.epoch != before.epoch + || after.generation != before.generation + || after.state != before.state + }); + if owner_changed + || !same_snapshot_source(view, latest) + || value["parentThreadId"].as_str() != Some(parent) + || value["childThreadId"].as_str() != Some(request.child_thread_id.as_str()) + { + return Err(ChildReadError::SourceUnavailable); + } + if let Some(snapshot) = value.get_mut("snapshot") { + if snapshot["threadId"].as_str() != Some(request.child_thread_id.as_str()) + || snapshot["provider"] != "codex" + || snapshot["sessionType"] != "freshcodex" + { + return Err(ChildReadError::SourceUnavailable); + } + stamp_readonly_child(snapshot, parent); + } + Ok(Some(value)) + } async fn snapshot( &self, request: freshell_freshagent::rest_lane::RestLaneSnapshot, @@ -129,6 +268,7 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { + collaboration_mode: request.collaboration_mode, pane_id: request.pane_id, request_id: request.request_id, session_type, @@ -277,7 +417,83 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { } } -fn same_snapshot_source( +fn catalog_source_key( + view: &freshell_runtime_protocol::RuntimeView, + owner: Option<&freshell_ownership::OwnershipSnapshot>, +) -> String { + serde_json::json!([ + "codex", + view.soul_id, + view.incarnation_id, + view.host_boot_id, + view.execution_generation, + view.intent_revision, + view.native_session_id, + view.fresh_agent_session_id, + view.fresh_agent_session_type, + view.recovery_state, + view.desired_state, + owner.map(|o| (o.epoch, o.generation, format!("{:?}", o.state))) + ]) + .to_string() +} + +struct HostedCodexCatalogProbe { + client: RuntimeClient, + view: freshell_runtime_protocol::RuntimeView, + context: freshell_freshagent::model_capabilities::CodexCatalogContext, + ownership: Option>, + owner: Option, +} + +impl freshell_freshagent::model_capabilities::ModelCatalogProbe for HostedCodexCatalogProbe { + fn probe<'a>( + &'a self, + cwd: Option<&'a str>, + ) -> freshell_opencode::serve::BoxFuture<'a, freshell_freshagent::model_capabilities::CatalogOut> + { + Box::pin(async move { + use freshell_freshagent::model_capabilities::{ + codex_capabilities, source_changed, CapabilityError, + }; + let value = self.client.fresh_agent_model_catalog(self.view.soul_id.clone(),cwd.map(str::to_string)).await.map_err(|error| { + tracing::warn!(provider="codex",session_id=?self.view.native_session_id,soul_id=%self.view.soul_id,code=?error.runtime_code(),"fresh_agent.hosted_catalog_unavailable"); + CapabilityError {code:"CAPABILITY_PROBE_FAILED".into(),message:"The conversation's Codex model catalog is unavailable".into(),retryable:true} + })?; + let inventory = self + .client + .inventory() + .await + .map_err(|_| source_changed())?; + let current = inventory + .iter() + .rev() + .find(|row| row.soul_id == self.view.soul_id) + .ok_or_else(source_changed)?; + let owner = self.ownership.as_ref().map(|registry| { + let id = registry.resolve_canonical( + "codex", + self.context.native_reference().expect("validated context"), + ); + registry.observe("codex", &id) + }); + if !same_snapshot_source(&self.view, current) + || catalog_source_key(current, owner.as_ref()) + != catalog_source_key(&self.view, self.owner.as_ref()) + { + return Err(source_changed()); + } + let rows = serde_json::from_value(value).map_err(|_| CapabilityError { + code: "CAPABILITY_PAYLOAD_INVALID".into(), + message: "Codex host returned an invalid model catalog".into(), + retryable: true, + })?; + Ok(codex_capabilities(rows)) + }) + } +} + +pub(super) fn same_snapshot_source( before: &freshell_runtime_protocol::RuntimeView, after: &freshell_runtime_protocol::RuntimeView, ) -> bool { diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 98c172b6e3..7c6374c3ba 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -6,6 +6,549 @@ use freshell_runtime_protocol::{ }; use tokio::net::UnixListener; +#[tokio::test] +async fn task010_hosted_new_conversation_admits_only_its_verified_current_name() { + use freshell_freshagent::naming::{ + HostedCodexNamingFact, NameActivity, NameActivityReason, RenameNameInput, + }; + use freshell_protocol::native_location::{ + NativeAcquisition, NativeEvidenceKind, NativeLocation, NativePersistence, + }; + use freshell_protocol::session_names::{NameIntent, SessionNameRef}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("new-name.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let switch_after = Arc::new(AtomicUsize::new(0)); + let switch = switch_after.clone(); + let server = tokio::spawn(async move { + let changed = AtomicBool::new(false); + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }, + AdminCommand::Inventory => { + if switch.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) == Ok(1) + { + changed.store(true, Ordering::SeqCst); + } + AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"name-soul","incarnationId":"name-incarnation","hostBootId":if changed.load(Ordering::SeqCst) {"new-boot"} else {"name-boot"}, + "launchState":"running","cleanupState":"none","intentRevision":3,"executionGeneration":2, + "desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated", + "evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex", + "nativeSessionId":"new-native","freshAgentSessionId":"same-opaque-ref","freshAgentSessionType":"freshcodex" + })).unwrap()]) + } + other => panic!("name admission must perform no provider work: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let proxy = snapshot_outage_proxy(&socket); + let names = crate::session_names::SessionNames::open(dir.path().join("names")).unwrap(); + proxy.set_session_naming(names.clone()); + let target = |id: &str| SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: id.into(), + }; + let old = target("old-native"); + let new = target("new-native"); + names.ensure_session(old.clone(), None).await.unwrap(); + let old_record = names + .rename(RenameNameInput { + target: old.clone(), + name: "Protected old manual name".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap() + .record; + let acquisition = |id: &str, persistence| HostedCodexNamingFact::Acquisition { + target: target(id), + cwd: Some("/work/recovery".into()), + acquisition: NativeAcquisition { + location: NativeLocation::Codex { + codex_home: "/owning/codex".into(), + native_thread_id: Some(id.into()), + rollout_path: Some(format!("/owning/codex/sessions/rollout-{id}.jsonl")), + persistence_evidence: Some("verified header".into()), + }, + evidence: NativeEvidenceKind::PersistedMetadata, + persistence, + }, + }; + for (id, persistence) in [ + ("old-native", NativePersistence::Verified), + ("new-native", NativePersistence::Prospective), + ] { + proxy + .fold_hosted_codex_naming( + "same-opaque-ref", + id, + serde_json::to_value(acquisition(id, persistence)).unwrap(), + ) + .await; + } + assert!( + names.get(vec![new.clone()]).await.unwrap().is_empty(), + "stale or prospective facts cannot admit a name" + ); + let fact = + serde_json::to_value(acquisition("new-native", NativePersistence::Verified)).unwrap(); + proxy + .fold_hosted_codex_naming("same-opaque-ref", "new-native", fact.clone()) + .await; + let admitted = names.get(vec![new.clone()]).await.unwrap(); + assert_eq!( + admitted.len(), + 1, + "a verified new conversation must have its own saved name" + ); + assert_eq!(admitted[0].record.name_ref, new); + assert_ne!(admitted[0].record.name, old_record.name); + assert!(names.current_location_revision(&new).await.unwrap() > 0); + assert_eq!(names.get(vec![old]).await.unwrap()[0].record, old_record); + // The accepted input can now upgrade this record without transferring + // the old conversation's protected name or redirecting either native ref. + names + .activity(NameActivity { + target: new.clone(), + mode: "freshcodex".into(), + event_id: "recovery-input".into(), + reason: NameActivityReason::AcceptedUserMessage, + first_user_message: Some("New recovery subject".into()), + cwd: Some("/work/recovery".into()), + }) + .await + .unwrap(); + assert_eq!( + names.get(vec![new]).await.unwrap()[0].record.name, + "New recovery subject" + ); + let manual = names + .rename(RenameNameInput { + target: target("new-native"), + name: "New protected manual name".into(), + intent: NameIntent::User, + if_revision: None, + cwd: None, + }) + .await + .unwrap(); + proxy + .fold_hosted_codex_naming("same-opaque-ref", "new-native", fact) + .await; + assert_eq!( + names.get(vec![target("new-native")]).await.unwrap()[0].record, + manual.record, + "replayed acquisition must preserve the new conversation's own manual name" + ); + let location_revision = names + .current_location_revision(&target("new-native")) + .await + .unwrap(); + let mut stale_location = + serde_json::to_value(acquisition("new-native", NativePersistence::Verified)).unwrap(); + stale_location["data"]["acquisition"]["location"]["rolloutPath"] = + serde_json::json!("/changed-during-admission.jsonl"); + switch_after.store(2, Ordering::SeqCst); + proxy + .fold_hosted_codex_naming("same-opaque-ref", "new-native", stale_location) + .await; + assert_eq!( + names + .current_location_revision(&target("new-native")) + .await + .unwrap(), + location_revision, + "a host source change across admission cannot authorize a new native location" + ); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); +} + +#[tokio::test] +async fn hosted_native_name_reads_pin_the_source_and_preserve_typed_delivery_failures() { + use crate::session_name_native::{NativeCallResult, NativeNameTarget}; + use freshell_runtime_protocol::{ + FreshAgentNativeNameError as Error, FreshAgentNativeNameReply as Reply, + }; + let dir = tempfile::tempdir().unwrap(); + for scenario in [ + "ok", + "missing", + "source-changes", + "unavailable", + "unsupported", + "refused", + "timeout", + "closed", + "transport", + "invalid-response", + ] { + let socket = dir.path().join(format!("native-name-{scenario}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let seen = calls.clone(); + let server = tokio::spawn(async move { + let mut inventories = 0; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }, + AdminCommand::Inventory => { + inventories += 1; + AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"name-soul","incarnationId":"name-incarnation","hostBootId":if scenario=="source-changes"&&inventories>1 {"replaced-boot"}else{"name-boot"}, + "launchState":"running","cleanupState":"none","intentRevision":3,"executionGeneration":2, + "desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated", + "evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex", + "nativeSessionId":if scenario=="missing" {"foreign-native"}else{"name-native"},"freshAgentSessionId":"public-name-session","freshAgentSessionType":"freshcodex" + })).unwrap()]) + } + AdminCommand::FreshAgentNativeName(request) => { + assert_eq!(request.soul_id.as_str(), "name-soul"); + assert_eq!(request.codex_home, "/owning/codex"); + assert_eq!(request.source.native_session_id, "name-native"); + assert_eq!(request.source.incarnation_id.as_str(), "name-incarnation"); + assert_eq!(request.source.host_boot_id.as_str(), "name-boot"); + assert_eq!( + ( + request.source.execution_generation, + request.source.intent_revision + ), + (2, 3) + ); + assert_eq!(request.expected_control_epoch, Some(7)); + assert_eq!( + request.operation, + freshell_runtime_protocol::FreshAgentNativeNameOperation::Read + ); + seen.lock().await.push(request); + AdminResult::FreshAgentNativeName(match scenario { + "ok" | "source-changes" => Reply::Title(Some("Native title".into())), + "unavailable" => Reply::Failed(Error::Unavailable { + message: "current connection absent".into(), + }), + "unsupported" => Reply::Failed(Error::Rpc { + code: -32601, + message: "method unavailable".into(), + }), + "refused" => Reply::Failed(Error::Rpc { + code: -32602, + message: "invalid name input".into(), + }), + "timeout" => Reply::Failed(Error::Timeout { timeout_ms: 20_000 }), + "closed" => Reply::Failed(Error::Closed), + "transport" => Reply::Failed(Error::Transport { + message: "send refused".into(), + }), + "invalid-response" => Reply::Failed(Error::InvalidResponse { + detail: "malformed metadata".into(), + }), + _ => unreachable!(), + }) + } + other => panic!("native naming attempted unrelated operation: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let backend = snapshot_outage_proxy(&socket).native_name_backend(); + let reply = backend + .read(NativeNameTarget { + name_ref: freshell_protocol::session_names::SessionNameRef::Session { + provider: NamedProvider::Codex, + session_id: "name-native".into(), + }, + location: freshell_protocol::native_location::NativeLocation::Codex { + codex_home: "/owning/codex".into(), + native_thread_id: Some("name-native".into()), + rollout_path: None, + persistence_evidence: None, + }, + location_revision: 1, + }) + .await; + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); + match scenario { + "ok" => assert!( + matches!(reply,NativeCallResult::Confirmed(value) if value.title.as_deref()==Some("Native title")) + ), + "missing" | "unavailable" | "refused" | "transport" => assert!( + matches!(reply, NativeCallResult::Undelivered(_)), + "{scenario}: {reply:?}" + ), + "unsupported" => assert!(matches!(reply, NativeCallResult::Unsupported(_))), + _ => assert!( + matches!(reply, NativeCallResult::Ambiguous(_)), + "{scenario}: {reply:?}" + ), + } + assert_eq!(calls.lock().await.len(), usize::from(scenario != "missing")); + } +} + +#[tokio::test] +async fn codex_model_catalog_routes_only_to_current_root_and_discards_changed_sources() { + use tower::ServiceExt; + let mut env = crate::test_env_lock::TestEnv::lock().await; + let dir = tempfile::tempdir().unwrap(); + isolate_snapshot_env(&mut env, dir.path()); + for scenario in ["same", "incarnation", "native", "boot", "generation"] { + let socket = dir.path().join(format!("catalog-{scenario}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let calls = Arc::new(Mutex::new(Vec::::new())); + let seen = calls.clone(); + let server = tokio::spawn(async move { + let mut inventories = 0; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body["method"].as_str().unwrap() { + "health" => serde_json::to_value(AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }) + .unwrap(), + "inventory" => { + inventories += 1; + let changed = inventories >= 2; + serde_json::json!({"kind":"inventory","data":[{ + "soulId":"current-root-soul","incarnationId":if changed && scenario=="incarnation" {"replacement"} else {"original"}, + "hostBootId":if changed && scenario=="boot" {"new-boot"} else {"original-boot"}, + "launchState":"running","cleanupState":"none","intentRevision":1, + "executionGeneration":if changed && scenario=="generation" {2} else {1}, + "desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated", + "evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex", + "nativeSessionId":if changed && scenario=="native" {"new-native"} else {"current-native"}, + "freshAgentSessionId":"public-conversation","freshAgentSessionType":"freshcodex" + }]}) + } + "fresh_agent_model_catalog" => { + seen.lock().await.push(envelope.body["params"].clone()); + serde_json::json!({"kind":"fresh_agent_model_catalog","data":[{ + "id":"managed-current","displayName":"Managed Current","supportedEffortLevels":["ultra"],"defaultEffort":"ultra","inputModalities":["text","image"] + }]}) + } + other => panic!("catalog attempted unrelated operation: {other}"), + }; + write_frame( + &mut stream, + &serde_json::json!({"requestId":envelope.request_id,"result":{"Ok":result}}), + ) + .await + .unwrap(); + } + }); + let (broadcast, _) = broadcast::channel(16); + let owner = + freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), Arc::new(broadcast)); + owner + .set_hosted_rest_gateway(snapshot_outage_proxy(&socket)) + .unwrap(); + let app = freshell_freshagent::model_capabilities::router(owner); + let response = app.oneshot(axum::http::Request::builder().uri("/api/fresh-agent/model-capabilities/freshcodex?sessionId=public-conversation&cwd=/managed/project&soulId=stale-frontend-soul").header("x-auth-token","tok").body(axum::body::Body::empty()).unwrap()).await.unwrap(); + let status = response.status(); + let body: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + server.abort(); + assert!(server.await.unwrap_err().is_cancelled()); + if scenario == "same" { + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + assert_eq!(body["models"][0]["id"], "managed-current"); + assert_eq!( + body["models"][0]["supportedEffortLevels"], + serde_json::json!(["ultra"]) + ); + } else { + assert_eq!( + status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "{scenario}: {body}" + ); + assert_eq!(body["models"], serde_json::json!([])); + } + let calls = calls.lock().await; + assert_eq!(calls.len(), 1, "only the current host is queried"); + assert_eq!(calls[0]["soulId"], "current-root-soul"); + assert_eq!(calls[0]["cwd"], "/managed/project"); + assert_eq!(calls[0]["expectedControlEpoch"], 7); + } +} + +#[tokio::test] +async fn child_route_reads_only_the_parent_provider_volume_and_fences_replaced_sources_and_ephemeral_history( +) { + use tower::ServiceExt; + let mut env = crate::test_env_lock::TestEnv::lock().await; + let dir = tempfile::tempdir().unwrap(); + isolate_snapshot_env(&mut env, dir.path()); + let local = dir + .path() + .join("configured-codex/sessions/rollout-child-native.jsonl"); + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + let gateway_history = "{\"type\":\"session_meta\",\"payload\":{\"id\":\"child-native\",\"cwd\":\"/gateway\"}}\n{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Wrong gateway child\"}]}}\n"; + std::fs::write(&local, gateway_history).unwrap(); + for scenario in [ + "saved", + "live", + "ephemeral", + "replaced-owner", + "wrong-child", + "unrelated", + ] { + let socket = dir.path().join(format!("child-{scenario}.sock")); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tokio::spawn(async move { + let mut inventories = 0; + let mut methods = vec![]; + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let mut done = false; + let result = match envelope.body { + AdminCommand::Health => Ok(AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }), + AdminCommand::Inventory => { + inventories += 1; + done = inventories == 2; + Ok(AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"parent-root-soul","incarnationId":if scenario == "replaced-owner" && inventories == 2 {"new-owner"} else {"original-owner"}, + "launchState":"running","cleanupState":"none","intentRevision":1,"executionGeneration":1,"desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated","evidenceRevision":0,"successfulRecoveriesInWindow":0, + "provider":"codex","nativeSessionId":"parent-native","freshAgentSessionId":"parent-alias","freshAgentSessionType":"freshcodex" + })).unwrap()])) + } + AdminCommand::FreshAgentReadChild(request) => { + methods.push("read_child"); + assert_eq!(request.soul_id.as_str(), "parent-root-soul"); + assert_eq!(request.child_thread_id, "child-native"); + assert_eq!(request.expected_control_epoch, Some(7)); + if scenario == "unrelated" { + done = true; + Err(freshell_runtime_protocol::RuntimeError::new( + RuntimeErrorCode::InvalidRequest, + "child is unrelated", + )) + } else { + Ok(AdminResult::FreshAgentChildSnapshot( + if scenario == "ephemeral" { + serde_json::json!({"parentThreadId":"parent-native","childThreadId":"child-native","status":"unavailable","reason":"ephemeral_history_unavailable","retryable":false}) + } else { + serde_json::json!({"parentThreadId":"parent-native","childThreadId":if scenario == "wrong-child" {"foreign-child"} else {"child-native"},"status":if scenario == "live" {"live"} else {"saved"},"snapshot":{"threadId":"child-native","provider":"codex","sessionType":"freshcodex","turns":[{"text":"Provider volume child"}],"capabilities":{"send":true,"interrupt":true,"fork":true,"undo":true},"pendingApprovals":[],"pendingQuestions":[],"extensions":{}}}) + }, + )) + } + } + other => panic!("observer attempted an unexpected root operation: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result, + }, + ) + .await + .unwrap(); + if done { + return methods; + } + } + }); + let (broadcast, _) = broadcast::channel(16); + let broadcast = Arc::new(broadcast); + let owner = + freshell_freshagent::FreshAgentState::new(Arc::new("tok".into()), broadcast.clone()); + owner + .set_hosted_rest_gateway(snapshot_outage_proxy(&socket)) + .unwrap(); + let app = freshell_freshagent::snapshot::router( + freshell_freshagent::snapshot::SnapshotState::new( + Arc::new("tok".into()), + freshell_freshagent::FreshCodexState::new( + Arc::new("tok".into()), + broadcast.clone(), + serde_json::json!({}), + ), + owner, + freshell_freshagent::FreshClaudeState::new(broadcast), + ), + ); + let response = app.oneshot(axum::http::Request::builder().uri("/api/fresh-agent/threads/freshcodex/codex/parent-alias/children/child-native").header("x-auth-token","tok").body(axum::body::Body::empty()).unwrap()).await.unwrap(); + assert_eq!(response.status(), axum::http::StatusCode::OK); + let body: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(response.into_body(), 32 * 1024 * 1024) + .await + .unwrap(), + ) + .unwrap(); + let methods = tokio::time::timeout(Duration::from_secs(3), server) + .await + .unwrap() + .unwrap(); + assert_eq!(methods, vec!["read_child"]); + if matches!(scenario, "saved" | "live") { + assert_eq!( + body["snapshot"]["turns"][0]["text"], + "Provider volume child" + ); + assert_eq!(body["parentThreadId"], "parent-native"); + assert_eq!(body["status"], scenario); + assert_eq!(body["snapshot"]["capabilities"]["send"], false); + assert_eq!(body["snapshot"]["capabilities"]["fork"], false); + } else if scenario == "ephemeral" { + assert_eq!(body["parentThreadId"], "parent-native"); + assert_eq!(body["childThreadId"], "child-native"); + assert_eq!(body["status"], "unavailable"); + assert_eq!(body["reason"], "ephemeral_history_unavailable"); + assert_eq!(body["retryable"], false); + assert!(body.get("snapshot").is_none()); + } else { + assert_eq!(body["reason"], "source_unavailable"); + assert!(body.get("snapshot").is_none()); + } + assert!(!body.to_string().contains("Wrong gateway child")); + } + assert_eq!(std::fs::read_to_string(local).unwrap(), gateway_history); +} + #[test] fn managed_provider_fresh_claude_retains_validated_mcp_recipe() { let args = vec!["--mcp-config".into(), ".claude/freshell-mcp.json".into()]; @@ -33,6 +576,7 @@ fn managed_provider_fresh_codex_retains_terminal_and_native_sidecar_arguments() let args = crate::managed_provider_bootstrap::managed_codex_mcp_args().unwrap(); let context = freshell_runtime_protocol::ProviderLaunchContext { preparation: freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: None, tui_args: args.clone(), sidecar_args: args.clone(), }, @@ -44,6 +588,7 @@ fn managed_provider_fresh_codex_retains_terminal_and_native_sidecar_arguments() let freshell_runtime_protocol::ProviderPreparation::Codex { tui_args, sidecar_args, + .. } = context.preparation else { panic!("Codex preparation missing") @@ -58,8 +603,8 @@ fn managed_provider_fresh_codex_retains_terminal_and_native_sidecar_arguments() ); } -#[test] -fn hosted_created_frame_uses_the_public_request_identity() { +#[tokio::test] +async fn hosted_created_frame_uses_the_public_request_identity() { let (broadcast, mut receiver) = broadcast::channel(8); let proxy = HostedFreshAgentProxy { client: RuntimeClient::new("/tmp/unopened-fresh-agent-proxy.sock", "fixture"), @@ -72,16 +617,18 @@ fn hosted_created_frame_uses_the_public_request_identity() { checkpoint_barrier: OnceLock::new(), ownership: OnceLock::new(), }; - proxy.forward_host_event( - AgentEvent::Provider { - payload: serde_json::json!({ - "type":"freshAgent.created", "requestId":"host-internal", "sessionId":"native" - }), - }, - "claude", - "public", - "freshclaude", - ); + proxy + .forward_host_event( + AgentEvent::Provider { + payload: serde_json::json!({ + "type":"freshAgent.created", "requestId":"host-internal", "sessionId":"native" + }), + }, + "claude", + "public", + "freshclaude", + ) + .await; assert!( receiver.try_recv().is_err(), "internal create ID must not reach the client" @@ -155,6 +702,149 @@ fn configure_proxy( (proxy, receiver) } +async fn hosted_codex_preparation_refusal_case(rest: bool, operational: bool) { + let mut environment = crate::test_env_lock::TestEnv::lock().await; + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let workspace = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&workspace).unwrap(); + let source = home.join(".codex/config.toml"); + std::fs::write( + &source, + if operational { + "approval_policy = 'never'\n" + } else { + "profile = 'legacy'\n" + }, + ) + .unwrap(); + environment.set("HOME", &home); + environment.set("CODEX_HOME", home.join(".codex")); + let socket = owned.path().join("supervisor.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let calls = Arc::new(Mutex::new(Vec::new())); + let observed = calls.clone(); + let (stop, mut stopping) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + loop { + let accepted = tokio::select! { _ = &mut stopping => break, accepted = listener.accept() => accepted }; + let (mut stream, _) = accepted.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + observed.lock().await.push(request.body.clone()); + let result = match request.body { + AdminCommand::Health => Ok(AdminResult::Health { control_epoch: 1, installation_id: InstallationId::new() }), + AdminCommand::ReserveProviderInputs(_input) if operational => { + Err(freshell_runtime_protocol::RuntimeError::new(RuntimeErrorCode::BackendUnavailable, "owned supervisor unavailable")) + } + AdminCommand::ReserveProviderInputs(input) => Ok(AdminResult::ProviderInputReservation(serde_json::from_value(serde_json::json!({ + "reservationId":"owned-config-reservation", "installationId":InstallationId::new(), + "soulId":input.soul_id,"provider":input.provider,"creationSeedRef":input.creation_seed_ref, + "intentRevision":0,"providerVolumeName":"owned-provider-volume","imageRef":"fixture-image", + "hostBinaryPath":"/opt/freshell-session-host","hostBinarySha256":"a".repeat(64), + "observation":{"kind":"absent","daemonId":"owned-test-daemon"} + })).unwrap())), + other => panic!("configuration refusal must not launch or mutate runtime ownership: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result, + }, + ) + .await + .unwrap(); + } + }); + let (proxy, mut receiver) = configure_proxy(&socket, HashMap::new()); + let request_id = "codex-configuration-refusal"; + if rest { + let error = proxy + .clone() + .create_agent(RestLaneCreate { + request_id: request_id.into(), + provider: "codex".into(), + session_type: "freshcodex".into(), + cwd: Some(workspace.to_str().unwrap().into()), + model: Some("original-model".into()), + effort: Some(Some("high".into())), + permission_mode: Some("untrusted".into()), + ..Default::default() + }) + .await + .expect_err("unsupported native source must refuse before launch"); + assert_eq!( + error.kind, + freshell_freshagent::rest_lane::RestLaneErrorKind::BadRequest + ); + assert_eq!(error.code, "INVALID_REQUEST"); + assert_eq!(error.retryable, Some(false)); + assert!( + error.message.contains("profile") && error.message.contains(source.to_str().unwrap()), + "{}", + error.message + ); + } else { + let message = serde_json::from_value(serde_json::json!({ + "requestId":request_id,"sessionType":"freshcodex","provider":"codex", "cwd":workspace, + "model":"original-model","effort":"high","permissionMode":"untrusted","sandbox":"read-only" + })).unwrap(); + proxy + .clone() + .handle(HostedFreshAgentCommand::Create(message)) + .await; + let frame: serde_json::Value = serde_json::from_str(&receiver.try_recv().unwrap()).unwrap(); + assert_eq!(frame["type"], "freshAgent.create.failed"); + assert_eq!(frame["requestId"], request_id); + if operational { + assert_ne!( + frame["code"], "INVALID_REQUEST", + "a supervisor outage is not invalid user configuration" + ); + assert_eq!(frame["retryable"], true); + assert!(frame["message"] + .as_str() + .unwrap() + .contains("owned supervisor unavailable")); + } else { + assert_eq!(frame["code"], "INVALID_REQUEST"); + assert_eq!(frame["retryable"], false); + let message = frame["message"].as_str().unwrap(); + assert!( + message.contains("profile") && message.contains(source.to_str().unwrap()), + "{message}" + ); + } + assert!( + receiver.try_recv().is_err(), + "one initiating-request failure, no created event" + ); + } + stop.send(()).unwrap(); + settled(server).await; + assert!(!calls + .lock() + .await + .iter() + .any(|command| matches!(command, AdminCommand::Launch(_)))); + assert!(proxy.aliases.lock().await.is_empty()); + assert!(proxy.pollers.lock().await.is_empty()); +} + +#[tokio::test] +async fn hosted_codex_preparation_refusal_reaches_the_initiating_request() { + hosted_codex_preparation_refusal_case(false, false).await; +} +#[tokio::test] +async fn hosted_codex_preparation_refusal_keeps_rest_configuration_diagnostics() { + hosted_codex_preparation_refusal_case(true, false).await; +} +#[tokio::test] +async fn hosted_codex_preparation_refusal_distinguishes_control_outage() { + hosted_codex_preparation_refusal_case(false, true).await; +} + /// The fake supervisor must have served every expected request by now; a /// proxy that never called it fails here instead of hanging the suite. async fn settled(server: tokio::task::JoinHandle) -> T { @@ -327,9 +1017,10 @@ async fn hosted_configure_calls_the_runtime_with_the_mapped_settings() { assert_eq!( configure.settings, freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: None, cwd: Some("/workspace".into()), model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("acceptEdits".into()), sandbox: Some("workspace-write".into()), } @@ -1446,7 +2137,8 @@ async fn real_gateway_outage_preserves_exact_cold_native_history_without_live_au ] { assert_eq!( snapshot_route_value(&app, kind, provider, id).await.0, - axum::http::StatusCode::SERVICE_UNAVAILABLE + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "{kind}/{id} must have unavailable exact native source" ); } // Exact-looking names cannot substitute for a readable, matching native source. @@ -1454,7 +2146,7 @@ async fn real_gateway_outage_preserves_exact_cold_native_history_without_live_au std::fs::write(&wrong_codex, std::fs::read(&codex_path).unwrap()).unwrap(); let unreadable_claude = claude_path.with_file_name("55555555-5555-4555-8555-555555555555.jsonl"); - std::fs::write(&unreadable_claude, [0xff, 0xfe]).unwrap(); + std::fs::write(&unreadable_claude, [0xff, 0xfe, b'\n']).unwrap(); for (kind, provider, id) in [ ("freshcodex", "codex", "wrong-native"), ( @@ -1465,7 +2157,8 @@ async fn real_gateway_outage_preserves_exact_cold_native_history_without_live_au ] { assert_eq!( snapshot_route_value(&app, kind, provider, id).await.0, - axum::http::StatusCode::SERVICE_UNAVAILABLE + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "{kind}/{id} must have unavailable exact native source" ); } for (path, (bytes, modified)) in paths.iter().zip(before) { @@ -1827,6 +2520,7 @@ async fn hosted_configure_reaches_the_owning_host_through_the_real_supervisor() effective_limits: limits, }, HostCommand::FreshAgentConfigure { .. } => HostResult::FreshAgentCommand { + native_identity: None, state: freshell_runtime_protocol::CommandState::ProviderAcked, native_session_id: None, }, @@ -1914,7 +2608,7 @@ async fn hosted_configure_reaches_the_owning_host_through_the_real_supervisor() assert_eq!(incarnation_id, &prepared.incarnation_id); assert_eq!(request_id.as_str(), "configure-through-supervisor"); assert_eq!(settings.model.as_deref(), Some("opus")); - assert_eq!(settings.effort.as_deref(), Some("high")); + assert_eq!(settings.effort, Some(Some("high".into()))); let recovered = registry .recovery_context(soul) .await @@ -1925,3 +2619,366 @@ async fn hosted_configure_reaches_the_owning_host_through_the_real_supervisor() assert_eq!(recovered.effort.as_deref(), Some("high")); assert_eq!(recovered.permission_mode.as_deref(), Some("default")); } + +#[tokio::test] +async fn task5_hosted_elicitation_for_other_providers_is_explicitly_unsupported() { + let dir = tempfile::tempdir().unwrap(); + let (proxy, mut receiver) = configure_proxy( + &dir.path().join("absent-host.sock"), + HashMap::from([( + ("claude".into(), "claude-thread".into()), + SoulId::parse("claude-soul").unwrap(), + )]), + ); + proxy.handle(HostedFreshAgentCommand::Elicitation(serde_json::from_value(serde_json::json!({"provider":"claude","sessionType":"freshclaude","sessionId":"claude-thread","requestId":"41","action":"decline","content":null})).unwrap())).await; + let frame: serde_json::Value = serde_json::from_str(&receiver.try_recv().unwrap()).unwrap(); + assert_eq!(frame["event"]["code"], "UNSUPPORTED_CAPABILITY"); + assert!(frame["event"]["message"] + .as_str() + .unwrap() + .contains("elicitation")); +} + +#[tokio::test] +async fn task5_hosted_elicitation_reuses_resolve_with_tagged_typed_content_and_opaque_ids() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("elicitation.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let server = tokio::spawn(async move { + let mut ids = Vec::new(); + while ids.len() < 2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 77, + installation_id: InstallationId::new(), + }, + AdminCommand::FreshAgentResolve(resolve) => { + assert_eq!(resolve.soul_id.as_str(), "codex-soul"); + assert_eq!(resolve.expected_control_epoch, Some(77)); + assert_eq!( + resolve.decision, + serde_json::json!({"elicitation":{"action":"accept","content":{"count":3,"enabled":false}}}) + ); + ids.push(resolve.decision_id); + AdminResult::FreshAgentCommand { + state: freshell_runtime_protocol::CommandState::ProviderAcked, + } + } + other => panic!("unexpected control opcode: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + ids + }); + let (proxy, mut receiver) = configure_proxy( + &socket, + HashMap::from([( + ("codex".into(), "public-thread".into()), + SoulId::parse("codex-soul").unwrap(), + )]), + ); + for id in ["41", "\"41\""] { + proxy.clone().handle(HostedFreshAgentCommand::Elicitation(serde_json::from_value(serde_json::json!({"provider":"codex","sessionType":"freshcodex","sessionId":"public-thread","requestId":id,"action":"accept","content":{"count":3,"enabled":false}})).unwrap())).await; + } + assert_eq!(settled(server).await, vec!["41", "\"41\""]); + assert!(receiver.try_recv().is_err()); +} + +async fn task6_gateway_source_scenario(scenario: &'static str) { + let replaced = scenario != "current" && scenario != "ready-current"; + let ready = scenario.starts_with("ready-"); + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("retained-gateway.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let read_started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let server_read_started = read_started.clone(); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body { + AdminCommand::Health => AdminResult::Health { control_epoch:7, installation_id:InstallationId::new() }, + AdminCommand::Inventory => AdminResult::Inventory(vec![serde_json::from_value(serde_json::json!({ + "soulId":"retained-soul", "incarnationId":if scenario == "before-incarnation" || matches!(scenario,"after-incarnation" | "ready-after-incarnation") && server_read_started.load(std::sync::atomic::Ordering::SeqCst) {"replacement-incarnation"} else {"original-incarnation"}, + "hostBootId":if scenario == "after-boot" && server_read_started.load(std::sync::atomic::Ordering::SeqCst) {"replacement-boot"} else {"original-boot"}, "launchState":"running","cleanupState":"none","intentRevision":1,"executionGeneration":1,"desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated","evidenceRevision":0,"successfulRecoveriesInWindow":0, + "provider":"codex","nativeSessionId":if scenario == "before-native" {"replacement-native"} else {"native-owned"},"freshAgentSessionId":"public-owned","freshAgentSessionType":"freshcodex" + })).unwrap()]), + AdminCommand::FreshAgentReadEvents(_) => { + server_read_started.store(true,std::sync::atomic::Ordering::SeqCst); + AdminResult::FreshAgentEvents(serde_json::from_value(serde_json::json!({ + "retainedFrom":10,"head":10,"resetRequired":true, + "retainedState":{"source":{"soulId":"retained-soul","incarnationId":"original-incarnation","hostBootId":"original-boot","projectionId":"source-projection","ownerEpoch":0,"ownerGeneration":0}, + "snapshot":{"provider":"codex","sessionType":"freshcodex","threadId":"native-owned","turns":[{"text":"Latest retained body"}]}, + "attentionMarks":[{"provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","lastTurnCompleteAt":100}],"pendingControls":[]}, + "events":if replaced {serde_json::json!([{"sequence":10,"event":{"kind":"provider","data":{"payload":{"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.notice","message":"stale native source"}}}}}])} else {serde_json::json!([])} + })).unwrap()) + } + other => panic!("recovery must read retained source, never native history or resume: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let proxy = snapshot_outage_proxy(&socket); + if ready { + let marks = proxy.attention_marks().await; + server.abort(); + let _ = server.await; + if replaced { + assert!(marks.is_empty(), "ready must reject a replaced source"); + } else { + assert_eq!(marks.len(), 1); + assert_eq!(marks[0].session_id, "public-owned"); + assert_eq!(marks[0].last_turn_complete_at, Some(100)); + } + return; + } + let mut frames = proxy.broadcast.subscribe(); + proxy + .start_poller( + SoulId::parse("retained-soul").unwrap(), + "codex".into(), + "public-owned".into(), + "freshcodex".into(), + ) + .await; + let first = tokio::time::timeout(Duration::from_millis(750), async { + loop { + let value: serde_json::Value = + serde_json::from_str(&frames.recv().await.unwrap()).unwrap(); + if value["event"]["type"] != "freshAgent.host.journal.reset" || !replaced { + break value; + } + } + }) + .await; + server.abort(); + let _ = server.await; + assert!( + read_started.load(std::sync::atomic::Ordering::SeqCst), + "actual controller event read must run" + ); + if replaced { + assert!( + first.is_err(), + "a source replaced during the read must not forward its old events: {first:?}" + ); + } else { + let first = + first.expect("journal reset must independently deliver retained provider state"); + assert_eq!(first["sessionId"], "public-owned"); + assert_eq!( + first["event"]["retainedState"]["snapshot"]["turns"][0]["text"], "Latest retained body", + "gateway must apply the authoritative retained source immediately: {first}" + ); + } +} + +#[tokio::test] +async fn task6_gateway_applies_authoritative_source_on_journal_reset() { + task6_gateway_source_scenario("current").await; +} +#[tokio::test] +async fn task6_gateway_rechecks_source_after_controller_read() { + task6_gateway_source_scenario("after-incarnation").await; +} + +#[tokio::test] +async fn task6_gateway_rejects_an_old_source_at_admission() { + task6_gateway_source_scenario("before-incarnation").await; +} +#[tokio::test] +async fn task6_gateway_rechecks_host_boot_after_controller_read() { + task6_gateway_source_scenario("after-boot").await; +} +#[tokio::test] +async fn task6_gateway_rejects_mismatched_native_identity() { + task6_gateway_source_scenario("before-native").await; +} +#[tokio::test] +async fn task6_gateway_ready_uses_current_provider_retained_attention() { + task6_gateway_source_scenario("ready-current").await; +} +#[tokio::test] +async fn task6_gateway_ready_rejects_a_replaced_provider_source() { + task6_gateway_source_scenario("ready-after-incarnation").await; +} + +async fn task6_gateway_recovery_receipts( + replay: bool, + replace_on_continuation: bool, + replace_native: bool, +) { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("recovery-receipts.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let pages = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let server_pages = pages.clone(); + let receipts = vec![ + serde_json::json!({"type":"freshAgent.send.accepted","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","requestId":"accepted-answer","submittedTurnId":"native-turn"}), + serde_json::json!({"type":"freshAgent.send.accepted","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","requestId":"accepted-ordinary","submittedTurnId":"ordinary-turn"}), + serde_json::json!({"type":"error","code":"INTERNAL_ERROR","message":"CODEX_TURN_START_FAILED: native input refused","timestamp":"2026-10-26T00:00:00.000Z","requestId":"refused-answer"}), + serde_json::json!({"type":"error","code":"INTERNAL_ERROR","message":"CODEX_DELIVERY_AMBIGUOUS: native response was lost","timestamp":"2026-10-26T00:00:00.000Z","requestId":"ambiguous-answer"}), + serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"checkpointTurnIdentity","requestId":"accepted-answer","logicalAttemptId":"logical-owned","sessionId":"native-owned","nativeTurnId":"native-turn","displayTurnId":"native-turn:row-0"}}), + serde_json::json!({"type":"freshAgent.checkpoint.outcome","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","requestId":"accepted-answer","logicalAttemptId":"logical-owned","status":"available","checkpointId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","nativeTurnId":"native-turn","displayTurnId":"native-turn:row-0"}), + serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.rolledBack","requestId":"undo-owned","removedPromptText":"restore the exact prompt"}}), + serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.error","requestId":"undo-refused","code":"BUSY_TURN","message":"The agent is still working."}}), + ]; + let expected = receipts.clone(); + let server = tokio::spawn(async move { + let mut payloads = vec![ + AgentEvent::Provider { + payload: serde_json::json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"native-owned","event":{"type":"freshAgent.transcript.patch","text":"stale transcript must not replay"}}), + }, + AgentEvent::PermissionRequested { + decision_id: "stale-control".into(), + payload: serde_json::json!({"type":"freshAgent.question.request","requestId":"stale-control"}), + }, + ]; + payloads.extend( + receipts + .into_iter() + .map(|payload| AgentEvent::Provider { payload }), + ); + loop { + let (mut stream, _) = listener.accept().await.unwrap(); + let envelope: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match envelope.body { + AdminCommand::Health => AdminResult::Health { + control_epoch: 7, + installation_id: InstallationId::new(), + }, + AdminCommand::Inventory => { + let current: freshell_runtime_protocol::RuntimeView = serde_json::from_value(serde_json::json!({ + "soulId":"retained-soul","incarnationId":if replace_on_continuation && !replace_native && server_pages.load(std::sync::atomic::Ordering::SeqCst)>=2 {"replacement-incarnation"} else {"original-incarnation"},"hostBootId":"original-boot","launchState":"running","cleanupState":"none","intentRevision":1,"executionGeneration":1,"desiredState":"running","recoveryState":"live","durabilityState":"unknown","allocationState":"allocated","evidenceRevision":0,"successfulRecoveriesInWindow":0,"provider":"codex","nativeSessionId":if replace_on_continuation && replace_native && server_pages.load(std::sync::atomic::Ordering::SeqCst)>=2 {"replacement-native"} else {"native-owned"},"freshAgentSessionId":"public-owned","freshAgentSessionType":"freshcodex" + })).unwrap(); + // Real inventory retains stopped historical incarnations + // before the current running row for the same soul. + let mut retired = current.clone(); + retired.launch_state = freshell_runtime_protocol::LaunchState::Stopped; + retired.incarnation_id = + freshell_runtime_protocol::IncarnationId::parse("retired-incarnation") + .unwrap(); + retired.host_boot_id = + Some(freshell_runtime_protocol::HostBootId::parse("retired-boot").unwrap()); + AdminResult::Inventory(vec![retired, current]) + } + AdminCommand::FreshAgentReadEvents(request) => { + server_pages.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + AdminResult::FreshAgentEvents(freshell_runtime_protocol::AgentEventBatch { + retained_state:(request.after_sequence==0).then(||serde_json::json!({ + "source":{"soulId":"retained-soul","incarnationId":"original-incarnation","hostBootId":"original-boot","projectionId":"source-projection","ownerEpoch":0,"ownerGeneration":0}, + "snapshot":{"provider":"codex","sessionType":"freshcodex","threadId":"native-owned","turns":[]},"attentionMarks":[], + "pendingControls":[{"type":"freshAgent.question.request","requestId":"current-control"}] + })), + retained_from:1,head:payloads.len() as u64,reset_required:request.after_sequence==0, + // The controller's own bound can be smaller than the requested + // page. Receipts deliberately lie beyond the first page. + events:payloads.iter().enumerate().filter(|(index,_)|(*index as u64)+1>request.after_sequence).take(request.max_events.min(2) as usize).map(|(index,event)|freshell_runtime_protocol::AgentJournalEvent {sequence:index as u64+1,event:event.clone()}).collect(), + }) + } + other => panic!("recovery must only read events and source identity: {other:?}"), + }; + write_frame( + &mut stream, + &AdminReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + }); + let proxy = snapshot_outage_proxy(&socket); + let mut frames = proxy.broadcast.subscribe(); + let soul = SoulId::parse("retained-soul").unwrap(); + if replay { + proxy + .replay_once(soul, "codex", "public-owned", "freshcodex") + .await; + } else { + proxy + .start_poller( + soul, + "codex".into(), + "public-owned".into(), + "freshcodex".into(), + ) + .await; + } + let mut observed = Vec::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while observed.len() < expected.len() + 2 { + let Ok(Ok(frame)) = tokio::time::timeout_at(deadline, frames.recv()).await else { + break; + }; + observed.push(serde_json::from_str::(&frame).unwrap()); + } + server.abort(); + let _ = server.await; + assert!( + pages.load(std::sync::atomic::Ordering::SeqCst) >= 2, + "the real Unix controller must serve continuation pages" + ); + assert_eq!(observed.len(),if replace_on_continuation {2} else {expected.len()+2},"retained recovery must deliver every request receipt across bounded pages, only under the current owner: {observed:?}"); + assert_eq!( + observed[0]["event"]["retainedState"]["snapshot"]["threadId"], + "native-owned" + ); + assert_eq!(observed[1]["event"]["requestId"], "current-control"); + for (actual, mut expected) in observed.into_iter().skip(2).zip(expected) { + rewrite_presentation_id(&mut expected, "public-owned"); + assert_eq!( + actual, expected, + "keep exact request/attempt/native IDs and refusal/ambiguity reasons" + ); + } +} + +#[tokio::test] +async fn task6_gateway_poller_preserves_request_receipts_beyond_retained_first_page() { + task6_gateway_recovery_receipts(false, false, false).await; +} + +#[tokio::test] +async fn task6_gateway_replay_preserves_request_receipts_beyond_retained_first_page() { + task6_gateway_recovery_receipts(true, false, false).await; +} + +#[tokio::test] +async fn task6_gateway_poller_fences_receipts_after_source_changes_between_pages() { + task6_gateway_recovery_receipts(false, true, false).await; +} + +#[tokio::test] +async fn task6_gateway_replay_fences_receipts_after_source_changes_between_pages() { + task6_gateway_recovery_receipts(true, true, false).await; +} + +#[tokio::test] +async fn task6_gateway_poller_fences_receipts_after_native_changes_between_pages() { + task6_gateway_recovery_receipts(false, true, true).await; +} + +#[tokio::test] +async fn task6_gateway_replay_fences_receipts_after_native_changes_between_pages() { + task6_gateway_recovery_receipts(true, true, true).await; +} diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index 6bbd19ba53..92c2a1d171 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -332,6 +332,7 @@ impl PaneIdentitySink for LedgerIdentitySink { }; let stored_genesis = upsert.genesis.stored_value(); let w = FreshAgentBindingWrite { + collaboration_mode: upsert.settings.collaboration_mode.as_deref(), genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( stored_genesis.as_ref(), upsert.genesis.is_clear(), @@ -451,6 +452,7 @@ impl PaneIdentitySink for LedgerIdentitySink { return None; } let s = FreshAgentSettings { + collaboration_mode: row.collaboration_mode, model: row.model, sandbox: row.sandbox, permission_mode: row.permission_mode, @@ -824,6 +826,94 @@ mod tests { use freshell_freshagent::{FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink}; use std::sync::Arc; + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_complete_codex_defaults_survive_the_actual_ledger_restart() { + let root = tempfile::tempdir().unwrap(); + let settings = FreshAgentSettings { + model: Some("provider-current".into()), + effort: None, + sandbox: Some("read-only".into()), + permission_mode: Some("untrusted".into()), + cwd: Some("/workspace/project".into()), + collaboration_mode: Some("plan".into()), + plugins: None, + }; + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + root.path().into(), + ))); + let sink = LedgerIdentitySink::new(ledger.clone(), None); + sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, + name_transition: None, + provider: "codex".into(), + session_id: "settings-ledger".into(), + mode: "freshcodex".into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + provenance: freshell_freshagent::ProvenanceUpdate::Inherit, + observed_epoch: Some(11), + observed_generation: Some(3), + authoritative: false, + settings: settings.clone(), + }) + .await + .unwrap(); + assert_eq!( + sink.load_settings("codex", "settings-ledger"), + Some(settings.clone()) + ); + drop(sink); + drop(ledger); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + root.path().into(), + ))); + let recovered = LedgerIdentitySink::new(ledger.clone(), None); + assert_eq!( + recovered.load_settings("codex", "settings-ledger"), + Some(settings) + ); + let row = ledger.load_binding("codex", "settings-ledger").unwrap(); + assert_eq!((row.owner_epoch, row.owner_generation), (Some(11), Some(3))); + } + + #[tokio::test(flavor = "multi_thread")] + async fn task008_settings_a_work_mode_only_record_is_recoverable() { + let root = tempfile::tempdir().unwrap(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + root.path().into(), + ))); + let sink = LedgerIdentitySink::new(ledger, None); + sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, + name_transition: None, + provider: "codex".into(), + session_id: "mode-only".into(), + mode: "freshcodex".into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + provenance: freshell_freshagent::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: FreshAgentSettings { + collaboration_mode: Some("plan".into()), + ..Default::default() + }, + }) + .await + .unwrap(); + assert!(sink.was_recorded("codex", "mode-only")); + assert_eq!( + sink.load_settings("codex", "mode-only") + .unwrap() + .collaboration_mode + .as_deref(), + Some("plan") + ); + } + #[tokio::test(flavor = "multi_thread")] async fn writes_through_to_ledger_and_reads_back() { let tmp = tempfile::tempdir().unwrap(); @@ -846,6 +936,7 @@ mod tests { authoritative: false, settings: FreshAgentSettings { + collaboration_mode: None, model: Some("gpt-5.3-codex-spark".into()), sandbox: Some("workspace-write".into()), permission_mode: Some("on-request".into()), @@ -1243,6 +1334,7 @@ mod tests { .expect("seed write (codex)"); let settings = FreshAgentSettings { + collaboration_mode: None, model: None, sandbox: None, permission_mode: None, diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 373d603825..416a7fa9db 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1174,7 +1174,13 @@ async fn main() -> ExitCode { fresh_agent_state .set_rest_create_settings_reader(Arc::new(move || { let settings = Arc::clone(&rest_create_settings); - Box::pin(async move { settings.read().await.fresh_agent.clone() }) + Box::pin(async move { + let settings = settings.read().await; + freshell_freshagent::rest_lane::RestCreateSettings { + fresh_agent: settings.fresh_agent.clone(), + coding_cli: Some(settings.coding_cli.clone()), + } + }) })) .expect("REST create settings reader is installed once"); // The freshopencode WS fresh-agent slice: the post-handshake loop dispatches @@ -1463,6 +1469,12 @@ async fn main() -> ExitCode { > }, )); + #[cfg(feature = "managed-runtime-v1")] + let codex_adapter = codex_adapter.with_hosted_backend( + hosted_fresh_gateway + .as_ref() + .map(|gateway| gateway.native_name_backend()), + ); let opencode_adapter = Some(session_name_native::OpencodeNativeNameAdapter::new( agent_state.opencode_shared_handle(), )); @@ -1560,6 +1572,9 @@ async fn main() -> ExitCode { fresh_opencode_state.set_terminal_liveness(terminal_liveness); let fresh_claude_state = fresh_claude_state; let fresh_codex_state = fresh_codex_state; + fresh_agent_state + .set_codex_catalog_state(fresh_codex_state.clone()) + .expect("one shared registered Codex state"); let fresh_opencode_state = fresh_opencode_state; // kata b8ke Task 3: the bounded Starting-state timeout watchdog (5s @@ -2044,6 +2059,15 @@ async fn main() -> ExitCode { ), )) .expect("local Claude REST lane is installed once"); + let codex_rest_enabled = fresh_codex_state.clone(); + fresh_agent_state + .set_codex_rest_lane(Arc::new( + freshell_freshagent::codex_rest_lane::CodexRestLane::new( + fresh_codex_state.clone(), + Arc::new(move || codex_rest_enabled.is_enabled()), + ), + )) + .expect("local Codex REST lane is installed once"); // Lane D1: the crash-event channel for terminal auto-resume. The receiver // is consumed by `auto_resume::spawn_auto_resume_hub`, spawned right // after `ws_state` is assembled (the hub needs the full state). diff --git a/crates/freshell-server/src/managed_mcp_capability.rs b/crates/freshell-server/src/managed_mcp_capability.rs index 61e83bfad7..19a7200a1c 100644 --- a/crates/freshell-server/src/managed_mcp_capability.rs +++ b/crates/freshell-server/src/managed_mcp_capability.rs @@ -319,6 +319,8 @@ struct CapabilityCallback { workspace: Option, auth_lookup_authority: Option, claude_recovery_source: Option, + #[serde(default)] + codex_source_runtime: Option, cwd: Option, provider_secret_references: Option>, prior_context: Option, @@ -590,15 +592,52 @@ async fn apply_prepared_callback( provider, callback.prior_context.as_ref(), )? { - if let Some(plan) = ¤t_claude_plan { - confirm_claude_callback_candidate(&callback, store, directory, client, plan, &reply) + let mut replay = true; + if provider == "codex" { + use freshell_platform::codex_inputs::{CodexInputManifest, NativeSourceRuntime}; + let manifest: CodexInputManifest = serde_json::from_slice(&read_prior_manifest_bytes( + provider, + store, + directory, + &callback.soul_id, + &reply.provider_launch_context, + )?) + .map_err(|_| "pending Codex source manifest metadata invalid")?; + manifest.validate().map_err(str::to_string)?; + let current = callback.codex_source_runtime.unwrap_or_default(); + if manifest.native_source_runtime != current { + if current != NativeSourceRuntime::AppServer { + return Err("Codex current consumer cannot use the pending app-server preparation; retain inputs and reconcile".into()); + } + // A legacy candidate may still contain a public untrusted + // declaration. Reprepare its accepted selectors, never relabel + // copied bytes. Existing pending ownership fences own cleanup. + store.abort_preparation( + &reply + .provider_launch_context + .mcp_capability + .as_ref() + .ok_or("pending Codex capability unavailable")? + .grant_id, + &callback.soul_id, + directory, + )?; + replay = false; + } + } + if replay { + if let Some(plan) = ¤t_claude_plan { + confirm_claude_callback_candidate( + &callback, store, directory, client, plan, &reply, + ) .await?; + } + return freshell_runtime_protocol::bounded_json_bytes( + &reply, + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES, + ) + .map_err(|_| "replacement provider context exceeds control metadata profile".into()); } - return freshell_runtime_protocol::bounded_json_bytes( - &reply, - freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES, - ) - .map_err(|_| "replacement provider context exceeds control metadata profile".into()); } if matches!(provider, "codex" | "claude" | "amplifier") { let mut context = callback @@ -625,10 +664,13 @@ async fn apply_prepared_callback( } let origins = match provider { "codex" => { - let manifest: freshell_platform::codex_inputs::CodexInputManifest = + let mut manifest: freshell_platform::codex_inputs::CodexInputManifest = serde_json::from_slice(&bytes) .map_err(|_| "prior Codex source manifest metadata invalid")?; manifest.validate().map_err(str::to_string)?; + // Only the authenticated current consumer supplies this role. + // Accepted selectors and the old generation remain unchanged. + manifest.native_source_runtime = callback.codex_source_runtime.unwrap_or_default(); AcceptedOrigins::Codex(manifest) } "claude" => { @@ -2224,6 +2266,9 @@ fn stage_provider_root( )?; } if let Some(resources) = &ordinary_resources { + if provider == "codex" { + ordinary_stage::stage_codex_approval(&temporary, resources)?; + } if provider == "claude" { claude_stage::stage_settings( &temporary, @@ -2806,6 +2851,32 @@ impl CapabilityStore { .map_err(|_| "ordinary prepared inputs unavailable")?, ); context.config.clear(); + if provider == "codex" { + let descriptor = root.join(".freshell/codex-approval.json"); + if descriptor.exists() { + let bytes = source_stage::read_source_view( + root, + ".freshell/codex-approval.json", + )?; + let default: freshell_runtime_protocol::CodexApprovalDefault = + serde_json::from_str(&bytes).map_err(|_| { + "Codex approval_policy prepared intent unavailable" + })?; + if !default.validate() { + return Err( + "Codex approval_policy prepared source metadata is invalid" + .into(), + ); + } + if let freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default, + .. + } = &mut context.preparation + { + *approval_default = Some(default); + } + } + } } let result = publish_prepared_context_metadata( root, @@ -3729,6 +3800,84 @@ mod tests { } } + // A file-specific kernel read event works on noatime artifact volumes too. + // Other Unix targets retain their existing access-time observation. + #[cfg(unix)] + struct PayloadReadWatch { + #[cfg(target_os = "linux")] + notification: std::fs::File, + #[cfg(not(target_os = "linux"))] + path: PathBuf, + } + #[cfg(unix)] + impl PayloadReadWatch { + fn new(path: &Path) -> Self { + #[cfg(target_os = "linux")] + { + use std::os::{fd::FromRawFd, unix::ffi::OsStrExt}; + let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + assert!(fd >= 0, "{}", std::io::Error::last_os_error()); + let notification = unsafe { std::fs::File::from_raw_fd(fd) }; + let path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + assert!( + unsafe { libc::inotify_add_watch(fd, path.as_ptr(), libc::IN_ACCESS) } >= 0, + "{}", + std::io::Error::last_os_error() + ); + Self { notification } + } + #[cfg(not(target_os = "linux"))] + { + let result = Self { + path: path.to_owned(), + }; + result.reset_access_time(); + result + } + } + #[cfg(not(target_os = "linux"))] + fn reset_access_time(&self) { + std::fs::File::open(&self.path) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_accessed(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .unwrap(); + } + fn take_access(&self) -> bool { + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + let mut events = [0u8; 4096]; + let count = unsafe { + libc::read( + self.notification.as_raw_fd(), + events.as_mut_ptr().cast(), + events.len(), + ) + }; + if count < 0 { + assert_eq!( + std::io::Error::last_os_error().kind(), + std::io::ErrorKind::WouldBlock + ); + false + } else { + assert!(count > 0); + true + } + } + #[cfg(not(target_os = "linux"))] + { + let accessed = std::fs::metadata(&self.path).unwrap().accessed().unwrap() + != std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); + self.reset_access_time(); + accessed + } + } + } + #[test] fn opencode_reissue_omits_a_removed_discovered_source() { let owned = tempfile::tempdir().unwrap(); @@ -3992,22 +4141,16 @@ mod tests { .open(&path) .unwrap(); file.set_len(128 * 1024 + 1).unwrap(); - let old_access = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + let watch = PayloadReadWatch::new(&path); file.read_exact(&mut [0]).unwrap(); - assert_ne!( - file.metadata().unwrap().accessed().unwrap(), - old_access, - "fixture filesystem must observe payload reads" + assert!( + watch.take_access(), + "fixture must observe actual payload reads" ); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); drop(file); assert!(protected_preparation_ids(owned.path()).is_err()); - assert_eq!( - std::fs::metadata(path).unwrap().accessed().unwrap(), - old_access, + assert!( + !watch.take_access(), "oversized preparation inventory payload was read" ); } @@ -4283,19 +4426,12 @@ mod tests { .open(&path) .unwrap(); sparse.set_len(64 * 1024 * 1024 + 1).unwrap(); - let old_access = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); - sparse - .set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + let watch = PayloadReadWatch::new(&path); std::io::Read::read_exact(&mut sparse, &mut [0u8; 1]).unwrap(); - assert_ne!( - std::fs::metadata(&path).unwrap().accessed().unwrap(), - old_access, - "fixture filesystem must report actual payload reads" + assert!( + watch.take_access(), + "fixture must report actual payload reads" ); - sparse - .set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); drop(sparse); let pages: u64 = std::fs::read_to_string("/proc/self/statm") .unwrap() @@ -4312,9 +4448,8 @@ mod tests { println!("recovery_sparse_manifest_reader_ready"); assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_AS, &limit) }, 0); assert!(read_prior_manifest_bytes("codex", &store, &directory, &soul, &accepted).is_err()); - assert_eq!( - std::fs::metadata(&path).unwrap().accessed().unwrap(), - old_access, + assert!( + !watch.take_access(), "oversized manifest payload was read before its physical bound was rejected" ); assert!(store.grants.lock().unwrap().contains_key(&grant.grant_id)); @@ -6598,7 +6733,7 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ std::fs::write(stage.join("usable-resource"), b"pending-input-nonce").unwrap(); let path = grant_path(&directory, &pending.grant_id).unwrap(); let before = std::fs::read(&path).unwrap(); - let old_access = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1); + let mut payload_watch = None; match case { "missing" => std::fs::remove_file(&path).unwrap(), "malformed" => std::fs::write(&path, b"{\"preparation\":").unwrap(), @@ -6609,16 +6744,13 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ .open(&path) .unwrap(); file.set_len(128 * 1024 + 1).unwrap(); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + let watch = PayloadReadWatch::new(&path); file.read_exact(&mut [0]).unwrap(); - assert_ne!( - file.metadata().unwrap().accessed().unwrap(), - old_access, - "fixture filesystem must observe ownership payload reads" + assert!( + watch.take_access(), + "fixture must observe ownership payload reads" ); - file.set_times(std::fs::FileTimes::new().set_accessed(old_access)) - .unwrap(); + payload_watch = Some(watch); } "assigned_pending" | "unrecorded" => { let mut stored: serde_json::Value = serde_json::from_slice(&before).unwrap(); @@ -6661,7 +6793,8 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ std::fs::read(&path).is_ok_and(|bytes| bytes == b"{\"preparation\":") } "oversize" => std::fs::metadata(&path).is_ok_and(|metadata| { - metadata.len() == 128 * 1024 + 1 && metadata.accessed().unwrap() == old_access + metadata.len() == 128 * 1024 + 1 + && !payload_watch.as_ref().unwrap().take_access() }), "assigned_pending" | "unrecorded" => { std::fs::read(&path).is_ok_and(|bytes| Some(bytes) == modified) @@ -7800,6 +7933,7 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ mcp_capability: None, prepared_inputs: None, preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: mcp.clone(), sidecar_args: mcp, }, @@ -8130,6 +8264,7 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ mcp_capability: None, prepared_inputs: None, preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: mcp.clone(), sidecar_args: mcp, }, @@ -8410,221 +8545,755 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ } #[test] - fn managed_provider_codex_selected_inputs_reach_private_generation() { + fn prepared_codex_role_refusal_keeps_prior_grant_and_inputs() { let owned = tempfile::tempdir().unwrap(); let home = owned.path().join("home"); - let workspace = owned.path().join("workspace"); - std::fs::create_dir_all(home.join(".codex/agents/nested")).unwrap(); - std::fs::create_dir_all(workspace.join(".codex/skills/fixture/scripts")).unwrap(); - std::fs::create_dir(workspace.join(".git")).unwrap(); - let raw = format!("model_instructions_file = 'instructions.md'\n[projects.{}]\ntrust_level = 'trusted'\n[agents.reader]\nconfig_file = 'agents/nested/reader.toml'\n", serde_json::to_string(workspace.to_str().unwrap()).unwrap()); - std::fs::write(home.join(".codex/config.toml"), &raw).unwrap(); - std::fs::write(home.join(".codex/instructions.md"), "home-input").unwrap(); - std::fs::write( - home.join(".codex/agents/nested/reader.toml"), - "model_instructions_file = 'instructions.md'\n", - ) - .unwrap(); - std::fs::write( - home.join(".codex/agents/nested/instructions.md"), - "role-input", - ) - .unwrap(); - std::fs::write( - workspace.join(".codex/config.toml"), - "model_instructions_file = 'instructions.md'\n", - ) - .unwrap(); - std::fs::write(workspace.join(".codex/instructions.md"), "project-input").unwrap(); - std::fs::write( - workspace.join(".codex/skills/fixture/SKILL.md"), - "skill-resource", - ) - .unwrap(); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex/roles")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let config = + "approval_policy = 'never'\n[agents.reader]\nconfig_file = 'roles/reader.toml'\n"; + let role = home.join(".codex/roles/reader.toml"); + let valid_role = "approval_policy = 'on-request'\n"; + std::fs::write(home.join(".codex/config.toml"), config).unwrap(); + std::fs::write(&role, valid_role).unwrap(); let context = crate::managed_provider_bootstrap::provider_launch_context_from_home( - "codex", &workspace, &home, None, + "codex", &cwd, &home, None, ) .unwrap() .unwrap(); let directory = owned.path().join("control/mcp-capabilities"); std::fs::create_dir_all(&directory).unwrap(); let store = CapabilityStore::default(); - let capability = store - .issue( - SoulId::new(), + let soul = SoulId::new(); + let endpoint = "http://host.docker.internal:4000"; + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let issue = || { + store.issue( + soul.clone(), &directory, - "http://host.docker.internal:4000".into(), - Some(("codex", &home, &context.config)), + endpoint.into(), + Some(("codex", &home, &[])), OpencodeEphemeralInput { - cwd: Some(&workspace), - workspace: Some(&workspace), + codex_manifest: Some(&manifest), launch_context: Some(&context), + cwd: Some(&cwd), + workspace: Some(&cwd), ..Default::default() }, None, ) - .unwrap(); - let accepted = read_prepared_context(&directory, &capability).unwrap(); - let inputs = accepted - .prepared_inputs - .expect("Codex selected layers and typed resources must be accepted as one generation"); - let stage = staged_provider_root_path(&directory, &capability.grant_id).unwrap(); - for (logical, expected) in [ - (home.join(".codex/instructions.md"), "home-input"), - ( - home.join(".codex/agents/nested/instructions.md"), - "role-input", - ), - (workspace.join(".codex/instructions.md"), "project-input"), - ] { - let mount = inputs - .resource_mounts - .iter() - .find(|mount| mount.logical_destination == logical.to_str().unwrap()) - .expect("ordinary selected resource must retain its original absolute alias"); - assert_eq!( - std::fs::read_to_string(stage.join(&mount.stage_relative_path)).unwrap(), - expected - ); - } - assert!(inputs - .resource_mounts - .iter() - .any(|mount| mount.logical_destination - == workspace.join(".codex/skills").to_str().unwrap())); - assert!(inputs - .config_binds - .iter() - .any(|bind| bind.logical_destination - == workspace.join(".codex/config.toml").to_str().unwrap())); - assert_eq!( - std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), - raw - ); - } - - #[tokio::test] - async fn managed_provider_legacy_codex_origin_unknown_blocks_and_retains_inputs() { - let owned = tempfile::tempdir().unwrap(); - let home = owned.path().join("current-home"); - let workspace = owned.path().join("workspace"); - std::fs::create_dir_all(home.join(".codex")).unwrap(); - std::fs::create_dir(&workspace).unwrap(); - std::fs::write( - home.join(".codex/config.toml"), - "model_instructions_file='./current.md'\n", - ) - .unwrap(); - std::fs::write( - home.join(".codex/current.md"), - b"must not substitute current HOME", - ) - .unwrap(); - let directory = owned.path().join("control/mcp-capabilities"); - std::fs::create_dir_all(&directory).unwrap(); - let store = CapabilityStore::default(); - let soul = SoulId::new(); - let endpoint = "http://host.docker.internal:4000"; - let prior = store - .issue( + }; + let prior = issue().unwrap(); + let incarnation = IncarnationId::new(); + store + .activate( + &prior.grant_id, soul.clone(), + incarnation.clone(), &directory, - endpoint.into(), - None, - Default::default(), - None, + endpoint, ) .unwrap(); - let legacy_root = staged_provider_root_path(&directory, &prior.grant_id).unwrap(); - std::fs::create_dir_all(&legacy_root).unwrap(); - std::fs::write( - legacy_root.join("retained-resource"), - b"retained legacy resource", + store + .commit_preparation(&prior.grant_id, &soul, &incarnation, &directory) + .unwrap(); + let prior_path = grant_path(&directory, &prior.grant_id).unwrap(); + let prior_bytes = std::fs::read(&prior_path).unwrap(); + let prior_stage = staged_provider_root_path(&directory, &prior.grant_id).unwrap(); + let descriptor: freshell_runtime_protocol::PreparedProviderInputs = serde_json::from_slice( + &std::fs::read(prior_stage.join(".freshell/prepared-inputs.json")).unwrap(), ) .unwrap(); - let private_before = - std::fs::read(grant_path(&directory, &prior.grant_id).unwrap()).unwrap(); - let context = freshell_runtime_protocol::ProviderLaunchContext { - preparation: freshell_runtime_protocol::ProviderPreparation::Codex { - tui_args: vec![], - sidecar_args: vec![], - }, - config: vec![], - mcp_capability: Some(prior.clone()), - prepared_inputs: None, - }; - let callback=serde_json::to_vec(&serde_json::json!({"action":"issue","controlSecret":"fixture-control","soulId":soul,"provider":"codex","workspace":workspace,"cwd":workspace,"priorContext":context,"providerSecretReferences":[],"authLookupAuthority":"fixture-owned-attempt"})).unwrap(); - let client = freshell_runtime_client::RuntimeClient::new( - owned.path().join("unused.sock"), - "fixture-control", - ); - let error = apply_prepared_callback( - &callback, - "fixture-control", - &store, - &directory, - endpoint, - &home, - &client, - ) - .await - .expect_err( - "legacy metadata cannot grant current HOME authority for an unrecorded original source", - ); - assert!(error.contains("original Codex source origins are unrecorded")); + let role_bind = descriptor + .config_binds + .iter() + .find(|bind| bind.logical_destination == role.to_str().unwrap()) + .unwrap(); + let prepared_role = prior_stage.join(&role_bind.source_relative_path); + assert_eq!(std::fs::read_to_string(&prepared_role).unwrap(), valid_role); + + let rejected_role = "approval_policy = 'untrusted'\n"; + std::fs::write(&role, rejected_role).unwrap(); + let error = issue().expect_err("unrepresentable role policy must not publish a generation"); assert!( - std::fs::read(grant_path(&directory, &prior.grant_id).unwrap()).unwrap() - == private_before, - "unknown origins must retain the original private capability unchanged" + error.contains("approval_policy") && error.contains(role.to_str().unwrap()), + "{error}" ); + assert_eq!(std::fs::read(&prior_path).unwrap(), prior_bytes); + assert_eq!(std::fs::read_to_string(prepared_role).unwrap(), valid_role); + assert_eq!(std::fs::read_to_string(&role).unwrap(), rejected_role); assert_eq!( - std::fs::read(legacy_root.join("retained-resource")).unwrap(), - b"retained legacy resource" + std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), + config ); + assert_eq!(store.grants.lock().unwrap().len(), 1); + assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1); assert_eq!( - std::fs::read_dir(directory.parent().unwrap().join("provider-roots")) + std::fs::read_dir(prior_stage.parent().unwrap()) .unwrap() .count(), 1 ); } - #[tokio::test] - async fn managed_provider_claude_pending_replay_requires_current_source_authority() { - for frozen_revision in 0..=2 { - qualify_claude_pending_replay_source_authority(frozen_revision).await; - } + #[test] + fn prepared_codex_nonprojected_policy_stays_under_native_authority() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write(home.join(".codex/config.toml"), "approval_policy = { granular = { sandbox_approval = true, rules = true, mcp_elicitations = true } }\n").unwrap(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let prepared = ordinary_stage::prepare(&home, &cwd, Some(&manifest)) + .expect("untrusted compatibility must not reject a policy it does not need to project"); + assert!(prepared.codex_approval.is_none()); } - async fn qualify_claude_pending_replay_source_authority(frozen_revision: u8) { - use freshell_platform::claude_inputs::{ - observation::{LocalDecision, NativeObservation}, - AcceptedNativeSourcePlan, ClaudeInputManifest, ClaudeSettingSource, - NativeSourceRuntime, - }; - use freshell_runtime_protocol::{ - bounded_json_bytes, ClaudeRecoverySourceBinding, ClaudeSourceAuthority, - ProviderLaunchContext, ProviderPreparation, RecoveryAttemptId, MAX_CONTROL_FRAME_BYTES, - }; + #[test] + fn prepared_codex_named_selection_refuses_for_managed_sidecar() { let owned = tempfile::tempdir().unwrap(); let home = owned.path().join("home"); let cwd = owned.path().join("workspace"); - let resumed = cwd.join("resume"); - let plugin = owned.path().join("selected-plugin"); - let directory = owned.path().join("control/mcp-capabilities"); - for path in [ - &home.join(".claude"), - &cwd.join(".claude"), - &directory, - &plugin.join("node_modules/probe-dependency"), - ] { - std::fs::create_dir_all(path).unwrap(); - } - std::fs::write(cwd.join(".claude/settings.local.json"), "{}").unwrap(); - std::fs::write(plugin.join("resource"), "selected-plugin-resource").unwrap(); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); std::fs::write( - plugin.join("node_modules/probe-dependency/index.js"), - "module.exports='installed-dependency'", + home.join(".codex/config.toml"), + "approval_policy = 'never'\n", + ) + .unwrap(); + let selected = home.join(".codex/selected.config.toml"); + std::fs::write(&selected, "model_reasoning_effort = 'low'\n").unwrap(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: Some("selected".into()), + }; + let error = ordinary_stage::prepare(&home, &cwd, Some(&manifest)).err() + .expect("managed TUI also has an app-server owner; selecting only the TUI config would lose intent"); + assert!(error.contains("--profile") && error.contains(selected.to_str().unwrap())); + } + + #[test] + fn prepared_codex_runtime_purpose_survives_replay() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write( + home.join(".codex/config.toml"), + "approval_policy = 'untrusted'\n", + ) + .unwrap(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let mut metadata = serde_json::to_value(&manifest).unwrap(); + let accepted = serde_json::from_value(metadata.clone()).unwrap(); + assert!(ordinary_stage::prepare(&home, &cwd, Some(&accepted)) + .unwrap() + .codex_approval + .is_some()); + metadata + .as_object_mut() + .unwrap() + .remove("nativeSourceRuntime"); + let legacy = serde_json::from_value(metadata).unwrap(); + assert!( + ordinary_stage::prepare(&home, &cwd, Some(&legacy)).is_err(), + "missing old purpose must not authorize public-policy rewriting" + ); + } + + #[test] + fn prepared_codex_tui_refuses_before_policy_is_lost() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let source = home.join(".codex/config.toml"); + std::fs::write(&source, "approval_policy = 'untrusted'\n").unwrap(); + let error = ordinary_stage::prepare(&home, &cwd, None).err() + .expect("a terminal CLI cannot silently lose policy that only app-server typed requests can preserve"); + assert!(error.contains("approval_policy") && error.contains(source.to_str().unwrap())); + } + + #[test] + fn prepared_codex_source_change_refuses_before_publishing_policy() { + use freshell_platform::provider_environment::{copy_environment, CopyLimits}; + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let source = home.join(".codex/config.toml"); + std::fs::write(&source, "approval_policy = 'untrusted'\nmodel = 'a'\n").unwrap(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: cwd.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let prepared = ordinary_stage::prepare(&home, &cwd, Some(&manifest)).unwrap(); + // Same-length unrelated content must still invalidate the accepted + // snapshot; the later policy declaration itself remains untrusted. + std::fs::write(&source, "approval_policy = 'untrusted'\nmodel = 'b'\n").unwrap(); + let stage = owned.path().join("stage"); + copy_environment(&prepared.selections, &stage, &[], CopyLimits::default()).unwrap(); + let error = ordinary_stage::stage_codex_approval(&stage, &prepared).unwrap_err(); + assert!(error.contains("approval_policy") && error.contains("source changed")); + assert!(!stage.join(".freshell/codex-approval.json").exists()); + } + + #[cfg(feature = "managed-provider-qualification")] + #[tokio::test] + async fn prepared_codex_native_config_projection_preserves_effective_source() { + use freshell_platform::codex_inputs::CodexInputManifest; + use std::process::Stdio; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + let repository = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let metadata: serde_json::Value = serde_json::from_slice( + &std::fs::read(repository.join("test/fixtures/coding-cli/codex-native-artifact.json")) + .unwrap(), + ) + .unwrap(); + let binary = + if std::env::var("FRESHELL_CODEX_QUALIFY_MANAGED_WRAPPER").as_deref() == Ok("1") { + let resource: serde_json::Value = serde_json::from_slice( + &std::fs::read(repository.join("docker/runtime/codex-native-sandbox.json")) + .unwrap(), + ) + .unwrap(); + PathBuf::from(resource["native"]["launchWrapper"].as_str().unwrap()) + } else { + repository + .join(".runtime-build/codex-contract/artifact") + .join(metadata["executable"].as_str().unwrap()) + }; + let version = std::process::Command::new(&binary) + .arg("--version") + .output() + .expect("connected verified native preparation must precede qualification"); + assert!(version.status.success()); + assert_eq!( + String::from_utf8(version.stdout).unwrap().trim(), + "codex-cli 0.164.0" + ); + async fn rpc( + stdin: &mut tokio::process::ChildStdin, + lines: &mut tokio::io::Lines>, + id: u64, + method: &str, + params: serde_json::Value, + ) -> serde_json::Value { + stdin + .write_all( + format!( + "{}\n", + serde_json::json!({"id":id,"method":method,"params":params}) + ) + .as_bytes(), + ) + .await + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(25), async { + loop { + let line = lines + .next_line() + .await + .unwrap() + .expect("native config parsing must survive the owned projection"); + let frame: serde_json::Value = serde_json::from_str(&line).unwrap(); + if frame.get("id").and_then(serde_json::Value::as_u64) == Some(id) { + assert!( + frame.get("error").is_none(), + "native {method} refused: {frame}" + ); + return frame["result"].clone(); + } + } + }) + .await + .expect("bounded actual native qualification response") + } + for (case, named_policy, project_policy, trusted, expected) in [ + ("inherited", None, None, true, "untrusted"), + ( + "trusted-project-override", + None, + Some("never"), + true, + "never", + ), + ("disabled-project", None, Some("never"), false, "untrusted"), + ] { + let owned = executable_fixture_directory(); + let home = owned.path().join("home"); + let workspace = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(workspace.join(".codex")).unwrap(); + std::fs::create_dir(workspace.join(".git")).unwrap(); + let base = format!("approval_policy = 'untrusted'\nmodel_reasoning_effort = 'low'\nmodel = 'contract-model'\nmodel_provider = 'fixture'\n[model_providers.fixture]\nname = 'Owned fixture'\nbase_url = 'http://127.0.0.1:1/v1'\nwire_api = 'responses'\nrequires_openai_auth = false\n[projects.{}]\ntrust_level = '{}'\n", serde_json::to_string(workspace.to_str().unwrap()).unwrap(), if trusted { "trusted" } else { "untrusted" }); + let named_policy: Option<&str> = named_policy; + let named = format!( + "model_reasoning_effort = 'low'\n{}", + named_policy + .map(|policy| format!("approval_policy = '{policy}'\n")) + .unwrap_or_default() + ); + std::fs::write(home.join(".codex/config.toml"), &base).unwrap(); + std::fs::write(home.join(".codex/selected.config.toml"), &named).unwrap(); + std::fs::write( + workspace.join(".codex/config.toml"), + project_policy + .map(|policy| format!("approval_policy = '{policy}'\n")) + .unwrap_or_default(), + ) + .unwrap(); + let original = std::fs::read(home.join(".codex/config.toml")).unwrap(); + let context = crate::managed_provider_bootstrap::provider_launch_context_from_home( + "codex", &workspace, &home, None, + ) + .unwrap() + .unwrap(); + let manifest = CodexInputManifest { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + version: 1, + original_home: home.clone(), + cwd: workspace.clone(), + codex_home: home.join(".codex"), + named_profile: None, + }; + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let grant = CapabilityStore::default() + .issue( + SoulId::new(), + &directory, + "http://host.docker.internal:4000".into(), + Some(("codex", &home, &context.config)), + OpencodeEphemeralInput { + cwd: Some(&workspace), + workspace: Some(&workspace), + codex_manifest: Some(&manifest), + launch_context: Some(&context), + ..Default::default() + }, + None, + ) + .unwrap(); + let accepted = read_prepared_context(&directory, &grant).unwrap(); + let intent = match &accepted.preparation { + freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: Some(intent), + .. + } => intent, + _ => panic!("actual prepared policy intent must survive complete publication"), + }; + assert_eq!(intent.policy, expected, "{case}"); + let source = if project_policy.is_some() && trusted { + workspace.join(".codex/config.toml") + } else if named_policy.is_some() { + home.join(".codex/selected.config.toml") + } else { + home.join(".codex/config.toml") + }; + assert_eq!(Path::new(&intent.logical_source), source); + assert_eq!( + std::fs::read(home.join(".codex/config.toml")).unwrap(), + original, + "preparation cannot edit its source" + ); + let stage = staged_provider_root_path(&directory, &grant.grant_id).unwrap(); + // Simulate the exact logical file mounts within this owned fixture; + // archive originals first, never modify a user's source HOME. + for (index, bind) in accepted + .prepared_inputs + .as_ref() + .unwrap() + .config_binds + .iter() + .enumerate() + { + let logical = Path::new(&bind.logical_destination); + if !logical.starts_with(owned.path()) { + continue; + } + std::fs::rename(logical, owned.path().join(format!("original-{index}"))).unwrap(); + std::os::unix::fs::symlink(stage.join(&bind.source_relative_path), logical) + .unwrap(); + } + let mut command = tokio::process::Command::new(&binary); + let mut child = command + .args(["app-server"]) + .process_group(0) + .current_dir(&workspace) + .env("HOME", &home) + .env("CODEX_HOME", home.join(".codex")) + .env_remove("OPENAI_API_KEY") + .env_remove("CODEX_API_KEY") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut lines = tokio::io::BufReader::new(child.stdout.take().unwrap()).lines(); + rpc(&mut stdin, &mut lines, 1, "initialize", serde_json::json!({"clientInfo":{"name":"freshell_prepared_codex", "version":"1"},"capabilities":{"experimentalApi":true}})).await; + stdin + .write_all(b"{\"method\":\"initialized\",\"params\":{}}\n") + .await + .unwrap(); + let config = rpc( + &mut stdin, + &mut lines, + 2, + "config/read", + serde_json::json!({"includeLayers":true}), + ) + .await; + assert_eq!( + config["config"]["model_reasoning_effort"], "low", + "unrelated setting survives projection" + ); + let started = rpc(&mut stdin, &mut lines, 3, "thread/start", serde_json::json!({"cwd":workspace,"model":"contract-model","approvalPolicy":intent.policy,"sandbox":"read-only"})).await; + assert_eq!( + started["approvalPolicy"], expected, + "actual native effective policy {case}" + ); + stop_codex_source_child(&mut child).await; + println!( + "provider_source_consumer {}", + serde_json::json!({"provider":"codex","case":case,"nativeVersion":"0.164.0","effectivePolicy":started["approvalPolicy"],"declaringSource":intent.logical_source,"modelWorkSubmitted":false}) + ); + } + } + + #[test] + fn managed_provider_codex_selected_inputs_reach_private_generation() { + for untrusted_policy in [false, true] { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let workspace = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex/agents/nested")).unwrap(); + std::fs::create_dir_all(workspace.join(".codex/skills/fixture/scripts")).unwrap(); + std::fs::create_dir(workspace.join(".git")).unwrap(); + let raw = format!("model_instructions_file = 'instructions.md'\n[projects.{}]\ntrust_level = 'trusted'\n[agents.reader]\nconfig_file = 'agents/nested/reader.toml'\n", serde_json::to_string(workspace.to_str().unwrap()).unwrap()); + let raw = if untrusted_policy { + format!("approval_policy = 'untrusted'\nmodel = 'synthetic'\n{raw}") + } else { + raw + }; + std::fs::write(home.join(".codex/config.toml"), &raw).unwrap(); + std::fs::write(home.join(".codex/instructions.md"), "home-input").unwrap(); + let role_config = "approval_policy = 'on-request'\nmodel = 'role-model'\nmodel_instructions_file = 'instructions.md'\n"; + std::fs::write(home.join(".codex/agents/nested/reader.toml"), role_config).unwrap(); + std::fs::write( + home.join(".codex/agents/nested/instructions.md"), + "role-input", + ) + .unwrap(); + std::fs::write( + workspace.join(".codex/config.toml"), + "model_instructions_file = 'instructions.md'\n", + ) + .unwrap(); + std::fs::write(workspace.join(".codex/instructions.md"), "project-input").unwrap(); + std::fs::write( + workspace.join(".codex/skills/fixture/SKILL.md"), + "skill-resource", + ) + .unwrap(); + let context = crate::managed_provider_bootstrap::provider_launch_context_from_home( + "codex", &workspace, &home, None, + ) + .unwrap() + .unwrap(); + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let store = CapabilityStore::default(); + let manifest = freshell_platform::codex_inputs::CodexInputManifest { + version: 1, + original_home: home.clone(), + cwd: workspace.clone(), + codex_home: home.join(".codex"), + named_profile: None, + native_source_runtime: if untrusted_policy { + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer + } else { + freshell_platform::codex_inputs::NativeSourceRuntime::Tui + }, + }; + let capability = store + .issue( + SoulId::new(), + &directory, + "http://host.docker.internal:4000".into(), + Some(("codex", &home, &context.config)), + OpencodeEphemeralInput { + cwd: Some(&workspace), + workspace: Some(&workspace), + launch_context: Some(&context), + codex_manifest: Some(&manifest), + ..Default::default() + }, + None, + ) + .unwrap(); + let accepted = read_prepared_context(&directory, &capability).unwrap(); + if untrusted_policy { + let freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: Some(default), + .. + } = &accepted.preparation + else { + panic!("relative resource preparation lost untrusted intent") + }; + assert_eq!(default.policy, "untrusted"); + assert_eq!( + default.canonical_source, + home.join(".codex/config.toml").to_str().unwrap() + ); + assert_eq!( + default.source_sha256, + format!("{:x}", Sha256::digest(raw.as_bytes())) + ); + } + let inputs = accepted.prepared_inputs.expect( + "Codex selected layers and typed resources must be accepted as one generation", + ); + let stage = staged_provider_root_path(&directory, &capability.grant_id).unwrap(); + let role_path = home.join(".codex/agents/nested/reader.toml"); + let role_bind = inputs + .config_binds + .iter() + .find(|bind| bind.logical_destination == role_path.to_str().unwrap()) + .expect("valid child policy must be published with its own configuration"); + assert_eq!( + std::fs::read_to_string(stage.join(&role_bind.source_relative_path)).unwrap(), + role_config + ); + assert_eq!(std::fs::read_to_string(&role_path).unwrap(), role_config); + if untrusted_policy { + let bind = inputs + .config_binds + .iter() + .find(|bind| { + bind.logical_destination + == home.join(".codex/config.toml").to_str().unwrap() + }) + .unwrap(); + let config: toml::Value = toml::from_str( + &std::fs::read_to_string(stage.join(&bind.source_relative_path)).unwrap(), + ) + .unwrap(); + assert!(config.get("approval_policy").is_none()); + assert_eq!(config["model"].as_str(), Some("synthetic")); + assert_eq!( + config["model_instructions_file"].as_str(), + Some("instructions.md") + ); + assert_eq!( + config["agents"]["reader"]["config_file"].as_str(), + Some("agents/nested/reader.toml") + ); + assert_eq!( + std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), + raw + ); + } + + for (logical, expected) in [ + (home.join(".codex/instructions.md"), "home-input"), + ( + home.join(".codex/agents/nested/instructions.md"), + "role-input", + ), + (workspace.join(".codex/instructions.md"), "project-input"), + ] { + let mount = inputs + .resource_mounts + .iter() + .find(|mount| mount.logical_destination == logical.to_str().unwrap()) + .expect("ordinary selected resource must retain its original absolute alias"); + assert_eq!( + std::fs::read_to_string(stage.join(&mount.stage_relative_path)).unwrap(), + expected + ); + } + assert!(inputs + .resource_mounts + .iter() + .any(|mount| mount.logical_destination + == workspace.join(".codex/skills").to_str().unwrap())); + assert!(inputs + .config_binds + .iter() + .any(|bind| bind.logical_destination + == workspace.join(".codex/config.toml").to_str().unwrap())); + assert_eq!( + std::fs::read_to_string(home.join(".codex/config.toml")).unwrap(), + raw + ); + } + } + + #[tokio::test] + async fn managed_provider_legacy_codex_origin_unknown_blocks_and_retains_inputs() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("current-home"); + let workspace = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir(&workspace).unwrap(); + std::fs::write( + home.join(".codex/config.toml"), + "model_instructions_file='./current.md'\n", + ) + .unwrap(); + std::fs::write( + home.join(".codex/current.md"), + b"must not substitute current HOME", + ) + .unwrap(); + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let store = CapabilityStore::default(); + let soul = SoulId::new(); + let endpoint = "http://host.docker.internal:4000"; + let prior = store + .issue( + soul.clone(), + &directory, + endpoint.into(), + None, + Default::default(), + None, + ) + .unwrap(); + let legacy_root = staged_provider_root_path(&directory, &prior.grant_id).unwrap(); + std::fs::create_dir_all(&legacy_root).unwrap(); + std::fs::write( + legacy_root.join("retained-resource"), + b"retained legacy resource", + ) + .unwrap(); + let private_before = + std::fs::read(grant_path(&directory, &prior.grant_id).unwrap()).unwrap(); + let context = freshell_runtime_protocol::ProviderLaunchContext { + preparation: freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: None, + tui_args: vec![], + sidecar_args: vec![], + }, + config: vec![], + mcp_capability: Some(prior.clone()), + prepared_inputs: None, + }; + let callback=serde_json::to_vec(&serde_json::json!({"action":"issue","controlSecret":"fixture-control","soulId":soul,"provider":"codex","workspace":workspace,"cwd":workspace,"priorContext":context,"providerSecretReferences":[],"authLookupAuthority":"fixture-owned-attempt"})).unwrap(); + let client = freshell_runtime_client::RuntimeClient::new( + owned.path().join("unused.sock"), + "fixture-control", + ); + let error = apply_prepared_callback( + &callback, + "fixture-control", + &store, + &directory, + endpoint, + &home, + &client, + ) + .await + .expect_err( + "legacy metadata cannot grant current HOME authority for an unrecorded original source", + ); + assert!(error.contains("original Codex source origins are unrecorded")); + assert!( + std::fs::read(grant_path(&directory, &prior.grant_id).unwrap()).unwrap() + == private_before, + "unknown origins must retain the original private capability unchanged" + ); + assert_eq!( + std::fs::read(legacy_root.join("retained-resource")).unwrap(), + b"retained legacy resource" + ); + assert_eq!( + std::fs::read_dir(directory.parent().unwrap().join("provider-roots")) + .unwrap() + .count(), + 1 + ); + } + + #[tokio::test] + async fn managed_provider_claude_pending_replay_requires_current_source_authority() { + for frozen_revision in 0..=2 { + qualify_claude_pending_replay_source_authority(frozen_revision).await; + } + } + + async fn qualify_claude_pending_replay_source_authority(frozen_revision: u8) { + use freshell_platform::claude_inputs::{ + observation::{LocalDecision, NativeObservation}, + AcceptedNativeSourcePlan, ClaudeInputManifest, ClaudeSettingSource, + NativeSourceRuntime, + }; + use freshell_runtime_protocol::{ + bounded_json_bytes, ClaudeRecoverySourceBinding, ClaudeSourceAuthority, + ProviderLaunchContext, ProviderPreparation, RecoveryAttemptId, MAX_CONTROL_FRAME_BYTES, + }; + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + let resumed = cwd.join("resume"); + let plugin = owned.path().join("selected-plugin"); + let directory = owned.path().join("control/mcp-capabilities"); + for path in [ + &home.join(".claude"), + &cwd.join(".claude"), + &directory, + &plugin.join("node_modules/probe-dependency"), + ] { + std::fs::create_dir_all(path).unwrap(); + } + std::fs::write(cwd.join(".claude/settings.local.json"), "{}").unwrap(); + std::fs::write(plugin.join("resource"), "selected-plugin-resource").unwrap(); + std::fs::write( + plugin.join("node_modules/probe-dependency/index.js"), + "module.exports='installed-dependency'", ) .unwrap(); std::fs::write(plugin.join("consume.cjs"), "process.stdout.write(require('node:fs').readFileSync(__dirname+'/resource','utf8')+':'+require('probe-dependency'))").unwrap(); @@ -9661,6 +10330,218 @@ process.stdout.write(readFileSync(new URL('./resource',import.meta.url),'utf8')+ ); } + #[tokio::test] + async fn managed_provider_legacy_codex_reissue_requires_current_consumer_role() { + use freshell_platform::codex_inputs::{CodexInputManifest, NativeSourceRuntime}; + use freshell_runtime_protocol::{PreparedProviderContext, ProviderPreparation}; + for retire_legacy_pending in [false, true] { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("original-home"); + let selected = owned.path().join("selected-codex"); + let cwd = owned.path().join("original-workspace"); + let wrong_home = owned.path().join("wrong-current-home"); + for path in [&home, &selected, &cwd, &wrong_home] { + std::fs::create_dir_all(path).unwrap(); + } + let source = selected.join("config.toml"); + std::fs::write(&source, "model = 'synthetic'\napproval_policy = 'never'\n").unwrap(); + let manifest = CodexInputManifest { + version: 1, + original_home: home.clone(), + codex_home: selected.clone(), + cwd: cwd.clone(), + named_profile: None, + native_source_runtime: NativeSourceRuntime::Tui, + }; + let directory = owned.path().join("control/mcp-capabilities"); + std::fs::create_dir_all(&directory).unwrap(); + let store = CapabilityStore::default(); + let soul = SoulId::new(); + let initial = crate::managed_provider_bootstrap::provider_launch_context_from_home( + "codex", &cwd, &home, None, + ) + .unwrap() + .unwrap(); + let prior = store + .issue( + soul.clone(), + &directory, + "http://host.docker.internal:4000".into(), + Some(("codex", &home, &initial.config)), + OpencodeEphemeralInput { + cwd: Some(&cwd), + workspace: Some(&cwd), + launch_context: Some(&initial), + codex_manifest: Some(&manifest), + ..Default::default() + }, + None, + ) + .unwrap(); + let context = read_prepared_context(&directory, &prior).unwrap(); + let prior_bytes = + read_prior_manifest_bytes("codex", &store, &directory, &soul, &context).unwrap(); + let accepted: CodexInputManifest = serde_json::from_slice(&prior_bytes).unwrap(); + assert_eq!(accepted.native_source_runtime, NativeSourceRuntime::Tui); + assert!(!serde_json::from_slice::(&prior_bytes) + .unwrap() + .as_object() + .unwrap() + .contains_key("nativeSourceRuntime")); + let original = + "model = 'synthetic'\napproval_policy = 'untrusted'\nmodel_reasoning_effort = 'low'\n"; + std::fs::write(&source, original).unwrap(); + let mut callback = serde_json::json!({"action":"issue", "controlSecret":"fixture-control", + "soulId":soul, "provider":"codex", "cwd":wrong_home, "workspace":cwd, + "priorContext":context, "providerSecretReferences":[], + "authLookupAuthority":"fixture-owned-attempt", "codexSourceRuntime":"app_server"}); + let client = freshell_runtime_client::RuntimeClient::new( + owned.path().join("unused.sock"), + "fixture-control", + ); + let invoke = |callback: &serde_json::Value| serde_json::to_vec(callback).unwrap(); + let legacy_pending = if retire_legacy_pending { + std::fs::write(&source, "model = 'synthetic'\napproval_policy = 'never'\n") + .unwrap(); + callback["codexSourceRuntime"] = "tui".into(); + let bytes = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .unwrap(); + let old: PreparedProviderContext = serde_json::from_slice(&bytes).unwrap(); + let stage = staged_provider_root_path( + &directory, + &old.provider_launch_context.mcp_capability.unwrap().grant_id, + ) + .unwrap(); + std::fs::write(&source, original).unwrap(); + callback["codexSourceRuntime"] = "app_server".into(); + Some(stage) + } else { + None + }; + let first = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .expect("known Freshcodex consumer must reprepare accepted legacy selectors"); + let ready: PreparedProviderContext = serde_json::from_slice(&first).unwrap(); + if let Some(old) = legacy_pending { + assert!( + !old.exists(), + "retire only the incompatible unactivated candidate" + ); + } + assert!( + staged_provider_root_path(&directory, &prior.grant_id) + .unwrap() + .exists(), + "retain the accepted prior generation" + ); + let replacement = &ready.provider_launch_context; + let ProviderPreparation::Codex { + approval_default: Some(default), + .. + } = &replacement.preparation + else { + panic!("replacement lost untrusted intent") + }; + assert_eq!(default.policy, "untrusted"); + assert_eq!(default.canonical_source, source.to_str().unwrap()); + let next_bytes = + read_prior_manifest_bytes("codex", &store, &directory, &soul, replacement).unwrap(); + let next: CodexInputManifest = serde_json::from_slice(&next_bytes).unwrap(); + assert_eq!(next.native_source_runtime, NativeSourceRuntime::AppServer); + assert_eq!( + ( + next.original_home, + next.codex_home, + next.cwd, + next.named_profile + ), + (home.clone(), selected.clone(), cwd.clone(), None) + ); + let stage = staged_provider_root_path( + &directory, + &replacement.mcp_capability.as_ref().unwrap().grant_id, + ) + .unwrap(); + let selected_copy = replacement + .prepared_inputs + .as_ref() + .unwrap() + .config_binds + .iter() + .find(|input| input.logical_destination == source.to_str().unwrap()) + .expect("selected original config remains bound"); + let prepared: toml::Value = toml::from_str( + &std::fs::read_to_string(stage.join(&selected_copy.source_relative_path)).unwrap(), + ) + .unwrap(); + assert!(prepared.get("approval_policy").is_none()); + assert_eq!(prepared["model_reasoning_effort"].as_str(), Some("low")); + assert_eq!(std::fs::read_to_string(&source).unwrap(), original); + assert_eq!( + read_prior_manifest_bytes("codex", &store, &directory, &soul, &context).unwrap(), + prior_bytes + ); + std::fs::remove_file(&source).unwrap(); + let repeated = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&repeated).unwrap(), + serde_json::from_slice::(&first).unwrap() + ); + for role in [Some("tui"), None] { + callback + .as_object_mut() + .unwrap() + .remove("codexSourceRuntime"); + if let Some(role) = role { + callback["codexSourceRuntime"] = role.into(); + } + let error = apply_prepared_callback( + &invoke(&callback), + "fixture-control", + &store, + &directory, + "http://host.docker.internal:4000", + &wrong_home, + &client, + ) + .await + .unwrap_err(); + assert!(error.contains("consumer"), "{error}"); + assert!( + stage.exists(), + "a refused consumer cannot retire a compatible pending generation" + ); + } + } + } + #[tokio::test] async fn managed_provider_replacement_complete_codex_metadata_replays_before_rescanning_roots() { diff --git a/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs b/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs index 4bdaaa8c09..e895088f5f 100644 --- a/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs +++ b/crates/freshell-server/src/managed_mcp_capability/amplifier_stage.rs @@ -77,6 +77,7 @@ pub(super) fn prepare_retained( }); } Ok(Preparation { + codex_approval: None, manifest_bytes, selections, binds: inputs.config_binds, diff --git a/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs b/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs index abfdf8116d..993846cd99 100644 --- a/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs +++ b/crates/freshell-server/src/managed_mcp_capability/ordinary_stage.rs @@ -1,7 +1,7 @@ //! Native-selected ordinary inputs use the shared resource copier and //! existing exact mounts. No configuration payload enters callback metadata. use super::{private_directory, write_private_bytes}; -use crate::managed_provider_references::codex::{discover, InputKind}; +use crate::managed_provider_references::codex::discover; use freshell_platform::{codex_inputs::CodexInputManifest, provider_environment::CopySelection}; use freshell_runtime_protocol::*; use std::{ @@ -14,6 +14,7 @@ pub(super) struct Preparation { pub selections: Vec, pub binds: Vec, pub mounts: Vec, + pub codex_approval: Option, } impl Preparation { pub(super) fn manifest_bytes(&self) -> &[u8] { @@ -26,22 +27,26 @@ pub(super) fn prepare( accepted: Option<&CodexInputManifest>, ) -> Result { let manifest = accepted.cloned().unwrap_or_else(|| CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.into(), cwd: cwd.into(), codex_home: home.join(".codex"), named_profile: None, }); - let discovery = discover(manifest).map_err(str::to_string)?; + let source = manifest.codex_home.join("config.toml"); + let discovery = + discover(manifest).map_err(|error| format!("{error}; source {}", source.display()))?; + let approval = crate::managed_provider_references::codex::approval_projection(&discovery)?; let configurations = discovery .inputs .iter() - .filter(|input| input.kind == InputKind::Configuration) + .filter(|input| input.kind.is_configuration()) .map(|input| (input.logical_path.clone(), input.canonical_source.clone())) .collect(); let mut ordinary = discovery.resource_roots.clone(); for input in &discovery.inputs { - if input.kind != InputKind::Configuration + if !input.kind.is_configuration() && !ordinary .iter() .any(|root| input.logical_path.starts_with(root)) @@ -49,14 +54,16 @@ pub(super) fn prepare( ordinary.push(input.logical_path.clone()); } } - prepare_selected( + let mut preparation = prepare_selected( "codex", &discovery.manifest.cwd, &discovery.manifest, configurations, ordinary, |logical| discovery.manifest.runtime_path(logical), - ) + )?; + preparation.codex_approval = approval; + Ok(preparation) } pub(super) fn prepare_selected( @@ -73,6 +80,7 @@ pub(super) fn prepare_selected( selections: Vec::new(), binds: Vec::new(), mounts: Vec::new(), + codex_approval: None, }; let mut files = BTreeMap::::new(); for (logical_path, canonical_source) in configurations { @@ -151,6 +159,63 @@ pub(super) fn prepare_selected( } Ok(result) } +pub(super) fn stage_codex_approval(root: &Path, preparation: &Preparation) -> Result<(), String> { + use sha2::{Digest, Sha256}; + let Some(projection) = &preparation.codex_approval else { + return Ok(()); + }; + for source in &projection.sources { + let selected = preparation + .selections + .iter() + .find(|selection| selection.source == source.canonical) + .ok_or("Codex approval_policy prepared source is unavailable")?; + let destination = root.join(&selected.destination_relative); + let bytes = std::fs::read(&destination).map_err(|_| { + format!( + "Codex approval_policy prepared source unreadable: {}", + source.canonical.display() + ) + })?; + if bytes.len() as u64 > freshell_platform::provider_input_profiles::MAX_SOURCE_VIEW_BYTES + || format!("{:x}", Sha256::digest(&bytes)) != source.sha256 + { + return Err(format!( + "Codex approval_policy source changed during preparation: {}", + source.canonical.display() + )); + } + if !source.strip_untrusted { + continue; + } + let mut value: toml::Value = toml::from_str( + std::str::from_utf8(&bytes).map_err(|_| "Codex approval_policy source is not UTF-8")?, + ) + .map_err(|_| "Codex approval_policy source TOML is invalid")?; + let table = value + .as_table_mut() + .ok_or("Codex approval_policy source must be a TOML table")?; + if table.get("approval_policy").and_then(toml::Value::as_str) != Some("untrusted") { + return Err(format!( + "Codex approval_policy source no longer declares the accepted policy: {}", + source.canonical.display() + )); + } + table.remove("approval_policy"); + let rewritten = toml::to_string(&value) + .map_err(|_| "Codex approval_policy prepared TOML could not be encoded")?; + super::replace_staged_private_bytes(&destination, rewritten.as_bytes())?; + } + write_private_bytes( + &root.join(".freshell/codex-approval.json"), + &bounded_json_bytes(&projection.default, 16 * 1024) + .map_err(|_| "Codex approval source metadata exceeds control budget")?, + )?; + tracing::info!(event="provider.codex.approval_projected", policy=%projection.default.policy, + source=%projection.default.logical_source, transformed_sources=projection.sources.len()); + Ok(()) +} + fn destinations(runtime_path: &impl Fn(&Path) -> PathBuf, logical: &Path) -> Vec { let runtime = runtime_path(logical); if runtime == logical { diff --git a/crates/freshell-server/src/managed_provider_bootstrap.rs b/crates/freshell-server/src/managed_provider_bootstrap.rs index deffad5ec3..3c8483bc7a 100644 --- a/crates/freshell-server/src/managed_provider_bootstrap.rs +++ b/crates/freshell-server/src/managed_provider_bootstrap.rs @@ -104,6 +104,7 @@ pub(crate) fn provider_launch_context_from_home_at_with_xdg( ".codex", &["config.toml", "AGENTS.md", "skills", "rules"][..], ProviderPreparation::Codex { + approval_default: None, tui_args: if mcp_enabled { managed_codex_mcp_args().ok_or("managed Codex MCP preparation unavailable")? } else { @@ -512,6 +513,7 @@ mod tests { let ProviderPreparation::Codex { tui_args, sidecar_args, + .. } = context.preparation else { panic!("Codex preparation missing"); diff --git a/crates/freshell-server/src/managed_provider_references/codex.rs b/crates/freshell-server/src/managed_provider_references/codex.rs index e04117d41e..5324ac674b 100644 --- a/crates/freshell-server/src/managed_provider_references/codex.rs +++ b/crates/freshell-server/src/managed_provider_references/codex.rs @@ -6,11 +6,16 @@ use std::path::{Path, PathBuf}; #[derive(Clone, Copy, PartialEq, Eq)] pub enum InputKind { - Configuration, + Configuration(LayerKind), Instructions, CompactPrompt, ModelCatalog, } +impl InputKind { + pub fn is_configuration(self) -> bool { + matches!(self, Self::Configuration(_)) + } +} pub struct SelectedInput { pub logical_path: PathBuf, pub canonical_source: PathBuf, @@ -32,11 +37,9 @@ pub struct SourceLayer { pub fn select_inputs( layers: &[SourceLayer], home: &Path, - profile: Option<&str>, ) -> Result, &'static str> { let mut selection = Selection { home, - profile, inputs: Vec::new(), active: std::collections::BTreeSet::new(), metadata_bytes: 0, @@ -49,7 +52,6 @@ pub fn select_inputs( struct Selection<'a> { home: &'a Path, - profile: Option<&'a str>, inputs: Vec, active: std::collections::BTreeSet<(PathBuf, PathBuf)>, metadata_bytes: usize, @@ -95,7 +97,7 @@ impl Selection<'_> { return Err("Codex role configuration depth exceeded"); } let logical = normalize(path); - let canonical = self.add(logical.clone(), InputKind::Configuration)?; + let canonical = self.add(logical.clone(), InputKind::Configuration(kind))?; if !enabled { return Ok(()); } @@ -126,14 +128,6 @@ impl Selection<'_> { .parent() .ok_or("Codex declaring configuration parent unavailable")?; self.file_fields(&value, parent)?; - if let Some(profile) = self.profile.filter(|_| kind == LayerKind::User) { - if let Some(value) = value - .get("profiles") - .and_then(|profiles| profiles.get(profile)) - { - self.file_fields(value, parent)?; - } - } let mut role_paths = Vec::new(); let mut role_path_bytes = 0usize; if let Some(roles) = value.get("agents").and_then(toml::Value::as_table) { @@ -317,6 +311,7 @@ fn normalize(path: &Path) -> PathBuf { } pub(crate) struct CodexPreparation<'a> { + pub native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime, pub client: &'a freshell_runtime_client::RuntimeClient, pub soul: &'a freshell_runtime_protocol::SoulId, pub creation_seed: &'a str, @@ -330,8 +325,10 @@ pub(crate) struct CodexPreparation<'a> { pub(crate) async fn prepare_managed( request: CodexPreparation<'_>, -) -> Result { +) -> Result +{ let CodexPreparation { + native_source_runtime, client, soul, creation_seed, @@ -342,6 +339,19 @@ pub(crate) async fn prepare_managed( args, references, } = request; + let invalid = |message: String| { + freshell_runtime_protocol::RuntimeError::new( + freshell_runtime_protocol::RuntimeErrorCode::InvalidRequest, + message, + ) + }; + let unavailable = |message: String| { + freshell_runtime_protocol::RuntimeError::new( + freshell_runtime_protocol::RuntimeErrorCode::BackendUnavailable, + message, + ) + }; + let manifest = (|| -> Result { let codex_home = if let Some(selected) = original("CODEX_HOME").filter(|value| !value.is_empty()) { let selected = PathBuf::from(selected); @@ -366,16 +376,34 @@ pub(crate) async fn prepare_managed( .map(|pair| pair[1].clone()) .next_back(); let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime, version: 1, original_home: home.into(), cwd: cwd.into(), codex_home, named_profile, }; + manifest.validate().map_err(str::to_string)?; + if let Some(name) = &manifest.named_profile { + return Err(format!("Codex setting --profile {name} in {} is incompatible with managed Codex0.164: its app-server cannot select that named user configuration", manifest.codex_home.join(format!("{name}.config.toml")).display())); + } + Ok(manifest) + })().map_err(invalid)?; + // Classify known source refusals before control provisioning. The actual + // staging operation still rechecks bytes and owns generation cleanup. + let discovery = discover(manifest.clone()).map_err(|message| { + invalid(format!( + "{message}; source {}", + manifest.codex_home.join("config.toml").display() + )) + })?; + approval_projection(&discovery).map_err(invalid)?; + drop(discovery); let mcp = crate::managed_provider_bootstrap::managed_codex_mcp_args() - .ok_or("managed Codex MCP preparation unavailable")?; + .ok_or_else(|| unavailable("managed Codex MCP preparation unavailable".into()))?; let context = freshell_runtime_protocol::ProviderLaunchContext { preparation: freshell_runtime_protocol::ProviderPreparation::Codex { + approval_default: None, tui_args: mcp.clone(), sidecar_args: mcp, }, @@ -388,12 +416,14 @@ pub(crate) async fn prepare_managed( let reservation = client .reserve_provider_inputs(soul.clone(), "codex".into(), creation_seed.into()) .await - .map_err(|error| error.to_string())?; + .map_err(|error| unavailable(error.to_string()))?; if reservation.provider != "codex" || &reservation.soul_id != soul || reservation.creation_seed_ref != creation_seed { - return Err("Codex selected resource reservation does not match this creation".into()); + return Err(unavailable( + "Codex selected resource reservation does not match this creation".into(), + )); } crate::managed_mcp_capability::issue_prepared_provider_context( "codex", @@ -409,12 +439,14 @@ pub(crate) async fn prepare_managed( ..Default::default() }, ) + .map_err(unavailable) } pub struct Discovery { pub manifest: freshell_platform::codex_inputs::CodexInputManifest, pub inputs: Vec, pub resource_roots: Vec, + pub layers: Vec, } pub fn discover( @@ -586,21 +618,128 @@ pub fn discover( } } } - let legacy = if manifest.named_profile.is_none() { - preceding.get("profile").and_then(toml::Value::as_str) - } else { - None - }; - let inputs = select_inputs(&layers, &manifest.original_home, legacy)?; + if preceding.get("profile").is_some() { + return Err("Codex legacy profile config is no longer supported; use --profile NAME with NAME.config.toml instead"); + } + let inputs = select_inputs(&layers, &manifest.original_home)?; resources.sort(); resources.dedup(); Ok(Discovery { manifest, inputs, resource_roots: resources, + layers, }) } +/// A bounded recipe for transforming only owned public config copies. The +/// original effective root policy is resolved before any declaration is omitted. +pub struct ApprovalProjection { + pub default: freshell_runtime_protocol::CodexApprovalDefault, + pub sources: Vec, +} +pub struct ApprovalSource { + pub canonical: PathBuf, + pub sha256: String, + pub strip_untrusted: bool, +} +pub fn approval_projection(discovery: &Discovery) -> Result, String> { + if let Some(name) = &discovery.manifest.named_profile { + return Err(format!("Codex setting --profile {name} in {} is incompatible: the managed native0.164 app-server owner cannot select that named user configuration", discovery.manifest.codex_home.join(format!("{name}.config.toml")).display())); + } + // Typed parent requests cannot express a separate role policy. Keep valid + // role config intact, and refuse untrusted rather than stripping it and + // letting the child inherit a potentially less restrictive parent policy. + for input in discovery + .inputs + .iter() + .filter(|input| input.kind == InputKind::Configuration(LayerKind::Role)) + { + let (value, _) = read_config_source(&input.canonical_source) + .map_err(|message| format!("{message}: {}", input.logical_path.display()))?; + if value.get("approval_policy").and_then(toml::Value::as_str) == Some("untrusted") { + return Err(format!("Codex setting approval_policy in {} is incompatible: native0.164 cannot preserve role-specific untrusted through typed parent app-server requests", input.logical_path.display())); + } + } + use sha2::{Digest, Sha256}; + let mut effective = None; + let mut sources = Vec::new(); + let mut transformed_logical = std::collections::BTreeSet::new(); + for layer in discovery.layers.iter().filter(|layer| layer.enabled) { + let canonical = std::fs::canonicalize(&layer.logical_path).map_err(|_| { + format!( + "Codex approval_policy source unavailable: {}", + layer.logical_path.display() + ) + })?; + let (value, bytes) = read_config_source(&canonical) + .map_err(|message| format!("{message}: {}", layer.logical_path.display()))?; + if value.get("profile").is_some() { + return Err(format!("Codex setting profile in {} is incompatible: legacy profile config is no longer supported", layer.logical_path.display())); + } + let sha256 = format!("{:x}", Sha256::digest(&bytes)); + let declared = value.get("approval_policy"); + let policy = declared.and_then(toml::Value::as_str); + if !sources + .iter() + .any(|source: &ApprovalSource| source.canonical == canonical) + { + sources.push(ApprovalSource { + canonical: canonical.clone(), + sha256: sha256.clone(), + strip_untrusted: policy == Some("untrusted"), + }); + } + if declared.is_none() { + continue; + } + effective = Some(freshell_runtime_protocol::CodexApprovalDefault { + policy: policy.unwrap_or_default().into(), + logical_source: layer + .logical_path + .to_str() + .ok_or("Codex approval source is not UTF-8")? + .into(), + canonical_source: canonical + .to_str() + .ok_or("Codex approval source is not UTF-8")? + .into(), + source_sha256: sha256.clone(), + }); + if policy == Some("untrusted") { + transformed_logical.insert(normalize(&layer.logical_path)); + } + } + if !sources.iter().any(|source| source.strip_untrusted) { + return Ok(None); + } + // One physical template can have several logical aliases. Refuse if a + // disabled or role-only alias would require different bytes. + for input in &discovery.inputs { + if input.kind.is_configuration() + && sources + .iter() + .any(|source| source.strip_untrusted && source.canonical == input.canonical_source) + && !transformed_logical.contains(&normalize(&input.logical_path)) + { + return Err(format!("Codex approval_policy in {} cannot be faithfully projected: an inactive or role alias shares its source", input.logical_path.display())); + } + } + let default = effective.ok_or("Codex approval_policy declaring source unavailable")?; + if discovery.manifest.native_source_runtime + != freshell_platform::codex_inputs::NativeSourceRuntime::AppServer + { + return Err(format!("Codex setting approval_policy in {} is incompatible with the terminal CLI: native0.164 can preserve untrusted only through typed app-server requests", default.logical_source)); + } + if !default.validate() { + return Err(format!( + "Codex approval_policy source metadata is incompatible: {}", + default.logical_source + )); + } + Ok(Some(ApprovalProjection { default, sources })) +} + fn add_rule_resources(root: &Path, resources: &mut Vec) -> Result<(), &'static str> { let entries = match std::fs::read_dir(root) { Ok(entries) => entries, @@ -801,6 +940,9 @@ fn exists(path: &Path) -> Result { } } fn read_config(path: &Path) -> Result { + read_config_source(path).map(|(value, _)| value) +} +fn read_config_source(path: &Path) -> Result<(toml::Value, Vec), &'static str> { use std::io::Read; let metadata = std::fs::metadata(path).map_err(|_| "Codex selected configuration is unavailable")?; @@ -821,7 +963,8 @@ fn read_config(path: &Path) -> Result { } let raw = std::str::from_utf8(&bytes).map_err(|_| "Codex selected configuration is not UTF-8")?; - toml::from_str(raw).map_err(|_| "Codex selected configuration TOML is invalid") + let value = toml::from_str(raw).map_err(|_| "Codex selected configuration TOML is invalid")?; + Ok((value, bytes)) } fn merge(target: &mut toml::Value, value: toml::Value) { if let (Some(target), Some(value)) = (target.as_table_mut(), value.as_table()) { @@ -944,6 +1087,109 @@ mod tests { } } + #[tokio::test] + async fn prepared_codex_role_untrusted_refuses_before_runtime_reservation() { + for parent_policy in ["never", "untrusted"] { + for selection in ["declared", "discovered", "nested"] { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + let codex_home = home.join(".codex"); + std::fs::create_dir_all(codex_home.join("roles")).unwrap(); + std::fs::create_dir_all(codex_home.join("agents")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + let role = codex_home.join(if selection == "discovered" { + "agents/reader.toml" + } else { + "roles/reader.toml" + }); + let declaration = match selection { + "declared" => "[agents.reader]\nconfig_file = 'roles/reader.toml'\n", + "discovered" => "", + "nested" => { + std::fs::write( + codex_home.join("roles/parent.toml"), + "[agents.reader]\nconfig_file = 'reader.toml'\n", + ) + .unwrap(); + "[agents.parent]\nconfig_file = 'roles/parent.toml'\n" + } + _ => unreachable!(), + }; + let config = format!("approval_policy = '{parent_policy}'\n{declaration}"); + let role_config = "approval_policy = 'untrusted'\nmodel = 'role-model'\n"; + std::fs::write(codex_home.join("config.toml"), &config).unwrap(); + std::fs::write(&role, role_config).unwrap(); + // There is no runtime to accept reservations or native work: + // this known incompatibility must be classified from sources. + let client = freshell_runtime_client::RuntimeClient::new( + owned.path().join("absent-supervisor.sock"), + "0123456789abcdef", + ); + let soul = freshell_runtime_protocol::SoulId::new(); + let result = prepare_managed(CodexPreparation { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::AppServer, + client: &client, + soul: &soul, + creation_seed: "role-policy-refusal", + home: &home, + cwd: &cwd, + workspace: &cwd, + original: &|_| None, + args: &[], + references: &[], + }) + .await; + let error = + result.expect_err("role-specific untrusted cannot inherit parent policy"); + assert_eq!( + error.code, + freshell_runtime_protocol::RuntimeErrorCode::InvalidRequest, + "{parent_policy}/{selection}: {}", + error.message + ); + assert!( + error.message.contains("approval_policy") + && error.message.contains(role.to_str().unwrap()), + "{}", + error.message + ); + assert_eq!(std::fs::read_to_string(&role).unwrap(), role_config); + assert_eq!( + std::fs::read_to_string(codex_home.join("config.toml")).unwrap(), + config + ); + } + } + } + + #[test] + fn prepared_codex_legacy_profile_is_refused_before_approval_projection() { + let owned = tempfile::tempdir().unwrap(); + let home = owned.path().join("home"); + let cwd = owned.path().join("workspace"); + std::fs::create_dir_all(home.join(".codex")).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::write( + home.join(".codex/config.toml"), + "profile = 'legacy'\n[profiles.legacy]\napproval_policy = 'untrusted'\n", + ) + .unwrap(); + let result = discover(freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, + version: 1, + original_home: home.clone(), + cwd, + codex_home: home.join(".codex"), + named_profile: None, + }); + assert!( + matches!(result, Err(error) if error.contains("legacy profile config is no longer supported")), + "unsupported native selector must not be projected into a different policy" + ); + } + #[test] fn managed_provider_codex_actual_source_read_limits_reject_before_reading() { use std::io::Write; @@ -963,9 +1209,7 @@ mod tests { file.set_times(std::fs::FileTimes::new().set_accessed(unread)) .unwrap(); let rejected = match kind { - "selection" => { - select_inputs(&[layer(path.clone(), true)], owned.path(), None).is_err() - } + "selection" => select_inputs(&[layer(path.clone(), true)], owned.path()).is_err(), "configuration" => read_config(&path).is_err(), "plugin" => is_agent_plugin(&root).is_err(), _ => unreachable!(), @@ -1002,7 +1246,7 @@ mod tests { } match kind { "selection" => assert_eq!( - select_inputs(&[layer(path, true)], owned.path(), None) + select_inputs(&[layer(path, true)], owned.path()) .unwrap() .len(), 1 @@ -1049,8 +1293,7 @@ mod tests { }; let raw = document(8191, false); std::fs::write(&configuration, &raw).unwrap(); - let selected = - select_inputs(&[layer(configuration.clone(), true)], owned.path(), None).unwrap(); + let selected = select_inputs(&[layer(configuration.clone(), true)], owned.path()).unwrap(); assert_eq!(selected.len(), 8192); assert_eq!( selected @@ -1060,16 +1303,14 @@ mod tests { .sum::(), 8 * 1024 * 1024 ); - assert!(selected - .iter() - .all(|input| input.kind == InputKind::Configuration)); + assert!(selected.iter().all(|input| input.kind.is_configuration())); assert!(selected[1..] .iter() .all(|input| input.logical_path == role && input.canonical_source == role)); let metadata_overflow = root.join("m".repeat(length)); std::fs::write(&metadata_overflow, document(8191, true)).unwrap(); assert_eq!( - select_inputs(&[layer(metadata_overflow, true)], owned.path(), None).err(), + select_inputs(&[layer(metadata_overflow, true)], owned.path()).err(), Some("Codex selected input metadata budget exceeded") ); let event_overflow = root.join("e".repeat(length)); @@ -1080,7 +1321,7 @@ mod tests { .set_times(std::fs::FileTimes::new().set_accessed(unread)) .unwrap(); assert_eq!( - select_inputs(&[layer(event_overflow, true)], owned.path(), None).err(), + select_inputs(&[layer(event_overflow, true)], owned.path()).err(), Some("Codex selected role event budget exceeded") ); assert_eq!( @@ -1170,6 +1411,7 @@ mod tests { let rule = canonical.join(".codex/rules/probe.rules"); std::fs::write(&rule, case).unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd: logical.join("nested"), @@ -1239,6 +1481,7 @@ mod tests { ) .unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1302,6 +1545,7 @@ mod tests { .unwrap(); } let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1370,6 +1614,7 @@ mod tests { .unwrap(); } let manifest = CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1455,6 +1700,7 @@ mod tests { ) .unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd: cwd.clone(), @@ -1496,6 +1742,7 @@ mod tests { ) .unwrap(); let disabled = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd, @@ -1544,6 +1791,7 @@ mod tests { .unwrap(); for (index, expected) in ["local", "zzz", "1.10.0"].into_iter().enumerate() { let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd: cwd.clone(), @@ -1625,6 +1873,7 @@ mod tests { .unwrap(); } let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1727,6 +1976,7 @@ mod tests { { std::fs::write(plugin.join("plugin.json"), unrelated).unwrap(); let found = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd: cwd.clone(), @@ -1767,6 +2017,7 @@ mod tests { ) .unwrap(); let error = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home, cwd, @@ -1799,6 +2050,7 @@ mod tests { ) .unwrap(); let error = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd, @@ -1825,6 +2077,7 @@ mod tests { ) .unwrap(); let error = discover(CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: home.clone(), cwd, @@ -1845,11 +2098,9 @@ mod tests { "[[skills.config]]\npath = 'absent-skill'\nenabled = false\n", ) .unwrap(); - let inputs = select_inputs(&[layer(config,true)],owned.path(),None).expect("native disabled path selectors do not require an existing file or grant a new skill discovery root"); + let inputs = select_inputs(&[layer(config,true)],owned.path()).expect("native disabled path selectors do not require an existing file or grant a new skill discovery root"); assert_eq!(inputs.len(), 1); - assert!(inputs - .iter() - .all(|input| input.kind == InputKind::Configuration)); + assert!(inputs.iter().all(|input| input.kind.is_configuration())); } #[test] @@ -1864,12 +2115,8 @@ mod tests { std::fs::write(root.join("agents/z.md"), b"Z_INSTRUCTIONS").unwrap(); std::fs::write(nested.join("a.md"), b"A_NESTED_INSTRUCTIONS").unwrap(); std::fs::write(nested.join("not-a-role.json"), b"not a TOML role").unwrap(); - let selected = select_inputs( - &[layer(root.join("config.toml"), true)], - fixture.path(), - None, - ) - .unwrap(); + let selected = + select_inputs(&[layer(root.join("config.toml"), true)], fixture.path()).unwrap(); let instructions = selected .iter() .filter(|input| input.kind == InputKind::Instructions) @@ -1878,13 +2125,9 @@ mod tests { assert_eq!(instructions,vec![digest(&nested.join("a.md")),digest(&root.join("agents/z.md"))], "native recursive role discovery must retain each role's declaring parent in sorted file order"); assert_eq!( - select_inputs( - &[layer(root.join("config.toml"), false)], - fixture.path(), - None - ) - .unwrap() - .len(), + select_inputs(&[layer(root.join("config.toml"), false)], fixture.path(),) + .unwrap() + .len(), 1, "disabled project configuration must not activate its discovered role inputs" ); @@ -1908,7 +2151,7 @@ mod tests { "[agents.worker]\nconfig_file='./agents/declared.toml'\n", ) .unwrap(); - let selected = select_inputs(&[layer(config, true)], fixture.path(), None).unwrap(); + let selected = select_inputs(&[layer(config, true)], fixture.path()).unwrap(); assert_eq!( selected .iter() @@ -1930,7 +2173,7 @@ mod tests { } #[test] - fn managed_provider_codex_typed_inputs_keep_each_logical_role_and_profile_origin() { + fn managed_provider_codex_typed_inputs_keep_logical_origins_without_legacy_profiles() { let fixture = tempfile::tempdir().unwrap(); let home = fixture.path().join("home"); let logical = fixture.path().join("project/.codex"); @@ -1943,12 +2186,13 @@ mod tests { std::fs::write(logical.join("instructions.md"), "logical-layer").unwrap(); std::fs::write(actual.join("instructions.md"), "wrong-storage-origin").unwrap(); std::fs::write(logical.join("roles/instructions.md"), "logical-role").unwrap(); - std::fs::write(logical.join("profile.md"), "selected-profile").unwrap(); + std::fs::write(logical.join("compact.md"), "root-compact-prompt").unwrap(); std::fs::write(logical.join("skills/SKILL.md"), "selected-skill").unwrap(); std::fs::write( actual.join("layer.toml"), r#" model_instructions_file = "./instructions.md" +experimental_compact_prompt_file = "./compact.md" notify = ["./missing-command", "./missing-argument"] [mcp_servers.fixture] command = "./missing-command" @@ -1957,7 +2201,7 @@ cwd = "./missing-cwd" [agents.worker] config_file = "./roles/worker.toml" [profiles.chosen] -experimental_compact_prompt_file = "./profile.md" +experimental_compact_prompt_file = "./missing-profile.md" [profiles.unselected] model_instructions_file = "./missing-unselected.md" [[skills.config]] @@ -1974,12 +2218,7 @@ enabled = false std::os::unix::fs::symlink(actual.join("layer.toml"), logical.join("config.toml")).unwrap(); std::os::unix::fs::symlink(actual.join("role.toml"), logical.join("roles/worker.toml")) .unwrap(); - let selected = select_inputs( - &[layer(logical.join("config.toml"), true)], - &home, - Some("chosen"), - ) - .unwrap(); + let selected = select_inputs(&[layer(logical.join("config.toml"), true)], &home).unwrap(); let instructions = selected .iter() .filter(|input| input.kind == InputKind::Instructions) @@ -1995,7 +2234,7 @@ enabled = false assert!(selected .iter() .any(|input| input.kind == InputKind::CompactPrompt - && digest(&input.canonical_source) == digest(&logical.join("profile.md")))); + && digest(&input.canonical_source) == digest(&logical.join("compact.md")))); assert!( !selected .iter() @@ -2015,19 +2254,19 @@ enabled = false let path = fixture.path().join("config.toml"); std::fs::write(&path, "[agents.loop]\nconfig_file='./config.toml'\n").unwrap(); assert_eq!( - select_inputs(&[layer(path.clone(), true)], fixture.path(), None).err(), + select_inputs(&[layer(path.clone(), true)], fixture.path()).err(), Some("Codex role configuration cycle detected") ); std::fs::write(&path, "model_instructions_file='./missing'\n").unwrap(); - let disabled = select_inputs(&[layer(path.clone(), false)], fixture.path(), None).unwrap(); + let disabled = select_inputs(&[layer(path.clone(), false)], fixture.path()).unwrap(); assert_eq!(disabled.len(), 1); - assert!(disabled[0].kind == InputKind::Configuration); + assert!(disabled[0].kind.is_configuration()); std::fs::write( &path, "model_instructions_file='PRIVATE_INVALID_TOML_SENTINEL\n", ) .unwrap(); - let error = select_inputs(&[layer(path, true)], fixture.path(), None) + let error = select_inputs(&[layer(path, true)], fixture.path()) .err() .unwrap(); assert_eq!(error, "Codex selected configuration TOML is invalid"); @@ -2056,7 +2295,6 @@ path='./missing-user-only-selector' kind: LayerKind::Project, }], fixture.path(), - Some("chosen"), ) .unwrap(); assert_eq!(selected.len(), 2); diff --git a/crates/freshell-server/src/managed_runtime.rs b/crates/freshell-server/src/managed_runtime.rs index e2c7cd4320..776e9ee41c 100644 --- a/crates/freshell-server/src/managed_runtime.rs +++ b/crates/freshell-server/src/managed_runtime.rs @@ -638,6 +638,8 @@ impl ManagedTerminalController for ServerManagedRuntimeController { }; let context = crate::managed_provider_references::codex::prepare_managed( crate::managed_provider_references::codex::CodexPreparation { + native_source_runtime: + freshell_platform::codex_inputs::NativeSourceRuntime::Tui, client: &self.client, soul: &soul_id, creation_seed: create_key, @@ -649,7 +651,8 @@ impl ManagedTerminalController for ServerManagedRuntimeController { references: &provider_secret_references, }, ) - .await?; + .await + .map_err(|error| error.message)?; (context.mcp_capability.clone(), Some(context)) } else if request.mode == "claude" { let home = original_provider_home(&request.env)?; diff --git a/crates/freshell-server/src/opencode_limits.rs b/crates/freshell-server/src/opencode_limits.rs index 680c88d90e..a3a3713e1a 100644 --- a/crates/freshell-server/src/opencode_limits.rs +++ b/crates/freshell-server/src/opencode_limits.rs @@ -240,6 +240,8 @@ mod tests { limit: Option<(i64, Option, Option)>, ) -> ModelCapability { ModelCapability { + default_effort: None, + input_modalities: None, id: id.to_string(), display_name: id.to_string(), provider: "opencode", diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index e8f7bb8c54..563d647ac9 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -103,6 +103,7 @@ fn binding_row_at( ) -> BindingRow { let (state, retired_reason, superseded_by) = state_parts; BindingRow { + collaboration_mode: None, genesis: None, ledger_version: LEDGER_VERSION, provider: provider.to_string(), @@ -3115,6 +3116,7 @@ fn fresh_agent_create_completed_after_the_pane_closed_is_never_offered() { let ledger = PaneLedger::new(Some(tmp.path().to_path_buf())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "S1", @@ -3180,6 +3182,7 @@ fn fresh_agent_create_asserted_inside_the_kill_window_stays_offered_despite_a_la let ledger = PaneLedger::new(Some(tmp.path().to_path_buf())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "S1", @@ -3318,6 +3321,7 @@ fn supersession_after_the_freeze_judges_on_the_parents_assertion_time() { .expect("parent binding write"); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "child-id", diff --git a/crates/freshell-server/src/session_name_native.rs b/crates/freshell-server/src/session_name_native.rs index 35b9134b24..a0d3f479a2 100644 --- a/crates/freshell-server/src/session_name_native.rs +++ b/crates/freshell-server/src/session_name_native.rs @@ -128,7 +128,7 @@ pub enum NativeClassified { } impl NativeClassified { - fn into_call(self) -> NativeCallResult { + pub(crate) fn into_call(self) -> NativeCallResult { match self { Self::Undelivered(reason) => NativeCallResult::Undelivered(reason), Self::Ambiguous(reason) => NativeCallResult::Ambiguous(reason), @@ -1299,11 +1299,21 @@ pub type CodexRootClientResolver = Arc< pub struct CodexNativeNameAdapter { client_for_root: CodexRootClientResolver, + hosted: Option>, } impl CodexNativeNameAdapter { pub fn new(client_for_root: CodexRootClientResolver) -> Self { - Self { client_for_root } + Self { + client_for_root, + hosted: None, + } + } + + #[cfg(feature = "managed-runtime-v1")] + pub fn with_hosted_backend(mut self, hosted: Option>) -> Self { + self.hosted = hosted; + self } } @@ -1383,7 +1393,7 @@ fn rpc_diagnoses_missing_target(error: &freshell_codex::protocol::RpcError) -> b /// failure (retryable) UNLESS it diagnoses the target as missing or the API /// as unsupported (a settled capability failure). Never reduced to a /// generic timeout or a blanket unsupported. -fn classify_codex_error( +pub(crate) fn classify_codex_error( error: freshell_codex::app_server::CodexAppServerError, what: &str, ) -> NativeClassified { @@ -1421,9 +1431,13 @@ fn classify_codex_error( impl NativeNameBackend for CodexNativeNameAdapter { fn read(&self, target: NativeNameTarget) -> NativeFuture { let resolver = Arc::clone(&self.client_for_root); + let hosted = self.hosted.clone(); Box::pin(async move { let (client, thread_id) = match codex_root_matched_thread(&resolver, &target).await { Ok(pair) => pair, + Err(_) if hosted.is_some() => { + return hosted.expect("checked hosted route").read(target).await + } Err(classified) => return classified.into_call(), }; // The metadata read rides its OWN 20-second native request bound @@ -1440,10 +1454,14 @@ impl NativeNameBackend for CodexNativeNameAdapter { fn write(&self, attempt: NativeNameAttempt) -> NativeFuture<()> { let resolver = Arc::clone(&self.client_for_root); + let hosted = self.hosted.clone(); Box::pin(async move { let (client, thread_id) = match codex_root_matched_thread(&resolver, &attempt.target).await { Ok(pair) => pair, + Err(_) if hosted.is_some() => { + return hosted.expect("checked hosted route").write(attempt).await + } Err(classified) => return classified.into_call(), }; match client.set_thread_name(&thread_id, &attempt.title).await { @@ -1459,7 +1477,16 @@ impl NativeNameBackend for CodexNativeNameAdapter { // LIVE root-matched app-server connection — the series pauses until // one exists, consuming nothing. let resolver = Arc::clone(&self.client_for_root); - Box::pin(async move { codex_root_matched_thread(&resolver, &target).await.is_ok() }) + let hosted = self.hosted.clone(); + Box::pin(async move { + if codex_root_matched_thread(&resolver, &target).await.is_ok() { + return true; + } + match hosted { + Some(hosted) => hosted.route_available(target).await, + None => false, + } + }) } } diff --git a/crates/freshell-server/src/session_name_native_tests.rs b/crates/freshell-server/src/session_name_native_tests.rs index 948e6fafe2..51c1e78b4b 100644 --- a/crates/freshell-server/src/session_name_native_tests.rs +++ b/crates/freshell-server/src/session_name_native_tests.rs @@ -1658,11 +1658,11 @@ fn codex_error_classes_preserve_their_native_classification() { matches!( classified(CodexAppServerError::Rpc { method: "thread/name/set".into(), - error: RpcError { + error: Box::new(RpcError { code: -32004, message: "thread not found".into(), data: None, - }, + }), }), super::NativeClassified::Unsupported(_) ), @@ -1672,11 +1672,11 @@ fn codex_error_classes_preserve_their_native_classification() { matches!( classified(CodexAppServerError::Rpc { method: "thread/name/set".into(), - error: RpcError { + error: Box::new(RpcError { code: -32603, message: "internal error".into(), data: None, - }, + }), }), super::NativeClassified::Undelivered(_) ), diff --git a/crates/freshell-server/tests/codex_model_catalog.rs b/crates/freshell-server/tests/codex_model_catalog.rs new file mode 100644 index 0000000000..2648ead90f --- /dev/null +++ b/crates/freshell-server/tests/codex_model_catalog.rs @@ -0,0 +1,310 @@ +//! Public WS creation -> bound HTTP model discovery, using the real server and +//! owned Codex CLI fixture. Every provider process and HOME belongs to this test. +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::{ + path::Path, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +use tokio_tungstenite::tungstenite::Message; + +const TOKEN: &str = "codex-catalog-owned-fixture"; +type Socket = + tokio_tungstenite::WebSocketStream>; + +struct OwnedServer(Child); +impl Drop for OwnedServer { + fn drop(&mut self) { + unsafe { + libc::kill(self.0.id() as libc::pid_t, libc::SIGTERM); + } + let end = Instant::now() + Duration::from_secs(10); + while Instant::now() < end { + if self.0.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +async fn message(socket: &mut Socket, kind: &str) -> Value { + let mut observed = Vec::new(); + let result = tokio::time::timeout(Duration::from_secs(15), async { + while let Some(frame) = socket.next().await { + if let Message::Text(text) = frame.unwrap() { + let value: Value = serde_json::from_str(&text).unwrap(); + observed.push(value.clone()); + if value["type"] == kind { + return value; + } + assert_ne!(value["type"], "freshAgent.create.failed", "{value}"); + } + } + panic!("socket closed before {kind}") + }) + .await; + result.unwrap_or_else(|_| panic!("socket did not receive {kind}; observed: {observed:?}")) +} + +async fn boot(home: &Path, behavior: Value) -> (OwnedServer, String, Socket) { + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + std::fs::create_dir_all(home.join(".freshell")).unwrap(); + std::fs::write(home.join(".freshell/config.json"), json!({"settings":{"freshAgent":{"enabled":true},"network":{"configured":true,"host":"127.0.0.1"}}}).to_string()).unwrap(); + let bin = std::env::var_os("FRESHELL_SERVER_BIN") + .unwrap_or_else(|| env!("CARGO_BIN_EXE_freshell-server").into()); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs"); + let server = OwnedServer( + Command::new(bin) + .current_dir(home) + .env("PORT", port.to_string()) + .env("AUTH_TOKEN", TOKEN) + .env("FRESHELL_BIND_HOST", "127.0.0.1") + .env("HOME", home) + .env("FRESHELL_HOME", home) + .env("CODEX_HOME", home.join(".codex")) + .env("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1") + .env("CODEX_CMD", format!("node {}", fixture.display())) + .env("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()) + .env_remove("FRESHELL_MANAGED_RUNTIME_V1") + .env_remove("FRESHELL_MANAGED_FRESH_AGENT_V1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + let base = format!("http://127.0.0.1:{port}"); + let http = reqwest::Client::new(); + let end = Instant::now() + Duration::from_secs(20); + loop { + if http + .get(format!("{base}/api/health")) + .send() + .await + .is_ok_and(|r| r.status().is_success()) + { + break; + } + assert!(Instant::now() < end, "owned server did not start"); + tokio::time::sleep(Duration::from_millis(30)).await; + } + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws")) + .await + .unwrap(); + socket.send(Message::Text(json!({"type":"hello","protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"token":TOKEN}).to_string())).await.unwrap(); + message(&mut socket, "ready").await; + (server, base, socket) +} + +async fn assert_catalog_scenario(scenario: &str) { + let home = tempfile::tempdir().unwrap(); + let log = home.path().join("requests.jsonl"); + let first = json!({"id":"record-sol","model":"gpt-6.1-sol","displayName":"GPT 6.1 Sol","supportedReasoningEfforts":[{"reasoningEffort":"xhigh"}],"defaultReasoningEffort":"xhigh","inputModalities":["text","image"]}); + let second = json!({"id":"opaque-record","model":"provider-current","displayName":"Provider Current","supportedReasoningEfforts":[{"reasoningEffort":"none"},{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}); + let pages = match scenario { + "loop" => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":"1"}]), + "malformed" => json!([{"data":[first],"nextCursor":7}]), + _ => json!([{"data":[first],"nextCursor":"1"},{"data":[second],"nextCursor":null}]), + }; + let (server, base, mut socket) = boot(home.path(), json!({"modelCatalogPages":pages,"appendRequestLogPath":log,"ignoreMethods":if scenario=="deadline" {vec!["model/list"]} else {vec![]}})).await; + socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":"catalog-create","provider":"codex","sessionType":"freshcodex","cwd":home.path(),"model":"provider-current","effort":"ultra"}).to_string())).await.unwrap(); + let created = message(&mut socket, "freshAgent.created").await; + let http = reqwest::Client::new(); + let mut url = reqwest::Url::parse(&format!( + "{base}/api/fresh-agent/model-capabilities/freshcodex" + )) + .unwrap(); + url.query_pairs_mut() + .append_pair("sessionId", created["sessionId"].as_str().unwrap()); + let response = http + .get(url.clone()) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap(); + let status = response.status().as_u16(); + let body: Value = response.json().await.unwrap(); + let cached: Option = if scenario == "valid" { + Some( + http.get(url) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .json() + .await + .unwrap(), + ) + } else { + None + }; + // A failed or timed-out catalog must leave the existing provider usable. + socket.send(Message::Text(json!({"type":"freshAgent.send","provider":"codex","sessionType":"freshcodex","sessionId":created["sessionId"],"requestId":"after-catalog","text":"reuse the existing provider"}).to_string())).await.unwrap(); + message(&mut socket, "freshAgent.send.accepted").await; + socket.close(None).await.unwrap(); + drop(server); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|s| serde_json::from_str(s).unwrap()) + .collect(); + let lists: Vec<_> = requests + .iter() + .filter(|r| r["method"] == "model/list") + .collect(); + if scenario == "valid" { + assert_eq!(status, 200, "{body}"); + assert_eq!(body["models"][0]["id"], "gpt-6.1-sol"); + assert_eq!(body["models"][1]["id"], "provider-current"); + assert_eq!(body["models"][1]["defaultEffort"], "ultra"); + assert_eq!( + body["models"][1]["supportedEffortLevels"], + json!(["none", "ultra"]) + ); + assert_eq!( + body["models"][0]["inputModalities"], + json!(["text", "image"]) + ); + assert_eq!(cached.unwrap()["status"], "cached"); + assert_eq!(lists.len(), 2); + assert!(lists[0]["params"].get("cursor").is_none_or(Value::is_null)); + assert_eq!(lists[1]["params"]["cursor"], "1"); + assert_eq!(lists[0]["params"]["includeHidden"], false); + } else { + assert_eq!(status, 503, "{scenario}: {body}"); + assert_eq!(body["status"], "unavailable"); + assert_eq!(body["models"], json!([])); + assert!(body["error"]["code"] + .as_str() + .unwrap() + .starts_with("CAPABILITY_")); + assert!(lists.len() <= 2); + } + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "initialize") + .count(), + 1, + "catalog must reuse its registered process" + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "thread/start") + .count(), + 1 + ); + assert!(!requests.iter().any(|r| r["method"] == "thread/resume")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_paginates_and_caches() { + assert_catalog_scenario("valid").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_rejects_looping_cursors() { + assert_catalog_scenario("loop").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_rejects_malformed_cursor() { + assert_catalog_scenario("malformed").await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_bounds_native_deadline() { + assert_catalog_scenario("deadline").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn bound_native_model_catalog_separates_actual_provider_contexts_in_one_registry() { + let home = tempfile::tempdir().unwrap(); + let log = home.path().join("context-requests.jsonl"); + let mut pages = serde_json::Map::new(); + for name in ["a", "b"] { + let cwd = home.path().join(name); + std::fs::create_dir_all(&cwd).unwrap(); + pages.insert(cwd.to_string_lossy().into(),json!([{"data":[{"id":"opaque-record","model":format!("model-from-{name}"),"displayName":format!("Context {name}"),"supportedReasoningEfforts":[{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}],"nextCursor":null}])); + } + let (server, base, mut socket) = boot( + home.path(), + json!({"uniqueThreadIds":true,"appendRequestLogPath":log,"modelCatalogPagesByCwd":pages}), + ) + .await; + let http = reqwest::Client::new(); + let mut identities = Vec::new(); + for name in ["a", "b"] { + socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":format!("create-context-{name}"),"provider":"codex","sessionType":"freshcodex","cwd":home.path().join(name),"model":format!("model-from-{name}"),"effort":"ultra"}).to_string())).await.unwrap(); + let created = message(&mut socket, "freshAgent.created").await; + identities.push(created["sessionId"].as_str().unwrap().to_string()); + } + let mut results = Vec::new(); + for index in [0, 1, 0] { + let mut url = reqwest::Url::parse(&format!( + "{base}/api/fresh-agent/model-capabilities/freshcodex" + )) + .unwrap(); + url.query_pairs_mut() + .append_pair("sessionId", &identities[index]); + // Deliberately no cwd query: the live provider client is the authority, + // so two contexts must not collide in the default-cwd cache bucket. + let response = http + .get(url) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap(); + results.push((response.status(), response.json::().await.unwrap())); + } + socket.close(None).await.unwrap(); + drop(server); + for (index, (status, body)) in results.iter().enumerate() { + assert!(status.is_success(), "{body}"); + assert_eq!( + body["models"][0]["id"], + if index == 1 { + "model-from-b" + } else { + "model-from-a" + } + ); + } + assert_eq!(results[2].1["status"], "cached"); + let requests = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + let lists = requests + .iter() + .filter(|r| r["method"] == "model/list") + .collect::>(); + assert_eq!(lists.len(), 2); + for name in ["a", "b"] { + assert!(lists + .iter() + .any(|row| row["cwd"].as_str() == Some(home.path().join(name).to_str().unwrap()))); + } + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "initialize") + .count(), + 2 + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "thread/start") + .count(), + 2 + ); +} diff --git a/crates/freshell-server/tests/codex_rest_automation.rs b/crates/freshell-server/tests/codex_rest_automation.rs new file mode 100644 index 0000000000..80e8e31fba --- /dev/null +++ b/crates/freshell-server/tests/codex_rest_automation.rs @@ -0,0 +1,458 @@ +#![cfg(unix)] + +//! Headless REST automation uses the owning Codex runtime and shared name store. +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use std::{ + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; +use tokio_tungstenite::tungstenite::Message; + +const TOKEN: &str = "codex-rest-owned-fixture"; + +struct OwnedServer(Child); +impl Drop for OwnedServer { + fn drop(&mut self) { + unsafe { + libc::kill(self.0.id() as libc::pid_t, libc::SIGTERM); + } + let end = Instant::now() + Duration::from_secs(10); + while Instant::now() < end { + if self.0.try_wait().ok().flatten().is_some() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +fn find_file(root: &Path, name: &str) -> Option { + for entry in std::fs::read_dir(root).ok()?.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(found) = find_file(&path, name) { + return Some(found); + } + } else if entry.file_name() == name { + return Some(path); + } + } + None +} + +#[tokio::test] +async fn task009_codex_rest_saved_name_binds_to_the_actual_persisted_native_thread() { + assert_saved_name(SavedNameScenario::Persisted).await; +} + +#[tokio::test] +async fn task009_codex_rest_name_survives_a_connected_ws_control_runtime() { + assert_saved_name(SavedNameScenario::ConnectedControl).await; +} + +#[tokio::test] +async fn task009_codex_rest_name_survives_native_hydration_during_dispatch() { + assert_saved_name(SavedNameScenario::HydrationDuringDispatch).await; +} + +#[tokio::test] +async fn task010_codex_rest_adopted_pane_reads_the_established_conversation_name() { + assert_saved_name(SavedNameScenario::AdoptEstablished).await; +} + +#[derive(Clone, Copy)] +enum SavedNameScenario { + Persisted, + ConnectedControl, + HydrationDuringDispatch, + AdoptEstablished, +} + +async fn assert_saved_name(scenario: SavedNameScenario) { + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let project = workspace.path(); + std::fs::create_dir_all(home.path().join(".freshell")).unwrap(); + std::fs::write( + home.path().join(".freshell/config.json"), + json!({"settings":{ + "freshAgent":{"enabled":true},"codingCli":{"enabledProviders":["codex"]},"network":{"configured":true,"host":"127.0.0.1"} + }}) + .to_string(), + ) + .unwrap(); + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs"); + let wrapper = workspace.path().join("codex.mjs"); + std::fs::write(&wrapper, format!(r#"#!/usr/bin/env node +import {{ randomUUID }} from 'node:crypto' +import path from 'node:path' +const threadId=randomUUID() +const now=new Date() +const rollout=path.join(process.env.CODEX_HOME,'sessions',String(now.getUTCFullYear()),String(now.getUTCMonth()+1).padStart(2,'0'),String(now.getUTCDate()).padStart(2,'0'),`rollout-${{threadId}}.jsonl`) +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR=JSON.stringify({{ + ...JSON.parse(process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR),threadStartThreadId:threadId, + writeRolloutOnMethods:{{'turn/start':{{path:rollout,record:{{type:'session_meta',payload:{{id:threadId,cwd:process.cwd()}}}}}}}}, +}}) +await import({}) +"#,json!(fixture))).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let log = std::fs::File::create(home.path().join("server.log")).unwrap(); + let bin = std::env::var_os("FRESHELL_SERVER_BIN") + .unwrap_or_else(|| env!("CARGO_BIN_EXE_freshell-server").into()); + let mut server = OwnedServer( + Command::new(bin) + .current_dir(home.path()) + .env("PORT", port.to_string()) + .env("AUTH_TOKEN", TOKEN) + .env("FRESHELL_BIND_HOST", "127.0.0.1") + .env("HOME", home.path()) + .env("FRESHELL_HOME", home.path()) + .env("CODEX_HOME", home.path().join(".codex")) + .env("CODEX_CMD", &wrapper) + .env("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1") + .env( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + json!({"recordTurns":true,"echoRequestedModel":true}).to_string(), + ) + .env_remove("FRESHELL_MANAGED_RUNTIME_V1") + .env_remove("FRESHELL_MANAGED_FRESH_AGENT_V1") + .stdin(Stdio::null()) + .stdout(log.try_clone().unwrap()) + .stderr(log) + .spawn() + .unwrap(), + ); + let base = format!("http://127.0.0.1:{port}"); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let end = Instant::now() + Duration::from_secs(20); + loop { + if http + .get(format!("{base}/api/health")) + .send() + .await + .is_ok_and(|r| r.status().is_success()) + { + break; + } + assert!( + server.0.try_wait().unwrap().is_none(), + "owned server exited" + ); + assert!(Instant::now() < end, "owned server did not start"); + tokio::time::sleep(Duration::from_millis(30)).await; + } + let mut control_socket = None; + if matches!(scenario, SavedNameScenario::ConnectedControl) { + let (mut socket, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}/ws")) + .await + .unwrap(); + socket.send(Message::Text(json!({"type":"hello","protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"token":TOKEN}).to_string())).await.unwrap(); + async fn frame( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + wanted: &str, + ) -> Value { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if let Message::Text(raw) = socket.next().await.unwrap().unwrap() { + let value: Value = serde_json::from_str(&raw).unwrap(); + assert_ne!(value["type"], "freshAgent.create.failed", "{value}"); + if value["type"] == wanted { + break value; + } + } + } + }) + .await + .unwrap() + } + frame(&mut socket, "ready").await; + socket.send(Message::Text(json!({"type":"freshAgent.create","requestId":"control-create","provider":"codex","sessionType":"freshcodex","cwd":project,"namingHandle":"nh-ws-control"}).to_string())).await.unwrap(); + let control = frame(&mut socket, "freshAgent.created").await; + socket.send(Message::Text(json!({"type":"freshAgent.send","requestId":"control-send","provider":"codex","sessionType":"freshcodex","sessionId":control["sessionId"],"text":"normal native control"}).to_string())).await.unwrap(); + frame(&mut socket, "freshAgent.send.accepted").await; + let end = Instant::now() + Duration::from_secs(5); + loop { + let snapshot: Value = http + .get(format!( + "{base}/api/fresh-agent/threads/freshcodex/codex/{}", + control["sessionId"].as_str().unwrap() + )) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + if snapshot["status"] == "idle" + && snapshot["turns"] + .as_array() + .is_some_and(|rows| rows.len() == 2) + { + break; + } + assert!(Instant::now() < end, "control turn must settle normally"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + control_socket = Some(socket); + } + let created: Value = http + .post(format!("{base}/api/tabs")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","cwd":project,"model":"provider-current","effort":"xhigh","collaborationMode":"plan","sandbox":"workspace-write","permissionMode":"on-request"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let thread = created["data"]["sessionRef"]["sessionId"].as_str().unwrap(); + uuid::Uuid::parse_str(thread).unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + let send_http = http.clone(); + let send_url = format!("{base}/api/panes/{pane}/send-keys"); + let sending = tokio::spawn(async move { + send_http.post(send_url).header("x-auth-token",TOKEN) + .json(&json!({"data":"persist native naming metadata\r","requestId":"task009-name-send","timeout":3})) + .send().await.unwrap().error_for_status().unwrap().json::().await.unwrap() + }); + if matches!(scenario, SavedNameScenario::HydrationDuringDispatch) { + let end = Instant::now() + Duration::from_secs(5); + loop { + let snapshot: Value = http + .get(format!( + "{base}/api/fresh-agent/threads/freshcodex/codex/{thread}" + )) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + if snapshot["turns"] + .as_array() + .is_some_and(|rows| rows.len() == 2) + { + break; + } + assert!(Instant::now() < end, "native turn must become readable"); + tokio::time::sleep(Duration::from_millis(5)).await; + } + } + let sent = sending.await.unwrap(); + assert_eq!(sent["data"]["status"], "idle", "{sent}"); + let metadata_deadline = Instant::now() + Duration::from_secs(5); + let rollout = loop { + if let Some(path) = find_file( + &home.path().join(".codex/sessions"), + &format!("rollout-{thread}.jsonl"), + ) { + break path; + } + assert!( + Instant::now() < metadata_deadline, + "the executable native fake must persist its actual rollout" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + }; + let persisted = std::fs::read_to_string(&rollout).unwrap(); + let meta: Value = serde_json::from_str(persisted.lines().next().unwrap()).unwrap(); + assert_eq!(meta["type"], "session_meta"); + assert_eq!(meta["payload"]["id"], thread); + assert_eq!(meta["payload"]["cwd"], project.to_str().unwrap()); + let snapshot_deadline = Instant::now() + Duration::from_secs(5); + loop { + let snapshot: Value = http + .get(format!( + "{base}/api/fresh-agent/threads/freshcodex/codex/{thread}" + )) + .header("x-auth-token", TOKEN) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + if snapshot["status"] == "idle" + && snapshot["turns"] + .as_array() + .is_some_and(|rows| rows.len() == 2) + { + break; + } + assert!( + Instant::now() < snapshot_deadline, + "the owning runtime must consume normal completion: {snapshot}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + // Other background writers and canonical live adoption must not detach + // this conversation's pending-to-durable naming binding. + let split: Value = http + .post(format!("{base}/api/panes/{pane}/split")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","direction":"vertical","cwd":project})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_ne!(split["data"]["sessionRef"], created["data"]["sessionRef"]); + let other: Value = http + .post(format!("{base}/api/tabs")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","cwd":project})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let adopted: Value = http + .post(format!("{base}/api/tabs")) + .header("x-auth-token", TOKEN) + .json(&json!({"agent":"codex","sessionRef":other["data"]["sessionRef"]})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(adopted["data"]["sessionRef"], other["data"]["sessionRef"]); + let renamed: Value = http + .patch(format!("{base}/api/panes/{pane}")) + .header("x-auth-token", TOKEN) + .json(&json!({"name":"Task009 saved Codex","nameIntent":"user"})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let end = Instant::now() + Duration::from_secs(5); + loop { + let names: Value = http + .post(format!("{base}/api/session-names/read")) + .header("x-auth-token", TOKEN) + .json(&json!({"refs":[{"kind":"session","provider":"codex","sessionId":thread}]})) + .send() + .await + .unwrap() + .error_for_status() + .unwrap() + .json() + .await + .unwrap(); + let record = &names["names"][0]["record"]; + if record["ref"] == json!({"kind":"session","provider":"codex","sessionId":thread}) + && record["name"] == "Task009 saved Codex" + && record["source"] == "manual" + { + break; + } + let saved_names = find_file(home.path(), "session-names.json") + .map(|path| std::fs::read_to_string(path).unwrap()); + assert!(Instant::now() Result<(), String> { Some("fresh-agent-fixture-state-worker") => providers::run_fresh_agent_fixture_state_worker(&args[2..]), Some("opencode-identity-worker") => pty::run_opencode_identity_worker(&args[2..]), Some("provider-probe-worker") => providers::run_probe_worker(&args[2..]), + Some("codex-naming-proof") => providers::run_naming_proof(&args[2..]), + Some("codex-owned-cleanup") => providers::run_owned_cleanup(&args[2..]).await, Some("refresh-provider-config") => refresh_provider_config_worker(&args[2..]), Some("prepare-provider-features") => providers::run_provider_features_worker(&args[2..]), Some("prepare-amplifier-state") => providers::run_amplifier_state_worker(&args[2..]), @@ -300,7 +302,12 @@ async fn handle_connection(mut stream: UnixStream, state: Arc) -> Res serde_json::json!({"errorCode":error.code,"message":error.message}), ); } - let limit = if matches!(result, Ok(HostResult::FreshAgentSnapshot(_))) { + let limit = if matches!( + result, + Ok(HostResult::FreshAgentSnapshot(_) + | HostResult::FreshAgentChildSnapshot(_) + | HostResult::FreshAgentEvents(_)) + ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES @@ -581,6 +588,7 @@ async fn dispatch( .await .clone() .ok_or_else(unsupported_fresh_agent)?; + let before = actor.profile().await; let command_state = actor .dispatch_with_checkpoint_identity( request_id, @@ -590,8 +598,24 @@ async fn dispatch( ) .await .map_err(map_actor_error)?; - let native_session_id = actor.profile().await.native_session_id; + let after = actor.profile().await; + let native_session_id = after.native_session_id.clone(); + let native_identity = + if after.provider == freshell_runtime_protocol::FreshProvider::Codex { + before.native_session_id.zip(after.native_session_id).map( + |(previous_native_session_id, native_session_id)| { + freshell_runtime_protocol::NativeDispatchIdentity { + previous_native_session_id, + native_session_id, + pending_naming_handle: after.naming_handle, + } + }, + ) + } else { + None + }; Ok(HostResult::FreshAgentCommand { + native_identity, state: command_state, native_session_id, }) @@ -645,6 +669,7 @@ async fn dispatch( .await .map_err(map_actor_error)?; Ok(HostResult::FreshAgentCommand { + native_identity: None, state: command_state, native_session_id: actor.profile().await.native_session_id, }) @@ -667,6 +692,7 @@ async fn dispatch( .map_err(map_actor_error); emit_environment_event(configure_outcome_event(&request_id, &outcome)); Ok(HostResult::FreshAgentCommand { + native_identity: None, state: outcome?, native_session_id: actor.profile().await.native_session_id, }) @@ -699,6 +725,7 @@ async fn dispatch( .await .map_err(map_actor_error)?; Ok(HostResult::FreshAgentCommand { + native_identity: None, state: command_state, native_session_id: actor.profile().await.native_session_id, }) @@ -721,6 +748,43 @@ async fn dispatch( .map_err(map_actor_error)?, )) } + HostCommand::FreshAgentModelCatalog { + incarnation_id, + cwd, + } => { + ensure_incarnation(&incarnation_id, state)?; + let actor = state + .fresh_agent + .lock() + .await + .clone() + .ok_or_else(unsupported_fresh_agent)?; + Ok(HostResult::FreshAgentModelCatalog( + actor + .model_catalog(cwd.as_deref()) + .await + .map_err(map_actor_error)?, + )) + } + HostCommand::FreshAgentNativeName { + incarnation_id, + native_session_id, + codex_home, + operation, + } => { + ensure_incarnation(&incarnation_id, state)?; + let actor = state + .fresh_agent + .lock() + .await + .clone() + .ok_or_else(unsupported_fresh_agent)?; + Ok(HostResult::FreshAgentNativeName( + actor + .native_name(&native_session_id, &codex_home, &operation) + .await, + )) + } HostCommand::FreshAgentReadSnapshot { incarnation_id } => { ensure_incarnation(&incarnation_id, state)?; let actor = state @@ -733,6 +797,24 @@ async fn dispatch( actor.snapshot().await.map_err(map_actor_error)?, )) } + HostCommand::FreshAgentReadChild { + incarnation_id, + child_thread_id, + } => { + ensure_incarnation(&incarnation_id, state)?; + let actor = state + .fresh_agent + .lock() + .await + .clone() + .ok_or_else(unsupported_fresh_agent)?; + Ok(HostResult::FreshAgentChildSnapshot( + actor + .read_child(&child_thread_id) + .await + .map_err(map_actor_error)?, + )) + } HostCommand::FreshAgentResolve { incarnation_id, decision_id, @@ -751,6 +833,7 @@ async fn dispatch( .map_err(map_actor_error)?; let native_session_id = actor.profile().await.native_session_id; Ok(HostResult::FreshAgentCommand { + native_identity: None, state: freshell_runtime_protocol::CommandState::Completed, native_session_id, }) @@ -9672,6 +9755,7 @@ mod tests { let context = ProviderLaunchContext { prepared_inputs: None, preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, @@ -10185,6 +10269,7 @@ mod tests { ) -> Result { self.dispatches.fetch_add(1, Ordering::SeqCst); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.to_string()), }) } @@ -10202,6 +10287,7 @@ mod tests { .unwrap() .push(logical_attempt_id.map(str::to_owned)); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.to_string()), }) } @@ -10257,6 +10343,20 @@ mod tests { ) } + async fn model_catalog(&self, cwd: Option<&str>) -> Result { + assert_eq!(cwd, Some("/workspace")); + Ok( + serde_json::json!([{"id":"rpc-catalog","displayName":"RPC Catalog","supportedEffortLevels":["ultra"],"defaultEffort":"ultra","inputModalities":["text"]}]), + ) + } + + async fn read_child(&self, child_thread_id: &str) -> Result { + assert_eq!(child_thread_id, "fixture-child"); + Ok( + serde_json::json!({"parentThreadId":"fixture-native-thread","childThreadId":child_thread_id,"status":"saved","snapshot":{"threadId":child_thread_id,"provider":"codex","sessionType":"freshcodex","turns":[{"text":"x".repeat(2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES)}]}}), + ) + } + async fn stop(self: Arc) -> Result<(), String> { self.stops.fetch_add(1, Ordering::SeqCst); Ok(()) @@ -10311,6 +10411,8 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("rpc-configure-agent"), FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "fixture-sdk".into(), cwd: "/workspace".into(), @@ -10338,9 +10440,10 @@ mod tests { incarnation_id: state.incarnation_id.clone(), request_id: RequestId::parse("rpc-configure-one").unwrap(), settings: freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, }, @@ -10355,6 +10458,7 @@ mod tests { HostResult::FreshAgentCommand { state: freshell_runtime_protocol::CommandState::ProviderAcked, native_session_id: Some(ref native), + .. } if native == "fixture-native-thread" )); } @@ -10455,6 +10559,159 @@ mod tests { } } + #[tokio::test] + async fn authenticated_child_observation_uses_parent_actor_and_large_read_reply_without_dispatch( + ) { + let state = test_host_state(); + let transport = Arc::new(RpcFixtureTransport { + dispatches: AtomicUsize::new(0), + checkpoint_attempts: std::sync::Mutex::new(Vec::new()), + decisions: AtomicUsize::new(0), + operations: AtomicUsize::new(0), + captures: AtomicUsize::new(0), + stops: AtomicUsize::new(0), + }); + let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( + state.state_dir.join("child-observer"), + FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, + provider: FreshProvider::Codex, + runtime_variant: "freshcodex".into(), + cwd: "/workspace".into(), + model: None, + effort: None, + permission_mode: None, + sandbox: None, + provider_store_id: "parent-volume".into(), + native_session_id: None, + plugins: None, + model_selection: None, + session_ref: None, + provider_launch_context: None, + provider_secret_references: vec![], + }, + transport.clone(), + ) + .await + .unwrap(); + let before = actor.profile().await; + *state.fresh_agent.lock().await = Some(actor.clone()); + let (mut client_stream, server_stream) = UnixStream::pair().unwrap(); + let serving = tokio::spawn(handle_connection(server_stream, state.clone())); + write_frame( + &mut client_stream, + &authenticated_host_envelope( + &state, + HostCommand::FreshAgentReadChild { + incarnation_id: state.incarnation_id.clone(), + child_thread_id: "fixture-child".into(), + }, + ), + ) + .await + .unwrap(); + let reply: HostReply = freshell_runtime_protocol::read_frame_with_limit( + &mut client_stream, + freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, + ) + .await + .unwrap(); + serving.await.unwrap().unwrap(); + let HostResult::FreshAgentChildSnapshot(value) = reply.result.unwrap() else { + panic!("expected child observation"); + }; + assert_eq!(value["parentThreadId"], "fixture-native-thread"); + assert_eq!( + value["snapshot"]["turns"][0]["text"] + .as_str() + .unwrap() + .len(), + 2 * freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES + ); + assert_eq!( + actor.profile().await.native_session_id, + before.native_session_id + ); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 0); + assert_eq!(transport.operations.load(Ordering::SeqCst), 0); + assert_eq!(transport.stops.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn authenticated_model_catalog_reads_the_parent_actor_without_starting_work() { + let state = test_host_state(); + let transport = Arc::new(RpcFixtureTransport { + dispatches: AtomicUsize::new(0), + checkpoint_attempts: std::sync::Mutex::new(Vec::new()), + decisions: AtomicUsize::new(0), + operations: AtomicUsize::new(0), + captures: AtomicUsize::new(0), + stops: AtomicUsize::new(0), + }); + let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( + state.state_dir.join("catalog-observer"), + FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, + provider: FreshProvider::Codex, + runtime_variant: "freshcodex".into(), + cwd: "/workspace".into(), + model: None, + effort: None, + permission_mode: None, + sandbox: None, + provider_store_id: "parent-volume".into(), + native_session_id: None, + plugins: None, + model_selection: None, + session_ref: None, + provider_launch_context: None, + provider_secret_references: vec![], + }, + transport.clone(), + ) + .await + .unwrap(); + let before = actor.profile().await; + *state.fresh_agent.lock().await = Some(actor.clone()); + let (mut client_stream, server_stream) = UnixStream::pair().unwrap(); + let serving = tokio::spawn(handle_connection(server_stream, state.clone())); + write_frame( + &mut client_stream, + &authenticated_host_envelope( + &state, + HostCommand::FreshAgentModelCatalog { + incarnation_id: state.incarnation_id.clone(), + cwd: Some("/workspace".into()), + }, + ), + ) + .await + .unwrap(); + let reply: HostReply = freshell_runtime_protocol::read_frame_with_limit( + &mut client_stream, + freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES, + ) + .await + .unwrap(); + serving.await.unwrap().unwrap(); + let HostResult::FreshAgentModelCatalog(value) = reply.result.unwrap() else { + panic!("expected model catalog"); + }; + assert_eq!(value[0]["id"], "rpc-catalog"); + assert_eq!(value[0]["defaultEffort"], "ultra"); + assert_eq!( + actor.profile().await.native_session_id, + before.native_session_id + ); + assert_eq!(transport.dispatches.load(Ordering::SeqCst), 0); + assert_eq!(transport.decisions.load(Ordering::SeqCst), 0); + assert_eq!(transport.operations.load(Ordering::SeqCst), 0); + assert_eq!(transport.stops.load(Ordering::SeqCst), 0); + } + #[tokio::test] async fn authenticated_fresh_agent_rpc_keeps_one_host_owned_transport_until_explicit_stop() { let state = test_host_state(); @@ -10469,6 +10726,8 @@ mod tests { let actor = freshell_agent_runtime::host_actor::FreshAgentHostActor::open( state.state_dir.join("rpc-fixture-agent"), FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "fixture-sdk".into(), cwd: "/workspace".into(), @@ -10515,6 +10774,7 @@ mod tests { HostResult::FreshAgentCommand { state: freshell_runtime_protocol::CommandState::ProviderAcked, native_session_id: Some(ref native), + .. } if native == "fixture-native-thread" )); diff --git a/crates/freshell-session-host/src/provider_input_preparation.rs b/crates/freshell-session-host/src/provider_input_preparation.rs index 2574d125d7..78c1249f7b 100644 --- a/crates/freshell-session-host/src/provider_input_preparation.rs +++ b/crates/freshell-session-host/src/provider_input_preparation.rs @@ -1695,6 +1695,7 @@ mod tests { let owned = tempfile::tempdir().unwrap(); std::fs::create_dir(owned.path().join(".freshell")).unwrap(); let manifest = freshell_platform::codex_inputs::CodexInputManifest { + native_source_runtime: freshell_platform::codex_inputs::NativeSourceRuntime::Tui, version: 1, original_home: "/absent-original-home".into(), cwd: "/absent-original-workspace".into(), @@ -1711,6 +1712,7 @@ mod tests { }; let context = ProviderLaunchContext { preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, diff --git a/crates/freshell-session-host/src/providers/cli.rs b/crates/freshell-session-host/src/providers/cli.rs index 0188d6ea5e..e64e613c8b 100644 --- a/crates/freshell-session-host/src/providers/cli.rs +++ b/crates/freshell-session-host/src/providers/cli.rs @@ -9,27 +9,7 @@ pub async fn probe_as_provider( run_as_gid: u32, ) -> Result { let executable = std::env::current_exe().map_err(|error| error.to_string())?; - // Fixture hosts already run as their intended workload uid/gid and have no - // SETGID capability. Calling `setpriv --clear-groups` in that case fails - // before the read-only probe starts. Real managed terminals target the - // provider uid and therefore take the capability-scoped setpriv path. - let current_uid = unsafe { libc::geteuid() }; - let current_gid = unsafe { libc::getegid() }; - let mut child = if needs_privilege_drop(run_as_uid, run_as_gid, current_uid, current_gid) { - let mut command = tokio::process::Command::new("/usr/bin/setpriv"); - command - .arg("--reuid") - .arg(run_as_uid.to_string()) - .arg("--regid") - .arg(run_as_gid.to_string()) - .arg("--clear-groups") - .arg("--no-new-privs") - .arg("--") - .arg(&executable); - command - } else { - tokio::process::Command::new(&executable) - }; + let mut child = provider_command(&executable, run_as_uid, run_as_gid); child .arg("provider-probe-worker") .arg("--provider-home") @@ -65,6 +45,34 @@ pub async fn probe_as_provider( .map_err(|error| format!("decode provider recovery probe: {error}")) } +pub(super) fn provider_command( + executable: &Path, + run_as_uid: u32, + run_as_gid: u32, +) -> tokio::process::Command { + // Fixture hosts already run as their intended workload uid/gid and have no + // SETGID capability. Calling `setpriv --clear-groups` in that case fails + // before the read-only probe starts. Real managed terminals target the + // provider uid and therefore take the capability-scoped setpriv path. + let current_uid = unsafe { libc::geteuid() }; + let current_gid = unsafe { libc::getegid() }; + if needs_privilege_drop(run_as_uid, run_as_gid, current_uid, current_gid) { + let mut command = tokio::process::Command::new("/usr/bin/setpriv"); + command + .arg("--reuid") + .arg(run_as_uid.to_string()) + .arg("--regid") + .arg(run_as_gid.to_string()) + .arg("--clear-groups") + .arg("--no-new-privs") + .arg("--") + .arg(executable); + command + } else { + tokio::process::Command::new(executable) + } +} + pub fn run_probe_worker(args: &[String]) -> Result<(), String> { let provider_home = required_arg(args, "--provider-home")?; let resume_spec: ResumeSpec = serde_json::from_reader(std::io::stdin()) diff --git a/crates/freshell-session-host/src/providers/codex.rs b/crates/freshell-session-host/src/providers/codex.rs index 2acaac4ff1..56984e9361 100644 --- a/crates/freshell-session-host/src/providers/codex.rs +++ b/crates/freshell-session-host/src/providers/codex.rs @@ -8,10 +8,183 @@ use freshell_codex::{ }, remote_proxy::RemoteProxyEvent, }; +#[cfg(target_os = "linux")] +use freshell_freshagent::session_lease::kill_and_confirm_recorded_tree_dead; +use freshell_freshagent::session_lease::CondemnedRuntimeIdentity; use freshell_runtime_protocol::{ResumeSpec, TerminalLaunchSpec}; use std::sync::Arc; use tokio::sync::mpsc; +/// Retire only a sidecar identity already owned by this host invocation. +/// Run under the provider uid so private /proc evidence and signals retain +/// their normal permissions; the host gains no DAC or KILL capability. +pub async fn cleanup_sidecar( + recorded: CondemnedRuntimeIdentity, + uid: u32, + gid: u32, +) -> Result { + use tokio::io::AsyncWriteExt; + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let mut child = cli::provider_command(std::path::Path::new("/usr/bin/timeout"), uid, gid) + .args(["--kill-after=1s", "5s"]) + .arg(executable) + .args([ + "codex-owned-cleanup", + "--uid", + &uid.to_string(), + "--gid", + &gid.to_string(), + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|error| format!("run owned Codex cleanup: {error}"))?; + let payload = owned_cleanup_payload(&recorded)?; + let mut input = child + .stdin + .take() + .ok_or("owned Codex cleanup has no input")?; + input + .write_all(&payload) + .await + .map_err(|error| error.to_string())?; + drop(input); + let output = child + .wait_with_output() + .await + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(format!( + "owned Codex cleanup exited {:?}", + output.status.code() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("decode owned Codex cleanup: {error}")) +} + +fn owned_cleanup_payload(recorded: &CondemnedRuntimeIdentity) -> Result, String> { + serde_json::to_vec(recorded).map_err(|error| error.to_string()) +} + +async fn confirm_owned_cleanup_input(input: impl std::io::Read) -> Result { + let request: CondemnedRuntimeIdentity = serde_json::from_reader(input.take(16 * 1024)) + .map_err(|_| "invalid owned Codex cleanup request")?; + Ok(confirm_owned_cleanup(request).await) +} + +pub async fn run_owned_cleanup(args: &[String]) -> Result<(), String> { + let identity = |key: &str| { + args.windows(2) + .find(|pair| pair[0] == key) + .and_then(|pair| pair[1].parse::().ok()) + .ok_or_else(|| format!("missing cleanup identity {key}")) + }; + if unsafe { libc::geteuid() } != identity("--uid")? + || unsafe { libc::getegid() } != identity("--gid")? + { + return Err("owned Codex cleanup identity mismatch".into()); + } + let confirmed = confirm_owned_cleanup_input(std::io::stdin()).await?; + serde_json::to_writer(std::io::stdout(), &confirmed) + .map_err(|error| format!("encode owned Codex cleanup: {error}")) +} + +async fn confirm_owned_cleanup(request: CondemnedRuntimeIdentity) -> bool { + #[cfg(target_os = "linux")] + { + use freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV; + if !request.capture_complete { + return false; + } + let Some(expected_start) = request.start_time else { + return false; + }; + if request.pid == 0 + || request.pid > i32::MAX as u32 + || request + .ownership_id + .strip_prefix("codex-sidecar-") + .and_then(|id| uuid::Uuid::parse_str(id).ok()) + .is_none() + { + return false; + } + if let Some(observed) = freshell_codex::sidecar_store::proc_starttime(request.pid as i32) { + if observed != expected_start { + return false; + } + let Ok(environment) = std::fs::read(format!("/proc/{}/environ", request.pid)) else { + return false; + }; + let needle = freshell_codex::durability::ownership_needle(&request.ownership_id); + if !environment + .split(|byte| *byte == 0) + .any(|entry| entry == needle.as_bytes()) + { + return false; + } + } + // The common kill path enriches under this provider identity before + // any signal, retaining every original member and coverage gap. + kill_and_confirm_recorded_tree_dead(&request, CODEX_SIDECAR_OWNERSHIP_ENV).await + } + #[cfg(not(target_os = "linux"))] + { + let _ = request; + false + } +} + +/// Inspect the native recorder under its owner, without starting or resuming +/// Codex and without giving the host access to the private provider HOME. +pub async fn naming_rollout( + home: String, + thread_id: String, + uid: u32, + gid: u32, +) -> Result, String> { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let output = cli::provider_command(std::path::Path::new("/usr/bin/timeout"), uid, gid) + .args(["--kill-after=1s", "5s"]) + .arg(executable) + .args([ + "codex-naming-proof", + "--codex-home", + &home, + "--thread-id", + &thread_id, + ]) + .stdin(std::process::Stdio::null()) + .output() + .await + .map_err(|error| format!("run Codex naming proof: {error}"))?; + if !output.status.success() { + return Err(format!( + "Codex naming proof exited {:?}", + output.status.code() + )); + } + serde_json::from_slice(&output.stdout) + .map_err(|error| format!("decode Codex naming proof: {error}")) +} + +pub fn run_naming_proof(args: &[String]) -> Result<(), String> { + let argument = |key: &str| { + args.windows(2) + .find(|pair| pair[0] == key) + .map(|pair| pair[1].as_str()) + .ok_or_else(|| format!("missing {key}")) + }; + let rollout = freshell_codex::durability::locate_thread_rollout_in_home( + std::path::Path::new(argument("--codex-home")?), + argument("--thread-id")?, + ); + serde_json::to_writer(std::io::stdout(), &rollout) + .map_err(|error| format!("encode Codex naming proof: {error}")) +} + pub struct PreparedCodexLaunch { pub sidecar: Arc, pub events: mpsc::UnboundedReceiver, @@ -81,3 +254,329 @@ pub async fn probe( } cli::probe_as_provider(spec, run_as_uid, run_as_gid).await } + +#[cfg(all(test, target_os = "linux"))] +mod cleanup_tests { + use super::*; + use freshell_freshagent::session_lease::record_condemned_runtime_identity; + + #[tokio::test] + async fn owned_cleanup_refuses_a_late_opaque_reparented_writer() { + let _process_fixture = super::super::fresh_agent::NATIVE_PROVIDER_FIXTURE_LOCK + .lock() + .await; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let tag = format!("codex-sidecar-{}", uuid::Uuid::new_v4()); + let mut parent = tokio::process::Command::new("python3") + .args([ + "-c", + r#" +import ctypes, os, sys +print('ready', flush=True) +assert sys.stdin.readline() == 'fork\n' +reader, writer = os.pipe() +child = os.fork() +if child == 0: + os.close(writer) + assert ctypes.CDLL(None).prctl(4,0,0,0,0) == 0 + os.write(1, (str(os.getpid())+'\n').encode()) + assert os.read(reader,1) == b'H' + assert os.read(0,1) == b'X' + os._exit(0) +os.close(reader) +assert sys.stdin.readline() == 'exit\n' +os.write(writer,b'H') +os._exit(0) +"#, + ]) + .env( + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let pid = parent.id().unwrap(); + let mut commands = parent.stdin.take().unwrap(); + let mut replies = BufReader::new(parent.stdout.take().unwrap()).lines(); + assert_eq!(replies.next_line().await.unwrap().as_deref(), Some("ready")); + let original = record_condemned_runtime_identity( + pid, + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ); + commands.write_all(b"fork\n").await.unwrap(); + let child: i32 = replies.next_line().await.unwrap().unwrap().parse().unwrap(); + let start = freshell_codex::sidecar_store::proc_starttime(child).unwrap(); + assert!(!original.tree.iter().any(|(pid, _)| *pid == child)); + commands.write_all(b"exit\n").await.unwrap(); + assert!(parent.wait().await.unwrap().success()); + assert_eq!( + std::fs::read(format!("/proc/{child}/environ")) + .unwrap_err() + .kind(), + std::io::ErrorKind::PermissionDenied + ); + let confirmed = + confirm_owned_cleanup_input(owned_cleanup_payload(&original).unwrap().as_slice()) + .await + .unwrap(); + let still_live = freshell_codex::sidecar_store::proc_starttime(child) == Some(start); + // The inherited control pipe closes this real orphan even on Red. + commands.write_all(b"X").await.unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while freshell_codex::sidecar_store::proc_starttime(child) == Some(start) { + assert!(std::time::Instant::now() < deadline); + tokio::task::yield_now().await; + } + assert!(still_live, "unproved ownership must not authorize a signal"); + assert!( + !confirmed, + "a later opaque orphan is unknown, not proven unrelated or dead" + ); + } + + #[tokio::test] + async fn owned_cleanup_preserves_the_original_reparented_descendant() { + let _process_fixture = super::super::fresh_agent::NATIVE_PROVIDER_FIXTURE_LOCK + .lock() + .await; + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + let tag = format!("codex-sidecar-{}", uuid::Uuid::new_v4()); + // Both readiness acknowledgements precede the next command. The + // descendant becomes unreadable only after the complete record exists. + let mut parent = tokio::process::Command::new("python3") + .args([ + "-c", + r#" +import ctypes, os, sys, time +commands_r, commands_w = os.pipe() +acks_r, acks_w = os.pipe() +descendant = os.fork() +if descendant == 0: + os.close(commands_w) + os.close(acks_r) + os.write(acks_w, b'R') + assert os.read(commands_r, 1) == b'H' + assert ctypes.CDLL(None).prctl(4, 0, 0, 0, 0) == 0 + os.write(acks_w, b'H') + time.sleep(300) + os._exit(0) +os.close(commands_r) +os.close(acks_w) +assert os.read(acks_r, 1) == b'R' +print(descendant, flush=True) +assert sys.stdin.readline() == 'hide\n' +os.write(commands_w, b'H') +assert os.read(acks_r, 1) == b'H' +print('hidden', flush=True) +assert sys.stdin.readline() == 'exit\n' +os._exit(0) +"#, + ]) + .env( + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let pid = parent.id().unwrap(); + let mut commands = parent.stdin.take().unwrap(); + let mut replies = BufReader::new(parent.stdout.take().unwrap()).lines(); + let descendant: i32 = replies.next_line().await.unwrap().unwrap().parse().unwrap(); + let original = record_condemned_runtime_identity( + pid, + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ); + assert!(original.capture_complete); + assert!(original.start_time.is_some()); + assert!(original + .tree + .iter() + .any(|(member, _)| *member == pid as i32)); + assert!(original + .tree + .iter() + .any(|(member, _)| *member == descendant)); + commands.write_all(b"hide\n").await.unwrap(); + assert_eq!( + replies.next_line().await.unwrap().as_deref(), + Some("hidden") + ); + assert_eq!( + std::fs::read(format!("/proc/{descendant}/environ")) + .unwrap_err() + .kind(), + std::io::ErrorKind::PermissionDenied, + ); + commands.write_all(b"exit\n").await.unwrap(); + assert!(parent.wait().await.unwrap().success()); + let status = std::fs::read_to_string(format!("/proc/{descendant}/status")).unwrap(); + let ppid = status + .lines() + .find_map(|line| line.strip_prefix("PPid:")) + .unwrap() + .trim() + .parse::() + .unwrap(); + assert_ne!(ppid, pid, "the original descendant has actually reparented"); + + // Exercise the same encoder and stdin parser used by the provider + // helper, rather than asserting serialized field names. + let payload = owned_cleanup_payload(&original).unwrap(); + let confirmed = confirm_owned_cleanup_input(payload.as_slice()) + .await + .unwrap(); + let descendant_still_live = + freshell_codex::sidecar_store::proc_starttime(descendant).is_some(); + // Close the fixture even when the regression returns a false proof. + assert!( + kill_and_confirm_recorded_tree_dead( + &original, + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + ) + .await + ); + assert!( + confirmed, + "cleanup must confirm the same complete original tree" + ); + assert!( + !descendant_still_live, + "cleanup confirmed while an original recorded descendant was still live" + ); + } + + #[tokio::test] + async fn owned_cleanup_refuses_incomplete_original_without_signaling() { + let _process_fixture = super::super::fresh_agent::NATIVE_PROVIDER_FIXTURE_LOCK + .lock() + .await; + let tag = format!("codex-sidecar-{}", uuid::Uuid::new_v4()); + let mut child = tokio::process::Command::new("sleep") + .arg("60") + .env( + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ) + .kill_on_drop(true) + .spawn() + .unwrap(); + let original = record_condemned_runtime_identity( + child.id().unwrap(), + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ); + assert!(original.capture_complete && original.start_time.is_some()); + let legacy = serde_json::to_vec(&serde_json::json!({ + "pid":original.pid,"startTime":original.start_time,"ownershipId":original.ownership_id, + })) + .unwrap(); + let legacy_rejected = confirm_owned_cleanup_input(legacy.as_slice()) + .await + .is_err(); + let legacy_signaled = child.try_wait().unwrap().is_some(); + let mut incomplete = original.clone(); + incomplete.capture_complete = false; + let payload = owned_cleanup_payload(&incomplete).unwrap(); + let confirmed = confirm_owned_cleanup_input(payload.as_slice()) + .await + .unwrap(); + let signaled = child.try_wait().unwrap().is_some(); + assert!( + kill_and_confirm_recorded_tree_dead( + &original, + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + ) + .await + ); + child.wait().await.unwrap(); + assert!( + legacy_rejected, + "missing original tree/completeness must reject the legacy request" + ); + assert!( + !legacy_signaled, + "legacy input must not signal the live occupant" + ); + assert!( + !confirmed, + "an incomplete original record cannot become a complete fresh capture" + ); + assert!( + !signaled, + "incomplete original evidence must not signal its live occupant" + ); + } + + #[tokio::test] + async fn task010_owned_cleanup_rejects_unknown_and_stale_identity_before_signaling() { + let _process_fixture = super::super::fresh_agent::NATIVE_PROVIDER_FIXTURE_LOCK + .lock() + .await; + let tag = format!("codex-sidecar-{}", uuid::Uuid::new_v4()); + let mut child = tokio::process::Command::new("sleep") + .arg("60") + .env( + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + &tag, + ) + .kill_on_drop(true) + .spawn() + .unwrap(); + let pid = child.id().unwrap(); + let start = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let env = std::fs::read(format!("/proc/{pid}/environ")).unwrap_or_default(); + if env.split(|byte| *byte == 0).any(|entry| { + entry == freshell_codex::durability::ownership_needle(&tag).as_bytes() + }) { + break freshell_codex::sidecar_store::proc_starttime(pid as i32).unwrap(); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + for (start_time, ownership_id) in [ + (None, tag.clone()), + (Some(start + 1), tag.clone()), + ( + Some(start), + format!("codex-sidecar-{}", uuid::Uuid::new_v4()), + ), + ] { + assert!( + !confirm_owned_cleanup(CondemnedRuntimeIdentity { + pid, + start_time, + ownership_id, + tree: vec![(pid as i32, start)], + capture_complete: true, + }) + .await + ); + assert!( + child.try_wait().unwrap().is_none(), + "unknown or stale identity must not signal even this test's child" + ); + } + assert!( + confirm_owned_cleanup(CondemnedRuntimeIdentity { + pid, + start_time: Some(start), + ownership_id: tag, + tree: vec![(pid as i32, start)], + capture_complete: true, + }) + .await + ); + assert!(!child.wait().await.unwrap().success()); + } +} diff --git a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs index baeb3172dd..5f4ae8d5c2 100644 --- a/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/deterministic_fresh_agent.rs @@ -244,6 +244,7 @@ impl FreshAgentTransport for DeterministicFreshAgentTransport { .await .map_err(|_| non_ambiguous("deterministic event receiver closed"))?; return Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }); } @@ -255,6 +256,7 @@ impl FreshAgentTransport for DeterministicFreshAgentTransport { self.emit_completion(request_id).await?; } Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }) } @@ -644,6 +646,8 @@ mod tests { #[test] fn fixture_native_identity_is_store_and_provider_specific() { let profile = FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Codex, runtime_variant: "codex-app-server".into(), cwd: "/workspace".into(), @@ -678,6 +682,8 @@ mod tests { async fn provider_native_completion_crosses_the_durable_actor_once() { let root = tempfile::tempdir().unwrap(); let profile = FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "opencode-per-soul-http".into(), cwd: "/workspace".into(), diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 342cc1e980..4660c8bf1f 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -7,8 +7,8 @@ use async_trait::async_trait; use freshell_agent_runtime::host_actor::{ DispatchAck, DispatchFailure, ForkTransition, FreshAgentHostActor, FreshAgentOperation, - FreshAgentProfile, FreshAgentTransport, OperationAck, OperationFailure, OperationFailureKind, - TransportStart, + FreshAgentProfile, FreshAgentTransport, NativeDispatchIdentity, OperationAck, OperationFailure, + OperationFailureKind, TransportStart, }; use freshell_agent_runtime::snapshot_text::render_snapshot_text; use freshell_codex::launch_plan::CodexSidecarLaunchContext; @@ -16,12 +16,13 @@ use freshell_freshagent::rollback_record::{RollbackDirection, RollbackModeReq, R use freshell_freshagent::{FreshAgentState, FreshClaudeState, FreshCodexState, FreshOpencodeState}; use freshell_protocol::{ AgentProvider, FreshAgentApprovalRespond, FreshAgentCheckpointIntent, FreshAgentCompact, - FreshAgentConfigure, FreshAgentCreate, FreshAgentFork, FreshAgentInterrupt, FreshAgentKill, - FreshAgentQuestionRespond, FreshAgentSend, FreshAgentSendSettings, ServerMessage, - SessionLocator, SessionType, StringOrNumber, + FreshAgentConfigure, FreshAgentCreate, FreshAgentElicitationRespond, FreshAgentFork, + FreshAgentInterrupt, FreshAgentKill, FreshAgentQuestionRespond, FreshAgentSend, + FreshAgentSendSettings, ServerMessage, SessionLocator, SessionType, StringOrNumber, }; use freshell_runtime_protocol::{ AgentEvent, FreshAgentCapture, FreshAgentFixtureTransport, FreshAgentLaunchSpec, + FreshAgentNativeNameError, FreshAgentNativeNameOperation, FreshAgentNativeNameReply, FreshAgentRollbackDirection, FreshAgentRollbackMode, FreshProvider, ProviderLaunchContext, ProviderPreparation, RequestId, }; @@ -64,7 +65,7 @@ struct HostedTransport { /// journal); an operation subscribes to read the provider's answer. provider_frames: Arc>, profile: std::sync::Mutex>, - session_id: Mutex>, + session_id: Arc>>, created_rx: Mutex>>>, native_rx: Mutex>>, /// The native identity's writer, shared with the bridge: a claude fork @@ -77,6 +78,7 @@ struct HostedTransport { suppressed_kills: Arc>>, event_tx: mpsc::Sender, event_rx: std::sync::Mutex>>, + _bridge: BroadcastBridgeTasks, #[cfg(test)] test_delegate: Option>, } @@ -88,11 +90,21 @@ pub(crate) async fn open_hosted_fresh_agent( let profile = profile_from_launch(&launch); let transport: Arc = match launch.fixture_transport { None => { - HostedTransport::new_with_context( + let transport = HostedTransport::new_with_context( launch.provider, profile.provider_launch_context.as_ref(), ) - .await + .await; + if let ProviderState::Codex(state) = &transport.state { + let (uid, gid) = (launch.run_as_uid, launch.run_as_gid); + state.set_naming_rollout_probe(Arc::new(move |home, thread| { + Box::pin(super::codex::naming_rollout(home, thread, uid, gid)) + })); + state.set_sidecar_cleanup_probe(Arc::new(move |recorded| { + Box::pin(super::codex::cleanup_sidecar(recorded, uid, gid)) + })); + } + transport } Some(FreshAgentFixtureTransport::Deterministic) => { deterministic_transport(&profile, launch.run_as_uid, launch.run_as_gid).await? @@ -108,6 +120,8 @@ pub(crate) async fn open_hosted_fresh_agent( fn profile_from_launch(launch: &FreshAgentLaunchSpec) -> FreshAgentProfile { FreshAgentProfile { + naming_handle: launch.naming_handle.clone(), + collaboration_mode: launch.collaboration_mode.clone(), provider: launch.provider.clone(), runtime_variant: launch.runtime_variant.clone(), cwd: launch.cwd.clone(), @@ -211,7 +225,8 @@ impl HostedTransport { provider_exec_profile: true, config_args: sidecar_args, env: Default::default(), - }), + }) + .with_hosted_naming_events(), ) } FreshProvider::Opencode => { @@ -242,10 +257,16 @@ impl HostedTransport { let (send_outcomes, _) = broadcast::channel(64); let (kill_outcomes, _) = broadcast::channel(64); let suppressed_kills = Arc::new(std::sync::Mutex::new(HashSet::new())); - spawn_broadcast_bridge( + let session_id = Arc::new(Mutex::new(None)); + let bridge = spawn_broadcast_bridge( provider.clone(), broadcast_rx, BroadcastBridgeOutputs { + owned_session: Some(session_id.clone()), + retained_codex: match &state { + ProviderState::Codex(state) => Some(state.clone()), + _ => None, + }, created: created_tx, native: native_tx.clone(), events: event_tx.clone(), @@ -259,7 +280,7 @@ impl HostedTransport { state, provider_frames: broadcast_tx, profile: std::sync::Mutex::new(None), - session_id: Mutex::new(None), + session_id, created_rx: Mutex::new(created_rx), native_rx: Mutex::new(native_rx), native_tx, @@ -268,6 +289,7 @@ impl HostedTransport { suppressed_kills, event_tx, event_rx: std::sync::Mutex::new(Some(event_rx)), + _bridge: bridge, #[cfg(test)] test_delegate: None, }) @@ -470,7 +492,9 @@ impl FreshAgentTransport for HostedTransport { ProviderState::Claude(state) => { state.handle_create(create, None).await; } - ProviderState::Codex(state) => state.handle_create(create, None).await, + ProviderState::Codex(state) => { + state.handle_create(create, None).await; + } ProviderState::Opencode { runtime, .. } => runtime.handle_create(create, None).await, } let session_id = self.wait_created().await?; @@ -515,6 +539,32 @@ impl FreshAgentTransport for HostedTransport { text: &str, profile: &FreshAgentProfile, logical_attempt_id: Option<&str>, + ) -> Result { + let settings = freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: profile.collaboration_mode.clone(), + cwd: Some(profile.cwd.clone()), + model: profile.model.clone(), + effort: Some(profile.effort.clone()), + permission_mode: profile.effective_permission_mode().map(str::to_owned), + sandbox: profile.sandbox.clone(), + }; + self.dispatch_with_turn_settings( + request_id, + text, + profile, + Some(&settings), + logical_attempt_id, + ) + .await + } + + async fn dispatch_with_turn_settings( + &self, + request_id: &RequestId, + text: &str, + profile: &FreshAgentProfile, + requested: Option<&freshell_runtime_protocol::FreshAgentTurnSettings>, + logical_attempt_id: Option<&str>, ) -> Result { #[cfg(test)] if let Some(delegate) = self.test_delegate.as_ref() { @@ -530,25 +580,32 @@ impl FreshAgentTransport for HostedTransport { acceptance_ambiguous: false, })?; *self.profile.lock().expect("profile lock") = Some(profile.clone()); + let permission_mode = profile.effective_permission_mode().map(str::to_owned); let profile = profile.clone(); + let settings = if self.provider == FreshProvider::Codex { + requested.map(provider_send_settings) + } else { + Some(FreshAgentSendSettings { + collaboration_mode: profile.collaboration_mode, + cwd: Some(profile.cwd), + effort: profile.effort.map(Some), + model: profile.model, + permission_mode, + sandbox: profile.sandbox.as_deref().and_then(parse_sandbox), + }) + }; let mut outcomes = self.send_outcomes.subscribe(); let send = FreshAgentSend { observed_epoch: None, observed_generation: None, provider: self.provider_wire(), - session_id, + session_id: session_id.clone(), session_type: self.session_type(), text: text.to_string(), - cwd: Some(profile.cwd.clone()), + cwd: settings.as_ref().and_then(|settings| settings.cwd.clone()), images: None, request_id: Some(request_id.as_str().to_string()), - settings: Some(FreshAgentSendSettings { - cwd: Some(profile.cwd), - effort: profile.effort, - model: profile.model, - permission_mode: profile.permission_mode, - sandbox: profile.sandbox.as_deref().and_then(parse_sandbox), - }), + settings, checkpoint_intent: logical_attempt_id.map(|logical_attempt_id| { FreshAgentCheckpointIntent { logical_attempt_id: logical_attempt_id.to_string(), @@ -557,11 +614,55 @@ impl FreshAgentTransport for HostedTransport { }), }; match &self.state { - // The acceptance is read from the bus below, as for every provider. + // Legacy providers expose acceptance through their existing bus. ProviderState::Claude(state) => { state.handle_send(send).await; } - ProviderState::Codex(state) => state.handle_send(send).await, + ProviderState::Codex(state) => { + use freshell_freshagent::codex::CodexSendOutcome; + return match state.handle_send(send).await { + CodexSendOutcome::Accepted { + session_id: native, + submitted_turn_id, + incarnation_id, + pending_naming_handle, + } => { + let mut current = self.session_id.lock().await; + if current.as_deref() != Some(session_id.as_str()) { + return Err(DispatchFailure { + message: "native send source changed after acceptance".into(), + acceptance_ambiguous: true, + }); + } + *current = Some(native.clone()); + if let Some(profile) = self.profile.lock().expect("profile lock").as_mut() { + profile.native_session_id = Some(native.clone()); + profile.naming_handle = pending_naming_handle.clone(); + if let Some(reference) = profile.session_ref.as_mut() { + reference.session_id = native.clone(); + } + } + self.native_tx.send_replace(Some(native.clone())); + tracing::info!(request_id=%request_id,native_session_id=%native,native_incarnation_id=%incarnation_id,"freshagent.host.native_dispatch_accepted"); + Ok(DispatchAck { + provider_ack_id: Some(submitted_turn_id), + native_identity: Some(NativeDispatchIdentity { + previous_native_session_id: session_id, + native_session_id: native, + pending_naming_handle, + }), + }) + } + CodexSendOutcome::Refused { code, message, .. } => Err(DispatchFailure { + message: format!("{code}: {message}"), + acceptance_ambiguous: false, + }), + CodexSendOutcome::Ambiguous { code, message, .. } => Err(DispatchFailure { + message: format!("{code}: {message}"), + acceptance_ambiguous: true, + }), + }; + } ProviderState::Opencode { runtime, .. } => runtime.handle_send(send).await, } match tokio::time::timeout(SEND_ACK_TIMEOUT, async { @@ -580,6 +681,7 @@ impl FreshAgentTransport for HostedTransport { .await { Ok(Ok(true)) => Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().to_string()), }), Ok(Ok(false)) => Err(DispatchFailure { @@ -743,6 +845,40 @@ impl FreshAgentTransport for HostedTransport { acceptance_ambiguous: false, })?; let request_id = StringOrNumber::Str(decision_id.to_string()); + if let Some(elicitation) = decision.get("elicitation") { + let message = FreshAgentElicitationRespond { + provider: self.provider_wire(), + session_id, + session_type: self.session_type(), + request_id, + cwd: None, + action: serde_json::from_value(elicitation["action"].clone()).map_err(|_| { + DispatchFailure { + message: "invalid elicitation action".into(), + acceptance_ambiguous: false, + } + })?, + content: serde_json::from_value(elicitation["content"].clone()).map_err(|_| { + DispatchFailure { + message: "invalid elicitation content".into(), + acceptance_ambiguous: false, + } + })?, + }; + return match &self.state { + ProviderState::Codex(state) => { + if state.try_handle_elicitation_respond(message).await { + Ok(()) + } else { + Err(DispatchFailure { message:"Codex did not accept this elicitation response; the request remains pending.".into(), acceptance_ambiguous:false }) + } + } + _ => Err(DispatchFailure { + message: "provider has no elicitation flow".into(), + acceptance_ambiguous: false, + }), + }; + } if let Some(answers) = decision.get("answers").and_then(Value::as_object) { let answers = answers .iter() @@ -758,7 +894,11 @@ impl FreshAgentTransport for HostedTransport { }; match &self.state { ProviderState::Claude(state) => state.handle_question_respond(message).await, - ProviderState::Codex(state) => state.handle_question_respond(message).await, + ProviderState::Codex(state) => { + if !state.try_handle_question_respond(message).await { + return Err(DispatchFailure {message:"Codex did not accept this question answer; the request remains pending.".into(),acceptance_ambiguous:false}); + } + } ProviderState::Opencode { .. } => { return Err(DispatchFailure { message: "provider has no question flow".into(), @@ -777,7 +917,11 @@ impl FreshAgentTransport for HostedTransport { }; match &self.state { ProviderState::Claude(state) => state.handle_approval_respond(message).await, - ProviderState::Codex(state) => state.handle_approval_respond(message).await, + ProviderState::Codex(state) => { + if !state.try_handle_approval_respond(message).await { + return Err(DispatchFailure {message:"Codex did not accept this approval decision; the request remains pending.".into(),acceptance_ambiguous:false}); + } + } ProviderState::Opencode { .. } => { return Err(DispatchFailure { message: "provider has no approval flow".into(), @@ -888,13 +1032,7 @@ impl FreshAgentTransport for HostedTransport { session_type: self.session_type(), cwd: settings.cwd.clone(), request_id: Some(request_id.as_str().to_string()), - settings: FreshAgentSendSettings { - cwd: settings.cwd.clone(), - effort: settings.effort.clone(), - model: settings.model.clone(), - permission_mode: settings.permission_mode.clone(), - sandbox: settings.sandbox.as_deref().and_then(parse_sandbox), - }, + settings: provider_send_settings(settings), }; match &self.state { ProviderState::Claude(state) => state.handle_configure(message).await, @@ -1010,6 +1148,21 @@ impl FreshAgentTransport for HostedTransport { }) } + async fn retained_state(&self) -> Result, String> { + let ProviderState::Codex(state) = &self.state else { + return Ok(None); + }; + let native_id = self.session_id.lock().await.clone(); + match native_id { + Some(id) => state + .retained_state(&id) + .await + .map(Some) + .map_err(|error| format!("codex retained state unavailable: {error:?}")), + None => Ok(None), + } + } + async fn snapshot(&self) -> Result { #[cfg(test)] if let Some(delegate) = self.test_delegate.as_ref() { @@ -1032,6 +1185,125 @@ impl FreshAgentTransport for HostedTransport { Ok(snapshot) } + async fn model_catalog(&self, cwd: Option<&str>) -> Result { + let before = self.current_native_id().await?; + let ProviderState::Codex(state) = &self.state else { + return Err("model catalog requires a bound Codex runtime".into()); + }; + let value = state + .model_catalog( + freshell_freshagent::model_capabilities::CodexCatalogContext { + session_id: before.clone(), + session_ref: None, + cwd: cwd.map(str::to_string), + observed_epoch: None, + observed_generation: None, + }, + ) + .await + .map_err(|error| format!("{}: {}", error.code, error.message))?; + if self.current_native_id().await? != before { + return Err("model catalog source changed".into()); + } + Ok(value) + } + + async fn native_name( + &self, + native_session_id: &str, + codex_home: &str, + operation: &FreshAgentNativeNameOperation, + ) -> FreshAgentNativeNameReply { + let unavailable = || { + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "no current root-matched native naming connection".into(), + }) + }; + if self.current_native_id().await.as_deref() != Ok(native_session_id) { + return unavailable(); + } + let ProviderState::Codex(state) = &self.state else { + return unavailable(); + }; + let Some(client) = state.management_client_for_root(codex_home).await else { + return unavailable(); + }; + // The probe inspects existing authority only; it sends no native RPC. + let result = match operation { + FreshAgentNativeNameOperation::Probe => return FreshAgentNativeNameReply::Available, + FreshAgentNativeNameOperation::Read => client + .read_thread_metadata(native_session_id) + .await + .and_then(|value| { + if value["thread"]["id"].as_str() != Some(native_session_id) { + return Err( + freshell_codex::app_server::CodexAppServerError::InvalidResponse { + method: "thread/read".into(), + detail: "native metadata names another thread".into(), + }, + ); + } + Ok(FreshAgentNativeNameReply::Title( + freshell_codex::protocol::thread_name_from_result(&value), + )) + }), + FreshAgentNativeNameOperation::Write { title } => client + .set_thread_name(native_session_id, title) + .await + .map(|()| FreshAgentNativeNameReply::Renamed), + }; + if self.current_native_id().await.as_deref() != Ok(native_session_id) { + return FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed); + } + result.unwrap_or_else(|error| { + use freshell_codex::app_server::CodexAppServerError as Error; + let failure_kind = match &error { + Error::Rpc { .. } => "rpc", + Error::Timeout { .. } => "timeout", + Error::Closed { .. } => "closed", + Error::Transport { .. } => "transport", + Error::InvalidResponse { .. } => "invalid_response", + }; + tracing::warn!( + provider = "codex", + native_session_id, + failure_kind, + "freshagent.codex.native_name_failed" + ); + FreshAgentNativeNameReply::Failed(match error { + Error::Rpc { error, .. } => FreshAgentNativeNameError::Rpc { + code: error.code, + message: error.message, + }, + Error::Timeout { timeout_ms, .. } => { + FreshAgentNativeNameError::Timeout { timeout_ms } + } + Error::Closed { .. } => FreshAgentNativeNameError::Closed, + Error::Transport { message, .. } => { + FreshAgentNativeNameError::Transport { message } + } + Error::InvalidResponse { detail, .. } => { + FreshAgentNativeNameError::InvalidResponse { detail } + } + }) + }) + } + + async fn read_child(&self, child_thread_id: &str) -> Result { + #[cfg(test)] + if let Some(delegate) = self.test_delegate.as_ref() { + return delegate.read_child(child_thread_id).await; + } + let parent = self.current_native_id().await?; + match &self.state { + ProviderState::Codex(state) => state + .read_child(&parent, child_thread_id, None, None) + .await + .map_err(|error| format!("codex child observation unavailable: {error:?}")), + _ => Err("provider does not expose child observation".into()), + } + } + async fn registered_snapshot_identity(&self) -> Option { if self.provider != FreshProvider::Opencode || self.native_rx.lock().await.borrow().is_some() @@ -1094,6 +1366,19 @@ impl FreshAgentTransport for HostedTransport { } } +fn provider_send_settings( + settings: &freshell_runtime_protocol::FreshAgentTurnSettings, +) -> FreshAgentSendSettings { + FreshAgentSendSettings { + collaboration_mode: settings.collaboration_mode.clone(), + cwd: settings.cwd.clone(), + model: settings.model.clone(), + effort: settings.effort.clone(), + permission_mode: settings.permission_mode.clone(), + sandbox: settings.sandbox.as_deref().and_then(parse_sandbox), + } +} + fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { let (provider, session_type, provider_name) = match profile.provider { FreshProvider::Claude => (AgentProvider::Claude, SessionType::Freshclaude, "claude"), @@ -1106,14 +1391,19 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { ), }; FreshAgentCreate { + collaboration_mode: profile.collaboration_mode.clone(), pane_id: None, request_id: format!("host-create-{}", uuid::Uuid::new_v4()), observed_epoch: None, observed_generation: None, - naming_handle: None, + naming_handle: profile.naming_handle.clone(), session_type, cwd: Some(profile.cwd.clone()), - effort: profile.effort.clone(), + effort: if profile.provider == FreshProvider::Codex { + Some(profile.effort.clone()) + } else { + profile.effort.clone().map(Some) + }, legacy_restore_context: None, model: profile.model.clone(), model_selection: profile.model_selection.as_ref().map(|selection| { @@ -1124,7 +1414,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { model_id: selection.model_id.clone(), }) }), - permission_mode: profile.permission_mode.clone(), + permission_mode: profile.effective_permission_mode().map(str::to_owned), plugins: profile.plugins.clone(), provider: Some(provider), resume_session_id: None, @@ -1150,6 +1440,8 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { } struct BroadcastBridgeOutputs { + owned_session: Option>>>, + retained_codex: Option, created: watch::Sender>>, native: watch::Sender>, events: mpsc::Sender, @@ -1158,12 +1450,26 @@ struct BroadcastBridgeOutputs { suppressed_kills: Arc>>, } +struct BroadcastBridgeTasks { + recovery: tokio::task::JoinHandle<()>, + forward: tokio::task::JoinHandle<()>, +} + +impl Drop for BroadcastBridgeTasks { + fn drop(&mut self) { + self.recovery.abort(); + self.forward.abort(); + } +} + fn spawn_broadcast_bridge( provider: FreshProvider, mut receiver: broadcast::Receiver, outputs: BroadcastBridgeOutputs, -) { +) -> BroadcastBridgeTasks { let BroadcastBridgeOutputs { + owned_session, + retained_codex, created, native, events, @@ -1178,13 +1484,107 @@ fn spawn_broadcast_bridge( // id and announces nothing. The identity watch records the id last // announced (a fork's transport records its child there, C14). let claude = matches!(provider, FreshProvider::Claude | FreshProvider::Kilroy); - tokio::spawn(async move { + // One coalesced recovery slot, independent of later provider traffic. + let (recovery, mut recovery_rx) = watch::channel(None::); + let recovery_events = events.clone(); + let recovery_native = native.clone(); + let recovery_provider = provider.clone(); + let mut retained_changes = retained_codex + .as_ref() + .map(|state| state.subscribe_retained_state("")); + let recovery_task = tokio::spawn(async move { + loop { + let loss = tokio::select! { + result = recovery_rx.changed() => { if result.is_err() {break;} true }, + _ = async { match &mut retained_changes { Some(changes) => { let _ = changes.changed().await; }, None => std::future::pending::<()>().await } } => false, + _ = recovery_events.closed() => break, + }; + if !loss && recovery_events.capacity() > 0 { + continue; + } + let Ok(permit) = recovery_events.reserve().await else { + break; + }; + let mut payload = recovery_rx.borrow_and_update().clone(); + if let Some(changes) = &mut retained_changes { + changes.borrow_and_update(); + } + let mut pending = Vec::new(); + if let Some(state) = &retained_codex { + let owned = match &owned_session { + Some(session) => session.lock().await.clone(), + None => None, + }; + if let Some(session_id) = owned.or_else(|| recovery_native.borrow().clone()) { + match state.retained_state(&session_id).await { + Ok(retained) => { + pending = retained["pendingControls"] + .as_array() + .cloned() + .unwrap_or_default(); + let dropped = payload + .as_ref() + .and_then(|frame| frame["event"]["dropped"].as_u64()) + .unwrap_or(1); + let mut frame = bridge_recovery_frame( + &recovery_provider, + "freshcodex", + retained["snapshot"]["threadId"] + .as_str() + .unwrap_or(&session_id), + dropped, + ); + frame["event"]["retainedState"] = retained; + payload = Some(frame); + } + Err(error) => { + tracing::warn!(provider=recovery_provider.as_str(),session_id,error=?error,"freshagent.host.retained_source_unavailable") + } + } + } + } + let Some(payload) = payload else { continue }; + tracing::warn!( + provider = recovery_provider.as_str(), + session_id = payload["sessionId"].as_str().unwrap_or_default(), + dropped = payload["event"]["dropped"].as_u64().unwrap_or_default(), + retained = payload["event"]["retainedState"].is_object(), + "freshagent.host.bridge_recovered" + ); + permit.send(AgentEvent::Provider { payload }); + for payload in pending { + if let Some(decision_id) = payload["requestId"].as_str() { + if recovery_events + .send(AgentEvent::PermissionRequested { + decision_id: decision_id.to_owned(), + payload, + }) + .await + .is_err() + { + return; + } + } + } + } + }); + let forward_task = tokio::spawn(async move { let mut dropped = 0u64; + let mut last_route: Option<(String, String)> = None; loop { let frame = match receiver.recv().await { Ok(frame) => frame, Err(broadcast::error::RecvError::Lagged(count)) => { dropped = dropped.saturating_add(count); + if let Some((session_id, session_type)) = &last_route { + recovery.send_replace(Some(bridge_recovery_frame( + &provider, + session_type, + session_id, + dropped, + ))); + dropped = 0; + } continue; } Err(broadcast::error::RecvError::Closed) => break, @@ -1192,6 +1592,11 @@ fn spawn_broadcast_bridge( let Ok(value) = serde_json::from_str::(&frame) else { continue; }; + if let (Some(session_id), Some(session_type)) = + (value["sessionId"].as_str(), value["sessionType"].as_str()) + { + last_route = Some((session_id.to_owned(), session_type.to_owned())); + } if let Some(outcome) = parse_send_outcome(&value) { let _ = send_outcomes.send(outcome); } @@ -1284,6 +1689,7 @@ fn spawn_broadcast_bridge( } if event_type.contains("permission.request") || event_type.contains("question.request") + || event_type == "freshAgent.elicitation.request" { if let Some(request_id) = event.get("requestId").and_then(|value| match value { Value::String(value) => Some(value.clone()), @@ -1304,26 +1710,44 @@ fn spawn_broadcast_bridge( } } } - if dropped > 0 - && events - .try_send(AgentEvent::Provider { - payload: json!({"type":"freshAgent.host.backpressure","dropped":dropped}), - }) - .is_ok() - { - dropped = 0; - } if captured_decision { continue; } if events - .try_send(AgentEvent::Provider { payload: value }) + .try_send(AgentEvent::Provider { + payload: value.clone(), + }) .is_err() { dropped = dropped.saturating_add(1); } + if dropped > 0 { + if let Some(session_id) = value["sessionId"].as_str() { + recovery.send_replace(Some(bridge_recovery_frame( + &provider, + value["sessionType"].as_str().unwrap_or("freshcodex"), + session_id, + dropped, + ))); + dropped = 0; + } + } } }); + BroadcastBridgeTasks { + recovery: recovery_task, + forward: forward_task, + } +} + +fn bridge_recovery_frame( + provider: &FreshProvider, + session_type: &str, + session_id: &str, + dropped: u64, +) -> Value { + json!({"type":"freshAgent.event", "provider":if matches!(provider, FreshProvider::Kilroy) {"claude"} else {provider.as_str()}, + "sessionType":session_type,"sessionId":session_id,"event":{"type":"freshAgent.host.backpressure","dropped":dropped}}) } /// The claude conversation a `freshAgent.created` names in its `sessionRef`. @@ -1352,140 +1776,2681 @@ async fn announce_native( native_session_id: native_id.to_string(), }) .await - .is_ok() -} - -fn parse_sandbox(value: &str) -> Option { - match value { - "read-only" | "readOnly" | "readonly" => Some(freshell_protocol::Sandbox::ReadOnly), - "workspace-write" | "workspaceWrite" | "workspacewrite" => { - Some(freshell_protocol::Sandbox::WorkspaceWrite) + .is_ok() +} + +fn parse_sandbox(value: &str) -> Option { + match value { + "read-only" | "readOnly" | "readonly" => Some(freshell_protocol::Sandbox::ReadOnly), + "workspace-write" | "workspaceWrite" | "workspacewrite" => { + Some(freshell_protocol::Sandbox::WorkspaceWrite) + } + "danger-full-access" | "dangerFullAccess" | "dangerfullaccess" => { + Some(freshell_protocol::Sandbox::DangerFullAccess) + } + _ => None, + } +} + +fn session_type_wire(session_type: SessionType) -> &'static str { + match session_type { + SessionType::Freshclaude => "freshclaude", + SessionType::Freshcodex => "freshcodex", + SessionType::Kilroy => "kilroy", + SessionType::Freshopencode => "freshopencode", + } +} + +fn operation_supported_by_snapshot(operation: &FreshAgentOperation, snapshot: &Value) -> bool { + let capability = match operation { + FreshAgentOperation::Compact { .. } | FreshAgentOperation::Configure { .. } => return true, + FreshAgentOperation::Rollback { + direction: FreshAgentRollbackDirection::Undo, + .. + } => "undo", + FreshAgentOperation::Rollback { + direction: FreshAgentRollbackDirection::Redo, + .. + } => "redo", + }; + snapshot + .get("capabilities") + .and_then(|value| value.get(capability)) + .and_then(Value::as_bool) + .unwrap_or(false) +} + +fn find_operation_error_code(value: &Value) -> Option<&str> { + if value.get("type").and_then(Value::as_str) == Some("freshAgent.error") { + return value.get("code").and_then(Value::as_str); + } + match value { + Value::Object(object) => object.values().find_map(find_operation_error_code), + Value::Array(values) => values.iter().find_map(find_operation_error_code), + _ => None, + } +} + +/// The code of the error a provider answered a configure with, if any: a +/// `freshAgent.error` for the configured session, or a top-level `error` +/// correlated to the configure's request id (the unknown-session answer). +fn configure_refusal_code( + answers: &mut broadcast::Receiver, + session_id: &str, + request_id: &RequestId, +) -> Option { + loop { + let frame = match answers.try_recv() { + Ok(frame) => frame, + Err(broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(_) => return None, + }; + let Ok(value) = serde_json::from_str::(&frame) else { + continue; + }; + if value.get("type").and_then(Value::as_str) == Some("error") + && value.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) + { + return Some( + value + .get("code") + .and_then(Value::as_str) + .unwrap_or("error") + .to_string(), + ); + } + if value.get("sessionId").and_then(Value::as_str) != Some(session_id) { + continue; + } + if let Some(code) = value + .get("event") + .filter(|event| event.get("type").and_then(Value::as_str) == Some("freshAgent.error")) + .and_then(|event| event.get("code")) + .and_then(Value::as_str) + { + return Some(code.to_string()); + } + } +} + +fn find_operation_session_id(value: &Value) -> Option { + if matches!( + value.get("type").and_then(Value::as_str), + Some("freshAgent.rolledBack") | Some("freshAgent.redone") + ) { + return value + .get("newSessionId") + .or_else(|| value.get("sessionId")) + .and_then(Value::as_str) + .map(str::to_string); + } + match value { + Value::Object(object) => object.values().find_map(find_operation_session_id), + Value::Array(values) => values.iter().find_map(find_operation_session_id), + _ => None, + } +} + +fn parse_send_outcome(value: &Value) -> Option<(String, bool)> { + let event_type = value.get("type")?.as_str()?; + let request_id = value.get("requestId").and_then(|value| match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + _ => None, + })?; + match event_type { + "freshAgent.send.accepted" => Some((request_id, true)), + "error" => Some((request_id, false)), + _ => None, + } +} + +// Native provider fixtures and opaque cleanup fixtures share one process view. +#[cfg(test)] +pub(super) static NATIVE_PROVIDER_FIXTURE_LOCK: Mutex<()> = Mutex::const_new(()); + +#[cfg(test)] +mod tests { + use super::*; + use freshell_freshagent::session_lease::CondemnedRuntimeIdentity; + + #[tokio::test] + async fn task6_dropping_transport_closes_its_owned_bridge_without_more_provider_frames() { + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; + let transport = HostedTransport::new(FreshProvider::Codex).await; + let mut events = transport.take_event_stream().unwrap(); + // No native process/session was started. Dropping this transport must + // release its producers even when the consumer is still waiting. + drop(transport); + let final_event = tokio::time::timeout(Duration::from_millis(300), events.recv()) + .await + .expect("owned bridge must close without another provider frame"); + assert!(final_event.is_none()); + } + + // The real host actor drains the tested bridge; startup is already complete + // in the native fixture, so this adapter only hands over its owned stream. + struct StartedBridgeTransport { + native: String, + events: std::sync::Mutex>>, + } + + #[async_trait] + impl FreshAgentTransport for StartedBridgeTransport { + async fn start(&self, _: &FreshAgentProfile) -> Result { + Ok(TransportStart { + native_session_id: Some(self.native.clone()), + }) + } + async fn dispatch( + &self, + _: &RequestId, + _: &str, + _: &FreshAgentProfile, + ) -> Result { + unreachable!() + } + async fn resolve_permission(&self, _: &str, _: Value) -> Result<(), DispatchFailure> { + Err(DispatchFailure { + message: "this actor does not own that decision".into(), + acceptance_ambiguous: false, + }) + } + async fn interrupt(&self) -> Result<(), String> { + unreachable!() + } + async fn stop(self: Arc) -> Result<(), String> { + Ok(()) + } + fn take_event_stream(&self) -> Option> { + self.events.lock().unwrap().take() + } + } + + #[tokio::test] + async fn task6_recovery_does_not_journal_another_sessions_pending_decision() { + let mut fixture = native_codex_fixture(json!({"recordTurns":true,"serverRequestsByPrompt":{"ask":{"id":71,"method":"item/tool/requestUserInput","params":{"isBlocking":true,"questions":[{"id":"choice","header":"Choice","question":"Other session only?","options":null}]}}}})).await; + let transport = fixture.transport.clone(); + transport.start(&fixture.profile).await.unwrap(); + transport + .dispatch( + &RequestId::parse("other-pending").unwrap(), + "ask", + &fixture.profile, + ) + .await + .unwrap(); + let other_control = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.question.request" { + break frame["event"].clone(); + } + } + }) + .await + .unwrap(); + let ProviderState::Codex(state) = &transport.state else { + unreachable!() + }; + let state = state.clone(); + let mut owned_profile = fixture.profile.clone(); + owned_profile.native_session_id = Some("thread-owned-2".into()); + state + .handle_create(create_request_for_profile(&owned_profile), None) + .await; + assert!( + state.retained_state("thread-owned-2").await.is_ok(), + "both native sessions must actually be registered" + ); + state.handle_kill(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":"thread-owned-2"})).unwrap()).await; + assert!(state.retained_state("thread-owned-2").await.is_err()); + assert_eq!( + state.retained_state("thread-new-1").await.unwrap()["pendingControls"][0], + other_control + ); + + let (bus, receiver) = broadcast::channel(8); + let (events, stream) = mpsc::channel(1); + events + .send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .await + .unwrap(); + let (native, _) = watch::channel(Some("thread-owned-2".into())); + let (created, _) = watch::channel(None); + let bridge = spawn_broadcast_bridge( + FreshProvider::Codex, + receiver, + BroadcastBridgeOutputs { + owned_session: None, + retained_codex: Some(state.clone()), + created, + native, + events: events.clone(), + send_outcomes: broadcast::channel(8).0, + kill_outcomes: broadcast::channel(8).0, + suppressed_kills: Arc::new(std::sync::Mutex::new(HashSet::new())), + }, + ); + bus.send(json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"thread-owned-2","event":{"type":"freshAgent.notice","message":"last dropped frame"}}).to_string()).unwrap(); + tokio::time::sleep(Duration::from_millis(30)).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("isolated-actor"), + owned_profile, + Arc::new(StartedBridgeTransport { + native: "thread-owned-2".into(), + events: std::sync::Mutex::new(Some(stream)), + }), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(3),async { + loop { + if actor.read_events(0,256).await.events.iter().any(|entry|matches!(&entry.event,AgentEvent::Provider {payload} if payload["event"]["type"]=="freshAgent.host.backpressure")) {break;} + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("capacity alone must settle the final lost frame"); + // No later provider frame is emitted. Allow the recovered durable-control + // conversion to finish, then inspect the actual actor journal/decision API. + tokio::time::sleep(Duration::from_millis(100)).await; + let batch = actor.read_events(0, 256).await; + let resolution = actor + .resolve_permission( + other_control["requestId"].as_str().unwrap(), + json!({"answers":{"choice":"wrong actor"}}), + ) + .await; + drop(bridge); + drop(events); + actor.stop().await.unwrap(); + transport.stop().await.unwrap(); + assert!(!batch.events.iter().any(|entry|matches!(&entry.event,AgentEvent::PermissionRequested {..})),"another registered source must never become this actor's durable pending decision: {batch:?}"); + assert!( + matches!( + resolution, + Err(freshell_agent_runtime::host_actor::ActorError::UnknownDecision) + ), + "the foreign decision must not exist in this actor: {resolution:?}" + ); + assert!(!batch.events.iter().any(|entry|matches!(&entry.event,AgentEvent::Provider {payload} if payload["event"]["retainedState"]["snapshot"]["threadId"]=="thread-new-1")),"a recovery marker cannot carry another source's transcript"); + } + + struct NativeCodexFixture { + transport: Arc, + profile: FreshAgentProfile, + diagnostic: broadcast::Receiver, + dir: tempfile::TempDir, + _exec_dir: tempfile::TempDir, + _environment: SnapshotProviderEnv, + _lock: tokio::sync::MutexGuard<'static, ()>, + } + + async fn native_codex_fixture(mut behavior: Value) -> NativeCodexFixture { + behavior + .as_object_mut() + .unwrap() + .entry("echoRequestedModel") + .or_insert(json!(true)); + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs") + .canonicalize() + .unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = SnapshotProviderEnv( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + let exec_dir = match std::env::var_os("FRESHELL_TEST_EXECUTABLE_TMP_DIR") { + Some(directory) => tempfile::tempdir_in(directory), + None => tempfile::tempdir(), + } + .expect("owned executable fixture directory"); + let wrapper = exec_dir.path().join("codex.mjs"); + std::fs::write( + &wrapper, + format!( + r#"#!/usr/bin/env node +import fs from 'node:fs' +const stat = fs.readFileSync('/proc/self/stat', 'utf8').split(') ').pop().split(' ') +fs.appendFileSync({}, JSON.stringify({{pid:process.pid,startTime:Number(stat[19]),ownershipId:process.env.FRESHELL_CODEX_SIDECAR_ID}})+'\n') +await import({}); +"#, + serde_json::to_string(&dir.path().join("native-boots.jsonl")).unwrap(), + serde_json::to_string(&fixture).unwrap() + ), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::env::set_var("CODEX_CMD", wrapper); + let codex_home = dir.path().join("codex-home"); + std::fs::create_dir_all(&codex_home).unwrap(); + std::env::set_var("CODEX_HOME", codex_home); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()); + let transport = HostedTransport::new(FreshProvider::Codex).await; + let mut profile = parity_profile(FreshProvider::Codex); + profile.cwd = dir.path().to_string_lossy().into(); + profile.native_session_id = None; + profile.session_ref = None; + profile.plugins = None; + profile.provider_launch_context = None; + profile.provider_secret_references.clear(); + profile.model = Some("gpt-5.3-codex-spark".into()); + profile.model_selection = None; + profile.effort = None; + profile.permission_mode = Some("never".into()); + let diagnostic = transport.provider_frames.subscribe(); + NativeCodexFixture { + transport, + profile, + diagnostic, + dir, + _exec_dir: exec_dir, + _environment, + _lock, + } + } + + fn native_boots(fixture: &NativeCodexFixture) -> Vec { + std::fs::read_to_string(fixture.dir.path().join("native-boots.jsonl")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + fn native_boot_is_live(boot: &Value) -> bool { + freshell_codex::sidecar_store::proc_starttime(boot["pid"].as_i64().unwrap() as i32) + == boot["startTime"].as_u64() + } + + async fn wait_for_native_exit( + fixture: &NativeCodexFixture, + boot: &Value, + deadline: tokio::time::Instant, + ) -> Result<(), tokio::time::error::Elapsed> { + tokio::time::timeout_at(deadline, async { + while native_boot_is_live(boot) || fixture.transport.is_live().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + } + + async fn read_native_thread( + fixture: &NativeCodexFixture, + original: &str, + deadline: tokio::time::Instant, + ) -> Value { + let ProviderState::Codex(state) = &fixture.transport.state else { + unreachable!() + }; + let client = state + .management_client_for_root(fixture.dir.path().join("codex-home").to_str().unwrap()) + .await + .unwrap(); + tokio::time::timeout_at(deadline, client.read_thread(original, true)) + .await + .unwrap() + .expect("armed native must answer a real read_thread RPC") + } + + struct NativeExitGate { + ready: std::path::PathBuf, + release: std::path::PathBuf, + } + + impl NativeExitGate { + fn new(directory: &std::path::Path) -> Self { + Self { + ready: directory.join("native-exit-ready"), + release: directory.join("native-exit-release"), + } + } + + fn behavior(&self) -> Value { + json!({"readyPath":self.ready,"releasePath":self.release}) + } + + async fn armed(&self, boot: &Value, deadline: tokio::time::Instant) -> Value { + tokio::time::timeout_at(deadline, async { + loop { + assert!(native_boot_is_live(boot), + "native process physically exited while its exit release was absent: {boot}"); + if let Ok(bytes) = std::fs::read(&self.ready) { + let ready: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(ready["pid"], boot["pid"]); + assert_eq!(ready["ownershipId"], boot["ownershipId"]); + assert!(!self.release.exists()); + return ready; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("native exit must arm within the existing lifecycle budget") + } + + fn release(&self) { + std::fs::write(&self.release, b"release").unwrap(); + } + } + + impl Drop for NativeExitGate { + fn drop(&mut self) { + // Failure cleanup releases only this fixture's waiting executable. + let _ = std::fs::write(&self.release, b"release"); + } + } + + #[tokio::test] + async fn task010_native_thread_start_exit_gate_holds_until_release() { + assert_native_exit_gate(true).await; + } + + #[tokio::test] + async fn task010_native_turn_start_exit_gate_holds_until_release() { + assert_native_exit_gate(false).await; + } + + async fn assert_native_exit_gate(pending: bool) { + let logs = tempfile::tempdir().unwrap(); + let gate = NativeExitGate::new(logs.path()); + let method = if pending { + "thread/start" + } else { + "turn/start" + }; + let fixture = native_codex_fixture(json!({"recordTurns":true, + "exitProcessAfterMethodsOnce":[method],"processExitGate":gate.behavior()})) + .await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("physical-exit-gate-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let original = actor.profile().await.native_session_id.unwrap(); + if !pending { + actor + .dispatch( + RequestId::parse("gate-first-input").unwrap(), + "first native input".into(), + None, + ) + .await + .unwrap(); + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let boots = native_boots(&fixture); + assert_eq!(boots.len(), 1, "the real native executable boots once"); + let boot = &boots[0]; + let ready = gate.armed(boot, deadline).await; + assert_eq!(ready["method"], method); + assert_eq!(ready["threadId"], original); + let native = read_native_thread(&fixture, &original, deadline).await; + assert_eq!(native["thread"]["id"], original); + assert_eq!( + native["thread"]["turns"].as_array().unwrap().len(), + usize::from(!pending) + ); + assert!(native_boot_is_live(boot)); + assert!(fixture.transport.is_live().await); + assert!(!gate.release.exists()); + gate.release(); + wait_for_native_exit(&fixture, boot, deadline) + .await + .expect("released native incarnation must physically exit"); + actor.stop().await.unwrap(); + } + + #[tokio::test] + async fn task010_failed_resume_confirms_its_owned_tree_before_minting_again() { + let logs = tempfile::tempdir().unwrap(); + let marker = logs.path().join("original-native-exit"); + let fixture = native_codex_fixture(json!({"recordTurns":true, + "exitProcessAfterMethodsOnce":["turn/start"],"exitProcessAfterMethodsOnceMarkerPath":marker, + "overrides":{"thread/resume":{"error":{"code":-32600,"message":"no rollout found for thread id old-owned-native"}}} + })).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("owned-tree-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + actor + .dispatch( + RequestId::parse("first-before-exit").unwrap(), + "first native input".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while fixture.transport.is_live().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the original owned executable must exit"); + let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs") + .canonicalize() + .unwrap(); + let ready = logs.path().join("failed-resume-descendant.ready"); + let wrapper = fixture._exec_dir.path().join("failed-resume-tree.mjs"); + let parameters = json!({"source":source,"marker":logs.path().join("resume-sidecar-seen"), + "ready":ready,"output":logs.path().join("failed-resume-descendant.log"),"port":logs.path().join("failed-resume-descendant.port"),"replacement":uuid::Uuid::new_v4().to_string()}); + let script = r#"#!/usr/bin/env node +import fs from 'node:fs' +import {spawn} from 'node:child_process' +const parameters=PARAMETERS +if(!fs.existsSync(parameters.marker)){ + fs.writeFileSync(parameters.marker,'first resume attempt') + const child=spawn(process.execPath,[parameters.source,'fake-durable-writer',parameters.output,parameters.ready,parameters.port],{ + env:{...process.env,FAKE_CODEX_WRITER_IGNORE_SIGTERM:'1'},stdio:'ignore'}) + child.unref() + const deadline=Date.now()+5000 + while(!fs.existsSync(parameters.ready)){ + if(child.exitCode!==null||Date.now()>deadline)throw Error('Owned failed-resume descendant did not become ready') + await new Promise(resolve=>setTimeout(resolve,10)) + } +} +const behavior=JSON.parse(process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR) +behavior.threadStartThreadId=parameters.replacement +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR=JSON.stringify(behavior) +await import(parameters.source) +"#.replace("PARAMETERS", ¶meters.to_string()); + std::fs::write(&wrapper, script).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + std::env::set_var("CODEX_CMD", wrapper); + let recovery = tokio::time::timeout( + Duration::from_secs(10), + actor.dispatch( + RequestId::parse("recover-after-missing-rollout").unwrap(), + "recovery native input".into(), + None, + ), + ) + .await; + let pid = std::fs::read_to_string(&ready) + .unwrap() + .trim() + .parse::() + .unwrap(); + let surviving_start = freshell_codex::sidecar_store::proc_starttime(pid); + actor.stop().await.unwrap(); + // This test's marked persistent descendant has no natural exit. Clean + // only its exact captured incarnation before asserting the result. + if let Some(start) = surviving_start { + if freshell_codex::sidecar_store::proc_starttime(pid) == Some(start) { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + } + recovery + .expect("owned recovery must settle") + .expect("native recovery input must be accepted"); + assert!(surviving_start.is_none(), "the failed resume's owned descendant must be confirmed terminal before mint-new acceptance"); + } + + #[tokio::test] + async fn task010_unknown_owned_cleanup_retains_claim_and_never_replays_input() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + let logs = tempfile::tempdir().unwrap(); + let requests = logs.path().join("native-requests.jsonl"); + let mut behavior = json!({"recordTurns":true,"appendRequestLogPath":requests, + "exitProcessAfterMethodsOnce":["turn/start"],"exitProcessAfterMethodsOnceMarkerPath":logs.path().join("original-exit"), + "overrides":{"thread/resume":{"error":{"code":-32600,"message":"no rollout found for thread id thread-new-1"}}} + }); + let fixture = native_codex_fixture(behavior.clone()).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("unknown-cleanup-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + // The original watcher already owns its local confirmation primitive. + // Only the subsequently spawned failed-resume tree sees this outage. + let ProviderState::Codex(state) = &fixture.transport.state else { + unreachable!() + }; + let available = Arc::new(AtomicBool::new(false)); + let attempts = Arc::new(AtomicUsize::new(0)); + state.set_sidecar_cleanup_probe(Arc::new({ + let available = available.clone(); + let attempts = attempts.clone(); + move |recorded| { + let available = available.clone(); + let attempts = attempts.clone(); + Box::pin(async move { + attempts.fetch_add(1, Ordering::SeqCst); + if !available.load(Ordering::SeqCst) { + return Err("owned cleanup temporarily unavailable".into()); + } + Ok( + freshell_freshagent::session_lease::kill_and_confirm_recorded_tree_dead( + &recorded, + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + ) + .await, + ) + }) + } + })); + actor + .dispatch( + RequestId::parse("original-input").unwrap(), + "original accepted input".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while fixture.transport.is_live().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + behavior["threadStartThreadId"] = json!(uuid::Uuid::new_v4().to_string()); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", behavior.to_string()); + let refused = tokio::time::timeout( + Duration::from_secs(5), + actor.dispatch( + RequestId::parse("cleanup-refused-input").unwrap(), + "must never be replayed".into(), + None, + ), + ) + .await + .unwrap(); + assert!( + refused.is_err(), + "unconfirmed cleanup must refuse recovery: {refused:?}" + ); + assert!(attempts.load(Ordering::SeqCst) > 0); + assert!( + state + .leases() + .peek_kill_handle("codex", "thread-new-1") + .is_some(), + "the failed resume must retain its exact owned claim" + ); + let read_requests = || { + std::fs::read_to_string(&requests) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>() + }; + let before = read_requests(); + assert_eq!( + before + .iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 1, + "unknown cleanup must not mint a replacement" + ); + assert_eq!( + before + .iter() + .filter(|row| row["method"] == "turn/start") + .count(), + 1, + "refused input must never reach native work" + ); + available.store(true, Ordering::SeqCst); + tokio::time::timeout(Duration::from_secs(5), async { + while state + .leases() + .peek_kill_handle("codex", "thread-new-1") + .is_some() + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("only positive exact-owned confirmation may release the claim"); + actor + .dispatch( + RequestId::parse("new-explicit-recovery-input").unwrap(), + "new explicit input after confirmation".into(), + None, + ) + .await + .unwrap(); + actor.stop().await.unwrap(); + let after = read_requests(); + assert_eq!( + after + .iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 2 + ); + let turns = after + .iter() + .filter(|row| row["method"] == "turn/start") + .collect::>(); + assert_eq!( + turns.len(), + 2, + "only original and new explicit inputs may be dispatched" + ); + assert!(!turns + .iter() + .any(|row| row.to_string().contains("must never be replayed"))); + assert!(turns[1] + .to_string() + .contains("new explicit input after confirmation")); + } + + struct NativeCodexCleanupReadiness { + available: Arc, + original: Arc>>, + capture: oneshot::Receiver<(String, CondemnedRuntimeIdentity)>, + callbacks: Arc>>, + ready: watch::Receiver>, + } + + fn native_cleanup_evidence(recorded: &CondemnedRuntimeIdentity) -> Value { + serde_json::to_value(recorded).unwrap() + } + + impl NativeCodexCleanupReadiness { + fn withheld(fixture: &NativeCodexFixture, pending: bool) -> Self { + use std::sync::atomic::{AtomicBool, Ordering}; + let ProviderState::Codex(state) = &fixture.transport.state else { + unreachable!() + }; + let available = Arc::new(AtomicBool::new(false)); + let original = Arc::new(std::sync::Mutex::new(None::)); + let callbacks = Arc::new(std::sync::Mutex::new(Vec::new())); + let (capture_tx, capture) = oneshot::channel(); + let capture_tx = std::sync::Mutex::new(Some(capture_tx)); + state.set_sidecar_capture_observer_for_tests(Arc::new(move |thread_id, recorded| { + if let Some(sender) = capture_tx.lock().unwrap().take() { + let _ = sender.send((thread_id.to_owned(), recorded.clone())); + } + })); + let (ready_tx, ready) = watch::channel(None); + state.set_sidecar_cleanup_probe(Arc::new({ + let available = available.clone(); + let original = original.clone(); + let callbacks = callbacks.clone(); + move |recorded| { + let available = available.clone(); + let original = original.clone(); + let callbacks = callbacks.clone(); + let ready_tx = ready_tx.clone(); + Box::pin(async move { + callbacks + .lock() + .unwrap() + .push(native_cleanup_evidence(&recorded)); + let is_original = { + let first = original.lock().expect("original owned identity"); + let same = first.as_ref().is_some_and(|first| { + native_cleanup_evidence(first) == native_cleanup_evidence(&recorded) + }); + if first + .as_ref() + .is_some_and(|first| first.ownership_id == recorded.ownership_id) + && !same + { + return Err("the original watcher record changed".into()); + } + same + }; + let withheld = !available.load(Ordering::SeqCst); + let confirmed = if withheld { + false + } else { + freshell_freshagent::session_lease::kill_and_confirm_recorded_tree_dead( + &recorded, + freshell_codex::durability::CODEX_SIDECAR_OWNERSHIP_ENV, + ) + .await + }; + eprintln!( + "{}", + json!({"event":"freshcodex.test.owned_cleanup_probe", + "severity":if confirmed {"info"} else {"warn"}, "pending":pending, + "identity":native_cleanup_evidence(&recorded), + "isOriginal":is_original,"withheld":withheld,"confirmed":confirmed}) + ); + if confirmed && is_original { + ready_tx.send_replace(Some(recorded)); + } + if withheld { + Err("controlled original teardown proof unavailable".into()) + } else { + Ok(confirmed) + } + }) + } + })); + Self { + available, + original, + capture, + callbacks, + ready, + } + } + + async fn observe_original( + &mut self, + session_id: &str, + boot: &Value, + require_complete: bool, + deadline: tokio::time::Instant, + ) -> CondemnedRuntimeIdentity { + // Receive once, never filter or recapture an incomplete original. + let (thread_id, recorded) = tokio::time::timeout_at(deadline, &mut self.capture) + .await + .unwrap() + .expect("exact watcher capture notification"); + assert_eq!(thread_id, session_id); + assert_eq!(json!(recorded.pid), boot["pid"]); + assert_eq!(json!(recorded.ownership_id), boot["ownershipId"]); + if let Some(start) = recorded.start_time { + assert_eq!(json!(start), boot["startTime"]); + } + if require_complete { + assert!( + recorded.capture_complete, + "first original capture must be complete before exit release: {}", + native_cleanup_evidence(&recorded) + ); + let start = recorded.start_time.expect("live original start time"); + assert!(recorded.tree.contains(&(recorded.pid as i32, start))); + assert!(native_boot_is_live(boot)); + } + let mut original = self.original.lock().unwrap(); + assert!(original.is_none()); + *original = Some(recorded.clone()); + recorded + } + + fn evidence(&self) -> Value { + let original = self.original.lock().expect("original owned identity"); + json!({"original":original.as_ref().map(native_cleanup_evidence), + "callbacks":*self.callbacks.lock().unwrap(), + "confirmed":self.ready.borrow().as_ref().map(native_cleanup_evidence)}) + } + + async fn assert_original_remains_unconfirmed( + &self, + state: &FreshCodexState, + session_id: &str, + deadline: tokio::time::Instant, + ) { + let before = self.callbacks.lock().unwrap().len(); + assert!(!tokio::time::timeout_at( + deadline, + state.confirm_fenced_prior_dead(session_id) + ) + .await + .unwrap()); + let callbacks = self.callbacks.lock().unwrap(); + assert!( + callbacks.len() > before, + "false must come from actual original cleanup, not a missing prior" + ); + let original = self.original.lock().unwrap(); + let original = native_cleanup_evidence(original.as_ref().unwrap()); + assert!(callbacks[before..] + .iter() + .all(|recorded| *recorded == original)); + eprintln!( + "{}", + json!({"event":"freshcodex.test.original_remains_unconfirmed", + "severity":"info","identity":original,"callbackDelta":callbacks.len()-before}) + ); + } + + async fn allow_and_wait( + &mut self, + state: &FreshCodexState, + session_id: &str, + pending: bool, + deadline: tokio::time::Instant, + ) { + self.available + .store(true, std::sync::atomic::Ordering::SeqCst); + // This existing lifecycle operation clears ownership only after + // actual confirmation. It never starts native work or replays input. + let readiness = tokio::time::timeout_at(deadline, async { + let confirmed = state.confirm_fenced_prior_dead(session_id).await; + eprintln!( + "{}", + json!({"event":"freshcodex.test.cleanup_readiness", + "severity":"info","pending":pending,"confirmReturned":confirmed, + "evidence":self.evidence()}) + ); + // A detached confirmer may have already completed the same + // record, so the exact original proof is the readiness oracle. + loop { + if self.ready.borrow().is_some() { + return; + } + self.ready + .changed() + .await + .expect("owned cleanup proof stream"); + } + }) + .await; + assert!( + readiness.is_ok(), + "positive exact-owned readiness required; pending={pending}; evidence={}", + self.evidence() + ); + } + } + + #[tokio::test] + async fn task010_native_mint_ack_updates_actor_before_snapshot_and_second_send() { + for pending in [false, true] { + assert_native_mint_ack(pending).await; + } + } + + #[tokio::test] + async fn task010_ungated_initial_native_exit_retains_claim_and_never_replays_input() { + let logs = tempfile::tempdir().unwrap(); + let requests = logs.path().join("ungated-requests.jsonl"); + let original = uuid::Uuid::new_v4().to_string(); + // Deliberately no exit gate: this is the genuine initial-capture race. + let fixture = native_codex_fixture(json!({"threadStartThreadId":original, + "recordTurns":true,"appendRequestLogPath":requests, + "exitProcessAfterMethodsOnce":["thread/start"]})) + .await; + let mut cleanup = NativeCodexCleanupReadiness::withheld(&fixture, true); + let mut profile = fixture.profile.clone(); + profile.naming_handle = Some("pending-ungated-recovery".into()); + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("ungated-actor"), + profile, + fixture.transport.clone(), + ) + .await + .unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let before = actor.profile().await; + assert_eq!(before.native_session_id.as_deref(), Some(original.as_str())); + let boots = native_boots(&fixture); + assert_eq!(boots.len(), 1); + let boot = &boots[0]; + let captured = cleanup + .observe_original(&original, boot, false, deadline) + .await; + wait_for_native_exit(&fixture, boot, deadline) + .await + .expect("ungated original incarnation must physically exit"); + let ProviderState::Codex(state) = &fixture.transport.state else { + unreachable!() + }; + // Fresh create has no in-flight resume/watchdog lease. Its actual + // unconfirmed ownership is the exact condemned record, not a new lease. + let initial_handle = state.leases().peek_kill_handle("codex", &original); + assert!(initial_handle.is_none()); + cleanup + .assert_original_remains_unconfirmed(state, &original, deadline) + .await; + let refused = tokio::time::timeout_at( + deadline, + actor.dispatch( + RequestId::parse("ungated-explicit-refused-input").unwrap(), + "ungated input must never reach native work".into(), + None, + ), + ) + .await + .unwrap(); + assert!( + matches!(refused, + Err(freshell_agent_runtime::host_actor::ActorError::Transport(ref message)) + if message.starts_with("SESSION_RESERVED:")), + "{refused:?}" + ); + assert_eq!(actor.profile().await, before); + assert_eq!( + state.leases().peek_kill_handle("codex", &original), + initial_handle + ); + cleanup + .assert_original_remains_unconfirmed(state, &original, deadline) + .await; + assert!(!cleanup.available.load(std::sync::atomic::Ordering::SeqCst)); + assert!(cleanup.ready.borrow().is_none()); + let callbacks = cleanup.callbacks.lock().unwrap().clone(); + assert!( + !callbacks.is_empty(), + "the unavailable original proof must actually run" + ); + assert!(callbacks + .iter() + .all(|recorded| *recorded == native_cleanup_evidence(&captured))); + assert_eq!( + native_boots(&fixture), + boots, + "refusal must not boot a replacement executable" + ); + let rows = std::fs::read_to_string(&requests).unwrap(); + let requests = rows + .lines() + .map(|row| serde_json::from_str::(row).unwrap()) + .collect::>(); + assert_eq!( + requests + .iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 1 + ); + assert!(!requests + .iter() + .any(|row| row["method"] == "thread/resume" || row["method"] == "turn/start")); + assert!(!rows.contains("ungated input must never reach native work")); + assert!(!fixture.dir.path().join("codex-home/sessions").exists()); + assert!(!fixture.dir.path().join("codex-home/fake-turns").exists()); + eprintln!( + "{}", + json!({"event":"freshcodex.test.ungated_initial_exit_refused", + "severity":"info","identity":native_cleanup_evidence(&captured), + "boot":boot,"physicalExit":true,"nativeBoots":boots.len(),"nativeTurns":0}) + ); + actor.stop().await.unwrap(); + } + + async fn assert_native_mint_ack(pending: bool) { + eprintln!( + "{}", + json!({"event":"freshcodex.test.native_mint_ack_case", + "severity":"info","pending":pending,"phase":"start"}) + ); + let logs = tempfile::tempdir().unwrap(); + let gate = NativeExitGate::new(logs.path()); + let log = logs.path().join("recovery-requests.jsonl"); + let original_id = uuid::Uuid::new_v4().to_string(); + let replacement_id = uuid::Uuid::new_v4().to_string(); + let behavior = json!({ + "threadStartThreadId":original_id,"recordTurns":true,"echoRequestedModel":true,"appendRequestLogPath":log, + "exitProcessAfterMethodsOnce":[if pending {"thread/start"} else {"turn/start"}], + "processExitGate":gate.behavior(), + "exitProcessAfterMethodsOnceMarkerPath":logs.path().join("first-native-exit"), + "overrides":{"thread/resume":{"error":{"code":-32600,"message":"no rollout found for thread id original-native"}}} + }); + let fixture = native_codex_fixture(behavior.clone()).await; + let mut cleanup = NativeCodexCleanupReadiness::withheld(&fixture, pending); + let qualified_behavior = |id: &str| { + let mut behavior = behavior.clone(); + behavior["threadStartThreadId"] = json!(id); + behavior["writeRolloutOnMethods"] = json!({"turn/start": { + "path": fixture.dir.path().join("codex-home/sessions").join(format!("rollout-{id}.jsonl")), + "record": {"type":"session_meta","payload":{"id":id,"cwd":fixture.profile.cwd}} + }}); + behavior + }; + // Qualified 0.164 writes the exact initial header before completion. + // Use the existing owned opt-in; shared fake defaults stay unchanged. + std::env::set_var( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + qualified_behavior(&original_id).to_string(), + ); + let mut profile = fixture.profile.clone(); + profile.naming_handle = pending.then(|| "pending-initial-recovery".into()); + profile.effort = Some("xhigh".into()); + profile.collaboration_mode = Some("plan".into()); + profile.sandbox = Some("read-only".into()); + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("recovery-actor"), + profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let original = actor.profile().await.native_session_id.unwrap(); + assert_eq!(original, original_id); + if !pending { + actor + .dispatch( + RequestId::parse("before-native-exit").unwrap(), + "first native input".into(), + None, + ) + .await + .unwrap(); + } + // Keep the original five-second lifecycle budget for both exit + // and readiness; no accepted recovery dispatch is retried. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let boots = native_boots(&fixture); + assert_eq!(boots.len(), 1); + let boot = &boots[0]; + let captured = cleanup + .observe_original(&original, boot, true, deadline) + .await; + let armed = gate.armed(boot, deadline).await; + assert_eq!(armed["threadId"], original); + let ProviderState::Codex(state) = &fixture.transport.state else { + unreachable!() + }; + let native = read_native_thread(&fixture, &original, deadline).await; + let turns = native["thread"]["turns"].as_array().unwrap(); + let configured_rollout = fixture + .dir + .path() + .join("codex-home/sessions") + .join(format!("rollout-{original}.jsonl")); + let prospective_rollout = std::path::Path::new(native["thread"]["path"].as_str().unwrap()); + let turn_store = fixture + .dir + .path() + .join("codex-home/fake-turns") + .join(format!("{original}.json")); + let before_exit = std::fs::read_to_string(&log).unwrap(); + let rows = before_exit + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 1 + ); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "turn/start") + .count(), + usize::from(!pending) + ); + assert_eq!(turns.len(), usize::from(!pending)); + assert_eq!(actor.profile().await.naming_handle, profile.naming_handle); + if pending { + assert!(!configured_rollout.exists()); + assert!(!prospective_rollout.exists()); + assert!(!turn_store.exists()); + let retained = state.retained_state(&original).await.unwrap(); + assert_eq!(retained["snapshot"]["threadId"], original); + assert!( + retained["snapshot"]["extensions"]["codex"]["hostedNamingFacts"] + .as_array() + .is_none_or(|facts| !facts + .iter() + .any(|fact| fact["kind"] == "acquisition" || fact["kind"] == "transition")) + ); + } else { + assert!(configured_rollout.exists()); + assert!(prospective_rollout.exists()); + let persisted: Value = + serde_json::from_slice(&std::fs::read(&turn_store).unwrap()).unwrap(); + assert_eq!(persisted.as_array().unwrap().len(), 1); + assert!(persisted.to_string().contains("first native input")); + } + assert!(native_boot_is_live(boot)); + assert!(!gate.release.exists()); + eprintln!( + "{}", + json!({"event":"freshcodex.test.original_before_exit_release", + "severity":"info","pending":pending,"identity":native_cleanup_evidence(&captured), + "nativeTurns":turns.len(),"configuredHeader":configured_rollout.exists(), + "prospectiveHeader":prospective_rollout.exists(),"turnStore":turn_store.exists()}) + ); + gate.release(); + let exited = wait_for_native_exit(&fixture, boot, deadline).await; + eprintln!( + "{}", + json!({"event":"freshcodex.test.native_exit", + "severity":"info","pending":pending,"exited":exited.is_ok(), + "evidence":cleanup.evidence()}) + ); + assert!( + exited.is_ok(), + "the owned native executable must actually exit; pending={pending}" + ); + + let refused = tokio::time::timeout_at( + deadline, + actor.dispatch( + RequestId::parse("before-cleanup-proof").unwrap(), + "unconfirmed cleanup must not reach native work".into(), + None, + ), + ) + .await; + eprintln!( + "{}", + json!({"event":"freshcodex.test.recovery_before_proof", + "severity":"info","pending":pending,"result":format!("{refused:?}"), + "evidence":cleanup.evidence()}) + ); + assert!( + matches!(refused, + Ok(Err(freshell_agent_runtime::host_actor::ActorError::Transport(ref message))) + if message.starts_with("SESSION_RESERVED:")), + "unconfirmed teardown must refuse; pending={pending}; result={refused:?}" + ); + let before_proof = std::fs::read_to_string(&log).unwrap(); + let rows = before_proof + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 1, + "no replacement writer before proof; pending={pending}" + ); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "turn/start") + .count(), + usize::from(!pending), + "refused input must not reach native work; pending={pending}" + ); + assert!(!before_proof.contains("unconfirmed cleanup must not reach native work")); + let ProviderState::Codex(state) = &fixture.transport.state else { + unreachable!() + }; + eprintln!( + "{}", + json!({"event":"freshcodex.test.pre_proof_guards_passed", + "severity":"info","pending":pending,"noReplacementWriter":true,"noReplay":true, + "nativeRequests":rows.iter().map(|row|json!({"method":row["method"], + "threadId":row["params"]["threadId"]})).collect::>(), + "evidence":cleanup.evidence()}) + ); + cleanup + .allow_and_wait(state, &original, pending, deadline) + .await; + std::env::set_var( + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + qualified_behavior(&replacement_id).to_string(), + ); + let recovery = actor + .dispatch( + RequestId::parse("recover-native").unwrap(), + "recover native input".into(), + None, + ) + .await; + eprintln!( + "{}", + json!({"event":"freshcodex.test.recovery_after_proof", + "severity":"info","pending":pending,"result":format!("{recovery:?}"), + "evidence":cleanup.evidence()}) + ); + recovery.unwrap(); + let recovered = actor.profile().await; + let native = recovered.native_session_id.clone().unwrap(); + assert_ne!(native, original,"the typed successful mint-new result must update durable actor identity before dispatch returns"); + assert_eq!(recovered.naming_handle, profile.naming_handle); + assert_eq!(recovered.model, profile.model); + assert_eq!(recovered.effort, profile.effort); + assert_eq!(recovered.collaboration_mode, profile.collaboration_mode); + assert_eq!(recovered.sandbox, profile.sandbox); + assert!(actor + .observe_native_identity("arbitrary-started".into()) + .await + .is_err()); + assert!(actor + .observe_native_identity(original.clone()) + .await + .is_err()); + assert!(actor.read_events(0, 256).await.events.iter().all(|entry| !matches!(&entry.event,AgentEvent::Started{native_session_id} if native_session_id != &native)),"historical Started must not re-key durable inventory after a trusted command transition"); + let snapshot = actor.snapshot().await.unwrap(); + assert_eq!(snapshot["threadId"], native); + actor + .dispatch( + RequestId::parse("second-current-native").unwrap(), + "second current native input".into(), + None, + ) + .await + .unwrap(); + assert_eq!( + actor.profile().await.native_session_id.as_deref(), + Some(native.as_str()) + ); + assert_eq!(actor.snapshot().await.unwrap()["threadId"], native); + let naming_snapshot = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = actor.snapshot().await.unwrap(); + let has_proof = snapshot["extensions"]["codex"]["hostedNamingFacts"] + .as_array() + .is_some_and(|facts| { + facts.iter().any(|fact| { + fact["kind"] == if pending { "transition" } else { "acquisition" } + }) + }); + if snapshot["status"] == "idle" && has_proof { + break snapshot; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + let naming_snapshot = match naming_snapshot { + Ok(snapshot) => snapshot, + Err(_) => actor.snapshot().await.unwrap(), + }; + actor.stop().await.unwrap(); + assert_eq!( + naming_snapshot["status"], "idle", + "recovered native turn must actually settle" + ); + let fact = naming_snapshot["extensions"]["codex"]["hostedNamingFacts"] + .as_array() + .and_then(|facts| { + facts + .iter() + .find(|fact| fact["kind"] == if pending { "transition" } else { "acquisition" }) + }) + .expect("the verified recovered native source must retain its naming acquisition"); + assert_eq!(fact["data"]["target"]["sessionId"], native); + let acquisition = if pending { + assert_eq!( + fact["data"]["transition"]["reason"], + "InitialMaterialization" + ); + assert_eq!( + fact["data"]["transition"]["pending"], + "pending-initial-recovery" + ); + &fact["data"]["transition"]["acquisition"] + } else { + &fact["data"]["acquisition"] + }; + assert_eq!(acquisition["persistence"], "verified"); + assert_eq!(acquisition["location"]["nativeThreadId"], native); + let rows = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "thread/start") + .count(), + 2, + "only the initial thread and explicit native missing-history fallback may create" + ); + let resume = rows + .iter() + .position(|row| { + row["method"] == "thread/resume" && row["params"]["threadId"] == original + }) + .expect("old native id must actually attempt resume"); + let replacement = rows + .iter() + .rposition(|row| row["method"] == "thread/start") + .unwrap(); + assert!( + resume < replacement, + "native missing-history refusal must precede mint-new" + ); + let starts = rows + .iter() + .filter(|row| row["method"] == "turn/start") + .collect::>(); + assert_eq!( + starts.len(), + if pending { 2 } else { 3 }, + "no accepted input may be replayed" + ); + assert!( + !starts.iter().any(|row| row + .to_string() + .contains("unconfirmed cleanup must not reach native work")), + "refused input must never be replayed after proof; pending={pending}" + ); + if !pending { + assert_eq!(starts[0]["params"]["threadId"], original); + } + for row in &starts[usize::from(!pending)..] { + assert_eq!(row["params"]["threadId"], native); + assert_eq!(row["params"]["effort"], "xhigh"); + assert_eq!(row["params"]["collaborationMode"]["mode"], "plan"); + assert_eq!(row["params"]["sandboxPolicy"]["type"], "readOnly"); + } + } + + /// Observer-boundary qualification, separate from public creation: the + /// release CLI's explicitly ephemeral parent/child contract is receipt16. + /// No application start parameter or native ephemerality is rewritten here. + #[tokio::test] + async fn task010_hosted_ephemeral_child_is_finite_and_never_becomes_a_writer() { + let log = tempfile::NamedTempFile::new().unwrap(); + let parent = json!({"id":"thread-new-1","ephemeral":true,"status":{"type":"active"},"turns":[{"id":"registered-child-turn","status":"inProgress","items":[{"id":"spawn","type":"collabAgentToolCall","tool":"spawnAgent","status":"completed","senderThreadId":"thread-new-1","receiverThreadIds":["ephemeral-child"],"agentsStates":{}}]}]}); + let child = json!({"id":"ephemeral-child","parentThreadId":"thread-new-1","ephemeral":true,"status":{"type":"active"},"turns":[]}); + let fixture = native_codex_fixture(json!({"appendRequestLogPath":log.path(),"threadReadResults":{"thread-new-1":parent,"ephemeral-child":child}})).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("ephemeral-observer-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let profile = actor.profile().await; + let before = std::fs::read_to_string(log.path()).unwrap(); + assert!(actor.read_child("unregistered-child").await.is_err()); + for _ in 0..2 { + let value = actor.read_child("ephemeral-child").await.unwrap(); + assert_eq!( + value, + json!({"parentThreadId":"thread-new-1","childThreadId":"ephemeral-child","status":"unavailable","reason":"ephemeral_history_unavailable","retryable":false}) + ); + } + assert_eq!(actor.profile().await, profile); + let after = std::fs::read_to_string(log.path()).unwrap(); + let rows: Vec = after + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let baseline: Vec = before + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + rows.iter() + .filter(|row| row["method"] == "thread/start") + .count(), + baseline + .iter() + .filter(|row| row["method"] == "thread/start") + .count() + ); + assert_eq!( + rows.iter() + .filter(|row| row["params"]["threadId"] == "ephemeral-child") + .count(), + 2 + ); + assert!(rows + .iter() + .filter(|row| row["params"]["threadId"] == "ephemeral-child") + .all(|row| row["method"] == "thread/read" && row["params"]["includeTurns"] == true)); + assert!(!rows + .iter() + .any(|row| row["params"]["threadId"] == "unregistered-child")); + // A final native read still reports true; observation did not persist, + // resume, configure, send to, stop or relabel the child. + let ProviderState::Codex(state) = &fixture.transport.state else { + panic!("Codex fixture") + }; + let native = state + .management_client_for_root(&fixture.dir.path().join("codex-home").to_string_lossy()) + .await + .unwrap() + .read_thread_metadata("ephemeral-child") + .await + .unwrap(); + assert_eq!(native["thread"]["ephemeral"], true); + assert_eq!(native["thread"]["parentThreadId"], "thread-new-1"); + actor.stop().await.unwrap(); + } + + #[tokio::test] + async fn task010_codex_native_name_uses_only_current_root_matched_metadata_methods() { + let log = tempfile::NamedTempFile::new().unwrap(); + let fixture = native_codex_fixture(json!({"recordTurns":true,"appendRequestLogPath":log.path(), + "overrides":{"thread/read":{"result":{"thread":{"id":"thread-new-1","name":"Native saved title"}}},"thread/name/set":{"result":{}}}})).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("name-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let home = fixture + .dir + .path() + .join("codex-home") + .to_string_lossy() + .into_owned(); + let requests = || { + std::fs::read_to_string(log.path()) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>() + }; + let before = requests().len(); + for (native, root) in [ + ("foreign-thread", home.as_str()), + ("thread-new-1", "/wrong/root"), + ] { + assert!(matches!( + actor + .native_name( + native, + root, + &FreshAgentNativeNameOperation::Write { + title: "Must not apply".into() + } + ) + .await, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { .. }) + )); + } + assert_eq!( + requests().len(), + before, + "stale targets perform no native work" + ); + assert_eq!( + actor + .native_name("thread-new-1", &home, &FreshAgentNativeNameOperation::Probe) + .await, + FreshAgentNativeNameReply::Available + ); + assert_eq!( + requests().len(), + before, + "capability probing is read-only and sends no native RPC" + ); + assert_eq!( + actor + .native_name("thread-new-1", &home, &FreshAgentNativeNameOperation::Read) + .await, + FreshAgentNativeNameReply::Title(Some("Native saved title".into())) + ); + assert_eq!( + actor + .native_name( + "thread-new-1", + &home, + &FreshAgentNativeNameOperation::Write { + title: "Manual title".into() + } + ) + .await, + FreshAgentNativeNameReply::Renamed + ); + let after = requests(); + assert_eq!( + after[before..] + .iter() + .map(|row| row["method"].as_str().unwrap()) + .collect::>(), + vec!["thread/read", "thread/name/set"] + ); + assert_eq!( + after[before]["params"], + json!({"threadId":"thread-new-1","includeTurns":false}) + ); + assert_eq!( + after[before + 1]["params"], + json!({"threadId":"thread-new-1","name":"Manual title"}) + ); + actor.stop().await.unwrap(); + } + + #[tokio::test] + async fn task010_codex_verified_naming_survives_event_replay_in_retained_source() { + let mut fixture = native_codex_fixture(json!({"recordTurns":true})).await; + let native_id = uuid::Uuid::new_v4().to_string(); + let rollout = fixture + .dir + .path() + .join("codex-home/sessions") + .join(format!("rollout-{native_id}.jsonl")); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", json!({"recordTurns":true,"echoRequestedModel":true, + "threadStartThreadId":native_id,"writeRolloutOnMethods":{"turn/start":{"path":rollout, + "record":{"type":"session_meta","payload":{"id":native_id,"cwd":fixture.profile.cwd}}}} + }).to_string()); + fixture.profile.naming_handle = Some("managed-native-name".into()); + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("naming-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let observed = tokio::time::timeout(Duration::from_secs(10), async { + actor + .dispatch( + RequestId::parse("managed-naming-first").unwrap(), + "Name the managed conversation".into(), + None, + ) + .await + .unwrap(); + loop { + let snapshot = actor.snapshot().await.unwrap(); + let facts = &snapshot["extensions"]["codex"]["hostedNamingFacts"]; + if snapshot["status"] == "idle" + && facts + .as_array() + .is_some_and(|facts| facts.iter().any(|fact| fact["kind"] == "transition")) + { + break (snapshot, actor.read_events(0, 256).await); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + actor.stop().await.unwrap(); + let (snapshot, events) = + observed.expect("verified naming must remain available without another provider token"); + let facts = snapshot["extensions"]["codex"]["hostedNamingFacts"] + .as_array() + .unwrap(); + assert_eq!(facts.len(), 2); + let activity = facts + .iter() + .find(|fact| fact["kind"] == "activity") + .unwrap(); + assert_eq!( + activity["data"]["target"], + json!({"kind":"pending","id":"managed-native-name"}) + ); + assert_eq!( + activity["data"]["first_user_message"], + "Name the managed conversation" + ); + let transition = facts + .iter() + .find(|fact| fact["kind"] == "transition") + .unwrap(); + assert_eq!( + transition["data"]["target"], + json!({"kind":"session","provider":"codex","sessionId":native_id}) + ); + assert_eq!( + transition["data"]["transition"]["acquisition"]["persistence"], + "verified" + ); + assert_eq!( + transition["data"]["transition"]["pending"], + "managed-native-name" + ); + assert!(events.events.iter().any(|entry| matches!(&entry.event,AgentEvent::Provider {payload} if payload["event"]["type"] == freshell_freshagent::naming::HOSTED_CODEX_NAMING_EVENT))); + } + + #[tokio::test] + async fn task008_settings_native_echo_survives_managed_next_send_and_reopen() { + let logs = tempfile::tempdir().unwrap(); + let log = logs.path().join("native-effective.jsonl"); + let native_cwd = logs.path().join("native-cwd"); + std::fs::create_dir(&native_cwd).unwrap(); + let mut fixture = native_codex_fixture(json!({ + "appendRequestLogPath":log, "recordTurns":true, + "effectiveThreadSettings":{"model":"provider-current","reasoningEffort":"low", + "cwd":native_cwd,"sandbox":{"type":"readOnly"},"approvalPolicy":"on-request"} + })) + .await; + fixture.profile.effort = Some("ultra".into()); + fixture.profile.collaboration_mode = Some("plan".into()); + let state_dir = fixture.dir.path().join("native-effective-actor"); + let actor = FreshAgentHostActor::open( + &state_dir, + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let startup = actor.snapshot().await.unwrap(); + actor + .dispatch( + RequestId::parse("native-effective-first").unwrap(), + "native effective first send".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while actor.snapshot().await.unwrap()["status"] != "idle" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let profile = actor.profile().await; + actor.stop().await.unwrap(); + let resumed = FreshAgentHostActor::open( + &state_dir, + profile, + HostedTransport::new(FreshProvider::Codex).await, + ) + .await + .unwrap(); + resumed + .dispatch( + RequestId::parse("native-effective-resumed").unwrap(), + "native effective resumed send".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while resumed.snapshot().await.unwrap()["status"] != "idle" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + // An explicit per-send choice still wins even when it equals the old + // launch intent: equality of profiles is not absence of a request. + resumed.dispatch(RequestId::parse("native-effective-explicit").unwrap(), "explicit old selection".into(), Some(serde_json::from_value(json!({ + "model":fixture.profile.model,"cwd":fixture.profile.cwd,"sandbox":"workspace-write","permissionMode":"never" + })).unwrap())).await.unwrap(); + resumed.stop().await.unwrap(); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let starts: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .map(|row| &row["params"]) + .collect(); + assert_eq!( + startup["settings"]["model"], "provider-current", + "the owning provider already exposes its native-effective settings" + ); + assert_eq!(starts.len(), 3); + assert_eq!(starts[2]["model"], fixture.profile.model.unwrap()); + assert_eq!(starts[2]["cwd"], fixture.profile.cwd); + assert_eq!(starts[2]["sandboxPolicy"]["type"], "workspaceWrite"); + assert_eq!(starts[2]["approvalPolicy"], "never"); + assert_eq!(starts[..2].iter().map(|params| json!({"model":params["model"],"cwd":params["cwd"],"sandbox":params["sandboxPolicy"]["type"],"approval":params["approvalPolicy"],"effort":params["effort"],"mode":params["collaborationMode"]["mode"]})).collect::>(), vec![json!({"model":"provider-current","cwd":native_cwd,"sandbox":"readOnly","approval":"on-request","effort":"ultra","mode":"plan"});2], "unchanged host intent must not put stale requested settings back over native echoes"); + } + + #[tokio::test] + async fn task008_settings_managed_actor_forwards_and_recovers_native_plan_defaults() { + let logs = tempfile::tempdir().unwrap(); + let log = logs.path().join("settings-requests.jsonl"); + let fixture = + native_codex_fixture(json!({"appendRequestLogPath":log,"recordTurns":true})).await; + let state_dir = fixture.dir.path().join("settings-actor"); + let actor = FreshAgentHostActor::open( + &state_dir, + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let cwd = fixture.dir.path().join("next"); + std::fs::create_dir(&cwd).unwrap(); + actor.configure(RequestId::parse("managed-settings").unwrap(), serde_json::from_value(json!({"model":"provider-current","effort":"ultra","cwd":cwd,"sandbox":"read-only","permissionMode":"on-request","collaborationMode":"plan"})).unwrap()).await.unwrap(); + actor + .dispatch( + RequestId::parse("managed-plan").unwrap(), + "managed plan".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if actor.snapshot().await.unwrap()["status"] == "idle" { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + actor + .configure( + RequestId::parse("managed-native-default").unwrap(), + serde_json::from_value(json!({"effort":null})).unwrap(), + ) + .await + .unwrap(); + actor + .dispatch( + RequestId::parse("managed-native-default-send").unwrap(), + "native default effort".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while actor.snapshot().await.unwrap()["status"] != "idle" { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let profile = actor.profile().await; + actor.stop().await.unwrap(); + let resumed = FreshAgentHostActor::open( + &state_dir, + profile, + HostedTransport::new(FreshProvider::Codex).await, + ) + .await + .unwrap(); + resumed + .dispatch( + RequestId::parse("managed-plan-reload").unwrap(), + "managed plan after reload".into(), + None, + ) + .await + .unwrap(); + let recovered = serde_json::to_value(resumed.profile().await).unwrap(); + resumed.stop().await.unwrap(); + let requests: Vec = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let starts: Vec<_> = requests + .iter() + .filter(|row| row["method"] == "turn/start") + .collect(); + assert_eq!(starts.len(), 3); + assert_eq!( + starts + .iter() + .map(|row| row["params"]["effort"].clone()) + .collect::>(), + vec![json!("ultra"), Value::Null, Value::Null] + ); + for (index, row) in starts.into_iter().enumerate() { + assert_eq!( + row["params"]["collaborationMode"], + json!({"mode":"plan","settings":{"model":"provider-current","reasoning_effort":if index == 0 {json!("ultra")} else {Value::Null},"developer_instructions":null}}) + ); + assert_eq!(row["params"]["cwd"], json!(cwd)); + assert_eq!(row["params"]["sandboxPolicy"]["type"], "readOnly"); + assert_eq!(row["params"]["approvalPolicy"], "on-request"); + } + assert_eq!(recovered["collaborationMode"], "plan"); + } + + #[tokio::test] + async fn model_catalog_uses_the_same_initialized_native_actor_process_without_a_turn() { + let logs = tempfile::tempdir().unwrap(); + let log = logs.path().join("requests.jsonl"); + let fixture=native_codex_fixture(json!({"appendRequestLogPath":log,"modelCatalogPages":[ + {"data":[{"id":"catalog-record","model":"gpt-6.1-sol","displayName":"Native Sol","supportedReasoningEfforts":[{"reasoningEffort":"xhigh"}],"defaultReasoningEffort":"xhigh","inputModalities":["text","image"]}],"nextCursor":"1"}, + {"data":[{"id":"record-current","model":"provider-current","displayName":"Provider Current","supportedReasoningEfforts":[{"reasoningEffort":"ultra"}],"defaultReasoningEffort":"ultra","inputModalities":["text"]}],"nextCursor":null}, + ]})).await; + let actor = FreshAgentHostActor::open( + fixture.dir.path().join("catalog-actor"), + fixture.profile.clone(), + fixture.transport.clone(), + ) + .await + .unwrap(); + let before = actor.profile().await; + // Ordinary creation queues its created + idle snapshot frames. Join + // that existing startup boundary before testing a read-only operation. + tokio::time::timeout(Duration::from_secs(10),async { + loop { + if actor.read_events(0,256).await.events.iter().any(|entry|matches!(&entry.event, + AgentEvent::Provider {payload} if payload["event"]["type"]=="freshAgent.session.snapshot" + && payload["event"]["status"]=="idle" && payload["sessionId"].as_str()==before.native_session_id.as_deref())) {break;} + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("ordinary native creation reaches its idle snapshot"); + let head = actor.read_events(0, 256).await.head; + let value = actor.model_catalog(Some(&before.cwd)).await.unwrap(); + let after = actor.profile().await; + let after_events = actor.read_events(head, 256).await; + let after_head = after_events.head; + actor.stop().await.unwrap(); + let requests = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>(); + assert_eq!(value[0]["id"], "gpt-6.1-sol"); + assert_eq!(value[1]["id"], "provider-current"); + assert_eq!(value[1]["defaultEffort"], "ultra"); + assert_eq!(before.native_session_id, after.native_session_id); + assert_eq!(before.model, after.model); + assert_eq!( + head, after_head, + "events during catalog read: {:?}", + after_events.events + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "initialize") + .count(), + 1 + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "thread/start") + .count(), + 1 + ); + assert_eq!( + requests + .iter() + .filter(|r| r["method"] == "model/list") + .count(), + 2 + ); + assert!(!requests.iter().any(|r| r["method"] == "turn/start" + || r["method"] == "thread/resume" + || r["method"] == "thread/fork")); + } + + #[tokio::test] + async fn task6_production_actor_owns_native_forwarding_through_fork_without_factory_handles() { + let NativeCodexFixture { + transport, + profile, + diagnostic: _, + dir, + _exec_dir, + _environment, + _lock, + } = native_codex_fixture(json!({"recordTurns":true})).await; + // Move the factory's only transport handle into the real production + // actor. No direct fixture handle can keep its bridge guard alive. + let actor = FreshAgentHostActor::open(dir.path().join("actor"), profile, transport) + .await + .unwrap(); + let parent = actor.profile().await.native_session_id.unwrap(); + for (request, text, expected_rows) in [ + ("parent-send", "actor parent turn", 2), + ("child-send", "actor child turn", 4), + ] { + let before = actor.read_events(0, 256).await.head; + actor + .dispatch(RequestId::parse(request).unwrap(), text.into(), None) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let batch = actor.read_events(before, 256).await; + if batch.events.iter().any(|entry| matches!(&entry.event, + AgentEvent::Provider {payload} if payload["event"]["type"] == "freshAgent.turn.complete")) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the actor must retain its bridge through native completion"); + let snapshot = actor.snapshot().await.unwrap(); + assert_eq!( + snapshot["turns"].as_array().unwrap().len(), + expected_rows, + "{snapshot}" + ); + assert_eq!(snapshot["status"], "idle"); + if request == "parent-send" { + let fork = actor + .fork( + RequestId::parse("actor-fork").unwrap(), + parent.clone(), + None, + ) + .await + .unwrap(); + assert_ne!(fork.child_session_id, parent); + assert_eq!( + actor.profile().await.native_session_id, + Some(fork.child_session_id) + ); + } + } + actor.stop().await.unwrap(); + } + + async fn task5_durable_native_control( + method: &str, + params: Value, + event_type: &str, + invalid_answer: Value, + valid_answer: Value, + ) { + let mut fixture = native_codex_fixture(json!({"recordTurns":true,"serverRequestsByPrompt":{"ask":{"id":41,"method":method,"params":params}}})).await; + let transport = fixture.transport.clone(); + let profile = fixture.profile.clone(); + let dir = &fixture.dir; + let diagnostic = &mut fixture.diagnostic; + let actor = FreshAgentHostActor::open(dir.path().join("actor"), profile, transport.clone()) + .await + .unwrap_or_else(|error| { + let mut frames = Vec::new(); + while let Ok(frame) = diagnostic.try_recv() { + frames.push(frame); + } + panic!("hosted native fixture startup failed: {error:?}; {frames:?}") + }); + actor + .dispatch(RequestId::parse("ask-native").unwrap(), "ask".into(), None) + .await + .unwrap_or_else(|error| { + let mut frames = Vec::new(); + while let Ok(frame) = diagnostic.try_recv() { + frames.push(frame); + } + panic!("hosted native fixture send failed: {error:?}; {frames:?}") + }); + let pending=tokio::time::timeout(Duration::from_secs(5),async { + loop { + if let Some(event)=actor.read_events(0,256).await.events.into_iter().find(|entry| matches!(&entry.event, AgentEvent::PermissionRequested {payload,..} if payload["type"]==event_type)) {break event;} + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("native elicitation must enter the durable pending-decision path before a user click"); + let AgentEvent::PermissionRequested { + decision_id, + payload, + } = pending.event + else { + unreachable!() + }; + assert_eq!(payload["providerRequest"]["id"], 41); + // Reading from a reconnect's cursor yields the exact same public/native IDs. + assert!(actor.read_events(0,256).await.events.iter().any(|entry| matches!(&entry.event,AgentEvent::PermissionRequested{decision_id:id,payload:p} if id==&decision_id && p==&payload))); + let invalid = actor.resolve_permission(&decision_id, invalid_answer).await; + assert!( + invalid.is_err(), + "an invalid native answer must not resolve its durable decision" + ); + assert!(!actor.read_events(0,256).await.events.iter().any(|entry|matches!(&entry.event,AgentEvent::DecisionResolved{decision_id:id} if id==&decision_id))); + actor + .resolve_permission(&decision_id, valid_answer.clone()) + .await + .unwrap(); + actor + .resolve_permission(&decision_id, valid_answer) + .await + .unwrap(); + actor.stop().await.unwrap(); + } + + #[tokio::test] + async fn task5_durable_native_elicitation_survives_reconnect_and_invalid_answer() { + task5_durable_native_control("mcpServer/elicitation/request",json!({"serverName":"contract","mode":"form","message":"Hosted form","requestedSchema":{"type":"object","properties":{"count":{"type":"integer","minimum":1}},"required":["count"]}}),"freshAgent.elicitation.request",json!({"elicitation":{"action":"accept","content":{"count":0}}}),json!({"elicitation":{"action":"accept","content":{"count":2}}})).await; + } + #[tokio::test] + async fn task5_durable_native_question_keeps_a_rejected_answer_pending() { + task5_durable_native_control("item/tool/requestUserInput",json!({"isBlocking":false,"questions":[{"id":"choice","header":"Choice","question":"Pick","isOther":false,"options":[{"label":"A","description":"Alpha"}]}]}),"freshAgent.question.request",json!({"answers":{"choice":"unoffered"}}),json!({"answers":{"choice":"A"}})).await; + } + #[tokio::test] + async fn task5_durable_native_approval_keeps_an_unoffered_decision_pending() { + task5_durable_native_control( + "item/commandExecution/requestApproval", + json!({"availableDecisions":["cancel"]}), + "freshAgent.permission.request", + json!({"behavior":"allow"}), + json!({"nativeDecision":"cancel"}), + ) + .await; + } + + #[tokio::test] + async fn task6_final_loss_recovers_native_retained_transcript_and_attention() { + let mut fixture = + native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; + let transport = fixture.transport.clone(); + let profile = fixture.profile.clone(); + let diagnostic = &mut fixture.diagnostic; + transport.start(&profile).await.unwrap(); + let mut events = transport.take_event_stream().unwrap(); + while events.try_recv().is_ok() {} + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + transport + .dispatch( + &RequestId::parse("retained-send").unwrap(), + "retained prompt", + &profile, + ) + .await + .unwrap(); + let mut accepted = None; + let completion = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = serde_json::from_str(&diagnostic.recv().await.unwrap()).unwrap(); + if frame["type"] == "freshAgent.send.accepted" + && frame["requestId"] == "retained-send" + { + accepted = Some(frame.clone()); + } + if frame["event"]["type"] == "freshAgent.turn.complete" { + break frame["event"]["at"].clone(); + } + } + }) + .await + .unwrap(); + // There are no more native frames; only draining the bounded output + // channel is allowed to drive recovery of this final completion. + tokio::time::sleep(Duration::from_millis(50)).await; + let recovered = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(AgentEvent::Provider { payload }) = events.recv().await { + if payload["event"]["type"] == "freshAgent.host.backpressure" { + break payload; + } + } + } + }) + .await + .unwrap(); + let snapshot = &recovered["event"]["retainedState"]["snapshot"]; + let marks = &recovered["event"]["retainedState"]["attentionMarks"]; + let retained = serde_json::to_string(snapshot).unwrap(); + let native_id = transport.session_id.lock().await.clone().unwrap(); + transport.clone().stop().await.unwrap(); + let accepted = accepted.expect( + "the real ordinary send must emit its correlated native receipt before completion", + ); + assert_eq!(accepted["sessionId"], native_id); + assert!(accepted["submittedTurnId"] + .as_str() + .is_some_and(|id| !id.is_empty())); + assert!( + retained.contains("Native streaming fragment and final answer"), + "recover exact event-owned body without thread/read: {recovered}" + ); + assert_eq!(snapshot["threadId"], native_id); + assert_eq!(marks[0]["lastTurnCompleteAt"], completion); + assert_eq!( + snapshot["extensions"]["codex"]["projection"]["turnOutputStates"] + .as_object() + .unwrap() + .len(), + 1 + ); + } + + #[tokio::test] + async fn task6_fork_recovery_uses_transport_owner_after_a_lost_native_identity_edge() { + let mut fixture = + native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; + let transport = fixture.transport.clone(); + let parent = transport + .start(&fixture.profile) + .await + .unwrap() + .native_session_id + .unwrap(); + let mut events = transport.take_event_stream().unwrap(); + let child = transport + .fork(&RequestId::parse("recovery-fork").unwrap(), &parent, None) + .await + .unwrap() + .child_session_id; + // The explicit fork already committed the child owner. Simulate losing + // its native identity frame while keeping the old watch value. + transport.native_tx.send_replace(Some(parent)); + while events.try_recv().is_ok() {} + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + transport + .dispatch( + &RequestId::parse("child-final-send").unwrap(), + "child output", + &fixture.profile, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["sessionId"].as_str() == Some(child.as_str()) + && frame["event"]["type"] == "freshAgent.turn.complete" + { + break; + } + } + }) + .await + .expect("observe the child's final native edge before releasing capacity"); + let recovered = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(AgentEvent::Provider { payload }) = events.recv().await { + if payload["event"]["retainedState"].is_object() { + break payload["event"]["retainedState"].clone(); + } + } + } + }) + .await; + transport.stop().await.unwrap(); + let recovered = recovered.expect( + "a committed fork owner must recover its final output without another provider frame", + ); + assert_eq!(recovered["snapshot"]["threadId"], child); + assert!(recovered["snapshot"] + .to_string() + .contains("Native streaming fragment and final answer")); + } + + #[tokio::test] + async fn task6_journal_reset_reads_authoritative_source_after_final_native_frame() { + let mut fixture = + native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true})).await; + let transport = fixture.transport.clone(); + let profile = fixture.profile.clone(); + let dir = &fixture.dir; + let diagnostic = &mut fixture.diagnostic; + let actor = FreshAgentHostActor::open_with_bounds( + dir.path().join("actor"), + profile, + transport.clone(), + 256 * 1024, + 8, + ) + .await + .unwrap(); + actor + .dispatch( + RequestId::parse("reset-send").unwrap(), + "retained prompt".into(), + None, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = serde_json::from_str(&diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.turn.complete" { + break; + } + } + }) + .await + .unwrap(); + for index in 0..20 { + actor + .record_event(AgentEvent::Provider { + payload: json!({"noise":index}), + }) + .await + .unwrap(); + } + let batch = actor.read_events(1, 8).await; + assert!(batch.reset_required); + let batch = serde_json::to_value(batch).unwrap(); + actor.stop().await.unwrap(); + assert!(serde_json::to_string(&batch["retainedState"]["snapshot"]).unwrap().contains("Native streaming fragment and final answer"), "journal reset must read the provider source even after its history was evicted: {batch}"); + assert!( + batch["retainedState"]["attentionMarks"][0]["lastTurnCompleteAt"] + .as_i64() + .is_some() + ); + } + + async fn task6_recover_final_native_question(blocking: Option) { + let witness = tempfile::tempdir().unwrap(); + let operations = witness.path().join("native-operations.jsonl"); + let behavior = if let Some(blocking) = blocking { + json!({"recordTurns":true,"liveTranscript":true,"appendThreadOperationLogPath":operations,"serverRequestsByPrompt":{"ask":{"id":51,"method":"item/tool/requestUserInput","params":{"isBlocking":blocking,"questions":[{"id":"choice","header":"Choice","question":"Where?","options":null}]}}}}) + } else { + json!({"appendThreadOperationLogPath":operations,"overrides":{"turn/start":{"result":{"turn":{"id":"ongoing-turn","status":"inProgress"}}}},"notificationsAfterMethods":{"turn/start":[{"method":"turn/started","params":{"threadId":"thread-new-1","turn":{"id":"ongoing-turn","status":"inProgress"}}},{"method":"item/completed","params":{"threadId":"thread-new-1","turnId":"ongoing-turn","item":{"id":"async-final","type":"agentMessage","text":"Ongoing work with an async question","delivery":"async","questions":[{"title":"Where?","options":["Staging"]}]}}}]}}) + }; + let mut fixture = native_codex_fixture(behavior).await; + let transport = fixture.transport.clone(); + transport.start(&fixture.profile).await.unwrap(); + let mut events = transport.take_event_stream().unwrap(); + while events.try_recv().is_ok() {} + let reads_before = std::fs::read_to_string(&operations) + .unwrap_or_default() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + transport + .dispatch( + &RequestId::parse("final-question").unwrap(), + "ask", + &fixture.profile, + ) + .await + .unwrap(); + let kind = if blocking.is_some() { + "freshAgent.turn.waiting" + } else { + "freshAgent.question.available" + }; + let at = tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == kind { + break frame["event"]["at"].clone(); + } + } + }) + .await + .unwrap(); + // No native frames follow the final waiting/action edge. Only releasing + // downstream capacity can restore the dropped content and current control. + tokio::time::sleep(Duration::from_millis(50)).await; + let (recovery, decisions) = tokio::time::timeout(Duration::from_secs(3), async { + let mut recovery = None; + let mut decisions = std::collections::BTreeMap::new(); + loop { + match events.recv().await.unwrap() { + AgentEvent::Provider { payload } + if payload["event"]["retainedState"].is_object() => + { + recovery = Some(payload["event"]["retainedState"].clone()) + } + AgentEvent::PermissionRequested { + decision_id, + payload, + } => { + decisions.insert(decision_id, payload); + } + _ => {} + } + if let Some(recovery) = + recovery.take_if(|_| blocking.is_none() || !decisions.is_empty()) + { + break (recovery, decisions); + } + } + }) + .await + .expect("the final native question must recover without another provider frame"); + let reads_after = std::fs::read_to_string(&operations) + .unwrap_or_default() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + let mut drained = None; + if blocking.is_some() { + let decision = decisions.keys().next().unwrap(); + transport + .resolve_permission(decision, json!({"answers":{"choice":"Staging"}})) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.turn.complete" { + break; + } + } + }) + .await + .unwrap(); + drained = transport.retained_state().await.unwrap(); } - "danger-full-access" | "dangerFullAccess" | "dangerfullaccess" => { - Some(freshell_protocol::Sandbox::DangerFullAccess) + transport.clone().stop().await.unwrap(); + assert_eq!( + reads_after, reads_before, + "bridge recovery must not invoke native history merely to repair loss" + ); + assert_eq!(recovery["snapshot"]["status"], "running"); + let mark = &recovery["attentionMarks"][0]; + assert!(mark.get("lastTurnCompleteAt").is_none()); + if let Some(blocking) = blocking { + assert_eq!(mark["lastWaitingAt"], at); + assert_eq!(recovery["pendingControls"].as_array().unwrap().len(), 1); + assert_eq!(recovery["pendingControls"][0]["isBlocking"], blocking); + assert_eq!( + decisions.len(), + 1, + "duplicate recovery deliveries retain one actual decision identity" + ); + assert_eq!( + decisions.values().next().unwrap()["providerRequest"]["id"], + 51 + ); + assert!(drained.unwrap()["attentionMarks"][0].get("lastWaitingAt").is_none(),"the recovered question remains answerable and its final awaited drain clears waiting"); + } else { + assert_eq!(mark["lastQuestionAvailableAt"], at); + assert!(mark.get("lastWaitingAt").is_none()); + assert!(recovery["pendingControls"].as_array().unwrap().is_empty()); + assert!( + decisions.is_empty(), + "async transcript content must never become a durable permission decision" + ); + assert!(serde_json::to_string(&recovery["snapshot"]) + .unwrap() + .contains("Ongoing work with an async question")); } - _ => None, } -} -fn session_type_wire(session_type: SessionType) -> &'static str { - match session_type { - SessionType::Freshclaude => "freshclaude", - SessionType::Freshcodex => "freshcodex", - SessionType::Kilroy => "kilroy", - SessionType::Freshopencode => "freshopencode", + #[tokio::test] + async fn task6_final_false_flag_question_recovers_an_answerable_decision() { + task6_recover_final_native_question(Some(false)).await; } -} - -fn operation_supported_by_snapshot(operation: &FreshAgentOperation, snapshot: &Value) -> bool { - let capability = match operation { - FreshAgentOperation::Compact { .. } | FreshAgentOperation::Configure { .. } => return true, - FreshAgentOperation::Rollback { - direction: FreshAgentRollbackDirection::Undo, - .. - } => "undo", - FreshAgentOperation::Rollback { - direction: FreshAgentRollbackDirection::Redo, - .. - } => "redo", - }; - snapshot - .get("capabilities") - .and_then(|value| value.get(capability)) - .and_then(Value::as_bool) - .unwrap_or(false) -} - -fn find_operation_error_code(value: &Value) -> Option<&str> { - if value.get("type").and_then(Value::as_str) == Some("freshAgent.error") { - return value.get("code").and_then(Value::as_str); + #[tokio::test] + async fn task6_final_blocking_question_recovers_an_answerable_decision() { + task6_recover_final_native_question(Some(true)).await; } - match value { - Value::Object(object) => object.values().find_map(find_operation_error_code), - Value::Array(values) => values.iter().find_map(find_operation_error_code), - _ => None, + #[tokio::test] + async fn task6_final_async_content_recovers_without_a_permission_or_waiting() { + task6_recover_final_native_question(None).await; } -} -/// The code of the error a provider answered a configure with, if any: a -/// `freshAgent.error` for the configured session, or a top-level `error` -/// correlated to the configure's request id (the unknown-session answer). -fn configure_refusal_code( - answers: &mut broadcast::Receiver, - session_id: &str, - request_id: &RequestId, -) -> Option { - loop { - let frame = match answers.try_recv() { - Ok(frame) => frame, - Err(broadcast::error::TryRecvError::Lagged(_)) => continue, - Err(_) => return None, - }; - let Ok(value) = serde_json::from_str::(&frame) else { - continue; - }; - if value.get("type").and_then(Value::as_str) == Some("error") - && value.get("requestId").and_then(Value::as_str) == Some(request_id.as_str()) - { - return Some( - value - .get("code") - .and_then(Value::as_str) - .unwrap_or("error") - .to_string(), - ); - } - if value.get("sessionId").and_then(Value::as_str) != Some(session_id) { - continue; - } - if let Some(code) = value - .get("event") - .filter(|event| event.get("type").and_then(Value::as_str) == Some("freshAgent.error")) - .and_then(|event| event.get("code")) - .and_then(Value::as_str) - { - return Some(code.to_string()); - } + #[tokio::test] + async fn task6_final_completion_overrides_cached_active_native_history_without_an_rpc() { + let witness = tempfile::tempdir().unwrap(); + let release = witness.path().join("release-turn"); + let operations = witness.path().join("operations.jsonl"); + let mut fixture=native_codex_fixture(json!({"recordTurns":true,"liveTranscript":true,"waitForTurnReleasePath":release,"appendThreadOperationLogPath":operations})).await; + let transport = fixture.transport.clone(); + transport.start(&fixture.profile).await.unwrap(); + let mut events = transport.take_event_stream().unwrap(); + transport + .dispatch( + &RequestId::parse("cached-active").unwrap(), + "run", + &fixture.profile, + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.transcript.patch" + && frame.to_string().contains("Native streaming fragment") + { + break; + } + } + }) + .await + .unwrap(); + let active = transport.snapshot().await.unwrap(); + assert_eq!( + active["status"], "running", + "precondition: exact native history was cached while genuinely active" + ); + let reads_before = std::fs::read_to_string(&operations) + .unwrap() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + while events.try_recv().is_ok() {} + while transport + .event_tx + .try_send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .is_ok() + {} + std::fs::write(&release, b"complete native turn").unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame: Value = + serde_json::from_str(&fixture.diagnostic.recv().await.unwrap()).unwrap(); + if frame["event"]["type"] == "freshAgent.turn.complete" { + break; + } + } + }) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + let recovered = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Some(AgentEvent::Provider { payload }) = events.recv().await { + if payload["event"]["retainedState"].is_object() { + break payload["event"]["retainedState"].clone(); + } + } + } + }) + .await + .unwrap(); + let reads_after = std::fs::read_to_string(operations) + .unwrap() + .lines() + .filter(|line| line.contains("thread/read")) + .count(); + transport.clone().stop().await.unwrap(); + assert_eq!(reads_after, reads_before); + assert_eq!( + recovered["snapshot"]["status"], "idle", + "a final completion is authoritative over an earlier active native read: {recovered}" + ); + assert_eq!(recovered["snapshot"]["capabilities"]["send"], true); + assert_eq!(recovered["snapshot"]["capabilities"]["interrupt"], false); } -} -fn find_operation_session_id(value: &Value) -> Option { - if matches!( - value.get("type").and_then(Value::as_str), - Some("freshAgent.rolledBack") | Some("freshAgent.redone") - ) { - return value - .get("newSessionId") - .or_else(|| value.get("sessionId")) - .and_then(Value::as_str) - .map(str::to_string); - } - match value { - Value::Object(object) => object.values().find_map(find_operation_session_id), - Value::Array(values) => values.iter().find_map(find_operation_session_id), - _ => None, + #[tokio::test] + async fn task6_upstream_lag_ending_in_a_control_recovers_without_later_frames() { + let (bus, rx) = broadcast::channel(1); + let (created, _) = watch::channel(None); + let (native, _) = watch::channel(Some("thread-upstream".to_string())); + let (events, mut event_rx) = mpsc::channel(1); + events + .send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .await + .unwrap(); + let (send_outcomes, _) = broadcast::channel(8); + let (kill_outcomes, _) = broadcast::channel(8); + let _bridge = spawn_broadcast_bridge( + FreshProvider::Codex, + rx, + BroadcastBridgeOutputs { + owned_session: None, + retained_codex: None, + created, + native, + events, + send_outcomes, + kill_outcomes, + suppressed_kills: Arc::new(std::sync::Mutex::new(HashSet::new())), + }, + ); + let request = |id: &str| { + json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"thread-upstream","event":{"type":"freshAgent.question.request","requestId":id}}).to_string() + }; + bus.send(request("first-control")).unwrap(); + // The first durable conversion waits for the full downstream queue. + tokio::time::sleep(Duration::from_millis(50)).await; + for _ in 0..10 { + bus.send(request("final-control")).unwrap(); + } + let recovered = tokio::time::timeout(Duration::from_secs(2), async { + loop { + if let Some(AgentEvent::Provider { payload }) = event_rx.recv().await { + if payload["event"]["type"] == "freshAgent.host.backpressure" { + break payload; + } + } + } + }) + .await; + assert!(recovered.is_ok(),"lag before the journal must recover even when the last retained frame is a durable control and the provider becomes silent"); } -} -fn parse_send_outcome(value: &Value) -> Option<(String, bool)> { - let event_type = value.get("type")?.as_str()?; - let request_id = value.get("requestId").and_then(|value| match value { - Value::String(value) => Some(value.clone()), - Value::Number(value) => Some(value.to_string()), - _ => None, - })?; - match event_type { - "freshAgent.send.accepted" => Some((request_id, true)), - "error" => Some((request_id, false)), - _ => None, + #[tokio::test] + async fn task6_final_dropped_frame_recovers_without_another_provider_frame() { + let (bus, rx) = broadcast::channel(8); + let (created, _) = watch::channel(None); + let (native, _) = watch::channel(Some("thread-final".to_string())); + let (events, mut event_rx) = mpsc::channel(1); + events + .send(AgentEvent::Provider { + payload: json!({"occupied":true}), + }) + .await + .unwrap(); + let (send_outcomes, _) = broadcast::channel(8); + let (kill_outcomes, _) = broadcast::channel(8); + let _bridge = spawn_broadcast_bridge( + FreshProvider::Codex, + rx, + BroadcastBridgeOutputs { + owned_session: None, + retained_codex: None, + created, + native, + events, + send_outcomes, + kill_outcomes, + suppressed_kills: Arc::new(std::sync::Mutex::new(HashSet::new())), + }, + ); + bus.send(json!({"type":"freshAgent.event","provider":"codex","sessionType":"freshcodex","sessionId":"thread-final","event":{"type":"freshAgent.turn.complete","at":100}}).to_string()).unwrap(); + // Leave capacity unavailable while the bridge consumes its only input. + tokio::time::sleep(Duration::from_millis(50)).await; + event_rx.recv().await.unwrap(); + let recovery = tokio::time::timeout(Duration::from_secs(2), event_rx.recv()) + .await + .expect("a final drop must independently await capacity and recover") + .unwrap(); + let AgentEvent::Provider { payload } = recovery else { + panic!("routed recovery event") + }; + assert_eq!(payload["type"], "freshAgent.event"); + assert_eq!(payload["provider"], "codex"); + assert_eq!(payload["sessionType"], "freshcodex"); + assert_eq!(payload["sessionId"], "thread-final"); + assert_eq!(payload["event"]["type"], "freshAgent.host.backpressure"); } -} - -#[cfg(test)] -mod tests { - use super::*; - - static OPENCODE_SNAPSHOT_ENV_LOCK: Mutex<()> = Mutex::const_new(()); #[tokio::test] async fn managed_provider_claude_native_identity_uses_actual_adapter_event_name() { @@ -1495,10 +4460,12 @@ mod tests { let (events, mut event_rx) = mpsc::channel(8); let (send_outcomes, _) = broadcast::channel(8); let (kill_outcomes, _) = broadcast::channel(8); - spawn_broadcast_bridge( + let _bridge = spawn_broadcast_bridge( FreshProvider::Claude, rx, BroadcastBridgeOutputs { + owned_session: None, + retained_codex: None, created, native, events, @@ -1538,7 +4505,7 @@ mod tests { #[tokio::test] async fn managed_provider_fresh_claude_sends_only_fixed_hosted_mcp_selection() { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let context = ProviderLaunchContext { preparation: ProviderPreparation::Claude { mcp_args: vec!["--mcp-config".into(), ".claude/freshell-mcp.json".into()], @@ -1682,6 +4649,8 @@ mod tests { fn profile(&self, resume: Option<&str>) -> FreshAgentProfile { FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), cwd: self.root.path().join("workspace").to_string_lossy().into(), @@ -1718,7 +4687,7 @@ mod tests { async fn a_claude_resume_start_resolves_from_created_with_no_prompt() { // The sidecar entry is resolved from this process's environment, which // other tests here set: hold their lock. - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let conversation = "4b0e8f6a-1d2c-4e3f-9a5b-6c7d8e9f0a1b"; let claude = SimulatedClaude::start(&[conversation]).await; let mut events = claude.transport.take_event_stream().expect("event stream"); @@ -1772,7 +4741,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_fresh_claude_start_resolves_its_uuid_before_any_prompt() { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let claude = SimulatedClaude::start(&[]).await; let mut events = claude.transport.take_event_stream().expect("event stream"); let profile = claude.profile(None); @@ -1827,7 +4796,7 @@ mod tests { /// a ledger keeps.) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_hosted_claude_undo_keeps_the_panes_plugins_and_model() { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let conversation = "6d1f0a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b"; let claude = SimulatedClaude::start(&[conversation]).await; let plugin = claude.root.path().join("workspace/.claude/plugins/review"); @@ -1877,7 +4846,7 @@ mod tests { /// second identity announcement (the actor records the child at the fork). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_claude_fork_boots_the_child_retires_the_parent_and_runs_the_next_turn_there() { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let conversation = "6d1f0a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b"; let claude = SimulatedClaude::start(&[conversation]).await; let mut events = claude.transport.take_event_stream().expect("event stream"); @@ -1993,7 +4962,7 @@ mod tests { /// the boot is refused (`create.failed`), so no session is reported started. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_claude_resume_start_of_a_missing_conversation_fails() { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let claude = SimulatedClaude::start(&[]).await; let profile = claude.profile(Some("9c8b7a6f-5e4d-4c3b-8a2f-1e0d9c8b7a6f")); let started = @@ -2027,7 +4996,7 @@ mod tests { reject_model: bool, settled_on: &str, ) -> HostedClaudeConfigure { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let claude = SimulatedClaude::start_with( &[], @@ -2041,6 +5010,8 @@ mod tests { .await; let transport = claude.transport.clone(); let profile = FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), cwd: claude @@ -2113,7 +5084,7 @@ mod tests { let run = hosted_claude_configure( freshell_runtime_protocol::FreshAgentTurnSettings { model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), ..Default::default() }, false, @@ -2189,7 +5160,7 @@ mod tests { let run = hosted_claude_configure( freshell_runtime_protocol::FreshAgentTurnSettings { model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), ..Default::default() }, true, @@ -2272,7 +5243,7 @@ mod tests { #[tokio::test] async fn actual_opencode_first_send_materializes_once_and_reuses_owned_native_http_session() { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../test/e2e-browser/fixtures/fake-opencode.cjs") @@ -2296,6 +5267,8 @@ mod tests { std::env::set_var("FAKE_OPENCODE_AUDIT_LOG", &audit); let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; let profile = FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), cwd: dir.path().to_string_lossy().into(), @@ -2446,7 +5419,7 @@ mod tests { #[tokio::test] async fn actual_opencode_snapshot_read_failure_preserves_owned_native_identity() { - let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let _lock = NATIVE_PROVIDER_FIXTURE_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../test/e2e-browser/fixtures/fake-opencode.cjs") @@ -2498,6 +5471,8 @@ mod tests { // including the product RED and setup-error paths. let observed: Result = async { let profile = FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), cwd: dir.path().to_string_lossy().into(), model: None, effort: None, permission_mode: None, sandbox: None, provider_store_id: "owned-store".into(), @@ -2656,6 +5631,8 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let transport = HostedTransport::new_with_context(FreshProvider::Opencode, None).await; let profile = FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: FreshProvider::Opencode, runtime_variant: "freshopencode".into(), cwd: dir.path().to_string_lossy().into(), @@ -2829,6 +5806,7 @@ mod tests { ProviderPreparation::Codex { tui_args, sidecar_args, + .. } => !tui_args.is_empty() || !sidecar_args.is_empty(), ProviderPreparation::Opencode { tui_config, @@ -2887,6 +5865,7 @@ mod tests { format!("accepted:{}", request_id.as_str()), ); Ok(DispatchAck { + native_identity: None, provider_ack_id: Some(request_id.as_str().into()), }) } @@ -3034,12 +6013,54 @@ mod tests { } } + #[test] + fn prepared_codex_approval_intent_reaches_create_without_becoming_explicit() { + let mut profile = parity_profile(FreshProvider::Codex); + profile.permission_mode = None; + let mut context = + serde_json::to_value(profile.provider_launch_context.as_ref().unwrap()).unwrap(); + context["preparation"]["codex"]["approval_default"] = json!({ + "policy":"untrusted", "logicalSource":"/original/.codex/config.toml", + "canonicalSource":"/original/.codex/config.toml", "sourceSha256":"a".repeat(64) + }); + profile.provider_launch_context = Some( + serde_json::from_value(context) + .expect("prepared native policy descriptor must survive the protocol boundary"), + ); + assert_eq!( + create_request_for_profile(&profile) + .permission_mode + .as_deref(), + Some("untrusted") + ); + assert!( + profile.permission_mode.is_none(), + "inherited policy must remain distinguishable from explicit settings" + ); + let replayed: FreshAgentProfile = + serde_json::from_slice(&serde_json::to_vec(&profile).unwrap()).unwrap(); + assert_eq!( + create_request_for_profile(&replayed) + .permission_mode + .as_deref(), + Some("untrusted") + ); + profile.permission_mode = Some("never".into()); + assert_eq!( + create_request_for_profile(&profile) + .permission_mode + .as_deref(), + Some("never") + ); + } + fn parity_profile(provider: FreshProvider) -> FreshAgentProfile { let preparation = match provider { FreshProvider::Claude => ProviderPreparation::Claude { mcp_args: Vec::new(), }, FreshProvider::Codex => ProviderPreparation::Codex { + approval_default: None, tui_args: Vec::new(), sidecar_args: Vec::new(), }, @@ -3052,6 +6073,8 @@ mod tests { FreshProvider::Kilroy => unreachable!(), }; FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: provider.clone(), runtime_variant: provider.as_str().into(), cwd: "/workspace".into(), @@ -3120,9 +6143,10 @@ mod tests { }, FreshAgentOperation::Configure { settings: freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("fixture-configured-model".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("acceptEdits".into()), sandbox: None, }, @@ -3180,7 +6204,7 @@ mod tests { let mut profile = provider_context.clone(); profile.cwd = message.cwd.clone().unwrap(); profile.model = message.model.clone(); - profile.effort = message.effort.clone(); + profile.effort = message.effort.clone().flatten(); profile.permission_mode = message.permission_mode.clone(); profile.sandbox = message .sandbox @@ -3543,6 +6567,7 @@ mod tests { ProviderPreparation::Codex { ref tui_args, ref sidecar_args, + .. } => !tui_args.is_empty() || !sidecar_args.is_empty(), ProviderPreparation::Opencode { ref tui_config, @@ -3582,6 +6607,7 @@ mod tests { ProviderPreparation::Codex { ref tui_args, ref sidecar_args, + .. } => !tui_args.is_empty() || !sidecar_args.is_empty(), ProviderPreparation::Opencode { ref tui_config, @@ -3651,6 +6677,7 @@ mod tests { mcp_args: Vec::new(), }, FreshProvider::Codex => ProviderPreparation::Codex { + approval_default: None, tui_args: Vec::new(), sidecar_args: vec!["-c".into(), "fixture_option=true".into()], }, @@ -3663,6 +6690,8 @@ mod tests { FreshProvider::Kilroy => unreachable!(), }; let profile = FreshAgentProfile { + naming_handle: None, + collaboration_mode: None, provider: provider.clone(), runtime_variant: provider.as_str().into(), cwd: "/workspace".into(), @@ -3769,6 +6798,7 @@ mod tests { ProviderPreparation::Codex { tui_args, sidecar_args, + .. }, ) => { assert!( diff --git a/crates/freshell-session-host/src/providers/mod.rs b/crates/freshell-session-host/src/providers/mod.rs index 7b06be80c3..3770fae1e9 100644 --- a/crates/freshell-session-host/src/providers/mod.rs +++ b/crates/freshell-session-host/src/providers/mod.rs @@ -1,6 +1,6 @@ //! Provider-specific recovery probes executed inside the soul enclosure. //! -//! The trusted host delegates store reads to a short-lived helper running as +//! The trusted host delegates store reads and exact owned sidecar cleanup to helpers running as //! the same unprivileged uid/gid as the provider. This avoids granting the host //! DAC override while keeping all provider-state inspection inside the runtime. @@ -18,6 +18,8 @@ pub(crate) use deterministic_fresh_agent::run_state_worker as run_fresh_agent_fi pub(crate) use deterministic_fresh_agent::run_worker as run_fresh_agent_fixture_worker; pub(crate) use fresh_agent::open_hosted_fresh_agent; +pub(crate) use codex::run_naming_proof; +pub(crate) use codex::run_owned_cleanup; pub(crate) use codex::PreparedCodexLaunch; use freshell_agent_runtime::ProviderStoreProbe; diff --git a/crates/freshell-session-host/src/pty.rs b/crates/freshell-session-host/src/pty.rs index 5a0c52c8cc..e35742b1ab 100644 --- a/crates/freshell-session-host/src/pty.rs +++ b/crates/freshell-session-host/src/pty.rs @@ -114,7 +114,15 @@ impl HostedPty { } }); let mut provider_args = crate::provider_identity_args(launch.run_as_uid, launch.run_as_gid); - provider_args.push(launch.program.clone()); + #[cfg(target_os = "linux")] + let provider_program = if launch.mode == "codex" { + freshell_codex::managed_launch::managed_program(&launch.program)? + } else { + launch.program.clone() + }; + #[cfg(not(target_os = "linux"))] + let provider_program = launch.program.clone(); + provider_args.push(provider_program); for (index, argument) in launch.args.iter().enumerate() { provider_args.push(if launch.mode == "claude" && index > 0 && launch.args[index - 1] == "--settings" && argument == freshell_platform::claude_inputs::MANAGED_SETTINGS_ARGUMENT { claude_settings.as_ref().ok_or("selected Claude settings origin is unavailable; retain inputs and reprepare before dispatch")?.clone() @@ -139,15 +147,23 @@ impl HostedPty { provider_env.insert("GIT_CONFIG_VALUE_0".into(), launch.workspace_path.clone()); let spawn = (|| { #[cfg(target_os = "linux")] - freshell_platform::provider_exec::check_final_exec( - &spec.program, - &spec.args, - &provider_env, - &[&launch.program], - freshell_platform::provider_exec::process_profile() - .map_err(|error| error.to_string())?, - ) - .map_err(|error| error.to_string())?; + if launch.mode == "codex" { + freshell_codex::managed_launch::check_launch( + &spec.program, + &spec.args, + &provider_env, + )?; + } else { + freshell_platform::provider_exec::check_final_exec( + &spec.program, + &spec.args, + &provider_env, + &[&launch.program], + freshell_platform::provider_exec::process_profile() + .map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + } PtyTerminal::spawn_with_sink( &spec, &provider_env, diff --git a/crates/freshell-supervisor/src/backend.rs b/crates/freshell-supervisor/src/backend.rs index 654b89ba66..95ad69ac01 100644 --- a/crates/freshell-supervisor/src/backend.rs +++ b/crates/freshell-supervisor/src/backend.rs @@ -1,4 +1,5 @@ mod claude_source_observation; +mod codex_sandbox; pub mod docker; mod helper_lifecycle; pub(crate) use helper_lifecycle::CLAUDE_SOURCE_READER_RETRY; @@ -42,6 +43,8 @@ pub enum BackendError { Malformed(String), #[error("runtime backend configuration rejected: {0}")] InvalidConfig(String), + #[error("Codex native configuration incompatible: {0}")] + CodexIncompatible(String), } #[derive(Debug, Clone)] @@ -455,7 +458,9 @@ fn docker_create_body( operator_home_record: Option, requested_limits: String, memory_swap: u64, -) -> Value { +) -> Result { + let security_options = + codex_sandbox::security_options(spec.terminal.as_ref(), spec.fresh_agent.as_ref())?; let cap_add: Vec<&str> = if spec.terminal.is_some() || spec.fresh_agent.is_some() { vec!["CHOWN", "SETGID", "SETUID"] } else { @@ -472,7 +477,7 @@ fn docker_create_body( if let Some(record) = operator_home_record { labels[OPERATOR_HOME_MOUNT_LABEL] = json!(record); } - json!({ + Ok(json!({ "Image": spec.image_ref, "Env": host_env, "Cmd": [ @@ -493,7 +498,7 @@ fn docker_create_body( "Privileged": false, "CapDrop": ["ALL"], "CapAdd": cap_add, - "SecurityOpt": ["no-new-privileges:true"], + "SecurityOpt": security_options, "RestartPolicy": {"Name":"no","MaximumRetryCount":0}, "NanoCpus": spec.limits.cpu_milli.saturating_mul(1_000_000), "Memory": spec.limits.memory_bytes, @@ -502,7 +507,7 @@ fn docker_create_body( "Binds": binds, "Tmpfs": runtime_tmpfs_config } - }) + })) } fn workload_mcp_capability<'a>( @@ -592,11 +597,16 @@ fn runtime_host_environment( ); } if agent.provider == freshell_runtime_protocol::FreshProvider::Codex { + let program = if agent.fixture_transport.is_none() { + codex_sandbox::managed_program("codex")? + } else { + "codex".into() + }; values.insert( "CODEX_CMD".into(), format!( - "/usr/bin/setpriv --reuid {} --regid {} --clear-groups --no-new-privs -- codex", - agent.run_as_uid, agent.run_as_gid + "/usr/bin/setpriv --reuid {} --regid {} --clear-groups --no-new-privs -- {}", + agent.run_as_uid, agent.run_as_gid, program ), ); } @@ -626,7 +636,9 @@ fn runtime_host_environment( "CODEX_CMD".into(), format!( "/usr/bin/setpriv --reuid {} --regid {} --clear-groups --no-new-privs -- {}", - terminal.run_as_uid, terminal.run_as_gid, terminal.program + terminal.run_as_uid, + terminal.run_as_gid, + codex_sandbox::managed_program(&terminal.program)? ), ); } @@ -883,7 +895,10 @@ impl RuntimeBackend for DockerEngineBackend { let operator_home = terminal_mounts .as_ref() .and_then(|mounts| mounts.operator_home.as_ref()); - let expected = ExpectedConfig::new(spec, &binary, &runtime_dir); + let expected = ExpectedConfig::new(spec, &binary, &runtime_dir)?; + if let Some(policy) = &expected.codex_native_sandbox { + tracing::info!(provider = "codex", incarnation_id = spec.incarnation_id.as_str(), image = %spec.image_ref, profile_id = %policy.id, profile_sha256 = %policy.profile_sha256, declared_native_version = %policy.declared_managed_version, supported_native_version = %policy.supported_native_version, helper_sha256 = %policy.helper_sha256, "runtime.codex_native_sandbox.selected"); + } let immutable_config_digest = digest_expected(&expected)?; let (daemon_id, rootless) = self.daemon_info().await?; self.ensure_reserved_initial_volume(spec, &daemon_id, rootless) @@ -944,7 +959,7 @@ impl RuntimeBackend for DockerEngineBackend { operator_home_record, requested_limits, memory_swap, - ); + )?; if let Some(mounts) = &terminal_mounts { let context = spec .terminal @@ -1258,6 +1273,8 @@ struct ExpectedConfig { terminal: Option, fresh_agent: Option, provider_volume_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + codex_native_sandbox: Option, } /// The registry updates session identity and turn settings while a container @@ -1299,8 +1316,12 @@ impl From<&FreshAgentLaunchSpec> for ImmutableFreshAgentConfig { } impl ExpectedConfig { - fn new(spec: &CreateRuntimeSpec, binary: &Path, runtime_dir: &Path) -> Self { - Self { + fn new( + spec: &CreateRuntimeSpec, + binary: &Path, + runtime_dir: &Path, + ) -> Result { + Ok(Self { image_ref: spec.image_ref.clone(), installation_id: spec.installation_id.to_string(), soul_id: spec.soul_id.to_string(), @@ -1311,7 +1332,11 @@ impl ExpectedConfig { terminal: spec.terminal.clone(), fresh_agent: spec.fresh_agent.as_ref().map(Into::into), provider_volume_name: spec.provider_volume_name.clone(), - } + codex_native_sandbox: codex_sandbox::identity( + spec.terminal.as_ref(), + spec.fresh_agent.as_ref(), + )?, + }) } } @@ -1327,6 +1352,7 @@ fn immutable_digest_from_handle(handle: &OwnedRuntimeHandle) -> Result Result<( let host_config = value .get("HostConfig") .ok_or_else(|| BackendError::OwnershipMismatch("missing HostConfig".into()))?; + if codex_sandbox::selected(handle.terminal(), handle.fresh_agent()) { + codex_sandbox::verify(host_config)?; + } let expected_network = if handle.terminal().is_some() || handle.fresh_agent().is_some() { "bridge" } else { @@ -1879,6 +1908,8 @@ mod tests { let binary = root.path().join("freshell-session-host"); std::fs::write(&binary, b"host").unwrap(); let agent = FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "session-one".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -1944,6 +1975,9 @@ mod tests { "PidMode":"", "Privileged":false, "CapAdd":["CHOWN","SETGID","SETUID"], + "CapDrop":["ALL"], + "ReadonlyRootfs":true, + "SecurityOpt":codex_sandbox::security_options(None, handle.fresh_agent()).unwrap(), "Tmpfs":runtime_tmpfs(Some("codex")), }, "Mounts": [ @@ -2044,7 +2078,7 @@ mod tests { assert_eq!(response.body, b"test"); } #[test] - fn codex_host_environment_drops_to_the_provider_uid_and_uses_soul_home() { + fn codex_host_environment_uses_soul_home_and_excludes_auth_token() { let terminal = TerminalLaunchSpec { terminal_id: "terminal-one".into(), stream_id: "stream-one".into(), @@ -2081,7 +2115,7 @@ mod tests { assert!(env .iter() .any(|value| value == "CODEX_HOME=/home/freshell/provider/.codex")); - assert!(env.iter().any(|value| value == "CODEX_CMD=/usr/bin/setpriv --reuid 65534 --regid 0 --clear-groups --no-new-privs -- codex")); + assert!(!env.iter().any(|value| value.contains("AUTH_TOKEN"))); } @@ -2172,7 +2206,7 @@ mod tests { serde_json::to_string(&spec.limits).unwrap(), spec.limits.memory_bytes, ); - let durable_json = serde_json::to_string(&body).unwrap(); + let durable_json = serde_json::to_string(&body.unwrap()).unwrap(); assert!(durable_json.contains(&keys.to_string_lossy().to_string())); // Docker needs only the exact read-only mount. The profile selector is // delivered later over the authenticated host grant, while resolved @@ -2224,6 +2258,8 @@ mod tests { #[test] fn fresh_agent_config_digest_survives_native_identity_and_turn_settings() { let agent = FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "presentation-one".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -2264,6 +2300,7 @@ mod tests { terminal: None, fresh_agent: Some((&agent).into()), provider_volume_name: "freshell-provider-one".into(), + codex_native_sandbox: codex_sandbox::identity(None, Some(&agent)).unwrap(), }) .unwrap() }; @@ -2305,6 +2342,7 @@ mod tests { let context = ProviderLaunchContext { prepared_inputs: Some(inputs), preparation: ProviderPreparation::Codex { + approval_default: None, tui_args: vec![], sidecar_args: vec![], }, @@ -2338,6 +2376,11 @@ mod tests { terminal: (!fresh_route).then_some(terminal.clone()), fresh_agent: fresh_route.then(|| (&fresh).into()), provider_volume_name: "owned-provider".into(), + codex_native_sandbox: codex_sandbox::identity( + (!fresh_route).then_some(&terminal), + fresh_route.then_some(&fresh), + ) + .unwrap(), }; let before = digest_expected(&expected).unwrap(); let context = if let Some(terminal) = expected.terminal.as_mut() { @@ -2358,6 +2401,8 @@ mod tests { fn fresh_agent_spec_fixture() -> FreshAgentLaunchSpec { FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "session-fixture".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -2607,6 +2652,138 @@ mod tests { assert!(verify_inspect_config(&handle, &inspected).is_ok()); } + fn codex_runtime_fixture(root: &Path) -> (OwnedRuntimeHandle, Value) { + let (original, mut inspected) = terminal_runtime_fixture(root, None); + let mut terminal = original.terminal().unwrap().clone(); + terminal.mode = "codex".into(); + terminal.program = "codex".into(); + let make_handle = |digest: String| { + OwnedRuntimeHandle::from_registry( + original.installation_id().clone(), + original.soul_id().clone(), + original.incarnation_id().clone(), + original.launch_nonce().clone(), + original.daemon_id().clone(), + original.container_id().into(), + original.image_ref().into(), + original.runtime_dir().into(), + original.host_binary_path().into(), + digest, + original.requested_limits(), + None, + Some(terminal.clone()), + None, + original.provider_volume_name().into(), + ) + }; + let provisional = make_handle(original.immutable_config_digest().into()); + let handle = make_handle(immutable_digest_from_handle(&provisional).unwrap()); + inspected["Config"]["Env"] = + json!(runtime_host_environment(Some(&terminal), None, None).unwrap()); + inspected["HostConfig"]["Tmpfs"] = json!(runtime_tmpfs(Some("codex"))); + inspected["HostConfig"]["ReadonlyRootfs"] = json!(true); + inspected["HostConfig"]["CapDrop"] = json!(["ALL"]); + inspected["HostConfig"]["SecurityOpt"] = + json!(codex_sandbox::security_options(Some(&terminal), None).unwrap()); + inspected["Id"] = json!(handle.container_id()); + inspected["State"] = json!({"Status":"created","Running":false}); + (handle, inspected) + } + + #[test] + fn codex_native_sandbox_policy_is_required_before_reusing_runtime() { + let root = tempfile::tempdir().unwrap(); + let (handle, mut inspected) = codex_runtime_fixture(root.path()); + assert!(verify_inspect_config(&handle, &inspected).is_ok()); + inspected["HostConfig"]["SecurityOpt"] = json!(["no-new-privileges:true"]); + assert!( + matches!( + verify_inspect_config(&handle, &inspected), + Err(BackendError::OwnershipMismatch(_)) + ), + "a live Codex runtime without the required native sandbox policy must not be reused" + ); + } + + #[tokio::test] + async fn codex_native_policy_is_verified_before_docker_start() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + let root = tempfile::tempdir().unwrap(); + let (handle, inspected) = codex_runtime_fixture(root.path()); + let socket = root.path().join("daemon.sock"); + let listener = tokio::net::UnixListener::bind(&socket).unwrap(); + let inspected = Arc::new(Mutex::new(inspected)); + let starts = Arc::new(AtomicUsize::new(0)); + let (stop, mut stopped) = tokio::sync::oneshot::channel::<()>(); + let (observed, calls, daemon) = ( + inspected.clone(), + starts.clone(), + handle.daemon_id().to_string(), + ); + let server = tokio::spawn(async move { + loop { + let (mut stream, _) = tokio::select! { + _ = &mut stopped => break, + accepted = listener.accept() => accepted.unwrap(), + }; + let mut request = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let count = stream.read(&mut chunk).await.unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&chunk[..count]); + if find_bytes(&request, b"\r\n\r\n").is_some() { + break; + } + } + let line = String::from_utf8_lossy(&request) + .lines() + .next() + .unwrap_or_default() + .to_owned(); + let (status, body) = if line.starts_with("GET /v1.47/info ") { + ( + 200, + serde_json::to_vec( + &json!({"ID":daemon,"SecurityOptions":["name=rootless"]}), + ) + .unwrap(), + ) + } else if line.starts_with("GET /v1.47/containers/container-one/json ") { + (200, serde_json::to_vec(&*observed.lock().unwrap()).unwrap()) + } else if line.starts_with("POST /v1.47/containers/container-one/start ") { + calls.fetch_add(1, Ordering::SeqCst); + (204, Vec::new()) + } else { + (404, Vec::new()) + }; + stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()).await.unwrap(); + stream.write_all(&body).await.unwrap(); + stream.shutdown().await.unwrap(); + } + }); + let backend = DockerEngineBackend::new(&socket); + backend.start_host(&handle).await.unwrap(); + assert_eq!(starts.load(Ordering::SeqCst), 1); + inspected.lock().unwrap()["HostConfig"]["SecurityOpt"] = json!(["no-new-privileges:true"]); + assert!(matches!( + backend.start_host(&handle).await, + Err(BackendError::OwnershipMismatch(_)) + )); + assert_eq!( + starts.load(Ordering::SeqCst), + 1, + "missing native policy must not issue another Docker start" + ); + stop.send(()).unwrap(); + server.await.unwrap(); + } + #[test] fn verify_inspect_config_uses_the_persisted_creation_time_mask_list() { // Late-directory case: a soul created with masks [".freshell"] because diff --git a/crates/freshell-supervisor/src/backend/claude_source_observation.rs b/crates/freshell-supervisor/src/backend/claude_source_observation.rs index e9b502db38..47bb068526 100644 --- a/crates/freshell-supervisor/src/backend/claude_source_observation.rs +++ b/crates/freshell-supervisor/src/backend/claude_source_observation.rs @@ -470,11 +470,14 @@ impl DockerEngineBackend { .push(json!({"Type":"bind","Source":common,"Target":common,"ReadOnly":true})); } descriptors.extend(prepared_inputs::mount_descriptors(&mounts, &backing.name)?); + // A candidate performs the workload's full durable projection, + // including installed dependencies, rather than a metadata-only read. + // Give that preparation the ordinary managed workload's CPU budget. let body = json!({"Image":image,"User":"0:0","Tty":false,"WorkingDir":cwd, "Entrypoint":["/runtime/freshell-session-host"],"Cmd":["project-claude-candidate-private"],"Labels":labels, "Env":self.private_helper_environment(image,&["HOME=/home/freshell/provider"]).await?, "HostConfig":{"NetworkMode":"none","ReadonlyRootfs":true,"CapDrop":["ALL"],"CapAdd":["CHOWN","SETGID","SETUID"], - "SecurityOpt":["no-new-privileges"],"Memory":256*1024*1024,"MemorySwap":256*1024*1024,"NanoCpus":500_000_000,"PidsLimit":32, + "SecurityOpt":["no-new-privileges"],"Memory":256*1024*1024,"MemorySwap":256*1024*1024,"NanoCpus":2_000_000_000,"PidsLimit":32, "LogConfig":{"Type":"none","Config":{}},"Tmpfs":{},"Mounts":descriptors}}); step.enter("create"); let created = reader diff --git a/crates/freshell-supervisor/src/backend/codex_sandbox.rs b/crates/freshell-supervisor/src/backend/codex_sandbox.rs new file mode 100644 index 0000000000..ed3bf2a0ce --- /dev/null +++ b/crates/freshell-supervisor/src/backend/codex_sandbox.rs @@ -0,0 +1,215 @@ +//! Codex's native sandbox needs only the declared namespace/mount delta. +//! Docker receives the policy itself, never a daemon-local file pathname. +use super::BackendError; +use freshell_runtime_protocol::{FreshAgentLaunchSpec, FreshProvider, TerminalLaunchSpec}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::sync::OnceLock; + +const RESOURCE: &str = include_str!("../../../../docker/runtime/codex-native-sandbox.json"); + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(super) struct Identity { + pub id: String, + pub profile_sha256: String, + pub supported_native_version: String, + pub declared_managed_version: String, + pub helper_sha256: String, + pub launch_wrapper: String, + pub npm_wrapper: String, + pub layout_file: String, + pub helper_directory: String, + pub npm_entrypoint_sha256: String, +} + +struct Policy { + identity: Identity, + profile: Value, + security_option: String, +} + +pub(super) fn selected( + terminal: Option<&TerminalLaunchSpec>, + agent: Option<&FreshAgentLaunchSpec>, +) -> bool { + terminal.is_some_and(|terminal| { + terminal.mode == "codex" + && (terminal.program == "codex" + || policy() + .map(|policy| { + terminal.program == policy.identity.npm_wrapper + || terminal.program == policy.identity.launch_wrapper + }) + .unwrap_or(true)) + }) || agent.is_some_and(|agent| { + agent.provider == FreshProvider::Codex && agent.fixture_transport.is_none() + }) +} + +fn parse(raw: &str) -> Result { + let resource: Value = + serde_json::from_str(raw).map_err(|error| format!("invalid resource JSON: {error}"))?; + if resource["schemaVersion"] != 1 + || resource["profile"]["defaultAction"] != "SCMP_ACT_ERRNO" + || resource["profile"]["defaultErrnoRet"] != 1 + || !resource["profile"]["syscalls"].is_array() + { + return Err("unsupported resource schema or native default-deny policy".into()); + } + let field = |pointer| { + resource + .pointer(pointer) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or_else(|| format!("missing native policy field {pointer}")) + }; + let mut profile = resource["profile"].clone(); + // Match the canonical bytes used by the executed native fixture profile. + // serde_json preserves source key order in this workspace. + profile.sort_all_objects(); + let profile_json = serde_json::to_string(&profile).map_err(|error| error.to_string())?; + let identity = Identity { + id: field("/id")?, + profile_sha256: format!("sha256:{:x}", Sha256::digest(profile_json.as_bytes())), + supported_native_version: field("/native/version")?, + declared_managed_version: freshell_agent_runtime::pinned_provider_version("codex") + .ok_or("managed Codex version is undeclared")? + .to_owned(), + helper_sha256: field("/native/bwrapSha256")?, + launch_wrapper: field("/native/launchWrapper")?, + npm_wrapper: field("/native/npmWrapper")?, + layout_file: field("/native/layoutFile")?, + helper_directory: field("/native/helperDirectory")?, + npm_entrypoint_sha256: field("/native/npmEntrypointSha256")?, + }; + if identity.supported_native_version != identity.declared_managed_version { + return Err("managed Codex pin and supported native sandbox version differ; rebuild an aligned workload image".into()); + } + Ok(Policy { + identity, + profile, + security_option: format!("seccomp={profile_json}"), + }) +} + +fn policy() -> Result<&'static Policy, BackendError> { + static POLICY: OnceLock> = OnceLock::new(); + POLICY + .get_or_init(|| parse(RESOURCE)) + .as_ref() + .map_err(|error| BackendError::CodexIncompatible(error.clone())) +} + +pub(super) fn identity( + terminal: Option<&TerminalLaunchSpec>, + agent: Option<&FreshAgentLaunchSpec>, +) -> Result, BackendError> { + if selected(terminal, agent) { + Ok(Some(policy()?.identity.clone())) + } else { + Ok(None) + } +} + +pub(super) fn security_options( + terminal: Option<&TerminalLaunchSpec>, + agent: Option<&FreshAgentLaunchSpec>, +) -> Result, BackendError> { + let mut options = vec!["no-new-privileges:true".to_owned()]; + if selected(terminal, agent) { + options.push(policy()?.security_option.clone()); + } + Ok(options) +} + +pub(super) fn managed_program(program: &str) -> Result { + let policy = policy()?; + Ok( + if program == "codex" || program == policy.identity.npm_wrapper { + policy.identity.launch_wrapper.clone() + } else { + program.to_owned() + }, + ) +} + +pub(super) fn verify(host: &Value) -> Result<(), BackendError> { + let expected = policy()?; + let fail = || { + BackendError::OwnershipMismatch( + "Codex native sandbox policy or outer isolation changed".into(), + ) + }; + if host["ReadonlyRootfs"] != true + || host["CapDrop"].as_array().is_none_or(|caps| { + caps.len() != 1 || !matches!(caps[0].as_str(), Some("ALL" | "CAP_ALL")) + }) + { + return Err(fail()); + } + let options = host["SecurityOpt"].as_array().ok_or_else(fail)?; + if options.len() != 2 { + return Err(fail()); + } + let mut no_new_privileges = false; + let mut matched = false; + for option in options { + let option = option.as_str().ok_or_else(fail)?; + if matches!( + option, + "no-new-privileges" | "no-new-privileges:true" | "no-new-privileges=true" + ) && !no_new_privileges + { + no_new_privileges = true; + } else if let Some(raw) = option.strip_prefix("seccomp=").filter(|_| !matched) { + let actual: Value = serde_json::from_str(raw).map_err(|_| fail())?; + if actual != expected.profile { + return Err(fail()); + } + matched = true; + } else { + return Err(fail()); + } + } + if !no_new_privileges || !matched { + return Err(fail()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_invalid_policy_is_a_configuration_refusal() { + assert!(parse("not JSON").is_err()); + assert!(parse("{}").is_err()); + let mut invalid: Value = serde_json::from_str(RESOURCE).unwrap(); + invalid["profile"]["defaultAction"] = Value::String("SCMP_ACT_ALLOW".into()); + assert!(parse(&invalid.to_string()).is_err()); + } + + #[test] + fn codex_policy_rejects_mutated_profile_and_outer_isolation() { + let policy = policy().unwrap(); + let valid = serde_json::json!({"ReadonlyRootfs":true,"CapDrop":["ALL"],"SecurityOpt":["no-new-privileges:true",policy.security_option]}); + assert!(verify(&valid).is_ok()); + let mut changed = valid.clone(); + changed["ReadonlyRootfs"] = Value::Bool(false); + assert!(verify(&changed).is_err()); + changed = valid.clone(); + changed["CapDrop"] = serde_json::json!([]); + assert!(verify(&changed).is_err()); + changed = valid.clone(); + changed["SecurityOpt"][1] = Value::String("seccomp=unconfined".into()); + assert!(verify(&changed).is_err()); + changed = valid; + let mut profile = policy.profile.clone(); + profile["defaultErrnoRet"] = Value::from(0); + changed["SecurityOpt"][1] = Value::String(format!("seccomp={profile}")); + assert!(verify(&changed).is_err()); + } +} diff --git a/crates/freshell-supervisor/src/backend/input_publisher.rs b/crates/freshell-supervisor/src/backend/input_publisher.rs index a1266138af..182264e588 100644 --- a/crates/freshell-supervisor/src/backend/input_publisher.rs +++ b/crates/freshell-supervisor/src/backend/input_publisher.rs @@ -1820,11 +1820,10 @@ mod tests { candidate.image_ref.clone(), candidate.runtime_dir.clone(), binary.clone(), - digest_expected(&ExpectedConfig::new( - candidate, - &binary, - &candidate.runtime_dir, - )) + digest_expected( + &ExpectedConfig::new(candidate, &binary, &candidate.runtime_dir) + .unwrap(), + ) .unwrap(), candidate.limits, None, diff --git a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs index c004985021..21ab42bd74 100644 --- a/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs +++ b/crates/freshell-supervisor/src/fresh_agent_configure_tests.rs @@ -49,7 +49,7 @@ impl RuntimeBackend for NoBackend { } } -struct RunningFreshClaude { +struct RunningFreshAgent { _root: tempfile::TempDir, supervisor: Supervisor, soul: SoulId, @@ -57,7 +57,17 @@ struct RunningFreshClaude { runtime_dir: std::path::PathBuf, } -async fn running_fresh_claude() -> RunningFreshClaude { +async fn running_fresh_claude() -> RunningFreshAgent { + running_fresh_provider(FreshProvider::Claude).await +} +async fn running_fresh_provider(provider: FreshProvider) -> RunningFreshAgent { + running_fresh_provider_with_name(provider, None).await +} + +async fn running_fresh_provider_with_name( + provider: FreshProvider, + naming_handle: Option, +) -> RunningFreshAgent { let root = tempfile::tempdir().unwrap(); let workspace = std::fs::canonicalize(root.path()).unwrap(); let registry = Registry::open(root.path().join("registry"), None).unwrap(); @@ -65,7 +75,7 @@ async fn running_fresh_claude() -> RunningFreshClaude { let prepared = registry .prepare_launch(LaunchPreparation { soul_id: soul.clone(), - provider: "claude".into(), + provider: provider.as_str().into(), provider_store_id: "fresh-store".into(), native_session_id: None, creation_seed_ref: "configure-seed".into(), @@ -77,10 +87,22 @@ async fn running_fresh_claude() -> RunningFreshClaude { fixture: None, terminal: None, fresh_agent: Some(FreshAgentLaunchSpec { + naming_handle, + collaboration_mode: None, session_id: "fresh-session".into(), - provider: FreshProvider::Claude, - session_type: "freshclaude".into(), - runtime_variant: "claude-agent-sdk".into(), + provider: provider.clone(), + session_type: if provider == FreshProvider::Codex { + "freshcodex" + } else { + "freshclaude" + } + .into(), + runtime_variant: if provider == FreshProvider::Codex { + "codex-app-server" + } else { + "claude-agent-sdk" + } + .into(), provider_store_id: "fresh-store".into(), cwd: workspace.to_string_lossy().into_owned(), workspace_path: workspace.to_string_lossy().into_owned(), @@ -149,7 +171,7 @@ async fn running_fresh_claude() -> RunningFreshClaude { }, ) .unwrap(); - RunningFreshClaude { + RunningFreshAgent { _root: root, supervisor, soul, @@ -158,17 +180,340 @@ async fn running_fresh_claude() -> RunningFreshClaude { } } +fn native_send_reply(previous: &str, native: &str, pending: Option) -> HostResult { + HostResult::FreshAgentCommand { + state: CommandState::ProviderAcked, + native_session_id: Some(native.into()), + native_identity: Some(NativeDispatchIdentity { + previous_native_session_id: previous.into(), + native_session_id: native.into(), + pending_naming_handle: pending, + }), + } +} + +fn native_send_host( + fixture: &RunningFreshAgent, + replies: Vec, + gate: Option<(Arc, Arc)>, +) -> ( + tokio::sync::oneshot::Sender<()>, + tokio::task::JoinHandle, +) { + let secret = b"synthetic-host-secret"; + std::fs::write(fixture.runtime_dir.join("secret"), secret).unwrap(); + let listener = UnixListener::bind(fixture.runtime_dir.join("host.sock")).unwrap(); + let boot = HostBootId::new(); + let incarnation = fixture.incarnation.clone(); + let (shutdown, mut stop) = tokio::sync::oneshot::channel(); + let host = tokio::spawn(async move { + let mut replies = replies.into_iter(); + let mut sends = 0; + loop { + let mut socket = + tokio::select! {_=&mut stop=>break,result=listener.accept()=>result.unwrap().0}; + let envelope: Envelope = read_frame(&mut socket).await.unwrap(); + let result = match envelope.body { + HostCommand::Hello {incarnation_id,challenge} => HostResult::Hello { + host_boot_id:boot.clone(),proof:host_proof(secret,&challenge,&boot,&incarnation_id),capabilities:vec![],effective_limits:crate::checkpoints::tests::limits() + }, + HostCommand::FreshAgentSend {incarnation_id,..} => { + assert_eq!(incarnation_id,incarnation); + assert_eq!(envelope.auth,Some(host_proof(secret,"command-auth",&boot,&incarnation))); + sends+=1; + if let Some((entered,release))=&gate {entered.notify_one();release.notified().await;} + replies.next().expect("no input may be automatically resent") + }, + other=>panic!("native acknowledgement must not create/start/configure/probe a provider: {other:?}") + }; + write_frame( + &mut socket, + &HostReply { + request_id: envelope.request_id, + result: Ok(result), + }, + ) + .await + .unwrap(); + } + sends + }); + (shutdown, host) +} + +#[tokio::test] +async fn accepted_native_recovery_preserves_presentation_and_pending_name_policy() { + for pending in [false, true] { + let fixture = + running_fresh_provider_with_name(FreshProvider::Codex, Some("pending-original".into())) + .await; + fixture + .supervisor + .registry + .record_native_session( + fixture.soul.clone(), + fixture.incarnation.clone(), + "native-original".into(), + ) + .await + .unwrap(); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(); + let before_spec = before.fresh_agent.unwrap(); + let handle = pending.then(|| "pending-original".to_string()); + let mut replies = Vec::new(); + if !pending { + replies.push(native_send_reply( + "native-original", + "native-original", + None, + )); + } + replies.push(native_send_reply( + "native-original", + "native-recovered", + handle.clone(), + )); + replies.push(native_send_reply( + "native-recovered", + "native-recovered", + handle.clone(), + )); + let expected_sends = replies.len(); + let (shutdown, host) = native_send_host(&fixture, replies, None); + let mut results = Vec::new(); + let mut same_native = None; + for index in 0..expected_sends { + let result = fixture + .supervisor + .fresh_agent_send( + RequestId::new(), + fixture.soul.clone(), + format!("controlled native input {index}"), + None, + None, + ) + .await; + let failed = result.is_err(); + results.push(result); + if !pending && index == 0 { + same_native = Some( + fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(), + ); + } + if failed { + break; + } + } + shutdown.send(()).unwrap(); + let sends = host.await.unwrap(); + for result in results { + result.expect( + "native acceptance must atomically persist current source and pending-name state", + ); + } + assert_eq!(sends, expected_sends); + if let Some(same) = same_native { + assert_eq!(same.native_session_id.as_deref(), Some("native-original")); + assert!(same.fresh_agent.unwrap().naming_handle.is_none(),"consumed pending authority must clear on ordinary same-native accepts, keeping the actor/recovery spec consistent"); + } + let after = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(); + let spec = after.fresh_agent.unwrap(); + assert_eq!(after.native_session_id.as_deref(), Some("native-recovered")); + assert_eq!( + spec.session_id, before_spec.session_id, + "presentation reference must remain byte-identical" + ); + assert_eq!(spec.native_session_id.as_deref(), Some("native-recovered")); + assert_eq!(spec.naming_handle, handle); + assert_eq!(spec.model, before_spec.model); + assert_eq!(spec.effort, before_spec.effort); + assert_eq!(spec.cwd, before_spec.cwd); + assert_eq!(spec.permission_mode, before_spec.permission_mode); + assert_eq!(spec.sandbox, before_spec.sandbox); + let connection = + rusqlite::Connection::open(fixture.supervisor.registry.database_path()).unwrap(); + let claims: Vec = connection + .prepare("SELECT native_session_id FROM writer_claims WHERE soul_id=?1") + .unwrap() + .query_map([fixture.soul.as_str()], |row| row.get(0)) + .unwrap() + .map(Result::unwrap) + .collect(); + assert_eq!(claims, vec!["native-recovered".to_string()]); + } +} + +#[tokio::test] +async fn native_dispatch_rejects_stale_invalid_and_nonaccepted_identity_authority() { + for scenario in [ + "untagged", + "wrong-parent", + "wrong-reply", + "missing-reply", + "invalid-native", + "foreign-pending", + "wrong-provider", + "ambiguous", + "refused", + "stop-intent", + ] { + let fixture = running_fresh_provider_with_name( + if scenario == "wrong-provider" { + FreshProvider::Claude + } else { + FreshProvider::Codex + }, + Some("pending-original".into()), + ) + .await; + fixture + .supervisor + .registry + .record_native_session( + fixture.soul.clone(), + fixture.incarnation.clone(), + "native-original".into(), + ) + .await + .unwrap(); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + let native = if scenario == "invalid-native" { + "native\ninvalid" + } else { + "native-recovered" + }; + let mut reply = native_send_reply( + if scenario == "wrong-parent" { + "stale-native" + } else { + "native-original" + }, + native, + if scenario == "foreign-pending" { + Some("unrelated-pending".into()) + } else { + None + }, + ); + if let HostResult::FreshAgentCommand { + state, + native_session_id, + native_identity, + } = &mut reply + { + match scenario { + "untagged" => *native_identity = None, + "wrong-reply" => *native_session_id = Some("different-reply".into()), + "missing-reply" => *native_session_id = None, + "ambiguous" => *state = CommandState::Ambiguous, + "refused" => *state = CommandState::Cancelled, + _ => {} + } + } + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let (shutdown, host) = native_send_host( + &fixture, + vec![reply], + (scenario == "stop-intent").then(|| (entered.clone(), release.clone())), + ); + let work = tokio::spawn({ + let supervisor = fixture.supervisor.clone(); + let soul = fixture.soul.clone(); + async move { + supervisor + .fresh_agent_send( + RequestId::new(), + soul, + "controlled refused identity".into(), + None, + None, + ) + .await + } + }); + if scenario == "stop-intent" { + tokio::time::timeout(std::time::Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + fixture + .supervisor + .registry + .begin_stop(fixture.soul.clone()) + .await + .unwrap(); + release.notify_one(); + } + let result = work.await.unwrap(); + shutdown.send(()).unwrap(); + assert_eq!(host.await.unwrap(), 1); + assert!( + !matches!( + result, + Ok(CommandState::ProviderAcked | CommandState::Completed) + ), + "{scenario}: {result:?}" + ); + let after = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap(); + assert_eq!( + after.native_session_id.as_deref(), + Some("native-original"), + "{scenario}" + ); + assert_eq!(after.fresh_agent, before, "{scenario}"); + } +} + /// An authenticated session host that records every command it is sent. fn fake_host( runtime_dir: &std::path::Path, +) -> (Arc>>, tokio::task::JoinHandle<()>) { + fake_host_with_boot_change(runtime_dir, false) +} +fn fake_host_with_boot_change( + runtime_dir: &std::path::Path, + change_boot: bool, +) -> (Arc>>, tokio::task::JoinHandle<()>) { + fake_host_with_boot(runtime_dir, change_boot, HostBootId::new()) +} +fn fake_host_with_boot( + runtime_dir: &std::path::Path, + change_boot: bool, + boot: HostBootId, ) -> (Arc>>, tokio::task::JoinHandle<()>) { let secret = b"synthetic-host-secret"; std::fs::write(runtime_dir.join("secret"), secret).unwrap(); let listener = UnixListener::bind(runtime_dir.join("host.sock")).unwrap(); - let boot = HostBootId::new(); let received = Arc::new(StdMutex::new(Vec::new())); let log = Arc::clone(&received); let task = tokio::spawn(async move { + let mut hello_count = 0; loop { let Ok((mut socket, _)) = listener.accept().await else { return; @@ -179,13 +524,49 @@ fn fake_host( HostCommand::Hello { incarnation_id, challenge, - } => HostResult::Hello { - host_boot_id: boot.clone(), - proof: host_proof(secret, &challenge, &boot, &incarnation_id), - capabilities: vec![], - effective_limits: crate::checkpoints::tests::limits(), - }, + } => { + hello_count += 1; + let current = if change_boot && hello_count > 1 { + HostBootId::new() + } else { + boot.clone() + }; + HostResult::Hello { + host_boot_id: current.clone(), + proof: host_proof(secret, &challenge, ¤t, &incarnation_id), + capabilities: vec![], + effective_limits: crate::checkpoints::tests::limits(), + } + } + HostCommand::FreshAgentModelCatalog { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some("/bound/project")); + HostResult::FreshAgentModelCatalog( + serde_json::json!([{"id":"provider-current","displayName":"Provider Current","supportedEffortLevels":["ultra"],"defaultEffort":"ultra"}]), + ) + } + HostCommand::FreshAgentNativeName { + native_session_id, + codex_home, + operation, + .. + } => { + assert_eq!(native_session_id, "name-native"); + assert_eq!(codex_home, "/owning/codex"); + HostResult::FreshAgentNativeName(match operation { + FreshAgentNativeNameOperation::Probe => { + FreshAgentNativeNameReply::Available + } + FreshAgentNativeNameOperation::Read => { + FreshAgentNativeNameReply::Title(Some("Native title".into())) + } + FreshAgentNativeNameOperation::Write { title } => { + assert_eq!(title, "Manual title"); + FreshAgentNativeNameReply::Renamed + } + }) + } HostCommand::FreshAgentConfigure { .. } => HostResult::FreshAgentCommand { + native_identity: None, state: CommandState::ProviderAcked, native_session_id: Some("native-claude".into()), }, @@ -234,14 +615,79 @@ fn configure_envelope_with( fn opus_high() -> FreshAgentTurnSettings { FreshAgentTurnSettings { + collaboration_mode: None, cwd: None, model: Some("opus".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: None, sandbox: None, } } +#[tokio::test] +async fn task008_settings_admin_codex_absent_effort_preserves_and_null_clears_recovery_defaults() { + let fixture = running_fresh_provider(FreshProvider::Codex).await; + let (received, host) = fake_host(&fixture.runtime_dir); + let epoch = fixture.supervisor.registry.control_epoch(); + let original = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent + .unwrap(); + for (request, patch, expected) in [ + ( + "codex-cwd-only", + serde_json::json!({"cwd":original.cwd,"collaborationMode":"plan","sandbox":"read-only"}), + Some("low"), + ), + ( + "codex-native-default", + serde_json::json!({"effort":null}), + None, + ), + ] { + let reply = fixture + .supervisor + .dispatch(configure_envelope_with( + &fixture.soul, + &RequestId::parse(request).unwrap(), + Some(epoch), + serde_json::from_value(patch).unwrap(), + )) + .await; + assert!(reply.result.is_ok(), "{:?}", reply.result); + let recovered = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent + .unwrap(); + assert_eq!(recovered.effort.as_deref(), expected); + assert_eq!(recovered.collaboration_mode.as_deref(), Some("plan")); + assert_eq!(recovered.sandbox.as_deref(), Some("read-only")); + assert_eq!(recovered.model, original.model); + } + host.abort(); + let _ = host.await; + let configures: Vec<_> = received + .lock() + .unwrap() + .iter() + .filter_map(|command| match command { + HostCommand::FreshAgentConfigure { settings, .. } => Some(settings.clone()), + _ => None, + }) + .collect(); + assert_eq!(configures.len(), 2); + assert_eq!(configures[0].effort, None); + assert_eq!(configures[1].effort, Some(None)); +} + #[tokio::test] async fn admin_configure_routes_to_the_souls_host_and_updates_the_recovery_profile() { let fixture = running_fresh_claude().await; @@ -437,3 +883,256 @@ fn host_configure_gets_the_fresh_agent_command_budget() { incarnation_id: IncarnationId::new(), })); } + +#[tokio::test] +async fn admin_native_name_rejects_stale_authority_before_host_work_and_preserves_recovery() { + for scenario in [ + "read", + "write", + "probe", + "busy", + "stale-native", + "stale-boot", + "stale-incarnation", + "stale-generation", + "stale-intent", + "stale-epoch", + "wrong-provider", + "boot-changes", + ] { + let fixture = running_fresh_provider(if scenario == "wrong-provider" { + FreshProvider::Claude + } else { + FreshProvider::Codex + }) + .await; + fixture + .supervisor + .registry + .record_native_session( + fixture.soul.clone(), + fixture.incarnation.clone(), + "name-native".into(), + ) + .await + .unwrap(); + let view = fixture + .supervisor + .registry + .inventory() + .await + .unwrap() + .into_iter() + .find(|view| view.soul_id == fixture.soul) + .unwrap(); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + let mut source = FreshAgentNativeNameSource { + incarnation_id: view.incarnation_id.clone(), + host_boot_id: view.host_boot_id.clone().unwrap(), + execution_generation: view.execution_generation, + intent_revision: view.intent_revision, + native_session_id: "name-native".into(), + }; + let (received, host) = fake_host_with_boot( + &fixture.runtime_dir, + scenario == "boot-changes", + source.host_boot_id.clone(), + ); + match scenario { + "stale-native" => source.native_session_id = "foreign-native".into(), + "stale-boot" => source.host_boot_id = HostBootId::new(), + "stale-incarnation" => source.incarnation_id = IncarnationId::new(), + "stale-generation" => source.execution_generation += 1, + "stale-intent" => source.intent_revision += 1, + _ => {} + } + let epoch = fixture.supervisor.registry.control_epoch(); + let mut request = Envelope::new( + RequestId::new(), + ControlRole::Web, + AdminCommand::FreshAgentNativeName(FreshAgentNativeNameRequest { + soul_id: fixture.soul.clone(), + source, + codex_home: "/owning/codex".into(), + operation: match scenario { + "write" => FreshAgentNativeNameOperation::Write { + title: "Manual title".into(), + }, + "probe" => FreshAgentNativeNameOperation::Probe, + _ => FreshAgentNativeNameOperation::Read, + }, + expected_control_epoch: Some(if scenario == "stale-epoch" { + epoch + 1 + } else { + epoch + }), + }), + ); + request.auth = Some(SECRET.into()); + let lifecycle = fixture.supervisor.lifecycle_lock(&fixture.soul).await; + let _busy_guard = if scenario == "busy" { + Some(lifecycle.lock().await) + } else { + None + }; + let reply = fixture.supervisor.dispatch(request).await; + host.abort(); + let _ = host.await; + if scenario == "stale-epoch" { + assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::StaleControlEpoch + ); + } else { + let AdminResult::FreshAgentNativeName(reply) = reply.result.unwrap() else { + panic!("wrong reply") + }; + match scenario { + "read" => assert_eq!( + reply, + FreshAgentNativeNameReply::Title(Some("Native title".into())) + ), + "write" => assert_eq!(reply, FreshAgentNativeNameReply::Renamed), + "probe" => assert_eq!(reply, FreshAgentNativeNameReply::Available), + "boot-changes" => assert_eq!( + reply, + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Closed) + ), + _ => assert!(matches!( + reply, + FreshAgentNativeNameReply::Failed( + FreshAgentNativeNameError::Unavailable { .. } + ) + )), + } + } + { + let calls = received.lock().unwrap(); + if scenario.starts_with("stale-") || scenario == "wrong-provider" || scenario == "busy" + { + assert!( + calls.is_empty(), + "{scenario}: no host/native work for stale authority" + ); + } else { + assert_eq!( + calls + .iter() + .filter(|command| matches!( + command, + HostCommand::FreshAgentNativeName { .. } + )) + .count(), + 1 + ); + } + } + assert_eq!( + fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent, + before, + "metadata does not change launch/recovery settings" + ); + } +} + +#[tokio::test] +async fn admin_model_catalog_uses_authenticated_current_host_and_preserves_recovery_settings() { + for scenario in ["live", "stale-epoch", "replaced-boot", "wrong-provider"] { + let fixture = running_fresh_provider(if scenario == "wrong-provider" { + FreshProvider::Claude + } else { + FreshProvider::Codex + }) + .await; + let (received, host) = + fake_host_with_boot_change(&fixture.runtime_dir, scenario == "replaced-boot"); + let before = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + let epoch = fixture.supervisor.registry.control_epoch(); + let mut request = Envelope::new( + RequestId::new(), + ControlRole::Web, + AdminCommand::FreshAgentModelCatalog(FreshAgentModelCatalogRequest { + soul_id: fixture.soul.clone(), + cwd: Some("/bound/project".into()), + expected_control_epoch: Some(if scenario == "stale-epoch" { + epoch + 1 + } else { + epoch + }), + }), + ); + request.auth = Some(SECRET.into()); + let reply = fixture.supervisor.dispatch(request).await; + host.abort(); + let _ = host.await; + match scenario { + "live" => assert!( + matches!(reply.result.unwrap(),AdminResult::FreshAgentModelCatalog(value) if value[0]["id"]=="provider-current" && value[0]["defaultEffort"]=="ultra") + ), + "stale-epoch" => assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::StaleControlEpoch + ), + "wrong-provider" => assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::UnsupportedOperation + ), + _ => assert_eq!( + reply.result.unwrap_err().code, + RuntimeErrorCode::OwnershipMismatch + ), + } + { + let commands = received.lock().unwrap(); + if scenario == "stale-epoch" || scenario == "wrong-provider" { + assert!(commands.is_empty()); + } else { + assert_eq!( + commands + .iter() + .filter(|c| matches!(c, HostCommand::FreshAgentModelCatalog { .. })) + .count(), + 1 + ); + assert!(commands.iter().all(|c| matches!( + c, + HostCommand::Hello { .. } | HostCommand::FreshAgentModelCatalog { .. } + ))); + for command in commands.iter() { + if let HostCommand::FreshAgentModelCatalog { incarnation_id, .. } = command { + assert_eq!(incarnation_id, &fixture.incarnation); + } + } + } + } + let after = fixture + .supervisor + .registry + .recovery_context(fixture.soul.clone()) + .await + .unwrap() + .fresh_agent; + assert_eq!( + before, after, + "catalog reads must never reconfigure recovery" + ); + } +} diff --git a/crates/freshell-supervisor/src/host_restart_recovery_tests.rs b/crates/freshell-supervisor/src/host_restart_recovery_tests.rs new file mode 100644 index 0000000000..4e79bd3052 --- /dev/null +++ b/crates/freshell-supervisor/src/host_restart_recovery_tests.rs @@ -0,0 +1,275 @@ +// An authenticated restarted host can inspect its owned store without +// claiming that a worker survived. Probe/recovery uses the real registry/IPC. + +async fn unconfirmed_codex_supervisor( + root: &std::path::Path, + native: Option<&str>, +) -> (Supervisor, RecoveryContext) { + use crate::registry::{BackendCreatedRecord, LaunchPreparation, Registry}; + use freshell_runtime_protocol::*; + let registry = Registry::open(root.join("registry"), None).unwrap(); + let soul = SoulId::new(); + let launch: FreshAgentLaunchSpec = serde_json::from_value(serde_json::json!({ + "sessionId":"managed-codex-stable", "provider":"codex", "sessionType":"freshcodex", + "runtimeVariant":"codex-app-server", "providerStoreId":"restart-store", + "cwd":root, "workspacePath":root, "runAsUid":65534, "runAsGid":0, + "nativeSessionId":native, "model":"catalog-model", "effort":"xhigh", + "permissionMode":"untrusted", "sandbox":"workspace-write", "collaborationMode":"plan" + })) + .unwrap(); + let prepared = registry + .prepare_launch(LaunchPreparation { + soul_id: soul.clone(), + provider: "codex".into(), + provider_store_id: "restart-store".into(), + native_session_id: native.map(str::to_owned), + creation_seed_ref: "original-seed".into(), + request_id: RequestId::new(), + payload_digest: "restart-fixture".into(), + requested_limits: checkpoints::tests::limits(), + profile: RuntimeProfile::Custom, + project_key: "fixture".into(), + fixture: None, + terminal: None, + fresh_agent: Some(launch), + view_intent: None, + admission: crate::admission::AdmissionPolicy::default(), + }) + .await + .unwrap(); + let runtime = root.join("runtime"); + std::fs::create_dir_all(&runtime).unwrap(); + std::fs::write(runtime.join("host"), b"synthetic-host").unwrap(); + registry + .commit_created( + prepared.incarnation_id.clone(), + BackendCreatedRecord { + daemon_id: DockerDaemonId::new(), + container_id: "a".repeat(64), + image_ref: format!("sha256:{}", "b".repeat(64)), + runtime_dir: runtime.clone(), + host_binary_path: runtime.join("host"), + immutable_config_digest: format!("sha256:{}", "c".repeat(64)), + }, + ) + .await + .unwrap(); + registry + .commit_execution_grant( + prepared.incarnation_id.clone(), + HostBootId::new(), + checkpoints::tests::limits(), + ) + .await + .unwrap(); + registry + .mark_running(prepared.incarnation_id) + .await + .unwrap(); + let context = registry.recovery_context(soul).await.unwrap(); + recovery_supervisor(root, registry, context, BackendRuntimeState::Running).await +} + +#[tokio::test] +async fn authenticated_unconfirmed_worker_probes_exact_saved_codex_before_recovery() { + use freshell_runtime_protocol::*; + for outcome in [ + "ready", + "unavailable", + "negative", + "wrong-native", + "superseded", + "unknown-native", + ] { + let temp = tempfile::tempdir().unwrap(); + let (supervisor, context) = unconfirmed_codex_supervisor( + temp.path(), + (outcome != "unknown-native").then_some("native-saved"), + ) + .await; + assert!(context.resume_spec.is_none()); + let original = context.fresh_agent.clone(); + let expected = crate::resume_catalog::exact_resume_candidate(&context, None).unwrap(); + let runtime = context.prior_handle.runtime_dir(); + let secret = b"synthetic-host-secret"; + std::fs::write(runtime.join("secret"), secret).unwrap(); + let listener = tokio::net::UnixListener::bind(runtime.join("host.sock")).unwrap(); + let boot = HostBootId::new(); + let entered = std::sync::Arc::new(tokio::sync::Notify::new()); + let release = std::sync::Arc::new(tokio::sync::Notify::new()); + let (shutdown, mut stopping) = tokio::sync::oneshot::channel(); + let host = tokio::spawn({ + let expected = expected.clone(); + let entered = entered.clone(); + let release = release.clone(); + let incarnation = context.prior_handle.incarnation_id().clone(); + async move { + let mut probes = 0; + loop { + let mut socket = tokio::select! { _=&mut stopping => break, result=listener.accept()=>result.unwrap().0 }; + let envelope: Envelope = read_frame(&mut socket).await.unwrap(); + let result = match envelope.body { + HostCommand::Hello { + incarnation_id, + challenge, + } => Ok(HostResult::Hello { + host_boot_id: boot.clone(), + proof: host_proof(secret, &challenge, &boot, &incarnation_id), + capabilities: vec![], + effective_limits: checkpoints::tests::limits(), + }), + command => { + assert_eq!( + envelope.auth, + Some(host_proof(secret, "command-auth", &boot, &incarnation)) + ); + match command { + HostCommand::Status { incarnation_id } => { + assert_eq!(incarnation_id, incarnation); + Err(RuntimeError::new(RuntimeErrorCode::HostUnreachable,"initialization was recorded but worker liveness is unconfirmed")) + } + HostCommand::ProbeRecovery { + incarnation_id, + resume_spec, + run_as_uid, + run_as_gid, + .. + } => { + assert_eq!(incarnation_id, incarnation); + assert_eq!((run_as_uid, run_as_gid), (65534, 0)); + assert_eq!( + resume_spec.as_deref().map(|s| &s.provider_session), + expected.as_ref().map(|s| &s.provider_session) + ); + probes += 1; + if outcome == "superseded" { + entered.notify_one(); + release.notified().await; + } + if outcome == "unavailable" { + Ok(HostResult::RecoveryProbe(RecoveryProbe::Blocked { + path: RecoveryPath::NativeResume, + reason: RecoveryBlockReason::AmbiguousIdentity, + retry_hint: RetryHint { + automatic_after_ms: None, + manual_retry: true, + repair: None, + }, + evidence: vec!["no exact readable positive".into()], + })) + } else if outcome == "negative" { + Ok(HostResult::RecoveryProbe( + RecoveryProbe::DefinitivelyUnavailable { + path: RecoveryPath::NativeResume, + reason: "no matching file".into(), + evidence: vec!["partial store no-match".into()], + store_state: EvidenceStoreState::PresentReadable, + negative_paths: vec![], + }, + )) + } else { + let mut resume = expected.clone().expect("unknown native identity must never reach a store probe"); + if outcome == "wrong-native" { + resume.provider_session.native_session_id = + "foreign-native".into(); + } + Ok(HostResult::RecoveryProbe(RecoveryProbe::ResumeReady { + evidence_revision: 0, + resume_spec: Box::new(resume), + })) + } + } + other => panic!( + "probe must not start, send, stop or configure: {other:?}" + ), + } + } + }; + write_frame( + &mut socket, + &HostReply { + request_id: envelope.request_id, + result, + }, + ) + .await + .unwrap(); + } + probes + } + }); + let pending = tokio::spawn({ + let supervisor = supervisor.clone(); + let soul = context.soul_id.clone(); + async move { supervisor.probe_recovery(soul).await } + }); + let newer = if outcome == "superseded" { + tokio::time::timeout(Duration::from_secs(3), entered.notified()) + .await + .unwrap(); + let mut newer = expected.clone().unwrap(); + newer.durable_position.provider_cursor = Some("newer-proof".into()); + let stored = supervisor + .registry + .store_verified_resume_spec(context.soul_id.clone(), newer) + .await + .unwrap(); + release.notify_one(); + Some(stored) + } else { + None + }; + let result = tokio::time::timeout(Duration::from_secs(3), pending) + .await + .unwrap() + .unwrap(); + shutdown.send(()).unwrap(); + let probes = host.await.unwrap(); + let after = supervisor + .registry + .recovery_context(context.soul_id.clone()) + .await + .unwrap(); + assert_eq!( + after.fresh_agent, original, + "probe must preserve settings/presentation identity" + ); + assert_eq!( + after.prior_handle.incarnation_id(), + context.prior_handle.incarnation_id() + ); + assert_eq!(probes,usize::from(outcome!="unknown-native"),"{outcome}: an authenticated host must inspect the recorded exact native store despite unconfirmed worker liveness"); + match outcome { + "ready" => { + assert!( + matches!(result, Ok(RecoveryProbe::ResumeReady { .. })), + "{result:?}" + ); + assert_eq!( + after.resume_spec.unwrap().provider_session, + expected.unwrap().provider_session + ); + assert!(after.evidence_revision > 0); + } + "superseded" => { + assert!( + matches!(result, Ok(RecoveryProbe::Blocked { .. })), + "{result:?}" + ); + assert_eq!(after.resume_spec, newer); + } + _ => { + assert!( + !matches!( + result, + Ok(RecoveryProbe::ResumeReady { .. } | RecoveryProbe::ReattachReady { .. }) + ), + "{outcome}: {result:?}" + ); + assert!(after.resume_spec.is_none()); + assert_eq!(after.evidence_revision, 0); + assert!(matches!(result,Ok(RecoveryProbe::Blocked{..})|Err(_)),"an unconfirmed worker permits only a positive exact resume proof, never absence/loss authority: {result:?}"); + } + } + } +} diff --git a/crates/freshell-supervisor/src/provider_callback_budget_tests.rs b/crates/freshell-supervisor/src/provider_callback_budget_tests.rs index 79ebda62df..5d46cda990 100644 --- a/crates/freshell-supervisor/src/provider_callback_budget_tests.rs +++ b/crates/freshell-supervisor/src/provider_callback_budget_tests.rs @@ -17,6 +17,14 @@ struct RecoveryFixture { /// A retained Claude terminal with one accepted prepared-input generation, /// and a recovery attempt that begins from it. async fn claude_recovery(budgets: ProviderCallbackBudgets) -> RecoveryFixture { + recovery_fixture(budgets, "claude", false).await +} + +async fn recovery_fixture( + budgets: ProviderCallbackBudgets, + provider: &str, + fresh: bool, +) -> RecoveryFixture { let root = tempfile::tempdir().unwrap(); let binary = root.path().join("host"); std::fs::write(&binary, b"synthetic helper artifact").unwrap(); @@ -29,16 +37,28 @@ async fn claude_recovery(budgets: ProviderCallbackBudgets) -> RecoveryFixture { "preparedInputs":{"generationId":generation,"manifest":{"relativePath":".freshell/input-manifest.json","sha256":"a".repeat(64),"sourceCount":0,"aliasCount":0,"recipeCount":0}} })) .unwrap(); + let context = if provider == "codex" { + let mut value = serde_json::to_value(&context).unwrap(); + value["preparation"] = serde_json::json!({"codex":{"tui_args":[],"sidecar_args":[]}}); + serde_json::from_value(value).unwrap() + } else { + context + }; let terminal: TerminalLaunchSpec = serde_json::from_value(serde_json::json!({ - "terminalId":"callback-budget","streamId":"callback-budget-stream","mode":"claude","program":"claude","args":[], + "terminalId":"callback-budget","streamId":"callback-budget-stream","mode":provider,"program":provider,"args":[], "cwd":root.path(),"workspacePath":root.path(),"runAsUid":65534,"runAsGid":0,"cols":80,"rows":24, "projectKey":"callback-budget-workspace","providerLaunchContext":&context })) .unwrap(); + let fresh_agent: FreshAgentLaunchSpec = serde_json::from_value(serde_json::json!({ + "sessionId":"callback-codex", "provider":"codex", "sessionType":"freshcodex", + "runtimeVariant":"codex", "providerStoreId":"synthetic-store", "cwd":root.path(), + "workspacePath":root.path(), "runAsUid":65534, "runAsGid":0, "providerLaunchContext":context, + })).unwrap(); let prepared = registry .prepare_launch(LaunchPreparation { soul_id: soul.clone(), - provider: "claude".into(), + provider: provider.into(), provider_store_id: "synthetic-store".into(), native_session_id: None, creation_seed_ref: "synthetic-seed".into(), @@ -53,8 +73,8 @@ async fn claude_recovery(budgets: ProviderCallbackBudgets) -> RecoveryFixture { profile: RuntimeProfile::Custom, project_key: "callback-budget-workspace".into(), fixture: None, - terminal: Some(terminal), - fresh_agent: None, + terminal: (!fresh).then_some(terminal), + fresh_agent: fresh.then_some(fresh_agent), view_intent: None, admission: crate::admission::AdmissionPolicy::default(), }) @@ -236,3 +256,41 @@ async fn ordinary_callback_keeps_the_ordinary_wait() { ); server.abort(); } + +#[tokio::test] +async fn codex_replacement_callback_uses_validated_current_consumer() { + for fresh in [false, true] { + let fixture = recovery_fixture(budgets(1000, 1000), "codex", fresh).await; + let server = slow_callback_server( + &fixture.supervisor, + Duration::ZERO, + b"not a prepared context", + move |request, _| { + assert_eq!(request["provider"], "codex"); + assert_eq!( + request["codexSourceRuntime"], + if fresh { "app_server" } else { "tui" }, + "current owned workload must supply role independently of saved configuration" + ); + }, + ); + let error = fixture + .supervisor + .issue_replacement_mcp_capability( + &fixture.start, + "codex", + &fixture.context, + &[], + "/fixture/instance", + "/fixture/workspace", + ) + .await + .unwrap_err(); + server.await.unwrap(); + assert!( + error.message.contains("invalid scoped grant reference"), + "{}", + error.message + ); + } +} diff --git a/crates/freshell-supervisor/src/recovery.rs b/crates/freshell-supervisor/src/recovery.rs index e911841833..0b5fcbbc8f 100644 --- a/crates/freshell-supervisor/src/recovery.rs +++ b/crates/freshell-supervisor/src/recovery.rs @@ -96,6 +96,16 @@ impl Supervisor { { Ok(status) => status, Err(_) if context.resume_spec.is_some() => return offline_probe(&context), + Err(error) + if error.code == RuntimeErrorCode::HostUnreachable + && context.fresh_agent.as_ref().is_some_and(|agent| { + agent.provider + == freshell_runtime_protocol::FreshProvider::Codex + }) + && context.native_session_id.is_some() => + { + return self.probe_unconfirmed_worker_store(&context, &host).await; + } Err(error) => { return Ok(blocked( RecoveryPath::Reattach, @@ -142,33 +152,7 @@ impl Supervisor { "probe": store_probe, }), ); - let captured = match store_probe { - RecoveryProbe::ResumeReady { resume_spec, .. } => { - let stored = self - .registry - .store_verified_resume_spec_for_live_incarnation( - context.soul_id.clone(), - context.prior_handle.incarnation_id().clone(), - context.evidence_revision, - *resume_spec, - ) - .await - .map_err(map_registry)?; - let Some(stored) = stored else { - return Ok(blocked( - RecoveryPath::NativeResume, - RecoveryBlockReason::ProviderUnavailable, - "provider-store evidence was superseded by a newer incarnation or recovery state; retry the probe", - Some(2_000), - )); - }; - RecoveryProbe::ResumeReady { - evidence_revision: stored.evidence_revision, - resume_spec: Box::new(stored), - } - } - other => other, - }; + let captured = self.persist_resume_probe(&context, store_probe).await?; return Ok(after_native_probe(&context, captured)); } @@ -202,6 +186,78 @@ impl Supervisor { )) } + async fn probe_unconfirmed_worker_store( + &self, + context: &RecoveryContext, + host: &crate::service::AuthenticatedHost, + ) -> Result { + // A restarted host cannot certify a surviving + // worker from initialization alone. It can still + // inspect its exact owned store as the provider UID. + let candidate = match resume_catalog::exact_resume_candidate(context, None) { + Ok(Some(candidate)) => candidate, + Ok(None) => { + return Ok(blocked( + RecoveryPath::NativeResume, + RecoveryBlockReason::AmbiguousIdentity, + "native identity is unrecorded", + None, + )) + } + Err(probe) => return Ok(probe), + }; + let probe = self + .probe_host_store(context, host, Some(candidate)) + .await?; + crate::service::append_event( + &self.config.lifecycle_log, + "supervisor.recovery.unconfirmed_worker_store_probe", + serde_json::json!({ + "severity":"info", "soulId":context.soul_id, "incarnationId":context.prior_handle.incarnation_id(), + "nativeSessionId":context.native_session_id, "probe":probe + }), + ); + match probe { + ready @ RecoveryProbe::ResumeReady { .. } => { + self.persist_resume_probe(context, ready).await + } + other @ RecoveryProbe::Blocked { .. } => Ok(other), + _ => Ok(blocked( + RecoveryPath::NativeResume, + RecoveryBlockReason::ProviderUnavailable, + "unconfirmed worker requires a positive exact-session store proof", + Some(2_000), + )), + } + } + + async fn persist_resume_probe( + &self, + context: &RecoveryContext, + probe: RecoveryProbe, + ) -> Result { + let RecoveryProbe::ResumeReady { resume_spec, .. } = probe else { + return Ok(probe); + }; + let stored = self + .registry + .store_verified_resume_spec_for_live_incarnation( + context.soul_id.clone(), + context.prior_handle.incarnation_id().clone(), + context.evidence_revision, + *resume_spec, + ) + .await + .map_err(map_registry)?; + Ok(match stored { + Some(stored) => RecoveryProbe::ResumeReady { + evidence_revision:stored.evidence_revision, resume_spec:Box::new(stored), + }, + None => blocked(RecoveryPath::NativeResume, RecoveryBlockReason::ProviderUnavailable, + "provider-store evidence was superseded by a newer incarnation or recovery state; retry the probe", Some(2_000)), + }) + } + /// Capture a verified exact-session resume proof while the provider store /// is still reachable. A live process remains reattachable even when the /// provider cannot produce a proof yet; the lifecycle event records that @@ -2011,9 +2067,14 @@ fn fresh_agent_replay_settings( agent: &freshell_runtime_protocol::FreshAgentLaunchSpec, ) -> freshell_runtime_protocol::FreshAgentTurnSettings { freshell_runtime_protocol::FreshAgentTurnSettings { + collaboration_mode: agent.collaboration_mode.clone(), cwd: Some(agent.cwd.clone()), model: agent.model.clone(), - effort: agent.effort.clone(), + effort: if agent.provider == freshell_runtime_protocol::FreshProvider::Codex { + Some(agent.effort.clone()) + } else { + agent.effort.clone().map(Some) + }, permission_mode: agent.permission_mode.clone(), sandbox: agent.sandbox.clone(), } @@ -2413,12 +2474,15 @@ fn map_registry(error: RegistryError) -> RuntimeError { mod tests { include!("shell_recovery_tests.rs"); include!("retained_output_tests.rs"); + include!("host_restart_recovery_tests.rs"); use super::*; use freshell_runtime_protocol::{FreshAgentLaunchSpec, FreshProvider, IncarnationId}; #[test] fn queued_fresh_prompt_replays_with_the_exact_persisted_turn_profile() { let agent = FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "presentation".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -2446,7 +2510,7 @@ mod tests { let settings = fresh_agent_replay_settings(&agent); assert_eq!(settings.cwd.as_deref(), Some("/workspace/nested")); assert_eq!(settings.model.as_deref(), Some("model-exact")); - assert_eq!(settings.effort.as_deref(), Some("high")); + assert_eq!(settings.effort, Some(Some("high".into()))); assert_eq!(settings.permission_mode.as_deref(), Some("ask")); assert_eq!(settings.sandbox.as_deref(), Some("workspace-write")); } diff --git a/crates/freshell-supervisor/src/registry.rs b/crates/freshell-supervisor/src/registry.rs index 283dcd643a..7222b14c97 100644 --- a/crates/freshell-supervisor/src/registry.rs +++ b/crates/freshell-supervisor/src/registry.rs @@ -2,9 +2,10 @@ use crate::admission::{AdmissionPolicy, ReservationTotals}; use freshell_runtime_protocol::{ AllocationState, CleanupState, DesiredState, DockerDaemonId, DurabilityState, FixtureKind, FreshAgentLaunchSpec, FreshAgentTurnSettings, GrantId, HostBootId, IncarnationId, IncidentId, - InstallationId, LaunchNonce, LaunchState, NoticeId, RecoveryAttemptId, RecoveryBlockReason, - RecoveryPath, RecoveryState, RecoveryTrigger, RequestId, ResumeSpec, RuntimeLimits, - RuntimeProfile, RuntimeView, SoulId, StopOutcome, TerminalLaunchSpec, ViewIntentRequest, + InstallationId, LaunchNonce, LaunchState, NativeDispatchIdentity, NoticeId, RecoveryAttemptId, + RecoveryBlockReason, RecoveryPath, RecoveryState, RecoveryTrigger, RequestId, ResumeSpec, + RuntimeLimits, RuntimeProfile, RuntimeView, SoulId, StopOutcome, TerminalLaunchSpec, + ViewIntentRequest, }; use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use serde::{Deserialize, Serialize}; @@ -353,6 +354,13 @@ enum SettingsFold { Configure, } +enum NativeIdentityChange { + FollowNative, + AcceptedDispatch { + pending_naming_handle: Option, + }, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputJournalDisposition { Dispatch, @@ -1081,7 +1089,7 @@ impl Registry { let mut spec: FreshAgentLaunchSpec = serde_json::from_str(&encoded) .map_err(|_| RegistryError::Integrity("invalid fresh-agent launch spec".into()))?; let effort = match fold { - SettingsFold::Turn => settings.effort.clone().or_else(|| spec.effort.clone()), + SettingsFold::Turn => settings.effort.clone().unwrap_or_else(|| spec.effort.clone()), SettingsFold::Configure => settings.effort_after_configure( &spec.provider, spec.model.as_deref(), @@ -1112,6 +1120,9 @@ impl Registry { if let Some(value) = settings.sandbox { spec.sandbox = Some(value); } + if let Some(value) = settings.collaboration_mode { + spec.collaboration_mode = Some(value); + } spec.validate().map_err(|error| { RegistryError::InvalidState(error.message) })?; @@ -1317,11 +1328,54 @@ impl Registry { expected_parent: &str, child_session_id: &str, ) -> Result<(), RegistryError> { + self.transition_native_session_inner( + soul_id, + incarnation_id, + expected_parent, + child_session_id, + NativeIdentityChange::FollowNative, + ) + .await + } + + pub async fn record_fresh_agent_dispatch_identity( + &self, + soul_id: SoulId, + incarnation_id: IncarnationId, + identity: NativeDispatchIdentity, + ) -> Result<(), RegistryError> { + self.transition_native_session_inner( + soul_id, + incarnation_id, + &identity.previous_native_session_id, + &identity.native_session_id, + NativeIdentityChange::AcceptedDispatch { + pending_naming_handle: identity.pending_naming_handle, + }, + ) + .await + } + + async fn transition_native_session_inner( + &self, + soul_id: SoulId, + incarnation_id: IncarnationId, + expected_parent: &str, + child_session_id: &str, + change: NativeIdentityChange, + ) -> Result<(), RegistryError> { + let pending_naming_handle = match change { + NativeIdentityChange::FollowNative => None, + NativeIdentityChange::AcceptedDispatch { + pending_naming_handle, + } => Some(pending_naming_handle), + }; + let preserve_presentation = pending_naming_handle.is_some(); let expected_parent = expected_parent.to_string(); let child_session_id = child_session_id.to_string(); if expected_parent.is_empty() || child_session_id.is_empty() - || expected_parent == child_session_id + || (!preserve_presentation && expected_parent == child_session_id) || expected_parent.chars().any(char::is_control) || child_session_id.chars().any(char::is_control) { @@ -1344,9 +1398,18 @@ impl Registry { if owner != soul_id.as_str() { return Err(RegistryError::NativeIdentityConflict); } + if preserve_presentation { + let current: bool=tx.query_row( + "SELECT EXISTS(SELECT 1 FROM incarnations i JOIN souls s ON s.soul_id=i.soul_id JOIN writer_claims w ON w.soul_id=s.soul_id AND w.incarnation_id=i.incarnation_id WHERE s.soul_id=?1 AND i.incarnation_id=?2 AND i.launch_state='running' AND s.desired_state='running' AND w.provider=s.provider AND w.provider_store_id=s.provider_store_id AND w.native_session_id=?3 AND w.intent_revision=s.intent_revision)", + params![soul_id.as_str(),incarnation_id.as_str(),expected_parent],|row|row.get(0) + )?; + if provider!="codex" || existing.as_deref()!=Some(expected_parent.as_str()) || !current { + return Err(RegistryError::NativeIdentityConflict); + } + } // The response to a successful provider fork can be lost. Retrying // the same transition after durable commit is idempotent. - if existing.as_deref() == Some(child_session_id.as_str()) { + if !preserve_presentation && existing.as_deref() == Some(child_session_id.as_str()) { let claim: Option = tx.query_row( "SELECT incarnation_id FROM writer_claims WHERE provider=?1 AND provider_store_id=?2 AND native_session_id=?3", params![provider, store, child_session_id], |row| row.get(0), @@ -1373,6 +1436,20 @@ impl Registry { (None, Some(spec)) if provider == "opencode" && spec.mode == "opencode" => {}, _ => return Err(RegistryError::NativeIdentityConflict), } + if let Some(pending)=&pending_naming_handle { + let spec=fresh_spec.as_mut().ok_or(RegistryError::NativeIdentityConflict)?; + if pending.as_ref().is_some_and(|handle|spec.naming_handle.as_ref()!=Some(handle)) { + return Err(RegistryError::NativeIdentityConflict); + } + if expected_parent==child_session_id { + if spec.naming_handle!=*pending { + spec.naming_handle=pending.clone(); + tx.execute("UPDATE incarnations SET fresh_agent_spec=?1,updated_at=?2 WHERE incarnation_id=?3",params![serde_json::to_string(spec)?,now_millis(),incarnation_id.as_str()])?; + } + tx.commit()?; + return Ok(()); + } + } let conflicting: Option = tx.query_row( "SELECT incarnation_id FROM writer_claims WHERE provider=?1 AND provider_store_id=?2 AND native_session_id=?3", params![provider, store, child_session_id], |row| row.get(0), @@ -1391,14 +1468,16 @@ impl Registry { "INSERT INTO writer_claims (provider,provider_store_id,native_session_id,soul_id,incarnation_id,intent_revision) VALUES (?1,?2,?3,?4,?5,?6)", params![provider, store, child_session_id, soul_id.as_str(), incarnation_id.as_str(), intent], )?; - // An in-soul reroot changes both the provider-native writer key - // and the externally resumable presentation key. Persist them - // together so a web-server restart cannot rediscover the retired - // parent through inventory fallback. + // Fork/reroot follows the new native presentation key. A direct + // accepted recovery keeps the exact existing presentation key. + // Both persist the source and writer claim in this transaction. let now = now_millis(); if let Some(spec) = fresh_spec.as_mut() { - spec.session_id = child_session_id.clone(); + if !preserve_presentation { + spec.session_id = child_session_id.clone(); + } spec.native_session_id = Some(child_session_id.clone()); + spec.naming_handle = pending_naming_handle.clone().flatten(); if let Some(reference) = spec.session_ref.as_mut() { reference.session_id = child_session_id.clone(); } @@ -3796,6 +3875,8 @@ mod tests { launch.provider = "claude".into(); launch.provider_store_id = "fresh-store".into(); launch.fresh_agent = Some(FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "fresh-session".into(), provider: freshell_runtime_protocol::FreshProvider::Claude, session_type: "freshclaude".into(), @@ -4347,6 +4428,23 @@ mod tests { // soul and enclosure stay live. { let conn = open_connection(®istry.inner.db_path).unwrap(); + let encoded: String = conn + .query_row( + "SELECT fresh_agent_spec FROM incarnations WHERE incarnation_id=?1", + params![prepared.incarnation_id.as_str()], + |row| row.get(0), + ) + .unwrap(); + let mut spec: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + spec["namingHandle"] = serde_json::json!("parent-only-pending-name"); + conn.execute( + "UPDATE incarnations SET fresh_agent_spec=?1 WHERE incarnation_id=?2", + params![ + serde_json::to_string(&spec).unwrap(), + prepared.incarnation_id.as_str() + ], + ) + .unwrap(); conn.execute( "UPDATE souls SET durability_state='checkpoint_captured', allocation_state='verified_durable', checkpoint_revision=9, resume_spec='{}' WHERE soul_id=?1", params![soul.as_str()], @@ -4363,6 +4461,17 @@ mod tests { ) .await .unwrap(); + assert!( + registry + .active_handle_for_soul(soul.clone()) + .await + .unwrap() + .fresh_agent() + .unwrap() + .naming_handle + .is_none(), + "the child launch must not reuse the parent's pending name" + ); // A retry after the transition is idempotent, but an unrelated expected parent is not. registry .transition_native_session( @@ -4552,9 +4661,10 @@ mod tests { soul.clone(), prepared.incarnation_id, FreshAgentTurnSettings { + collaboration_mode: None, cwd: Some(nested.to_string_lossy().into_owned()), model: Some("recovery-model".into()), - effort: Some("high".into()), + effort: Some(Some("high".into())), permission_mode: Some("accept-edits".into()), sandbox: Some("danger-full-access".into()), }, diff --git a/crates/freshell-supervisor/src/resume_catalog.rs b/crates/freshell-supervisor/src/resume_catalog.rs index d714eb8f0c..e434b72feb 100644 --- a/crates/freshell-supervisor/src/resume_catalog.rs +++ b/crates/freshell-supervisor/src/resume_catalog.rs @@ -355,6 +355,8 @@ mod tests { context.provider = "codex".into(); context.provider_store_id = "codex-store-42".into(); context.fresh_agent = Some(FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "presentation-42".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -400,6 +402,8 @@ mod tests { context.provider = "codex".into(); context.provider_store_id = "fixture-store-42".into(); context.fresh_agent = Some(FreshAgentLaunchSpec { + naming_handle: None, + collaboration_mode: None, session_id: "presentation-fixture-42".into(), provider: FreshProvider::Codex, session_type: "freshcodex".into(), @@ -459,20 +463,7 @@ mod tests { } #[test] - fn every_terminal_resume_adapter_uses_the_checked_in_provider_pin() { - assert_eq!( - pinned_provider_version("claude").as_deref(), - Some("2.1.263") - ); - assert_eq!(pinned_provider_version("codex").as_deref(), Some("0.147.0")); - assert_eq!( - pinned_provider_version("opencode").as_deref(), - Some("1.18.21") - ); - assert_eq!( - pinned_provider_version("amplifier").as_deref(), - Some("0.1.1") - ); + fn unknown_provider_has_no_resume_version() { assert_eq!(pinned_provider_version("not-a-provider"), None); } } diff --git a/crates/freshell-supervisor/src/service.rs b/crates/freshell-supervisor/src/service.rs index 53282b1c3e..cc568a6b13 100644 --- a/crates/freshell-supervisor/src/service.rs +++ b/crates/freshell-supervisor/src/service.rs @@ -62,6 +62,7 @@ struct McpCapabilityRequest<'a> { workspace: Option<&'a str>, auth_lookup_authority: Option<&'a str>, claude_recovery_source: Option<&'a freshell_runtime_protocol::ClaudeRecoverySourceBinding>, + codex_source_runtime: Option, cwd: Option<&'a str>, prior_context: Option<&'a freshell_runtime_protocol::ProviderLaunchContext>, provider_secret_references: Option<&'a [freshell_runtime_protocol::ProviderSecretReference]>, @@ -577,6 +578,23 @@ impl Supervisor { .await?, )) } + AdminCommand::FreshAgentModelCatalog(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + Ok(AdminResult::FreshAgentModelCatalog( + self.fresh_agent_model_catalog(request.soul_id, request.cwd) + .await?, + )) + } + AdminCommand::FreshAgentNativeName(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + Ok(AdminResult::FreshAgentNativeName( + self.fresh_agent_native_name(request).await?, + )) + } AdminCommand::FreshAgentReadSnapshot(request) => { self.registry .assert_epoch(request.expected_control_epoch) @@ -585,6 +603,15 @@ impl Supervisor { self.fresh_agent_snapshot(request.soul_id).await?, )) } + AdminCommand::FreshAgentReadChild(request) => { + self.registry + .assert_epoch(request.expected_control_epoch) + .map_err(map_registry)?; + Ok(AdminResult::FreshAgentChildSnapshot( + self.fresh_agent_read_child(request.soul_id, request.child_thread_id) + .await?, + )) + } AdminCommand::FreshAgentResolve(request) => { self.registry .assert_epoch(request.expected_control_epoch) @@ -1604,8 +1631,36 @@ impl Supervisor { Ok(HostResult::FreshAgentCommand { state: CommandState::ProviderAcked | CommandState::Completed, native_session_id, + native_identity, }) => { - if let Some(native_session_id) = native_session_id { + if let Some(identity) = native_identity { + let prior_native = active + .fresh_agent() + .and_then(|spec| spec.native_session_id.as_deref()); + if active.fresh_agent().is_none_or(|spec| { + spec.provider != freshell_runtime_protocol::FreshProvider::Codex + }) || prior_native != Some(identity.previous_native_session_id.as_str()) + || native_session_id.as_deref() != Some(identity.native_session_id.as_str()) + { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "accepted native identity does not match its captured command source", + )); + } + let update = classify_fresh_agent_operation_identity( + prior_native, + native_session_id.as_deref(), + )?; + self.registry + .record_fresh_agent_dispatch_identity( + soul_id.clone(), + handle.incarnation_id().clone(), + identity, + ) + .await + .map_err(map_registry)?; + tracing::info!(soul_id=%soul_id,incarnation_id=%handle.incarnation_id(),previous_native_session_id=prior_native,native_session_id=native_session_id.as_deref(),native_identity_update=?update,"freshagent.native_dispatch_identity_committed"); + } else if let Some(native_session_id) = native_session_id { self.registry .record_native_session( soul_id.clone(), @@ -1866,6 +1921,7 @@ impl Supervisor { HostResult::FreshAgentCommand { state, native_session_id, + .. } => { let identity_update = classify_fresh_agent_operation_identity( prior_native.as_deref(), @@ -1958,6 +2014,148 @@ impl Supervisor { } } + async fn fresh_agent_native_name( + &self, + request: freshell_runtime_protocol::FreshAgentNativeNameRequest, + ) -> Result { + use freshell_runtime_protocol::{FreshAgentNativeNameError, FreshAgentNativeNameReply}; + let unavailable = || { + FreshAgentNativeNameReply::Failed(FreshAgentNativeNameError::Unavailable { + message: "native naming source is no longer current".into(), + }) + }; + let lifecycle_lock = self.lifecycle_lock(&request.soul_id).await; + // Metadata discovery must not queue behind lifecycle work and then + // dispatch after the name worker's existing request budget expired. + let Ok(_guard) = lifecycle_lock.try_lock() else { + return Ok(unavailable()); + }; + let handle = self + .registry + .active_handle_for_soul(request.soul_id.clone()) + .await + .map_err(map_registry)?; + let before = find_view(&self.registry, handle.incarnation_id()).await?; + let matches_source = |view: &RuntimeView| { + view.incarnation_id == request.source.incarnation_id + && view.host_boot_id.as_ref() == Some(&request.source.host_boot_id) + && view.execution_generation == request.source.execution_generation + && view.intent_revision == request.source.intent_revision + && view.native_session_id.as_deref() + == Some(request.source.native_session_id.as_str()) + && view.launch_state == LaunchState::Running + && view.recovery_state == freshell_runtime_protocol::RecoveryState::Live + && view.desired_state == freshell_runtime_protocol::DesiredState::Running + }; + if !matches_source(&before) + || !handle.fresh_agent().is_some_and(|agent| { + agent.provider == freshell_runtime_protocol::FreshProvider::Codex + }) + || request.codex_home.is_empty() + { + return Ok(unavailable()); + } + let host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + if host.host_boot_id != request.source.host_boot_id { + return Ok(unavailable()); + } + let reply = self + .send_authenticated_host_command( + handle.incarnation_id().clone(), + handle.runtime_dir(), + &host, + HostCommand::FreshAgentNativeName { + incarnation_id: handle.incarnation_id().clone(), + native_session_id: request.source.native_session_id.clone(), + codex_home: request.codex_home.clone(), + operation: request.operation.clone(), + }, + ) + .await?; + let HostResult::FreshAgentNativeName(value) = reply else { + return Err(RuntimeError::new( + RuntimeErrorCode::HostAuthenticationFailed, + "unexpected native naming reply", + )); + }; + let after = find_view(&self.registry, handle.incarnation_id()).await?; + let after_host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + if !matches_source(&after) || after_host.host_boot_id != host.host_boot_id { + return Ok(FreshAgentNativeNameReply::Failed( + FreshAgentNativeNameError::Closed, + )); + } + Ok(value) + } + + async fn fresh_agent_model_catalog( + &self, + soul_id: SoulId, + cwd: Option, + ) -> Result { + let lifecycle_lock = self.lifecycle_lock(&soul_id).await; + let _guard = lifecycle_lock.lock().await; + let handle = self + .registry + .active_handle_for_soul(soul_id.clone()) + .await + .map_err(map_registry)?; + let agent = handle.fresh_agent().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::UnsupportedWorkload, + "soul is not a hosted fresh-agent", + ) + })?; + if agent.provider != freshell_runtime_protocol::FreshProvider::Codex { + return Err(RuntimeError::new( + RuntimeErrorCode::UnsupportedOperation, + "model catalog requires a bound Codex runtime", + )); + } + let host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + let reply = self + .send_authenticated_host_command( + handle.incarnation_id().clone(), + handle.runtime_dir(), + &host, + HostCommand::FreshAgentModelCatalog { + incarnation_id: handle.incarnation_id().clone(), + cwd, + }, + ) + .await?; + let HostResult::FreshAgentModelCatalog(value) = reply else { + return Err(RuntimeError::new( + RuntimeErrorCode::HostAuthenticationFailed, + "unexpected model catalog reply", + )); + }; + let current = self + .registry + .active_handle_for_soul(soul_id) + .await + .map_err(map_registry)?; + let after = self + .authenticate_host(current.incarnation_id(), current.runtime_dir()) + .await?; + if current.incarnation_id() != handle.incarnation_id() + || current.fresh_agent() != handle.fresh_agent() + || host.host_boot_id != after.host_boot_id + { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "model catalog source changed", + )); + } + Ok(value) + } + async fn fresh_agent_snapshot( &self, soul_id: SoulId, @@ -2019,6 +2217,71 @@ impl Supervisor { Ok(snapshot) } + async fn fresh_agent_read_child( + &self, + soul_id: SoulId, + child_thread_id: String, + ) -> Result { + let lifecycle_lock = self.lifecycle_lock(&soul_id).await; + let _guard = lifecycle_lock.lock().await; + let handle = self + .registry + .active_handle_for_soul(soul_id.clone()) + .await + .map_err(map_registry)?; + let agent = handle.fresh_agent().ok_or_else(|| { + RuntimeError::new( + RuntimeErrorCode::UnsupportedWorkload, + "soul is not a hosted fresh-agent", + ) + })?; + let provider = if agent.provider == freshell_runtime_protocol::FreshProvider::Kilroy { + "claude" + } else { + agent.provider.as_str() + }; + let host = self + .authenticate_host(handle.incarnation_id(), handle.runtime_dir()) + .await?; + let result = self + .send_authenticated_host_command( + handle.incarnation_id().clone(), + handle.runtime_dir(), + &host, + HostCommand::FreshAgentReadChild { + child_thread_id: child_thread_id.clone(), + incarnation_id: handle.incarnation_id().clone(), + }, + ) + .await?; + let HostResult::FreshAgentChildSnapshot(snapshot) = result else { + return Err(RuntimeError::new( + RuntimeErrorCode::HostAuthenticationFailed, + "unexpected fresh-agent snapshot reply", + )); + }; + let current = self + .registry + .active_handle_for_soul(soul_id) + .await + .map_err(map_registry)?; + if current.incarnation_id() != handle.incarnation_id() { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent snapshot owner changed", + )); + } + if provider != "codex" + || snapshot["childThreadId"].as_str() != Some(child_thread_id.as_str()) + { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent child snapshot identity mismatch", + )); + } + Ok(snapshot) + } + async fn fresh_agent_resolve( &self, soul_id: SoulId, @@ -2113,7 +2376,33 @@ impl Supervisor { ) .await? { - HostResult::FreshAgentEvents(events) => { + HostResult::FreshAgentEvents(mut events) => { + let current = self + .registry + .active_handle_for_soul(soul_id.clone()) + .await + .map_err(map_registry)?; + if current.incarnation_id() != handle.incarnation_id() { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent events owner changed", + )); + } + if let Some(retained) = &mut events.retained_state { + let after = self + .authenticate_host(current.incarnation_id(), current.runtime_dir()) + .await?; + if after.host_boot_id != host.host_boot_id { + return Err(RuntimeError::new( + RuntimeErrorCode::OwnershipMismatch, + "fresh-agent retained source boot changed", + )); + } + retained["source"]["soulId"] = serde_json::json!(soul_id); + retained["source"]["incarnationId"] = + serde_json::json!(handle.incarnation_id()); + retained["source"]["hostBootId"] = serde_json::json!(host.host_boot_id); + } for event in &events.events { if let freshell_runtime_protocol::AgentEvent::Started { native_session_id } = &event.event @@ -2328,6 +2617,7 @@ impl Supervisor { workspace: None, auth_lookup_authority: None, claude_recovery_source: None, + codex_source_runtime: None, cwd: None, prior_context: None, provider_secret_references: None, @@ -2551,6 +2841,37 @@ impl Supervisor { cwd: &str, workspace: &str, ) -> Result { + let codex_source_runtime = if provider == "codex" { + use freshell_platform::codex_inputs::NativeSourceRuntime; + let incompatible = + || { + RuntimeError::new(RuntimeErrorCode::InvalidRequest, + "Codex replacement current consumer is invalid; retain inputs and reconcile") + }; + if start.context.provider != "codex" { + return Err(incompatible()); + } + Some( + match ( + start.context.prior_handle.fresh_agent(), + start.context.prior_handle.terminal(), + ) { + (Some(spec), None) + if spec.provider == freshell_runtime_protocol::FreshProvider::Codex => + { + spec.validate()?; + NativeSourceRuntime::AppServer + } + (None, Some(spec)) if spec.mode == "codex" => { + spec.validate()?; + NativeSourceRuntime::Tui + } + _ => return Err(incompatible()), + }, + ) + } else { + None + }; let tui_source = match &context.preparation { freshell_runtime_protocol::ProviderPreparation::Opencode { tui_source, .. } => { tui_source.as_ref() @@ -2586,6 +2907,7 @@ impl Supervisor { workspace: Some(workspace), auth_lookup_authority: Some(&scope.key), claude_recovery_source: claude_recovery_source.as_ref(), + codex_source_runtime, cwd: Some(cwd), prior_context: Some(context), provider_secret_references: Some(references), @@ -2625,6 +2947,7 @@ impl Supervisor { "workspace": request.workspace, "authLookupAuthority": request.auth_lookup_authority, "claudeRecoverySource": request.claude_recovery_source, + "codexSourceRuntime": request.codex_source_runtime, "cwd":request.cwd, "priorContext":request.prior_context, "providerSecretReferences":request.provider_secret_references, @@ -3207,7 +3530,11 @@ where drop(supervisor); let limit = if matches!( reply.result, - Ok(AdminResult::FreshAgentHistory(_) | AdminResult::FreshAgentSnapshot(_)) + Ok(AdminResult::FreshAgentHistory(_) + | AdminResult::FreshAgentSnapshot(_) + | AdminResult::FreshAgentModelCatalog(_) + | AdminResult::FreshAgentChildSnapshot(_) + | AdminResult::FreshAgentEvents(_)) ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { @@ -3439,7 +3766,12 @@ pub(crate) async fn request_host_reply( write_frame(&mut stream, envelope) .await .map_err(|error| unreachable(error.to_string()))?; - let reply_limit = if matches!(envelope.body, HostCommand::FreshAgentReadSnapshot { .. }) { + let reply_limit = if matches!( + envelope.body, + HostCommand::FreshAgentReadSnapshot { .. } + | HostCommand::FreshAgentReadChild { .. } + | HostCommand::FreshAgentReadEvents { .. } + ) { freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES } else { freshell_runtime_protocol::MAX_CONTROL_FRAME_BYTES @@ -3562,6 +3894,7 @@ pub(crate) fn uses_fresh_agent_command_budget(command: &HostCommand) -> bool { matches!( command, HostCommand::FreshAgentSend { .. } + | HostCommand::FreshAgentNativeName { .. } | HostCommand::FreshAgentCompact { .. } | HostCommand::FreshAgentConfigure { .. } | HostCommand::FreshAgentRollback { .. } @@ -3646,8 +3979,8 @@ mod host_ipc_timeout_tests { use tokio::net::UnixListener; #[tokio::test] - async fn snapshot_host_reply_preserves_large_history_and_controls_stay_bounded() { - for snapshot_read in [true, false] { + async fn snapshot_and_child_host_replies_preserve_large_history_and_controls_stay_bounded() { + for (snapshot_read, child_read) in [(true, false), (true, true), (false, false)] { let dir = tempfile::tempdir().unwrap(); let socket = dir.path().join("host.sock"); let listener = UnixListener::bind(&socket).unwrap(); @@ -3663,16 +3996,23 @@ mod host_ipc_timeout_tests { &mut stream, &freshell_runtime_protocol::HostReply { request_id: envelope.request_id, - result: Ok(freshell_runtime_protocol::HostResult::FreshAgentSnapshot( - sent, - )), + result: Ok(if child_read { + freshell_runtime_protocol::HostResult::FreshAgentChildSnapshot(sent) + } else { + freshell_runtime_protocol::HostResult::FreshAgentSnapshot(sent) + }), }, freshell_runtime_protocol::MAX_NATIVE_HISTORY_FRAME_BYTES, ) .await }); let id = IncarnationId::parse("large-incarnation").unwrap(); - let command = if snapshot_read { + let command = if child_read { + HostCommand::FreshAgentReadChild { + incarnation_id: id, + child_thread_id: "native-child".into(), + } + } else if snapshot_read { HostCommand::FreshAgentReadSnapshot { incarnation_id: id } } else { HostCommand::Status { incarnation_id: id } @@ -3684,7 +4024,11 @@ mod host_ipc_timeout_tests { ) .await; let _ = server.await.unwrap(); - if snapshot_read { + if child_read { + assert!( + matches!(reply.unwrap().result.unwrap(), freshell_runtime_protocol::HostResult::FreshAgentChildSnapshot(value) if value == expected) + ); + } else if snapshot_read { assert!( matches!(reply.unwrap().result.unwrap(), freshell_runtime_protocol::HostResult::FreshAgentSnapshot(value) if value == expected) ); @@ -4427,6 +4771,7 @@ fn map_registry(error: RegistryError) -> RuntimeError { fn map_backend(error: BackendError) -> RuntimeError { let code = match &error { BackendError::OwnershipMismatch(_) => RuntimeErrorCode::OwnershipMismatch, + BackendError::CodexIncompatible(_) => RuntimeErrorCode::InvalidRequest, BackendError::Unavailable(_) | BackendError::DockerHttp { .. } => { RuntimeErrorCode::BackendUnavailable } @@ -4442,6 +4787,9 @@ fn backend_failure_diagnostic(error: &BackendError) -> serde_json::Value { BackendError::DockerHttp { status, body } => ("docker_http", Some(*status), body.as_str()), BackendError::Malformed(detail) => ("malformed", None, detail.as_str()), BackendError::InvalidConfig(detail) => ("invalid_config", None, detail.as_str()), + BackendError::CodexIncompatible(detail) => { + ("codex_configuration_incompatible", None, detail.as_str()) + } }; const MAX_DETAIL_CHARS: usize = 512; let redacted = freshell_runtime_observability::scrub(detail, ""); diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index 6d234b13cb..26ed1cae90 100644 --- a/crates/freshell-ws/src/codex_association.rs +++ b/crates/freshell-ws/src/codex_association.rs @@ -780,6 +780,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: TID, diff --git a/crates/freshell-ws/src/hosted_fresh_agent.rs b/crates/freshell-ws/src/hosted_fresh_agent.rs index bee0c5cf74..b50c215858 100644 --- a/crates/freshell-ws/src/hosted_fresh_agent.rs +++ b/crates/freshell-ws/src/hosted_fresh_agent.rs @@ -6,9 +6,9 @@ use freshell_protocol::{ ClientMessage, FreshAgentApprovalRespond, FreshAgentAttach, FreshAgentCompact, - FreshAgentConfigure, FreshAgentCreate, FreshAgentFork, FreshAgentInterrupt, FreshAgentKill, - FreshAgentQuestionRespond, FreshAgentRecoveryStop, FreshAgentRedo, FreshAgentSend, - FreshAgentUndo, + FreshAgentConfigure, FreshAgentCreate, FreshAgentElicitationRespond, FreshAgentFork, + FreshAgentInterrupt, FreshAgentKill, FreshAgentQuestionRespond, FreshAgentRecoveryStop, + FreshAgentRedo, FreshAgentSend, FreshAgentUndo, }; use futures::future::BoxFuture; use std::sync::{Arc, OnceLock}; @@ -24,6 +24,7 @@ pub enum HostedFreshAgentCommand { RecoveryStop(FreshAgentRecoveryStop), Approval(FreshAgentApprovalRespond), Question(FreshAgentQuestionRespond), + Elicitation(FreshAgentElicitationRespond), Compact(FreshAgentCompact), Fork(FreshAgentFork), Undo(FreshAgentUndo), @@ -58,6 +59,7 @@ pub(crate) fn hosted_route(message: &ClientMessage) -> HostedRoute { ClientMessage::FreshAgentRecoveryStop(value) => Command::RecoveryStop(value.clone()), ClientMessage::FreshAgentApprovalRespond(value) => Command::Approval(value.clone()), ClientMessage::FreshAgentQuestionRespond(value) => Command::Question(value.clone()), + ClientMessage::FreshAgentElicitationRespond(value) => Command::Elicitation(value.clone()), ClientMessage::FreshAgentCompact(value) => Command::Compact(value.clone()), ClientMessage::FreshAgentFork(value) => Command::Fork(value.clone()), ClientMessage::FreshAgentUndo(value) => Command::Undo(value.clone()), @@ -97,6 +99,9 @@ pub(crate) fn hosted_route(message: &ClientMessage) -> HostedRoute { } pub trait HostedFreshAgentGateway: Send + Sync { + fn attention_marks(&self) -> BoxFuture<'_, Vec> { + Box::pin(async { Vec::new() }) + } fn dispatch(self: Arc, command: HostedFreshAgentCommand) -> BoxFuture<'static, ()>; } @@ -111,6 +116,13 @@ pub fn install_gateway(gateway: Arc) -> Result<(), .map_err(|_| "hosted fresh-agent gateway is already installed") } +pub(crate) async fn retained_attention_marks() -> Vec { + match GATEWAY.get() { + Some(gateway) => gateway.attention_marks().await, + None => Vec::new(), + } +} + pub(crate) fn dispatch_if_installed(message: &ClientMessage) -> bool { let Some(gateway) = GATEWAY.get() else { return false; diff --git a/crates/freshell-ws/src/hosted_fresh_agent_tests.rs b/crates/freshell-ws/src/hosted_fresh_agent_tests.rs index def5919953..8b509d912b 100644 --- a/crates/freshell-ws/src/hosted_fresh_agent_tests.rs +++ b/crates/freshell-ws/src/hosted_fresh_agent_tests.rs @@ -20,6 +20,9 @@ fn fresh_agent_sample(kind: &str) -> serde_json::Value { "freshAgent.compact" => json!({"requestId": "compact-1"}), "freshAgent.approval.respond" => json!({"requestId": 7, "decision": {"allow": true}}), "freshAgent.question.respond" => json!({"requestId": "q-1", "answers": {"q": "a"}}), + "freshAgent.elicitation.respond" => { + json!({"requestId": "41", "action": "decline", "content": null}) + } "freshAgent.kill" => json!({}), "freshAgent.recovery.stop" => json!({"requestId": "stop-1"}), "freshAgent.fork" => json!({"requestId": "fork-1"}), @@ -49,6 +52,7 @@ fn command_wire_type(command: &HostedFreshAgentCommand) -> &'static str { HostedFreshAgentCommand::RecoveryStop(_) => "freshAgent.recovery.stop", HostedFreshAgentCommand::Approval(_) => "freshAgent.approval.respond", HostedFreshAgentCommand::Question(_) => "freshAgent.question.respond", + HostedFreshAgentCommand::Elicitation(_) => "freshAgent.elicitation.respond", HostedFreshAgentCommand::Compact(_) => "freshAgent.compact", HostedFreshAgentCommand::Fork(_) => "freshAgent.fork", HostedFreshAgentCommand::Undo(_) => "freshAgent.undo", diff --git a/crates/freshell-ws/src/invariants.rs b/crates/freshell-ws/src/invariants.rs index 2962570f08..b88b4b077d 100644 --- a/crates/freshell-ws/src/invariants.rs +++ b/crates/freshell-ws/src/invariants.rs @@ -1433,6 +1433,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "ses_freshagent", diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 104c3f6fdd..d8e0d545e5 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -658,9 +658,14 @@ pub async fn build_handshake_with_capabilities( fresh_agent_attention_replay_v1, } = capabilities; let boot_id = state.boot_id.as_ref().clone(); - let fresh_agent_attention = fresh_agent_attention_replay_v1 - .then(|| state.fresh_claude.attention_marks()) - .filter(|marks| !marks.is_empty()); + let fresh_agent_attention = if fresh_agent_attention_replay_v1 { + let mut marks = state.fresh_claude.attention_marks(); + marks.extend(state.fresh_codex.attention_marks().await); + marks.extend(hosted_fresh_agent::retained_attention_marks().await); + (!marks.is_empty()).then_some(marks) + } else { + None + }; if let Some(marks) = fresh_agent_attention.as_ref() { tracing::debug!(count = marks.len(), "ws.handshake.attention_marks"); } diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index 5fb7d37bc1..61dd0bc058 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -1324,6 +1324,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: SID, diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index 63ab8e4016..45415c3a47 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -867,6 +867,8 @@ pub struct BindingRow { #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub collaboration_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, /// G15: the claude pane's local plugin directories, which every launch of /// the session hands to the sidecar again. Serde-optional under @@ -1333,6 +1335,7 @@ impl<'a> GenesisWrite<'a> { /// so writes replace rather than merge. #[derive(Debug, Clone, Copy)] pub struct FreshAgentBindingWrite<'a> { + pub collaboration_mode: Option<&'a str>, pub provider: &'a str, pub session_id: &'a str, pub mode: &'a str, @@ -2271,6 +2274,7 @@ impl PaneLedger { .map(str::to_string) .or_else(|| existing.and_then(|r| r.origin_create_request_id.clone())); let row = BindingRow { + collaboration_mode: None, ledger_version: LEDGER_VERSION, provider: w.provider.to_string(), session_id: w.session_id.to_string(), @@ -2827,6 +2831,7 @@ impl PaneLedger { model: w.model.map(str::to_string), sandbox: w.sandbox.map(str::to_string), permission_mode: w.permission_mode.map(str::to_string), + collaboration_mode: w.collaboration_mode.map(str::to_string), effort: w.effort.map(str::to_string), plugins: w.plugins.map(<[String]>::to_vec), // A rebind during recovery must not erase a shutdown marker @@ -4721,6 +4726,7 @@ impl PaneLedger { && (r.model.is_some() || r.sandbox.is_some() || r.permission_mode.is_some() + || r.collaboration_mode.is_some() || r.effort.is_some() || r.cwd.is_some() || r.plugins.is_some()) diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index 4db3edc69b..70cc3474b3 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -283,6 +283,7 @@ fn write_provenance_at( fn fa_write<'a>(provider: &'a str, session_id: &'a str, now_ms: i64) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider, session_id, @@ -313,6 +314,7 @@ fn fa_write_fenced<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider, session_id, @@ -346,6 +348,7 @@ fn fa_write_target<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider, session_id, @@ -2960,6 +2963,7 @@ fn disabled_ledger_refuses_the_rollback_row_write_with_a_loud_error() { // The other lanes keep their existing disabled policy (silent no-op). ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "claude", session_id: "sid", @@ -4800,6 +4804,7 @@ fn crash_mid_supersession_two_bound_rows_repaired_by_updated_at_tiebreak() { // index load sees the forged rows — the actual post-crash boot shape. for (sid, at) in [("th-old", 1_000i64), ("th-new", 2_000i64)] { let row = BindingRow { + collaboration_mode: None, genesis: None, ledger_version: LEDGER_VERSION, provider: "codex".into(), @@ -4931,6 +4936,7 @@ fn fresh_agent_binding_roundtrips_settings_and_pane_kind() { let ledger = PaneLedger::new(Some(root.clone())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "thread-1", @@ -4967,6 +4973,7 @@ fn fresh_agent_binding_upsert_preserves_created_at_and_refreshes_settings() { let root = temp_root("fresh-agent-upsert"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "opencode", session_id: "ses_abc", @@ -5018,6 +5025,7 @@ fn supersedes_retires_the_old_row_and_links_the_chain() { let root = temp_root("fresh-agent-supersedes"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "old-thread", @@ -5085,6 +5093,7 @@ fn fresh_agent_upsert_preserves_advisory_create_request_id_when_absent() { let root = temp_root("fresh-agent-req-id"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "thread-1", @@ -5135,6 +5144,7 @@ fn fresh_agent_settings_recorded_keys_off_settings_bearing_rows() { let root = temp_root("fresh-agent-settings-recorded"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "opencode", session_id: "ses_full", @@ -5187,6 +5197,7 @@ fn supersedes_of_a_missing_old_row_is_a_silent_noop() { let ledger = PaneLedger::new(Some(root.clone())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: GenesisWrite::Keep, provider: "codex", session_id: "new-thread", @@ -8496,6 +8507,7 @@ fn close_record_covers_row_is_the_shared_recovery_predicate() { live_terminal_id: Option<&str>, ) -> BindingRow { BindingRow { + collaboration_mode: None, genesis: None, ledger_version: LEDGER_VERSION, provider: "claude".to_string(), diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 9b57f96e6b..4d063a7095 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1998,7 +1998,9 @@ async fn handle_client_text( let forward: futures::future::BoxFuture<'static, ()> = match send.provider { freshell_protocol::AgentProvider::Codex => { let fresh_codex = state.fresh_codex.clone(); - Box::pin(async move { fresh_codex.handle_send(send).await }) + Box::pin(async move { + fresh_codex.handle_send(send).await; + }) } freshell_protocol::AgentProvider::Claude => { let fresh_claude = state.fresh_claude.clone(); @@ -2178,6 +2180,14 @@ async fn handle_client_text( } true } + ClientMessage::FreshAgentElicitationRespond(respond) => { + let fresh_codex = state.fresh_codex.clone(); + tokio::spawn( + async move { fresh_codex.handle_elicitation_respond(respond).await } + .instrument(tracing::Span::current()), + ); + true + } ClientMessage::FreshAgentQuestionRespond(respond) => { if respond.provider == freshell_protocol::AgentProvider::Codex { let fresh_codex = state.fresh_codex.clone(); @@ -9553,6 +9563,13 @@ pub(crate) fn fresh_agent_control_refusal(message: &ClientMessage) -> Option ( + m.provider != AgentProvider::Codex, + "MCP elicitation is", + m.provider, + m.session_id.as_str(), + m.session_type, + ), ClientMessage::FreshAgentQuestionRespond(m) => ( !matches!(m.provider, AgentProvider::Claude | AgentProvider::Codex), "Questions are", diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index e1a7bd14be..2e55e25ab0 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -78,6 +78,8 @@ pub struct RigOptions { /// Hold the next shutdown interruption-marker write until the lifecycle /// test releases it, leaving the real sidecar alive during that window. pub turn_interrupted_marker_gate: Option>, + /// Hold lazy genesis persistence to exercise a simulated upgrade boundary. + pub genesis_clear_gate: Option>, /// The claude lane runs the way the hosted session host runs it: the lane /// selects the hosted Freshell MCP (`with_hosted_mcp`) and its sidecars see /// `FRESHELL_HOSTED_FRESH_AGENT=claude`, so the sidecar offers the fixed MCP @@ -476,6 +478,7 @@ impl ClaudeRig { .as_ref() .expect("seeding a binding row needs the rig's durable ledger") .record_fresh_agent_binding(&freshell_ws::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, provider: "claude", session_id, mode: "freshclaude", @@ -1079,6 +1082,9 @@ async fn spawn_rig_server( if let Some(gate) = options.turn_interrupted_marker_gate.clone() { sink = sink.with_turn_interrupted_marker_gate(gate); } + if let Some(gate) = options.genesis_clear_gate.clone() { + sink = sink.with_genesis_clear_gate(gate); + } fresh_claude.set_identity_sink(Arc::new(sink)); } let lane = fresh_claude.clone(); @@ -1167,7 +1173,7 @@ async fn spawn_rig_server( fresh_agent_state .set_rest_create_settings_reader(Arc::new(move || { let defaults = defaults.clone(); - Box::pin(async move { defaults.read().await.fresh_agent.clone() }) + Box::pin(async move { defaults.read().await.fresh_agent.clone().into() }) })) .expect("install the rig's REST defaults reader"); router = router.merge(freshell_freshagent::router(fresh_agent_state.clone())); diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index a1aaa540bd..dc327c7550 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -12,16 +12,15 @@ use std::sync::Arc; -/// Deterministic hold point for the shutdown-marker write in the lifecycle -/// integration test. The sidecar keeps running while this awaited write is -/// held, so the test can prove how a late CLI result races server shutdown. +/// Deterministic hold point for an awaited sink write. Integration tests use +/// it to control shutdown-marker and lazy genesis-clear persistence races. #[derive(Debug)] -pub struct TurnInterruptedMarkerGate { +pub struct LedgerWriteGate { entered: tokio::sync::Notify, release: tokio::sync::Semaphore, } -impl TurnInterruptedMarkerGate { +impl LedgerWriteGate { pub fn new() -> Self { Self { entered: tokio::sync::Notify::new(), @@ -48,6 +47,8 @@ impl TurnInterruptedMarkerGate { } } +pub type TurnInterruptedMarkerGate = LedgerWriteGate; + use freshell_freshagent::identity_sink::SinkCloseError; use freshell_freshagent::{ BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, @@ -64,7 +65,8 @@ pub struct LedgerSink { /// The ownership coordinator an authoritative write is re-validated /// against (the one the lanes writing through this sink are wired to). ownership: Option>, - turn_interrupted_marker_gate: Option>, + turn_interrupted_marker_gate: Option>, + genesis_clear_gate: Option>, } impl LedgerSink { @@ -74,6 +76,7 @@ impl LedgerSink { ledger, ownership: None, turn_interrupted_marker_gate: None, + genesis_clear_gate: None, } } @@ -87,13 +90,16 @@ impl LedgerSink { ledger, ownership, turn_interrupted_marker_gate: None, + genesis_clear_gate: None, } } - pub fn with_turn_interrupted_marker_gate( - mut self, - gate: Arc, - ) -> Self { + pub fn with_genesis_clear_gate(mut self, gate: Arc) -> Self { + self.genesis_clear_gate = Some(gate); + self + } + + pub fn with_turn_interrupted_marker_gate(mut self, gate: Arc) -> Self { self.turn_interrupted_marker_gate = Some(gate); self } @@ -207,6 +213,7 @@ impl PaneIdentitySink for LedgerSink { }; let stored_genesis = upsert.genesis.stored_value(); ledger.record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: upsert.settings.collaboration_mode.as_deref(), genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( stored_genesis.as_ref(), upsert.genesis.is_clear(), @@ -295,6 +302,7 @@ impl PaneIdentitySink for LedgerSink { return None; } let settings = FreshAgentSettings { + collaboration_mode: row.collaboration_mode, model: row.model, sandbox: row.sandbox, permission_mode: row.permission_mode, @@ -324,9 +332,13 @@ impl PaneIdentitySink for LedgerSink { fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { let ledger = self.ledger.clone(); + let gate = self.genesis_clear_gate.clone(); let (p, s) = (provider.to_string(), session_id.to_string()); let now = now_ms(); Box::pin(async move { + if let Some(gate) = &gate { + gate.hold().await?; + } tokio::task::spawn_blocking(move || { ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) }) diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 7b95535d7b..e9e2ce200f 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -3495,6 +3495,7 @@ async fn an_ordinary_codex_cold_resume_with_recovered_settings_lands_end_to_end( ); pane_ledger .record_fresh_agent_binding(&freshell_ws::pane_ledger::FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: &sid, diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs index 5e15503e68..3b89461d15 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -5,14 +5,21 @@ mod common; use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use common::ledger_sink::LedgerWriteGate; use freshell_ws::pane_ledger::{RetiredReason, RowState}; use serde_json::json; +use std::sync::Arc; -async fn legacy_close_survives_restart(close_before_restart: bool) { +async fn legacy_close_survives_restart( + close_before_restart: bool, + genesis_clear_gate: Option>, + upgrade_started: Option>, +) { let mut rig = ClaudeRig::start_with_options( RigKind::Sim, RigOptions { durable_ledger: true, + genesis_clear_gate, ..RigOptions::default() }, ) @@ -21,6 +28,10 @@ async fn legacy_close_survives_restart(close_before_restart: bool) { let durable = old_server.create("legacy-record-source").await; rig.converse(&mut old_server, &durable, &["saved before the upgrade"]) .await; + // A real upgrade ends the old process. This in-process rig keeps Tokio + // tasks alive across reloads, so finish the initial lazy write before + // giving a second ledger instance the store. + rig.wait_for_genesis_cleared(&durable).await; let placeholder = "persisted-old-pane-placeholder"; rig.pane_ledger .as_ref() @@ -29,6 +40,9 @@ async fn legacy_close_survives_restart(close_before_restart: bool) { .unwrap(); // The old runtime is gone. The alias is now exclusively on disk, as // it is when a server upgrade first reads an older pane's saved state. + if let Some(started) = upgrade_started { + let _ = started.send(()); + } rig.restart().await; assert!(rig .pane_ledger @@ -87,10 +101,36 @@ async fn legacy_close_survives_restart(close_before_restart: bool) { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_kill_by_legacy_placeholder_after_restart_closes_its_durable_row() { - legacy_close_survives_restart(false).await; + legacy_close_survives_restart(false, None, None).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_legacy_close_written_before_restart_still_blocks_the_late_attach() { - legacy_close_survives_restart(true).await; + legacy_close_survives_restart(true, None, None).await; +} + +// A rig reload shares the Tokio runtime with its predecessor. Hold the actual +// lazy ledger write to prove the legacy-upgrade fixture finishes persistence +// before it replaces that writer, then keep all cold-load/close/reopen checks. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn legacy_upgrade_waits_for_the_initial_genesis_write() { + let gate = Arc::new(LedgerWriteGate::new()); + let (started, mut upgrade_started) = tokio::sync::oneshot::channel(); + let scenario = legacy_close_survives_restart(true, Some(gate.clone()), Some(started)); + tokio::pin!(scenario); + tokio::select! { + _ = &mut scenario => panic!("the fixture finished before its held genesis clear"), + _ = gate.wait_until_entered() => {} + } + let replaced_before_clear = tokio::select! { + _ = &mut scenario => panic!("the fixture finished before its held genesis clear"), + _ = &mut upgrade_started => true, + _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => false, + }; + gate.release(); + scenario.await; + assert!( + !replaced_before_clear, + "the fixture replaced a ledger with a pending setup write" + ); } diff --git a/crates/freshell-ws/tests/freshagent_claude_turns.rs b/crates/freshell-ws/tests/freshagent_claude_turns.rs index ecbc119943..ea65097f52 100644 --- a/crates/freshell-ws/tests/freshagent_claude_turns.rs +++ b/crates/freshell-ws/tests/freshagent_claude_turns.rs @@ -441,14 +441,20 @@ fn session_events<'a>( .collect() } -/// The first streamed text delta: the turn runs (its command started). -async fn wait_for_streamed_text(conn: &mut RigConnection) { - conn.wait_for("the first streamed text", |v| { - v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.stream" - && v["event"]["event"]["type"] == "content_block_delta" +fn is_streamed_text(frame: &Value, session_id: &str) -> bool { + frame["type"] == "freshAgent.event" + && frame["sessionId"] == session_id + && frame["event"]["type"] == "freshAgent.stream" + && frame["event"]["event"]["type"] == "content_block_delta" + && frame["event"]["event"]["delta"]["type"] == "text_delta" +} + +/// Text from this send's session after its pre-send frame boundary. +async fn wait_for_streamed_text(conn: &mut RigConnection, start: usize, session_id: &str) -> Value { + conn.wait_for_since(start, "the first streamed text", |frame| { + is_streamed_text(frame, session_id) }) - .await; + .await } /// The turn ended aborted (T11), with no edge and no banner: the user's own @@ -510,8 +516,9 @@ async fn a_warm_stop_is_silent(kind: RigKind) { let rig = ClaudeRig::start(kind).await; let mut conn = rig.connect().await; let session_id = conn.create("create-1").await; + let start = conn.frames.len(); conn.send_text(&session_id, "send-1", "SLOW stop me").await; - wait_for_streamed_text(&mut conn).await; + wait_for_streamed_text(&mut conn, start, &session_id).await; stop(&mut conn, &session_id).await; wait_for_results(&mut conn, 1, "the stopped turn").await; conn.read_for(Duration::from_millis(500)).await; @@ -702,8 +709,9 @@ mod sim { let mut first = rig.connect().await; let mut second = rig.connect().await; let session_id = first.create("create-1").await; + let start = first.frames.len(); first.send_text(&session_id, "send-1", "SLOW stop me").await; - wait_for_streamed_text(&mut first).await; + wait_for_streamed_text(&mut first, start, &session_id).await; second .send_text(&session_id, "send-queued", "the other device's message") .await; @@ -893,9 +901,19 @@ mod sim { /// larger `at` (the client drops an edge whose `at` is not above the last). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_death_after_a_result_rings_with_a_strictly_larger_at() { - let program = json!({ "rules": [{ - "on": "msg:prompt", "match": { "text": "die" }, "emit": [{ "kind": "crash" }], - }] }) + let logs = log_capture::install(); + let program = json!({ "rules": [ + { + "on": "msg:prompt", "match": { "text": "hello" }, + "emit": [ + { "kind": "approval", "data": { "tool": "Bash", "input": { "command": "ls" } } }, + { "kind": "completion", "data": { "subtype": "success" } }, + ], + }, + { + "on": "msg:prompt", "match": { "text": "die" }, "emit": [{ "kind": "crash" }], + }, + ] }) .to_string(); let rig = ClaudeRig::start_with_env( RigKind::Sim, @@ -906,14 +924,93 @@ mod sim { ) .await; let mut conn = rig.connect().await; - let session_id = conn.create("create-1").await; + let created = conn + .request_create("create-1", json!({ "permissionMode": "default" })) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let session_id = created["sessionId"].as_str().unwrap().to_string(); + let is_event = |v: &Value, event_type: &str| { + v["type"] == "freshAgent.event" + && v["sessionId"] == session_id + && v["event"]["type"] == event_type + }; + + // Acceptance orders the sends. The unanswered approval holds hello + // before completion and before the CLI can absorb another command. conn.send_text(&session_id, "send-1", "hello").await; + let hello_uuid = accepted(&mut conn, "send-1").await["submittedTurnId"] + .as_str() + .expect("hello's submittedTurnId") + .to_string(); + let request = conn + .wait_for("hello's approval", |v| { + is_event(v, "freshAgent.permission.request") + }) + .await; + conn.wait_for("hello's waiting edge", |v| { + is_event(v, "freshAgent.turn.waiting") + }) + .await; + let processes = rig.runtime_processes(&session_id); conn.send_text(&session_id, "send-2", "die").await; + let die_uuid = accepted(&mut conn, "send-2").await["submittedTurnId"] + .as_str() + .expect("die's submittedTurnId") + .to_string(); + logs.wait_for("die queued behind hello's unanswered approval", |event| { + event.message == "freshagent.claude.command" + && event.field("session_id") == Some(session_id.as_str()) + && event.field("uuid") == Some(die_uuid.as_str()) + && event.field("state") == Some("queued") + }) + .await; + assert!( + rig.ledger_rows("result").is_empty(), + "hello cannot finish before its approval is answered" + ); + assert!( + session_events(&conn, &session_id, "freshAgent.turn.complete").is_empty(), + "the held turn has not completed" + ); + + // Denial releases the explicit success completion without running a + // native tool; the already queued die then runs and crashes. + conn.send(json!({ + "type": "freshAgent.approval.respond", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": request["event"]["requestId"], + "decision": { "behavior": "deny", "message": "Continue without the tool", "interrupt": false }, + })) + .await; conn.wait_for("the exit report", |v| { - v["event"]["code"] == "SIDECAR_EXITED" + is_event(v, "freshAgent.error") && v["event"]["code"] == "SIDECAR_EXITED" }) .await; - conn.read_for(Duration::from_millis(500)).await; + let exit_at = index_of(&conn, "the exit report", |v| { + is_event(v, "freshAgent.error") && v["event"]["code"] == "SIDECAR_EXITED" + }); + conn.wait_for_since(exit_at + 1, "the death's completion edge", |v| { + is_event(v, "freshAgent.turn.complete") + }) + .await; + processes.wait_until_exited("the queued die crashed").await; + + let result_frames = session_events(&conn, &session_id, "freshAgent.result"); + assert_eq!( + result_frames.len(), + 1, + "only hello returns a result: {:?}", + conn.frames + ); + assert_eq!(result_frames[0]["event"]["subtype"], "success"); + assert_eq!(result_frames[0]["event"]["isError"], false); + assert_eq!( + result_frames[0]["event"]["sessionBusy"], true, + "die is still owed" + ); + assert_eq!(rig.ledger_rows("result")[0]["uuid"], hello_uuid); let rings: Vec = session_events(&conn, &session_id, "freshAgent.turn.complete") .iter() .map(|v| v["event"]["at"].as_i64().expect("a numeric at")) @@ -925,6 +1022,33 @@ mod sim { conn.frames ); assert!(rings[1] > rings[0], "{rings:?}"); + let result_at = index_of(&conn, "hello's result", |v| { + is_event(v, "freshAgent.result") + }); + let result_ring_at = index_of(&conn, "the result's edge", |v| { + is_event(v, "freshAgent.turn.complete") && v["event"]["at"] == rings[0] + }); + assert!(result_at < result_ring_at && result_ring_at < exit_at); + let minted = logs.matching(|event| { + event.message == "freshagent.turn.complete" + && event.field("session_id") == Some(session_id.as_str()) + }); + assert_eq!( + minted + .iter() + .map(|event| event.field("source")) + .collect::>(), + [Some("result"), Some("death")], + "{minted:?}" + ); + assert_eq!( + minted + .iter() + .map(|event| event.field("at").unwrap().parse::().unwrap()) + .collect::>(), + rings, + "the logged sources mint the edges the client receives" + ); } /// The waiting edge is the server's: on the 0 -> >=1 pending transition, @@ -1057,7 +1181,23 @@ mod sim { /// registration, each edge and the close. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn turn_events_and_attention_hooks_follow_every_settled_turn() { - let rig = ClaudeRig::start_with_options(RigKind::Sim, RigOptions::default()).await; + const SECOND_TEXT: &str = "the observer's second turn is ready for Stop"; + let logs = log_capture::install(); + let program = json!({"rules":[{ + "on":"msg:prompt", "match":{"text":"SLOW stop me"}, "emit":[ + {"kind":"text", "data":{"text":SECOND_TEXT}}, + {"kind":"hang"}, + ], + }]}) + .to_string(); + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + env: vec![("FRESHELL_CLAUDE_SIM_PROGRAM".into(), program)], + ..RigOptions::default() + }, + ) + .await; let observer = std::sync::Arc::new(RecordingObserver::default()); let _ = rig .fresh_claude @@ -1089,22 +1229,47 @@ mod sim { } ); + let start = conn.frames.len(); conn.send_text(&session_id, "send-2", "SLOW stop me").await; - wait_for_streamed_text(&mut conn).await; + let second_uuid = accepted(&mut conn, "send-2").await["submittedTurnId"] + .as_str() + .expect("second submittedTurnId") + .to_string(); + let second_text = wait_for_streamed_text(&mut conn, start, &session_id).await; + let processes = rig.runtime_processes(&session_id); + let pids = rig.runtime_pids(&session_id); stop(&mut conn, &session_id).await; let silenced = tokio::time::timeout(RIG_FRAME_BUDGET, events.recv()) .await .expect("a turn event") .expect("the channel is open"); - assert!( - matches!( - &silenced, - ClaudeTurnEvent::Settled { retired_request_ids, outcome: TurnOutcome::Interrupted, at: None, .. } - if retired_request_ids == &vec!["send-2".to_string()] - ), - "{silenced:?}" - ); + let interrupts = sim_interrupts(&rig); + let stops = stop_logs(&logs, &session_id); conn.kill(&session_id).await; + processes.wait_until_exited("observer turn cleanup").await; + eprintln!( + "{}", + json!({"severity":"info","event":"claude_observer_stop_closed", + "sessionId":session_id,"secondUuid":second_uuid,"secondText":second_text, + "interrupts":interrupts,"settled":format!("{silenced:?}"), + "pids":pids,"ownedProcessesExited":true,"observerCalls":observer.calls()}) + ); + assert_eq!(second_text["event"]["event"]["delta"]["text"], SECOND_TEXT); + assert_eq!( + silenced, + ClaudeTurnEvent::Settled { + session_key: session_id.clone(), + retired_request_ids: vec!["send-2".into()], + retired_command_ids: vec![second_uuid], + outcome: TurnOutcome::Interrupted, + at: None, + } + ); + assert_eq!(interrupts.len(), 1); + assert_eq!(interrupts[0]["running"], true); + assert_eq!(stops.len(), 1); + assert_eq!(stops[0].field("outcome"), Some("sent")); + assert_eq!(stops[0].field("target"), Some("running")); assert_eq!( observer.calls(), vec![ @@ -1115,6 +1280,150 @@ mod sim { ); } + /// Retained text from an earlier completed command cannot establish that + /// another command, held before its text by an approval, is streaming. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn stream_wait_does_not_reuse_a_previous_turn() { + const SECOND_TEXT: &str = "the second command reached its text"; + let logs = log_capture::install(); + let program = json!({"rules":[{ + "on":"msg:prompt", "match":{"text":"held second command"}, "emit":[ + {"kind":"approval", "data":{"tool":"Bash","input":{"command":"ls"}}}, + {"kind":"text", "data":{"text":SECOND_TEXT}}, + {"kind":"hang"}, + ], + }]}) + .to_string(); + let rig = + ClaudeRig::start_with_env(RigKind::Sim, &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program)]) + .await; + let mut events = rig.fresh_claude.subscribe_turn_events(); + let mut conn = rig.connect().await; + let mut control = rig.connect().await; + let created = conn + .request_create("create-1", json!({"permissionMode":"default"})) + .await; + let session_id = created["sessionId"] + .as_str() + .expect("created session") + .to_string(); + let first_start = conn.frames.len(); + conn.send_text(&session_id, "send-1", "hello").await; + let first_uuid = accepted(&mut conn, "send-1").await["submittedTurnId"] + .as_str() + .expect("first submitted UUID") + .to_string(); + let first_text = wait_for_streamed_text(&mut conn, first_start, &session_id).await; + wait_for_results(&mut conn, 1, "hello").await; + let first_settled = tokio::time::timeout(RIG_FRAME_BUDGET, events.recv()) + .await + .expect("hello settles") + .expect("turn event channel open"); + + let second_start = conn.frames.len(); + conn.send_text(&session_id, "send-2", "held second command") + .await; + let second_uuid = accepted(&mut conn, "send-2").await["submittedTurnId"] + .as_str() + .expect("second submitted UUID") + .to_string(); + let approval = conn + .wait_for_since(second_start, "second command's held approval", |frame| { + frame["type"] == "freshAgent.event" + && frame["sessionId"] == session_id + && frame["event"]["type"] == "freshAgent.permission.request" + }) + .await; + let waiting = tokio::time::timeout(RIG_FRAME_BUDGET, events.recv()) + .await + .expect("held approval publishes waiting") + .expect("turn event channel open"); + let held_has_target_text = conn.frames[second_start..].iter().any(|frame| { + is_streamed_text(frame, &session_id) + && frame["event"]["event"]["delta"]["text"] == SECOND_TEXT + }); + let held_has_second_result = rig + .ledger_rows("result") + .iter() + .any(|row| row["uuid"] == second_uuid); + let processes = rig.runtime_processes(&session_id); + let pids = rig.runtime_pids(&session_id); + let mut readiness = Box::pin(wait_for_streamed_text(&mut conn, second_start, &session_id)); + let first_poll = futures_util::poll!(&mut readiness); + control.send(json!({ + "type":"freshAgent.approval.respond", "provider":"claude", "sessionType":"freshclaude", + "sessionId":session_id, "requestId":approval["event"]["requestId"], + "decision":{"behavior":"deny","message":"Continue without running the tool","interrupt":false}, + })).await; + let (premature, target_text) = match first_poll { + std::task::Poll::Ready(frame) => { + drop(readiness); + let target = conn + .wait_for_since(second_start, "released second text", |frame| { + is_streamed_text(frame, &session_id) + && frame["event"]["event"]["delta"]["text"] == SECOND_TEXT + }) + .await; + (Some(frame), target) + } + std::task::Poll::Pending => (None, readiness.await), + }; + let premature_index = premature + .as_ref() + .and_then(|hit| conn.frames.iter().position(|frame| frame == hit)); + stop(&mut conn, &session_id).await; + let silenced = tokio::time::timeout(RIG_FRAME_BUDGET, events.recv()) + .await + .expect("held turn settles after Stop") + .expect("turn event channel open"); + let interrupts = sim_interrupts(&rig); + let stops = stop_logs(&logs, &session_id); + conn.kill(&session_id).await; + processes + .wait_until_exited("stream-readiness regression cleanup") + .await; + eprintln!( + "{}", + json!({"severity":"info","event":"claude_stream_readiness_closed", + "sessionId":session_id,"firstUuid":first_uuid,"secondUuid":second_uuid, + "secondStart":second_start,"prematureIndex":premature_index,"prematureFrame":premature, + "heldHasTargetText":held_has_target_text,"heldHasSecondResult":held_has_second_result, + "targetText":target_text,"interrupts":interrupts,"settled":format!("{silenced:?}"), + "pids":pids,"ownedProcessesExited":true}) + ); + assert!(matches!(first_settled, ClaudeTurnEvent::Settled { + retired_request_ids, retired_command_ids, outcome:TurnOutcome::Completed, at:Some(_), .. + } if retired_request_ids == vec!["send-1"] && retired_command_ids == vec![first_uuid])); + assert!(!held_has_target_text && !held_has_second_result); + assert!( + matches!(waiting, ClaudeTurnEvent::Waiting { session_key, .. } if session_key == session_id) + ); + assert_eq!(target_text["event"]["event"]["delta"]["text"], SECOND_TEXT); + assert_eq!( + silenced, + ClaudeTurnEvent::Settled { + session_key: session_id, + retired_request_ids: vec!["send-2".into()], + retired_command_ids: vec![second_uuid], + outcome: TurnOutcome::Interrupted, + at: None, + } + ); + assert_eq!(interrupts.len(), 1); + assert_eq!(interrupts[0]["running"], true); + assert_eq!(stops.len(), 1); + assert_eq!(stops[0].field("outcome"), Some("sent")); + assert_eq!(stops[0].field("target"), Some("running")); + if let Some(frame) = &premature { + assert_eq!( + frame, &first_text, + "Red must observe the actual earlier turn's frame" + ); + assert!(premature_index.is_some_and(|index| index < second_start)); + } + assert!(premature.is_none(), "stream readiness reused a completed turn while the accepted second command was held at its approval"); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn submitted_turn_id_is_the_snapshot_turn_id() { super::submitted_turn_id_is_the_snapshot_turn_id(RigKind::Sim).await; diff --git a/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs b/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs new file mode 100644 index 0000000000..9216a02bca --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_codex_attention_replay.rs @@ -0,0 +1,334 @@ +mod common; +use common::*; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio_tungstenite::tungstenite::Message; +static ENV: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +struct Environment(Vec<(&'static str, Option)>); +impl Drop for Environment { + fn drop(&mut self) { + for (key, value) in &self.0 { + if let Some(value) = value { + std::env::set_var(key, value) + } else { + std::env::remove_var(key) + } + } + } +} + +async fn ready_with_replay(url: &str, enabled: bool) -> Value { + let (mut ws, _) = tokio_tungstenite::connect_async(url).await.unwrap(); + ws.send(Message::Text(json!({"type":"hello","token":AUTH_TOKEN,"protocolVersion":freshell_protocol::WS_PROTOCOL_VERSION,"capabilities":{"freshAgentAttentionReplayV1":enabled}}).to_string())).await.unwrap(); + let frame = tokio::time::timeout(FRAME_BUDGET, ws.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + let Message::Text(raw) = frame else { + panic!("ready text") + }; + serde_json::from_str(&raw).unwrap() +} +async fn ready(url: &str) -> Value { + ready_with_replay(url, true).await +} +async fn event(ws: &mut TestWs, kind: &str) -> Value { + loop { + let value = next_frame_of_type(ws, "freshAgent.event").await; + if value["event"]["type"] == kind { + return value["event"].clone(); + } + } +} +async fn run_replay(async_question: bool) { + let _guard = ENV.lock().await; + let dir = tempfile::tempdir().unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = Environment( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + std::env::set_var( + "CODEX_CMD", + format!( + "node {}/../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", + env!("CARGO_MANIFEST_DIR") + ), + ); + std::env::set_var("CODEX_HOME", dir.path().join("codex-home")); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"overrides":{"turn/start":{"result":{"turn":{"id":"replay-turn","status":"inProgress"}}}},"serverRequestsByPrompt":{"ask":{"id":51,"method":"item/tool/requestUserInput","params":{"isBlocking":false,"questions":[{"id":"choice","header":"Choice","question":"Which?","options":null}]}}},"notificationsAfterMethods":{"thread/read":[{"method":"item/completed","params":{"threadId":"thread-new-1","turnId":"replay-turn","item":{"id":"async-replay","type":"agentMessage","text":"Work continues","delivery":"async","questions":[{"title":"Where?","options":["Staging"]}]}}}]}}).to_string()); + let (url, _registry, state) = spawn_server_with_specs_and_state(vec![]).await; + state.fresh_codex.set_enabled(true); + let (mut first, _) = connect_and_capture_inventory(&url).await; + state.fresh_codex.handle_create(serde_json::from_value(json!({"requestId":"create-replay","provider":"codex","sessionType":"freshcodex","cwd":dir.path(),"permissionMode":"never"})).unwrap(),None).await; + let created = next_frame_of_type(&mut first, "freshAgent.created").await; + let session_id = created["sessionId"].as_str().unwrap(); + state.fresh_codex.handle_send(serde_json::from_value(json!({"requestId":"send-replay","provider":"codex","sessionType":"freshcodex","sessionId":session_id,"text":"ask"})).unwrap()).await; + let request = event(&mut first, "freshAgent.question.request").await; + let waiting = event(&mut first, "freshAgent.turn.waiting").await; + let available = if async_question { + state + .fresh_codex + .get_snapshot(session_id, None) + .await + .unwrap(); + Some(event(&mut first, "freshAgent.question.available").await) + } else { + None + }; + let unnegotiated = ready_with_replay(&url, false).await; + assert!( + unnegotiated.get("freshAgentAttention").is_none(), + "unnegotiated ready must omit replay" + ); + let before = ready(&url).await; + state.fresh_codex.handle_question_respond(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":session_id,"requestId":request["requestId"],"answers":{"choice":"answer"}})).unwrap()).await; + let after = ready(&url).await; + state.fresh_codex.shutdown().await; + let find = |value: &Value| { + value["freshAgentAttention"] + .as_array() + .and_then(|marks| marks.iter().find(|mark| mark["sessionId"] == session_id)) + .cloned() + .unwrap_or_else(|| json!(null)) + }; + if let Some(available) = available { + assert_eq!( + find(&before)["lastQuestionAvailableAt"], + available["at"], + "async question replay must be separate from awaited state: {before}" + ); + assert_eq!(find(&after)["lastQuestionAvailableAt"], available["at"]); + } else { + assert_eq!( + find(&before)["lastWaitingAt"], + waiting["at"], + "awaited Codex control must seed ready replay: {before}" + ); + assert!(find(&after).get("lastWaitingAt").is_none()); + } +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task5_ready_replays_awaited_codex_controls_only_while_pending() { + run_replay(false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task5_ready_separates_nonblocking_async_question_attention() { + run_replay(true).await; +} + +async fn replay_after_native_exit(mint_new: bool) { + let _guard = ENV.lock().await; + let dir = tempfile::tempdir().unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = Environment( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + std::env::set_var( + "CODEX_CMD", + format!( + "node {}/../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs", + env!("CARGO_MANIFEST_DIR") + ), + ); + std::env::set_var("CODEX_HOME", dir.path().join("codex")); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR", json!({"threadStartThreadId":"replay-old","recordTurns":true,"exitProcessAfterMethodsOnce":["turn/start"]}).to_string()); + let (url, _, state) = spawn_server_with_specs_and_state(vec![]).await; + state.fresh_codex.set_enabled(true); + let (mut ws, _) = connect_and_capture_inventory(&url).await; + state.fresh_codex.handle_create(serde_json::from_value(json!({"requestId":"recovery-create","provider":"codex","sessionType":"freshcodex","cwd":dir.path(),"permissionMode":"never"})).unwrap(),None).await; + let created = next_frame_of_type(&mut ws, "freshAgent.created").await; + let old = created["sessionId"].as_str().unwrap(); + state.fresh_codex.handle_send(serde_json::from_value(json!({"requestId":"recovery-send","provider":"codex","sessionType":"freshcodex","sessionId":old,"text":"finish"})).unwrap()).await; + let complete = event(&mut ws, "freshAgent.turn.complete").await; + loop { + let status = event(&mut ws, "freshAgent.status").await; + if status["status"] == "exited" { + break; + } + } + let before = ready(&url).await; + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",if mint_new {json!({"threadStartThreadId":"replay-new","overrides":{"thread/resume":{"error":{"code":-32001,"message":"Thread not found"}}}})} else {json!({})}.to_string()); + state.fresh_codex.handle_attach(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":old,"cwd":dir.path()})).unwrap()).await; + let current = if mint_new { + next_frame_of_type(&mut ws, "freshAgent.session.materialized").await["sessionId"] + .as_str() + .unwrap() + .to_string() + } else { + old.to_string() + }; + let after = ready(&url).await; + state + .fresh_codex + .handle_kill( + serde_json::from_value( + json!({"provider":"codex","sessionType":"freshcodex","sessionId":current}), + ) + .unwrap(), + ) + .await; + next_frame_of_type(&mut ws, "freshAgent.killed").await; + let closed = ready(&url).await; + state.fresh_codex.shutdown().await; + let mark = |value: &Value, id: &str| { + value["freshAgentAttention"] + .as_array() + .and_then(|marks| marks.iter().find(|mark| mark["sessionId"] == id)) + .cloned() + .unwrap_or(Value::Null) + }; + assert_eq!(mark(&before, old)["lastTurnCompleteAt"], complete["at"]); + assert_eq!( + mark(&after, old)["lastTurnCompleteAt"], + complete["at"], + "old envelope must retain its exact attention after actual native exit/recovery: {after}" + ); + assert_eq!( + mark(&after, ¤t)["lastTurnCompleteAt"], + complete["at"], + "materialized alias and current identity share one attention history: {after}" + ); + assert!( + mark(&closed, old).is_null() && mark(&closed, ¤t).is_null(), + "explicit close forgets every alias: {closed}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task6_ready_retains_completion_across_actual_native_exit_and_same_id_recovery() { + replay_after_native_exit(false).await; +} +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task6_ready_retains_materialization_aliases_and_close_forgets_them() { + replay_after_native_exit(true).await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn task6_actual_same_session_resume_keeps_unsaved_output_under_a_new_finite_source() { + let _guard = ENV.lock().await; + let dir = tempfile::tempdir().unwrap(); + let keys = [ + "CODEX_CMD", + "CODEX_HOME", + "FAKE_CODEX_APP_SERVER_BEHAVIOR", + "FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", + ]; + let _environment = Environment( + keys.iter() + .map(|key| (*key, std::env::var_os(key))) + .collect(), + ); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs") + .canonicalize() + .unwrap(); + let pid_file = dir.path().join("owned-native.pid"); + let notifications = dir.path().join("native-notifications.jsonl"); + let wrapper = dir.path().join("owned-codex.mjs"); + std::fs::write( + &wrapper, + format!( + "import fs from 'node:fs'; fs.writeFileSync({},String(process.pid)); await import({});", + json!(pid_file), + json!(fixture) + ), + ) + .unwrap(); + std::env::set_var("CODEX_CMD", format!("node {}", wrapper.display())); + std::env::set_var("CODEX_HOME", dir.path().join("codex")); + std::env::set_var("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES", "1"); + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"liveTranscript":true,"threadStartThreadId":"warm-same-session","waitForTurnReleasePath":dir.path().join("never-released"),"appendNativeNotificationLogPath":notifications}).to_string()); + let (url, _, state) = spawn_server_with_specs_and_state(vec![]).await; + state.fresh_codex.set_enabled(true); + let (mut ws, _) = connect_and_capture_inventory(&url).await; + state.fresh_codex.handle_create(serde_json::from_value(json!({"requestId":"warm-create","provider":"codex","sessionType":"freshcodex","cwd":dir.path(),"permissionMode":"never"})).unwrap(),None).await; + let created = next_frame_of_type(&mut ws, "freshAgent.created").await; + let session = created["sessionId"].as_str().unwrap(); + state.fresh_codex.handle_send(serde_json::from_value(json!({"requestId":"warm-send","provider":"codex","sessionType":"freshcodex","sessionId":session,"text":"Native partial before crash"})).unwrap()).await; + loop { + let patch = event(&mut ws, "freshAgent.transcript.patch").await; + if serde_json::to_string(&patch) + .unwrap() + .contains("Native streaming fragment") + { + break; + } + } + let before = state.fresh_codex.retained_state(session).await.unwrap(); + let native_turn = before["snapshot"]["extensions"]["codex"]["projection"]["eventOwnedTurnIds"] + [0] + .as_str() + .unwrap() + .to_string(); + let pid = std::fs::read_to_string(&pid_file) + .unwrap() + .parse::() + .unwrap(); + // The test-owned wrapper wrote this PID before importing the native fake. + // This exact child is inside the strict disposable process-kill sandbox. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + event(&mut ws, "freshAgent.turn.complete").await; + let dead = state.fresh_codex.retained_state(session).await.unwrap(); + // Canonical saved history deliberately lacks the event-owned tool/text body. + // A native refresh may reveal only that finite interrupted representation. + std::env::set_var("FAKE_CODEX_APP_SERVER_BEHAVIOR",json!({"recordTurns":true,"appendNativeNotificationLogPath":notifications,"threadReadResults":{session:{"id":session,"ephemeral":false,"status":{"type":"idle"},"turns":[{"id":native_turn,"status":"interrupted","itemsView":"full","items":[]}]}}}).to_string()); + state.fresh_codex.handle_attach(serde_json::from_value(json!({"provider":"codex","sessionType":"freshcodex","sessionId":session,"cwd":dir.path()})).unwrap()).await; + let rebound = state.fresh_codex.retained_state(session).await.unwrap(); + let refreshed = state.fresh_codex.get_snapshot(session, None).await.unwrap(); + state.fresh_codex.shutdown().await; + let starts = std::fs::read_to_string(notifications) + .unwrap() + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|frame| frame["method"] == "turn/started") + .count(); + assert!(serde_json::to_string(&before) + .unwrap() + .contains("Native streaming fragment")); + assert!(serde_json::to_string(&dead) + .unwrap() + .contains("Native streaming fragment")); + assert_ne!( + before["source"]["projectionId"], rebound["source"]["projectionId"], + "actual resume must allocate a new source, never reuse the old append authority" + ); + assert!(serde_json::to_string(&rebound).unwrap().contains("Native streaming fragment"),"new retained source must preserve available unsaved output without another native history RPC: {rebound}"); + assert_eq!( + rebound["snapshot"]["extensions"]["codex"]["projection"]["turnOutputStates"][&native_turn] + ["diagnostic"], + "interrupted" + ); + assert_eq!( + rebound["snapshot"]["extensions"]["codex"]["projection"]["turnOutputStates"][&native_turn] + ["refreshPending"], + false + ); + assert!( + serde_json::to_string(&refreshed) + .unwrap() + .contains("Native streaming fragment"), + "separate native refresh must keep available interrupted evidence" + ); + assert_eq!( + starts, 1, + "resume and retained reconciliation must never replay the prompt" + ); +} diff --git a/crates/freshell-ws/tests/freshagent_control_reply.rs b/crates/freshell-ws/tests/freshagent_control_reply.rs index 98ed6fcc5c..874fa645d9 100644 --- a/crates/freshell-ws/tests/freshagent_control_reply.rs +++ b/crates/freshell-ws/tests/freshagent_control_reply.rs @@ -585,3 +585,19 @@ async fn handled_control_frames_still_reach_their_dispatch_arms() { let frame = next_frame_of_type(&mut ws, "pong").await; assert!(frame["timestamp"].is_string(), "pong carries a timestamp"); } + +#[tokio::test] +async fn task5_elicitation_typed_ingress_dispatches_codex_and_refuses_other_providers() { + let (url, _registry) = spawn_server().await; + let (mut ws, _) = connect_and_capture_inventory(&url).await; + for (provider, session_type, expected) in [ + ("codex", "freshcodex", "INVALID_SESSION_ID"), + ("claude", "freshclaude", "UNSUPPORTED_CAPABILITY"), + ("opencode", "freshopencode", "UNSUPPORTED_CAPABILITY"), + ] { + send_json(&mut ws,serde_json::json!({"type":"freshAgent.elicitation.respond","provider":provider,"sessionType":session_type,"sessionId":"absent-native-thread","requestId":"41","action":"decline","content":null})).await; + let frame = next_frame_of_type(&mut ws, "freshAgent.event").await; + assert_eq!(frame["provider"], provider); + assert_eq!(frame["event"]["code"], expected); + } +} diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index 3cd8e30afc..9314980d01 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -713,6 +713,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { server .pane_ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "old-t", @@ -736,6 +737,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { server .pane_ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "new-t", @@ -777,6 +779,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { fn seed_claude_binding(ledger: &PaneLedger, session_id: &str, genesis: Option<&Value>) { ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + collaboration_mode: None, genesis: genesis.map_or(freshell_ws::pane_ledger::GenesisWrite::Keep, |genesis| { freshell_ws::pane_ledger::GenesisWrite::Set(genesis) }), diff --git a/docker/cloud-run/Dockerfile b/docker/cloud-run/Dockerfile index 5df621a5a8..c758a612e3 100644 --- a/docker/cloud-run/Dockerfile +++ b/docker/cloud-run/Dockerfile @@ -15,7 +15,7 @@ ARG FRESHELL_BUILD_COMMIT # --------------------------------------------------------------------------- # Stage 1: build the Rust server. # --------------------------------------------------------------------------- -FROM rust:1-bookworm AS rust-builder +FROM rust:1.96.0-bookworm AS rust-builder ARG FRESHELL_BUILD_COMMIT @@ -38,7 +38,19 @@ COPY Cargo.toml Cargo.lock ./ COPY crates/ ./crates/ COPY extensions/ ./extensions/ COPY docker/runtime/provider-versions.json ./docker/runtime/provider-versions.json -RUN FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" cargo build --release -p freshell-server --locked +COPY docker/runtime/codex-native-sandbox.json ./docker/runtime/codex-native-sandbox.json +RUN rustc --version && cargo --version \ + && FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" cargo build --release -p freshell-server --locked +# Compile the real-client requirement guard before entering native test lanes. +RUN set -eu; cargo test --release -p freshell-codex --features native-policy-qualification --lib --no-run --locked \ + ; set -- target/release/deps/freshell_codex-* \ + ; count=0; for probe in "$@"; do \ + if [ -f "$probe" ] && [ -x "$probe" ]; then \ + cp "$probe" /build/codex-policy-client-probe; count=$((count + 1)); \ + fi; \ + done; test "$count" -eq 1 \ + && chmod 755 /build/codex-policy-client-probe \ + && sha256sum /build/codex-policy-client-probe > /build/codex-policy-client-probe.sha256 # --------------------------------------------------------------------------- # Stage 2: build retained Node tooling and static artifacts. @@ -68,6 +80,7 @@ RUN npm install -g pnpm@10.34.5 \ && pnpm install --frozen-lockfile --ignore-scripts COPY . . +RUN node scripts/testing/prepare-codex-contract.mjs --output /opt/freshell-test-providers/codex/0.164.0-linux-x64 RUN FRESHELL_BUILD_COMMIT="$FRESHELL_BUILD_COMMIT" pnpm run build:client && pnpm run build:tools # --------------------------------------------------------------------------- @@ -105,6 +118,8 @@ RUN pnpm dlx playwright@1.58.2 install --with-deps chromium WORKDIR /app COPY --from=rust-builder /build/target/release/freshell-server /app/target/release/freshell-server +COPY --from=rust-builder /build/codex-policy-client-probe /opt/freshell-test-providers/codex/policy-client-probe +COPY --from=rust-builder /build/codex-policy-client-probe.sha256 /opt/freshell-test-providers/codex/policy-client-probe.sha256 COPY --from=rust-builder /usr/local/cargo /usr/local/cargo COPY --from=rust-builder /usr/local/rustup /usr/local/rustup COPY --from=node-builder /app/dist/client ./dist/client @@ -113,6 +128,7 @@ COPY --from=node-builder /app/node_modules ./node_modules # The pinned Claude contract exercises the isolated sidecar package directly. # Its node_modules links back to the root pnpm store copied above. COPY --from=node-builder /app/crates/freshell-claude-sidecar/node_modules ./crates/freshell-claude-sidecar/node_modules +COPY --from=node-builder /opt/freshell-test-providers/codex/0.164.0-linux-x64 /opt/freshell-test-providers/codex/0.164.0-linux-x64 # Fail the image build if the copied test toolchain cannot execute in this # Debian runtime. A container-level test checks the configured runtime user. @@ -123,6 +139,10 @@ RUN cargo --version && rustc --version # overwrite the staged artifacts. COPY . . +# This controlled test-only requirements layer enables the native network +# approval controller. No production runtime image receives it. +RUN node --input-type=module -e 'import fs from "node:fs"; import { CODEX_NETWORK_REQUIREMENTS } from "./test/integration/providers/codex-local-provider.mjs"; fs.mkdirSync("/etc/codex", { recursive: true }); fs.writeFileSync("/etc/codex/requirements.toml", CODEX_NETWORK_REQUIREMENTS, { mode: 0o644 })' + # Check the assembled runtime tree after all staged artifacts are present. # With --runtime-root the guard checks the required Rust/client/tools files and # executable, scans only shipped dist/, target/, and node_modules/ roots, @@ -131,6 +151,9 @@ COPY . . RUN scripts/verify-container-layout.sh --fixture /app --runtime-root ENV FRESHELL_E2E_RUST_SERVER_BIN=/app/target/release/freshell-server +ENV FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-test-providers/codex/0.164.0-linux-x64 +ENV FRESHELL_CODEX_CONTRACT_CAPTURE_STDOUT=1 +ENV FRESHELL_CODEX_POLICY_CLIENT_PROBE=/opt/freshell-test-providers/codex/policy-client-probe # COPY's requested mode can still be narrowed by rootless build/ACL metadata. # Normalize the copied artifact in the image layer so the final unprivileged @@ -143,6 +166,10 @@ RUN chmod 755 /usr/local/bin/e2e-entrypoint.sh \ RUN chown -R node:node /app USER node +# Verify the complete copied native tree as the final runtime identity, +# including /opt ancestors outside /app's ownership adjustment. +RUN node --input-type=module -e 'import { verifyArtifact, verifyPolicyClientProbe } from "./scripts/testing/prepare-codex-contract.mjs"; await verifyArtifact(process.env.FRESHELL_CODEX_CONTRACT_ROOT); await verifyPolicyClientProbe(process.env.FRESHELL_CODEX_POLICY_CLIENT_PROBE)' + # Verify the final runtime user can read, syntax-check, and execute the exact # entrypoint artifact. `--dry-run` exits before Playwright and exercises the # image's configured ENTRYPOINT behavior without running a test workload. diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index 4a4d958147..d7ab97f574 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -34,7 +34,7 @@ RUN npm install --global pnpm@10.34.5 \ FROM ubuntu@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG CLAUDE_CODE_VERSION=2.1.263 -ARG CODEX_VERSION=0.147.0 +ARG CODEX_VERSION=0.164.0 ARG OPENCODE_VERSION=1.18.21 ARG BUN_VERSION=1.4.3 ARG AMPLIFIER_APP_CLI_COMMIT=1873aa980535c99a743b17172e4231833f6c8741 @@ -76,6 +76,11 @@ RUN apt-get update \ && /opt/amplifier-src/.venv/bin/python -c 'import importlib.metadata as m; assert m.version("openai") == "2.8.1"; assert m.version("openai-harmony") == "0.0.8"; assert m.version("amplifier-module-provider-vllm") == "1.0.0"' \ && /lib/x86_64-linux-gnu/libc.so.6 | grep -F '2.39' +COPY docker/runtime/codex-native-sandbox.json /opt/freshell-codex/codex-native-sandbox.json +COPY docker/runtime/codex-launch.mjs /opt/freshell-codex/launch.mjs +COPY docker/runtime/codex-assemble.mjs /opt/freshell-codex/codex-assemble.mjs +RUN FRESHELL_CODEX_BUILD_VERSION="${CODEX_VERSION}" node /opt/freshell-codex/codex-assemble.mjs + # FreshClaude/Kilroy run inside the soul host, so their JS SDK bridge and its # pinned dependency must be part of the workload image rather than resolved # through a web-server worktree path. diff --git a/docker/runtime/codex-assemble.mjs b/docker/runtime/codex-assemble.mjs new file mode 100644 index 0000000000..999a2e13d4 --- /dev/null +++ b/docker/runtime/codex-assemble.mjs @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { validateManagedLayout } from './launch.mjs' + +const directory = path.dirname(fileURLToPath(import.meta.url)) +const { native: identity } = JSON.parse(fs.readFileSync(path.join(directory, 'codex-native-sandbox.json'), 'utf8')) +if (process.getuid() !== 0 || process.env.FRESHELL_CODEX_BUILD_VERSION !== identity.version) throw new Error('Codex assembly requires the exact declared version and owned root build context') +const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex') +const requireCodex = createRequire(path.join(identity.packageRoot, 'package.json')) +const platformPackageRoot = path.dirname(requireCodex.resolve('@openai/codex-linux-x64/package.json')) +const layout = { + version: identity.version, launchWrapper: identity.launchWrapper, nodeExecutable: identity.nodeExecutable, + npmWrapper: identity.npmWrapper, packageRoot: fs.realpathSync(identity.packageRoot), platformPackageRoot, + nativeExecutable: path.join(platformPackageRoot, identity.executable), bwrap: path.join(platformPackageRoot, identity.bwrap), helperDirectory: identity.helperDirectory, +} +function readableOwnedTree(root) { + fs.chmodSync(root, 0o755) + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const file = path.join(root, entry.name) + if (entry.isDirectory()) readableOwnedTree(file) + else if (entry.isFile()) fs.chmodSync(file, fs.statSync(file).mode & 0o111 ? 0o755 : 0o644) + else throw new Error('Unexpected native package resource type') + } +} +function protectedAncestors(file) { + for (let current = file; ; current = path.dirname(current)) { + const stat = fs.statSync(current) + if (stat.uid !== 0 || stat.mode & 0o022) throw new Error(`Codex managed resource is replaceable: ${current}`) + if (path.dirname(current) === current) break + } +} +readableOwnedTree(platformPackageRoot) +fs.chmodSync(identity.packageRoot, 0o755) +fs.chmodSync(path.dirname(fs.realpathSync(identity.npmWrapper)), 0o755) +fs.chmodSync(fs.realpathSync(identity.npmWrapper), 0o755) +if (hash(layout.nativeExecutable) !== identity.executableSha256) throw new Error('Installed Codex native bytes differ from the verified release') +if (hash(layout.bwrap) !== identity.bwrapSha256 || hash(fs.realpathSync(identity.npmWrapper)) !== identity.npmEntrypointSha256) throw new Error('Installed Codex helper or npm entrypoint bytes differ from the verified release') +fs.mkdirSync(identity.helperDirectory, { recursive: true, mode: 0o755 }) +fs.symlinkSync(layout.bwrap, path.join(identity.helperDirectory, 'bwrap')) +fs.symlinkSync('launch.mjs', identity.launchWrapper) +fs.chmodSync(path.join(directory, 'launch.mjs'), 0o755) +for (const file of [layout.nativeExecutable, layout.bwrap, fs.realpathSync(identity.npmWrapper), identity.launchWrapper, identity.helperDirectory]) protectedAncestors(file) +validateManagedLayout(layout, identity) +fs.writeFileSync(identity.layoutFile, `${JSON.stringify(layout, null, 2)}\n`, { mode: 0o644, flag: 'wx' }) +console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_layout_assembled', version: identity.version, nativeSha256: identity.executableSha256, helperSha256: identity.bwrapSha256, npmSha256: identity.npmEntrypointSha256, layout })) diff --git a/docker/runtime/codex-launch.mjs b/docker/runtime/codex-launch.mjs new file mode 100644 index 0000000000..01cb023d0e --- /dev/null +++ b/docker/runtime/codex-launch.mjs @@ -0,0 +1,52 @@ +#!/usr/local/bin/node +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' + +const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex') +const fail = reason => { throw new Error(`Codex managed launch is incompatible: ${reason}`) } + +export function validateManagedLayout(layout, identity) { + if (process.platform !== identity.platform || process.arch !== identity.arch || layout.version !== identity.version) fail('native platform or version changed') + for (const key of ['launchWrapper', 'nodeExecutable', 'npmWrapper', 'packageRoot', 'helperDirectory']) { + if (layout[key] !== identity[key] || !path.isAbsolute(layout[key])) fail(`declared ${key} changed`) + } + if (layout.nativeExecutable !== path.join(layout.platformPackageRoot, identity.executable) + || layout.bwrap !== path.join(layout.platformPackageRoot, identity.bwrap)) fail('installed native resource layout changed') + const main = JSON.parse(fs.readFileSync(path.join(layout.packageRoot, 'package.json'), 'utf8')) + const platform = JSON.parse(fs.readFileSync(path.join(layout.platformPackageRoot, 'package.json'), 'utf8')) + if (main.name !== '@openai/codex' || main.version !== identity.version || platform.version !== `${identity.version}-linux-x64`) fail('installed npm package version changed') + for (const file of [layout.nodeExecutable, layout.npmWrapper, layout.nativeExecutable, layout.bwrap]) fs.accessSync(file, fs.constants.R_OK | fs.constants.X_OK) + if (sha256(fs.realpathSync(layout.npmWrapper)) !== identity.npmEntrypointSha256) fail('published npm entrypoint bytes changed') + if (sha256(layout.bwrap) !== identity.bwrapSha256) fail('verified native helper bytes changed') + let helper + try { helper = fs.realpathSync(path.join(layout.helperDirectory, 'bwrap')) } catch { fail('owned bwrap helper is missing') } + if (helper !== fs.realpathSync(layout.bwrap)) fail('owned bwrap helper points to a different resource') +} + +export function launchManagedCodex(layout, identity, args, environment = process.env) { + validateManagedLayout(layout, identity) + if (typeof process.execve !== 'function') fail('the fixed Node runtime does not support execve') + const child = { ...environment } + const prefix = `${layout.helperDirectory}${path.delimiter}` + if (child.PATH !== layout.helperDirectory && !child.PATH?.startsWith(prefix)) child.PATH = child.PATH ? `${prefix}${child.PATH}` : layout.helperDirectory + for (const name of ['CODEX_MANAGED_BY_NPM', 'CODEX_MANAGED_BY_BUN', 'CODEX_MANAGED_BY_PNPM', 'CODEX_MANAGED_BY_VITE_PLUS', 'npm_config_user_agent', 'npm_execpath']) delete child[name] + // This immutable workload installation belongs to npm; the web server's + // package-manager environment does not describe this CLI installation. + child.CODEX_MANAGED_PACKAGE_ROOT = layout.packageRoot + child.CODEX_MANAGED_BY_NPM = '1' + process.execve(layout.nodeExecutable, [layout.nodeExecutable, layout.npmWrapper, ...args], child) +} + +if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const resource = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'codex-native-sandbox.json'), 'utf8')) + const identity = resource.native + const layout = JSON.parse(fs.readFileSync(identity.layoutFile, 'utf8')) + launchManagedCodex(layout, identity, process.argv.slice(2)) + } catch (error) { + console.error(JSON.stringify({ severity: 'error', event: 'codex_managed_launch_refused', reason: error.message })) + process.exitCode = 1 + } +} diff --git a/docker/runtime/codex-native-sandbox.json b/docker/runtime/codex-native-sandbox.json new file mode 100644 index 0000000000..5eb1eb0f42 --- /dev/null +++ b/docker/runtime/codex-native-sandbox.json @@ -0,0 +1,1000 @@ +{ + "schemaVersion": 1, + "id": "codex-native-0.164-docker-seccomp-v1", + "native": { + "version": "0.164.0", + "platform": "linux", + "arch": "x64", + "executable": "vendor/x86_64-unknown-linux-musl/bin/codex", + "executableSha256": "b93f6811d3f653f0fb83be4050d857fb9abbb111dfa2ce160563327daff7f306", + "bwrap": "vendor/x86_64-unknown-linux-musl/codex-resources/bwrap", + "bwrapSha256": "3a81c19ad10a70436529c58e77898e2a447abe80167f813fc20ec34bd9eafdeb", + "npmWrapper": "/usr/local/bin/codex", + "launchWrapper": "/opt/freshell-codex/launch", + "nodeExecutable": "/usr/local/bin/node", + "layoutFile": "/opt/freshell-codex/layout.json", + "helperDirectory": "/opt/freshell-codex/bin", + "packageRoot": "/usr/local/lib/node_modules/@openai/codex", + "npmEntrypointSha256": "61b0194f3bb6534439c8d26a3ed57d0805f84b884588b761795323eeb92fcf70" + }, + "provenance": { + "daemonVersion": "29.7.2", + "daemonGitCommit": "6a43e3d", + "daemonSource": "https://raw.githubusercontent.com/moby/moby/6a43e3d/go.mod", + "defaultModule": "github.com/moby/profiles/seccomp v0.2.3", + "defaultSource": "https://raw.githubusercontent.com/moby/profiles/836ae4d37ef2ec995c77c99fc55f5b5f3af3a897/seccomp/default.json", + "defaultSha256": "536529b665dd0972c37bfb569f5d4ac8a53592e7b00752bc39ff063ca9864c74", + "nativeSource": "https://github.com/openai/codex/tree/6aaac694803004a63c6f393d153c96d22521a6b9/codex-rs/linux-sandbox", + "addedRules": [ + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2013397009, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435456, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "mount", + "pivot_root", + "umount2" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 939655185, + "op": "SCMP_CMP_EQ" + } + ] + } + ] + }, + "profile": { + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, + "archMap": [ + { + "architecture": "SCMP_ARCH_X86_64", + "subArchitectures": [ + "SCMP_ARCH_X86", + "SCMP_ARCH_X32" + ] + }, + { + "architecture": "SCMP_ARCH_AARCH64", + "subArchitectures": [ + "SCMP_ARCH_ARM" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPS64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPS", + "SCMP_ARCH_MIPS64" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64N32" + ] + }, + { + "architecture": "SCMP_ARCH_MIPSEL64N32", + "subArchitectures": [ + "SCMP_ARCH_MIPSEL", + "SCMP_ARCH_MIPSEL64" + ] + }, + { + "architecture": "SCMP_ARCH_S390X", + "subArchitectures": [ + "SCMP_ARCH_S390" + ] + }, + { + "architecture": "SCMP_ARCH_RISCV64", + "subArchitectures": null + }, + { + "architecture": "SCMP_ARCH_LOONGARCH64", + "subArchitectures": null + } + ], + "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "adjtimex", + "alarm", + "bind", + "brk", + "cachestat", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_adjtime", + "clock_adjtime64", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fanotify_mark", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "getxattrat", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "ipc", + "kill", + "landlock_add_rule", + "landlock_create_ruleset", + "landlock_restrict_self", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listmount", + "listxattr", + "listxattrat", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "map_shadow_stack", + "membarrier", + "memfd_create", + "memfd_secret", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "mseal", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "process_mrelease", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "removexattrat", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "riscv_hwprobe", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "setxattrat", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statmount", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "uretprobe", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "minKernel": "4.8" + } + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 38, + "op": "SCMP_CMP_LT" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 39, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "socket" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 40, + "op": "SCMP_CMP_GT" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 0, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 8, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131072, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 131080, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "personality" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 4294967295, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "sync_file_range2", + "swapcontext" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "ppc64le" + ] + } + }, + { + "names": [ + "arm_fadvise64_64", + "arm_sync_file_range", + "sync_file_range2", + "breakpoint", + "cacheflush", + "set_tls" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "arm", + "arm64" + ] + } + }, + { + "names": [ + "arch_prctl" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32" + ] + } + }, + { + "names": [ + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "amd64", + "x32", + "x86" + ] + } + }, + { + "names": [ + "s390_pci_mmio_read", + "s390_pci_mmio_write", + "s390_runtime_instr" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "riscv_flush_icache" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "arches": [ + "riscv64" + ] + } + }, + { + "names": [ + "open_by_handle_at" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_DAC_READ_SEARCH" + ] + } + }, + { + "names": [ + "bpf", + "clone", + "clone3", + "fanotify_init", + "fsconfig", + "fsmount", + "fsopen", + "fspick", + "lookup_dcookie", + "lsm_get_self_attr", + "lsm_list_modules", + "lsm_set_self_attr", + "mount", + "mount_setattr", + "move_mount", + "open_tree", + "perf_event_open", + "quotactl", + "quotactl_fd", + "setdomainname", + "sethostname", + "setns", + "syslog", + "umount", + "umount2", + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ], + "arches": [ + "s390", + "s390x" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 1, + "value": 2114060288, + "op": "SCMP_CMP_MASKED_EQ" + } + ], + "comment": "s390 parameter ordering for clone is different", + "includes": { + "arches": [ + "s390", + "s390x" + ] + }, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "clone3" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 38, + "excludes": { + "caps": [ + "CAP_SYS_ADMIN" + ] + } + }, + { + "names": [ + "reboot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_BOOT" + ] + } + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_CHROOT" + ] + } + }, + { + "names": [ + "delete_module", + "init_module", + "finit_module" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_MODULE" + ] + } + }, + { + "names": [ + "acct" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PACCT" + ] + } + }, + { + "names": [ + "kcmp", + "pidfd_getfd", + "process_madvise", + "process_vm_readv", + "process_vm_writev", + "ptrace" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_PTRACE" + ] + } + }, + { + "names": [ + "iopl", + "ioperm" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_RAWIO" + ] + } + }, + { + "names": [ + "settimeofday", + "stime", + "clock_settime", + "clock_settime64" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TIME" + ] + } + }, + { + "names": [ + "vhangup" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_TTY_CONFIG" + ] + } + }, + { + "names": [ + "get_mempolicy", + "mbind", + "set_mempolicy", + "set_mempolicy_home_node" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYS_NICE" + ] + } + }, + { + "names": [ + "syslog" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_SYSLOG" + ] + } + }, + { + "names": [ + "bpf" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_BPF" + ] + } + }, + { + "names": [ + "perf_event_open" + ], + "action": "SCMP_ACT_ALLOW", + "includes": { + "caps": [ + "CAP_PERFMON" + ] + } + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 2013397009, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "unshare" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 268435456, + "op": "SCMP_CMP_EQ" + } + ] + }, + { + "names": [ + "mount", + "pivot_root", + "umount2" + ], + "action": "SCMP_ACT_ALLOW" + }, + { + "names": [ + "clone" + ], + "action": "SCMP_ACT_ALLOW", + "args": [ + { + "index": 0, + "value": 939655185, + "op": "SCMP_CMP_EQ" + } + ] + } + ] + } +} diff --git a/docker/runtime/provider-versions.json b/docker/runtime/provider-versions.json index 75c7ee2b3a..729408b747 100644 --- a/docker/runtime/provider-versions.json +++ b/docker/runtime/provider-versions.json @@ -24,7 +24,7 @@ }, "codex": { "package": "@openai/codex", - "version": "0.147.0" + "version": "0.164.0" }, "opencode": { "package": "opencode-ai", diff --git a/docs/development/codex-native-runtime.md b/docs/development/codex-native-runtime.md new file mode 100644 index 0000000000..61f8411633 --- /dev/null +++ b/docs/development/codex-native-runtime.md @@ -0,0 +1,15 @@ +# Codex managed runtime + +Freshcodex's managed Linux x64 workload uses Codex 0.164.0. The provider inventory in `docker/runtime/provider-versions.json` and the Docker build argument must agree. Other provider versions are independent. + +`docker/runtime/codex-native-sandbox.json` declares the native package, helper, npm entrypoint and seccomp identity. Its provenance records the official default from the tested Docker daemon and the small set of namespace/mount rules required by Codex's bundled bubblewrap. The profile retains the default denials, architecture and capability conditions. It is selected only for the managed Codex executable; explicit custom terminal executables and other providers retain their existing policy. + +The additional syscall rules apply inside the workload container. They do not configure the host or Docker daemon. The workload still has a read-only root, bounded mounts and resources, no new privileges and dropped capabilities; the provider runs with UID 65534 and GID 0. Native `never` approval with read-only or workspace-write sandboxing must independently deny forbidden file and network effects. An accepted `untrusted` approval can authorize Codex's native retry without sandboxing and is tested separately from `never` confinement. + +The workload image installs the published npm package. Its build assembler verifies the npm entrypoint, native executable and bundled helper, normalizes permissions only in the owned package tree, and writes `/opt/freshell-codex/layout.json`. The managed launcher at `/opt/freshell-codex/launch` validates that layout and executes the stock npm entrypoint through the fixed image Node runtime. It prefixes the owned bundled-helper directory only in the child environment and supplies npm installation context. It does not change the installed entrypoint or global PATH. Missing helpers or an incompatible layout refuse launch. Rust checks final argument and environment sizes after this child environment expansion, before dispatch. + +The supervisor passes the declared seccomp JSON to Docker and checks the inspected policy before start or reuse. Native, helper, wrapper and policy identities participate in runtime ownership. Existing containers built with a different identity require the existing replacement path; they cannot be adopted on labels alone. + +Codex 0.164 rejects public `approval_policy = "untrusted"` before typed request overrides. Freshcodex preserves its declaring source through the owned prepared-config path, omits that rejected declaration only from prepared copies, then sends the intended native typed policy. Unsupported source selectors and native requirements that prohibit the policy produce an explicit configuration error before work. Original user files remain untouched. + +To qualify a changed version, helper, wrapper or policy, use the Task 1 native harness. Connected preparation is `node scripts/testing/prepare-codex-contract.mjs --sandbox`; `--verify-sandbox` verifies completed prerequisites before networkless entry. The managed proof is `node test/integration/providers/codex-cli-contract.mjs --managed-runtime --receipt-dir ABSOLUTE_OWNED_PATH`. It builds an existing run-owned workload image, adds system requirements only in a disposable test derivative, and runs the native matrix through the installed npm wrapper as the provider identity. Artifact fixtures and version output alone do not qualify the managed runtime. Preserve raw scenario receipts, actual Docker inspection, image digest and denied-effect evidence. No production restart or deployment is part of qualification. diff --git a/docs/index.html b/docs/index.html index 4cc28a20fd..3edd012b3e 100644 --- a/docs/index.html +++ b/docs/index.html @@ -976,7 +976,7 @@

Task Board

~/code/freshell
-
Freshcodex and Freshclaude share model selection, attachments, questions, and approvals. Model and thinking changes apply to your next message. /undo rolls conversation turns back without changing your files; Freshclaude and Freshopencode also support /redo.
+
Freshcodex lists models from its running Codex installation and accepts exact custom IDs. Model and thinking changes apply to your next message; Default removes Freshell’s effort override. Build or Plan, File access, and approval policy are separate settings. Awaited questions pause work; async questions let it continue. MCP prompts require an explicit decision, even after opening a linked page. Child conversations open read-only, with truthful saved, retained, interrupted, or unavailable output. /undo changes conversation history; Rewind code to here restores files separately. Codex has no /redo.
Diffs
src/components/fresh-agent/FreshAgentView.tsx
@@ -996,7 +996,7 @@

Task Board

+ {codexCatalogStatus ?

{codexCatalogStatus}

: null} + {sessionType === 'freshcodex' ?
+ + +
: null} {!capabilities ? (

Loading model catalog… @@ -541,6 +626,7 @@ export function FreshAgentModelDialog({ : undefined const isHighlighted = row === highlightedLevelRow const stageLevel = () => { + if (sessionType === 'freshcodex' && highlightedModel) setStagedModel(highlightedModel) if (highlightedRow && !highlightedIsUserStaged) setHighlightedRowKey(highlightedRow.key) setHighlightedLevelIndex(index) setActiveColumn('levels') diff --git a/src/components/fresh-agent/FreshAgentQuestionBanner.tsx b/src/components/fresh-agent/FreshAgentQuestionBanner.tsx index 4668a2fb2c..d51c10f8e3 100644 --- a/src/components/fresh-agent/FreshAgentQuestionBanner.tsx +++ b/src/components/fresh-agent/FreshAgentQuestionBanner.tsx @@ -3,13 +3,15 @@ import { MessageCircleQuestion } from 'lucide-react' import { cn } from '@/lib/utils' type FreshAgentQuestion = { - requestId: string + requestId?: string questions: Array<{ id?: string question: string header: string options: Array<{ label: string; description: string }> multiSelect: boolean + isOther?: boolean + isSecret?: boolean }> } @@ -24,16 +26,17 @@ function SingleSelectQuestion({ onSelect: (answer: string) => void disabled?: boolean }) { - const [showOther, setShowOther] = useState(false) + const nativeFreeEntry = question.options.length === 0 && (question.isOther !== undefined || question.isSecret !== undefined) + const [showOther, setShowOther] = useState(nativeFreeEntry) const [otherText, setOtherText] = useState('') const otherInputRef = useRef(null) const submitOther = () => { - if (!disabled && otherText.trim()) onSelect(otherText.trim()) + if (!disabled && otherText.trim()) onSelect(question.isOther !== undefined || question.isSecret !== undefined ? otherText : otherText.trim()) } useEffect(() => { - if (showOther) otherInputRef.current?.focus() - }, [showOther]) + if (showOther && !nativeFreeEntry) otherInputRef.current?.focus() + }, [showOther, nativeFreeEntry]) return (

@@ -61,7 +64,7 @@ function SingleSelectQuestion({ ) : null} ))} - + : null}
{showOther ? (
settingsDisabled + || settingScopes?.[knob] === 'unsupported' + || Boolean(paneContent.sessionId && settingScopes?.[knob] === 'create-only') const styleValue = normalizeFreshAgentStyle( paneContent.style ?? providerDefaults?.style ?? DEFAULT_FRESH_AGENT_STYLE, ) @@ -184,6 +187,8 @@ export function FreshAgentSettingsButton({ const closeModelDialog = useCallback(() => setModelDialogOpen(false), []) const close = useCallback(() => setOpen(false), []) const persistProviderDefaults = useCallback((defaults: { + sandbox?: FreshAgentPaneContent['sandbox'] + collaborationMode?: FreshAgentPaneContent['collaborationMode'] modelSelection?: { kind: 'exact'; modelId: string } defaultPermissionMode?: string effort?: string @@ -199,6 +204,12 @@ export function FreshAgentSettingsButton({ })) }, [dispatch, paneContent.sessionType]) + const commitCodexSettings = (settings: { sandbox?: FreshAgentPaneContent['sandbox']; collaborationMode?: FreshAgentPaneContent['collaborationMode'] }) => { + dispatch(mergePaneContent({ tabId, paneId, updates: settings })) + sendFreshAgentConfigure(paneId, paneContent, settings) + persistProviderDefaults(settings) + } + // freshopencode + claude providers (freshclaude/kilroy): fetch the live // catalog when the popover opens. freshopencode renders display names and // reports catalog unavailability; claude providers merge the probed rows @@ -482,6 +493,42 @@ export function FreshAgentSettingsButton({ ) ) : null} + {descriptor?.settingsVisibility.sandbox ? ( + + ) : null} + + {descriptor?.settingsVisibility.collaborationMode ? ( + + ) : null} + {permissionModes.length > 0 ? (
+ ) }, [ + asyncQuestionContext, canSend, claudeSession?.restoreFailureMessage, activeStyle, @@ -5015,6 +5242,7 @@ export function FreshAgentView({ isRestoring, landedTranscript, liveSessionKey, + codexLiveMetadata, liveTailActive, loadError, managedRecoveryPending, @@ -5040,6 +5268,9 @@ export function FreshAgentView({ restartStuckSidecar, rewindToTurn, openDelegationSession, + runtimeOwnerEpoch, + runtimeOwnerGeneration, + requestSnapshotRefresh, runShellCommand, sessionEnded, sessionErrorMessage, diff --git a/src/components/fresh-agent/fresh-agent-event-classes.ts b/src/components/fresh-agent/fresh-agent-event-classes.ts index e1132df282..2ea8add3d7 100644 --- a/src/components/fresh-agent/fresh-agent-event-classes.ts +++ b/src/components/fresh-agent/fresh-agent-event-classes.ts @@ -7,18 +7,26 @@ export const FRESH_AGENT_BUSY_STATUSES: ReadonlySet = new Set(['running', 'compacting']) +// Pending approval/question/elicitation cards change the pane surface without +// making its transcript stale. Hidden panes owe a non-blocking reveal refresh. +export const SURFACE_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ + 'freshAgent.permission.request', + 'freshAgent.permission.cancelled', + 'freshAgent.question.request', + 'freshAgent.question.cancelled', + 'freshAgent.elicitation.request', + 'freshAgent.elicitation.cancelled', +]) + // Events that re-drive the pane's snapshot while it is visible. export const SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ + ...SURFACE_INVALIDATING_FRESH_AGENT_EVENTS, + 'freshAgent.host.backpressure', + 'freshAgent.host.journal.reset', 'freshAgent.session.changed', 'freshAgent.session.snapshot', 'freshAgent.result', 'freshAgent.turn.complete', - 'freshAgent.permission.request', - 'freshAgent.permission.cancelled', - 'freshAgent.question.request', - // A provider-cancelled question must also re-drive the snapshot, so the card clears - // even if the freshAgent.question.cancelled fold races (fresh-eyes round-3 F3). - 'freshAgent.question.cancelled', // kata 1wxv: rollback converges through the invalidating snapshot — the marker // bucket, the active prefix, and rollback{canRedo,undoneDepth} all ride it. 'freshAgent.session.rolledBack', @@ -33,30 +41,21 @@ export const SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new // hidden pane immediately usable when its last transcript snapshot is still // current. export const TRANSCRIPT_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ + 'freshAgent.host.backpressure', + 'freshAgent.host.journal.reset', 'freshAgent.session.changed', 'freshAgent.result', 'freshAgent.turn.complete', 'freshAgent.error', 'freshAgent.assistant', 'freshAgent.stream', + 'freshAgent.transcript.patch', 'freshAgent.session.rolledBack', 'freshAgent.session.redone', 'freshAgent.rolledBack', 'freshAgent.redone', ]) -// Approval and question lifecycle events change only the pane surface (the -// cards rendered from the snapshot's pendingApprovals/pendingQuestions). A -// hidden pane owes a non-blocking refresh for them on reveal; they must not -// take the blocking transcript path, whose overlay clears only on a newer -// transcript revision that an approval never produces. -export const SURFACE_INVALIDATING_FRESH_AGENT_EVENTS: ReadonlySet = new Set([ - 'freshAgent.permission.request', - 'freshAgent.permission.cancelled', - 'freshAgent.question.request', - 'freshAgent.question.cancelled', -]) - export type HiddenPaneEventClass = 'transcript' | 'surface' | 'none' function isRecord(value: unknown): value is Record { diff --git a/src/components/fresh-agent/shared/child-activity.ts b/src/components/fresh-agent/shared/child-activity.ts new file mode 100644 index 0000000000..c8b85e20e7 --- /dev/null +++ b/src/components/fresh-agent/shared/child-activity.ts @@ -0,0 +1,10 @@ +/** Display native child outcomes without substituting tool-call completion. */ +export function childActivitySummary(value: unknown): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) return typeof value === 'string' ? value : '' + const state = value as Record + const labels: Record = { nativeStatus: 'Activity', status: 'Status', model: 'Model', reasoningEffort: 'Thinking', message: 'Message', result: 'Result', error: 'Error' } + return ['nativeStatus', 'status', 'model', 'reasoningEffort', 'message', 'result', 'error'] + .filter((key) => state[key] != null && state[key] !== '') + .map((key) => `${labels[key]}: ${typeof state[key] === 'string' ? state[key] : JSON.stringify(state[key])}`) + .join(' · ') +} diff --git a/src/lib/api.ts b/src/lib/api.ts index e253c73db2..084d8bb832 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -20,6 +20,7 @@ import type { RecoveryInventory } from '@/lib/recovery/types' import type { Machine } from '@/lib/machine-identity' import { type FreshAgentModelCapabilitiesResponse, + type FreshAgentCodexCatalogContext, } from '@shared/fresh-agent-model-capabilities' import { ManagedRuntimeIncidentSummarySchema, @@ -612,10 +613,19 @@ export async function auditManagedRuntimeRepair( export async function getFreshAgentModelCapabilities( sessionType: string, - options: ApiRequestOptions & { cwd?: string } = {}, + options: ApiRequestOptions & { cwd?: string; codexContext?: FreshAgentCodexCatalogContext } = {}, ): Promise { - const { cwd, ...requestOptions } = options - const query = cwd ? `?${new URLSearchParams({ cwd }).toString()}` : '' + const { cwd, codexContext, ...requestOptions } = options + const params = new URLSearchParams() + if (codexContext) { + params.set('sessionId',codexContext.sessionId) + if (codexContext.sessionRef) params.set('sessionRef',JSON.stringify(codexContext.sessionRef)) + if (codexContext.observedEpoch !== undefined) params.set('observedEpoch',String(codexContext.observedEpoch)) + if (codexContext.observedGeneration !== undefined) params.set('observedGeneration',String(codexContext.observedGeneration)) + } + const catalogCwd = codexContext?.cwd ?? cwd + if (catalogCwd) params.set('cwd',catalogCwd) + const query = params.size ? `?${params.toString()}` : '' try { return parseFreshAgentModelCapabilitiesResponse( await api.get(`/api/fresh-agent/model-capabilities/${encodeURIComponent(sessionType)}${query}`, requestOptions), @@ -668,10 +678,19 @@ export async function getFreshAgentCheckpointOutcomePage( export async function refreshFreshAgentModelCapabilities( sessionType: string, - options: ApiRequestOptions & { cwd?: string } = {}, + options: ApiRequestOptions & { cwd?: string; codexContext?: FreshAgentCodexCatalogContext } = {}, ): Promise { - const { cwd, ...requestOptions } = options - const query = cwd ? `?${new URLSearchParams({ cwd }).toString()}` : '' + const { cwd, codexContext, ...requestOptions } = options + const params = new URLSearchParams() + if (codexContext) { + params.set('sessionId',codexContext.sessionId) + if (codexContext.sessionRef) params.set('sessionRef',JSON.stringify(codexContext.sessionRef)) + if (codexContext.observedEpoch !== undefined) params.set('observedEpoch',String(codexContext.observedEpoch)) + if (codexContext.observedGeneration !== undefined) params.set('observedGeneration',String(codexContext.observedGeneration)) + } + const catalogCwd = codexContext?.cwd ?? cwd + if (catalogCwd) params.set('cwd',catalogCwd) + const query = params.size ? `?${params.toString()}` : '' try { return parseFreshAgentModelCapabilitiesResponse( await api.post(`/api/fresh-agent/model-capabilities/${encodeURIComponent(sessionType)}/refresh${query}`, {}, requestOptions), diff --git a/src/lib/fresh-agent-async-questions.ts b/src/lib/fresh-agent-async-questions.ts new file mode 100644 index 0000000000..469417a669 --- /dev/null +++ b/src/lib/fresh-agent-async-questions.ts @@ -0,0 +1,28 @@ +/** Context for ordinary user input. None of these identities is a native control request. */ +export type AsyncQuestionAnswer = { itemId: string; questionId: string; question: string; text: string } +export type AsyncQuestionAnswerStatus = 'draft' | 'sending' | 'accepted' | 'refused' | 'ambiguous' +export const asyncQuestionKey = (itemId: string, questionId: string) => JSON.stringify([itemId, questionId]) +const HEADER = 'Answer to Codex question: ' + +export function asyncQuestionDraftPrefix(itemId: string, questionId: string): string { + return HEADER + JSON.stringify({ itemId, questionId }).slice(0, -1) + ',' +} + +export function formatAsyncQuestionAnswer(answer: AsyncQuestionAnswer): string { + return HEADER + JSON.stringify({ itemId: answer.itemId, questionId: answer.questionId, question: answer.question, answer: answer.text }) +} + +/** Recover only an ordinary draft's context on explicit Send, never a pending RPC or an automatic send. */ +export function asyncQuestionKeysInText(text: string): string[] { + const keys = new Set() + for (const line of text.split('\n')) { + if (!line.startsWith(HEADER)) continue + try { + const value: unknown = JSON.parse(line.slice(HEADER.length)) + if (!value || typeof value !== 'object') continue + const answer = value as Record + if (typeof answer.itemId === 'string' && typeof answer.questionId === 'string' && typeof answer.question === 'string' && typeof answer.answer === 'string') keys.add(asyncQuestionKey(answer.itemId, answer.questionId)) + } catch { /* Edited ordinary prose remains ordinary prose. */ } + } + return [...keys] +} diff --git a/src/lib/fresh-agent-configure.ts b/src/lib/fresh-agent-configure.ts index 53e3eda45e..b09b0510f4 100644 --- a/src/lib/fresh-agent-configure.ts +++ b/src/lib/fresh-agent-configure.ts @@ -21,8 +21,10 @@ import type { FreshAgentPaneContent } from '@/store/paneTypes' * `freshAgent.send.settings` carries (all optional; an absent key means "no * statement", so the caller states ONLY what changed). */ export type FreshAgentConfigureSettings = { + cwd?: string + collaborationMode?: 'default' | 'plan' model?: string - effort?: string + effort?: string | null permissionMode?: string sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' } @@ -51,8 +53,8 @@ export function sendSuppressedAwareFreshAgentFrame( * (sessionId set) can apply settings — pre-create panes stage on the pane * content and the next `freshAgent.send` carries them, exactly as before. * The settings object states ONLY the fields the caller changed; an absent - * effort key is the picker's explicit Default row (opencode clears it; the - * per-send providers keep the session's current value). + * effort key preserves Codex's selection; explicit null selects its Default. + * Other providers retain their existing model/absent-effort behavior. */ export function sendFreshAgentConfigure( paneId: string, diff --git a/src/lib/fresh-agent-context-usage.ts b/src/lib/fresh-agent-context-usage.ts index ee01526beb..29eaa21f2d 100644 --- a/src/lib/fresh-agent-context-usage.ts +++ b/src/lib/fresh-agent-context-usage.ts @@ -14,6 +14,21 @@ export type FreshAgentContextUsage = { thresholdTokens: number } +export type CodexLatestUsage = { observed: boolean; latestTokens?: number; totalTokens?: number; modelContextWindow?: number } + +/** Native last is a measured sample, not demonstrated current context occupancy. */ +export function latestCodexUsage(raw: unknown): CodexLatestUsage { + if (!raw || typeof raw !== 'object') return { observed: false } + const usage = raw as { last?: { totalTokens?: unknown }; total?: { totalTokens?: unknown }; modelContextWindow?: unknown } + const latestTokens = usage.last?.totalTokens + const totalTokens = usage.total?.totalTokens + if (typeof latestTokens !== 'number' || !Number.isSafeInteger(latestTokens) || latestTokens < 0 || typeof totalTokens !== 'number' || !Number.isSafeInteger(totalTokens) || totalTokens < 0) return { observed: false } + const window = usage.modelContextWindow + return { observed: true, latestTokens, totalTokens, + ...(typeof window === 'number' && Number.isSafeInteger(window) && window >= 0 ? { modelContextWindow: window } : {}), + } +} + export function findIndexedSessionById( projects: ProjectGroup[], provider: CodingCliProviderName, diff --git a/src/lib/fresh-agent-create-content.ts b/src/lib/fresh-agent-create-content.ts index a92b3fd6f5..e0a8412970 100644 --- a/src/lib/fresh-agent-create-content.ts +++ b/src/lib/fresh-agent-create-content.ts @@ -58,14 +58,15 @@ export function buildFreshAgentPaneContent({ ...(modelEffortLevels ? { modelEffortLevels } : {}), ...(permissionMode ? { permissionMode } : {}), sandbox: freshAgentType.runtimeProvider === 'codex' - ? codingCliSettings?.providers?.[freshAgentType.runtimeProvider]?.sandbox + ? providerSettings?.sandbox ?? codingCliSettings?.providers?.[freshAgentType.runtimeProvider]?.sandbox : undefined, // Undefined effort is meaningful for opencode live-catalog models: // the Default row (no variant) persists as the provider default. ...(createEffort ? { effort: createEffort } : {}), plugins: freshAgentType.runtimeProvider === 'claude' ? freshAgentSettings?.defaultPlugins : undefined, style: providerSettings?.style ?? DEFAULT_FRESH_AGENT_STYLE, - ...(cwd ? { initialCwd: cwd } : {}), + ...(cwd ?? providerSettings?.cwd ? { initialCwd: cwd ?? providerSettings?.cwd } : {}), + ...(freshAgentType.runtimeProvider === 'codex' ? { collaborationMode: providerSettings?.collaborationMode ?? 'default', effortIsNativeDefault: createEffort === undefined } : {}), ...(checkpointWorkspace ? { checkpointWorkspace } : {}), } } diff --git a/src/lib/fresh-agent-model-capabilities.ts b/src/lib/fresh-agent-model-capabilities.ts index 752b45ff30..7f0a6e6517 100644 --- a/src/lib/fresh-agent-model-capabilities.ts +++ b/src/lib/fresh-agent-model-capabilities.ts @@ -439,9 +439,9 @@ export function capFreshAgentModelSourceRows( /** * Map a provider's baked-in model menu into the live-catalog capability shape * so the shared model+thinking dialog treats baked-in and probed catalogs as - * data-only differences. freshcodex and the claude providers - * (freshclaude/kilroy) have baked-in static tables; freshopencode models come - * from the probed catalog alone. The claude arm carries no `source`, so + * data-only differences. Claude providers (freshclaude/kilroy) retain their + * static aliases. Codex requires a bound native catalog and OpenCode uses its + * probed catalog. The claude arm carries no `source`, so * statics and probed rows group together under the provider fallback exactly * as the probed claude catalog does. */ @@ -466,22 +466,5 @@ export function getFreshAgentStaticModelCapabilities( models, } } - if (sessionType !== 'freshcodex') return undefined - const models: FreshAgentModelCapability[] = FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex - .map((option) => ({ - id: option.value, - displayName: option.label, - provider: 'codex' as const, - source: { id: 'openai', displayName: 'openai' }, - supportsEffort: (option.thinkingEfforts?.length ?? 0) > 0, - supportedEffortLevels: [...(option.thinkingEfforts ?? [])], - supportsAdaptiveThinking: false, - })) - return { - sessionType: 'freshcodex', - runtimeProvider: 'codex', - status: 'fresh', - fetchedAt: 0, - models, - } + return undefined } diff --git a/src/lib/fresh-agent-permission-updates.ts b/src/lib/fresh-agent-permission-updates.ts index 769a352b30..c98aa89c24 100644 --- a/src/lib/fresh-agent-permission-updates.ts +++ b/src/lib/fresh-agent-permission-updates.ts @@ -10,6 +10,7 @@ import type { FreshAgentPermissionUpdate } from '@shared/fresh-agent-contract' export type FreshAgentApprovalDecision = + | { nativeDecision: 'accept' | 'acceptForSession' | 'decline' | 'cancel' } | { behavior: 'allow'; updatedPermissions?: FreshAgentPermissionUpdate[] } | { behavior: 'deny'; message: string; interrupt: false } diff --git a/src/lib/fresh-agent-registry.ts b/src/lib/fresh-agent-registry.ts index 7b120f9109..7ae34b5120 100644 --- a/src/lib/fresh-agent-registry.ts +++ b/src/lib/fresh-agent-registry.ts @@ -45,7 +45,7 @@ export type FreshAgentRegistryEntry = { icon: React.ComponentType<{ className?: string }> defaultModel?: string defaultPermissionMode: string - defaultEffort: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' + defaultEffort: string settingsVisibility: { model: boolean permissionMode: boolean @@ -53,6 +53,8 @@ export type FreshAgentRegistryEntry = { thinking: boolean tools: boolean timecodes: boolean + sandbox?: boolean + collaborationMode?: boolean } pickerShortcut: string hidden?: boolean @@ -88,6 +90,8 @@ export const FRESH_AGENT_REGISTRY: readonly FreshAgentRegistryEntry[] = [ defaultPermissionMode: 'on-request', defaultEffort: FRESHCODEX_DEFAULT_EFFORT, settingsVisibility: { + sandbox: true, + collaborationMode: true, model: true, permissionMode: true, effort: true, @@ -286,9 +290,10 @@ function usesStampedEffortLevels( * keeps the static-table normalization, unchanged. */ export function getEffectiveFreshAgentEffort( - content: Pick, + content: Pick, providerDefaults?: FreshAgentProviderDefaultsForModel, ): string | undefined { + if (content.provider === 'codex' && content.effortIsNativeDefault && !content.effort) return undefined const resolvedModel = resolveEffectiveFreshAgentModel(content, providerDefaults) if (usesStampedEffortLevels(content.sessionType, content.provider, resolvedModel, content.modelEffortLevels)) { return clampEffortToStampedLevels(content.modelEffortLevels, content.effort ?? providerDefaults?.effort) @@ -301,6 +306,16 @@ export function getEffectiveFreshAgentEffort( ) } +/** Public request spelling preserves an explicit Codex Default across resume + * and partial settings merges. Older panes still inherit provider defaults. */ +export function getFreshAgentRequestEffort( + content: Parameters[0], + providerDefaults?: FreshAgentProviderDefaultsForModel, +): string | null | undefined { + return getEffectiveFreshAgentEffort(content, providerDefaults) + ?? (content.provider === 'codex' && content.effortIsNativeDefault ? null : undefined) +} + /** * The effort a NEW pane starts with. Non-opencode providers fall back to the * registry default when nothing is staged. For opencode, `normalizeFreshAgentEffort` @@ -319,6 +334,7 @@ export function resolveFreshAgentPaneCreateEffort(args: { modelEffortLevels?: readonly string[] }): string | undefined { const { sessionType, provider, model, providerEffort, fallbackEffort, modelEffortLevels } = args + if (provider === 'codex') return normalizeFreshAgentEffort(sessionType,provider,model,providerEffort) if (usesStampedEffortLevels(sessionType, provider, model, modelEffortLevels)) { return clampEffortToStampedLevels(modelEffortLevels, providerEffort ?? fallbackEffort) } diff --git a/src/lib/fresh-agent-setting-scopes.ts b/src/lib/fresh-agent-setting-scopes.ts index 4e5fc003ee..37150c4154 100644 --- a/src/lib/fresh-agent-setting-scopes.ts +++ b/src/lib/fresh-agent-setting-scopes.ts @@ -5,13 +5,14 @@ export const SETTING_SCOPE_COPY = { 'create-only': 'Applies when a session starts — start a new conversation to change it.', } as const -export type FreshAgentSettingKnob = 'model' | 'effort' | 'sandbox' | 'permissionMode' +export type FreshAgentSettingKnob = 'model' | 'effort' | 'sandbox' | 'permissionMode' | 'cwd' | 'collaborationMode' /** Scope-driven hint. Older servers (field absent, or knob absent) fall back to * the pre-existing per-send copy: for a not-yet-created pane everything applies * at create, which that copy truthfully covers. */ export function settingScopeHint(scopes: FreshAgentSettingScopes | undefined, knob: FreshAgentSettingKnob): string { const scope = scopes?.[knob] + if (scope === 'unsupported') return 'This setting is unavailable for this session.' if (scope === 'create-only') return SETTING_SCOPE_COPY['create-only'] return SETTING_SCOPE_COPY['per-send'] } diff --git a/src/lib/fresh-agent-settings-convergence.ts b/src/lib/fresh-agent-settings-convergence.ts new file mode 100644 index 0000000000..24db3c6620 --- /dev/null +++ b/src/lib/fresh-agent-settings-convergence.ts @@ -0,0 +1,47 @@ +import type { FreshAgentPaneContent } from '@/store/paneTypes' + +const CODEX_FIELDS = ['model', 'effort', 'initialCwd', 'sandbox', 'permissionMode', 'collaborationMode'] as const +type Field = typeof CODEX_FIELDS[number] +type PaneSettings = Pick +export type CodexStatedSettings = Omit & { effort?: string | null } + +export type CodexSettingsConvergence = { + identity: string + pane: PaneSettings + stated: CodexStatedSettings + local: Set +} + +/** Reconcile stated session defaults without undoing a newer pane edit. + * A matching statement acknowledges that edit; absent older metadata says + * nothing. The cursor is local to one mounted pane, not a protocol clock. */ +export function convergeCodexSettings( + previous: CodexSettingsConvergence | undefined, + identity: string, + pane: PaneSettings, + stated: CodexStatedSettings, +): { cursor: CodexSettingsConvergence; updates: PaneSettings } { + const cursor: CodexSettingsConvergence = previous?.identity === identity + ? previous + : { identity, pane: { ...pane }, stated: {}, local: new Set() } + for (const field of CODEX_FIELDS) { + if (pane[field] !== cursor.pane[field]) cursor.local.add(field) + } + if (pane.effortIsNativeDefault !== cursor.pane.effortIsNativeDefault) cursor.local.add('effort') + const updates: Partial> = {} + let effortIsNativeDefault = pane.effortIsNativeDefault + for (const field of CODEX_FIELDS) { + const value = stated[field] + if (value === undefined || value === cursor.stated[field]) continue + Object.assign(cursor.stated, { [field]: value }) + const next = value === null ? undefined : value + if (cursor.local.has(field) && pane[field] !== next) continue + cursor.local.delete(field) + if (pane[field] !== next) updates[field] = next + if (field === 'effort') effortIsNativeDefault = value === null + } + const patch = updates as PaneSettings + if (effortIsNativeDefault !== pane.effortIsNativeDefault) patch.effortIsNativeDefault = effortIsNativeDefault + cursor.pane = { ...pane, ...patch } + return { cursor, updates: patch } +} diff --git a/src/lib/fresh-agent-transcript-projection.ts b/src/lib/fresh-agent-transcript-projection.ts new file mode 100644 index 0000000000..1ad79628d4 --- /dev/null +++ b/src/lib/fresh-agent-transcript-projection.ts @@ -0,0 +1,346 @@ +import { FreshAgentTranscriptPatchSchema, type FreshAgentSnapshot, type FreshAgentTranscriptItem, type FreshAgentTurn, type FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' + +export type TranscriptProjection = { + id: string + historyGeneration: number + eventRevision: number + turns: FreshAgentTurn[] + turnRows?: Record + closedItems?: Record + settledTurns?: Record + removedTurns?: Record + frozenTurns?: Record + turnRevisions?: Record + previewTurns?: Record + deferredOps?: FreshAgentTranscriptPatch['ops'] + deferredBytes?: number + needsResnapshot?: boolean + sessionId?: string + turnOrder?: string[] + nonAppendableItemIds?: Record +} + +const itemKey = (turnId: string, itemId: string) => JSON.stringify([turnId, itemId]) + +/** Read-only recovery context may coexist with event-owned append baselines. */ +export function applyFreshAgentTranscriptPatch(state: TranscriptProjection, input: unknown): TranscriptProjection { + const parsed = FreshAgentTranscriptPatchSchema.safeParse(input) + if (!parsed.success) return state + const patch = parsed.data + if (patch.projectionId !== state.id || patch.historyGeneration < state.historyGeneration) return state + // Appends can be coalesced across several revisions. The producer states + // their consumed prefix explicitly; a jump in the final revision is legal. + // After a gap, consumed revisions describe coverage owed to the client. + // They cannot authorize another append until a snapshot covers that prefix. + if (state.needsResnapshot || patch.afterRevision !== undefined && patch.afterRevision > state.eventRevision) { + return { ...state, needsResnapshot: true, eventRevision: Math.max(state.eventRevision, ...patch.ops.map((op) => op.revision)) } + } + const next: TranscriptProjection = patch.historyGeneration > state.historyGeneration + ? { id: state.id, historyGeneration: patch.historyGeneration, eventRevision: state.eventRevision, turns: [], turnRows: {}, closedItems: {}, settledTurns: {}, removedTurns: {}, turnRevisions: {}, deferredOps: [] } + : { ...state, turns: [...state.turns], turnRows: { ...state.turnRows }, closedItems: { ...state.closedItems }, settledTurns: { ...state.settledTurns }, removedTurns: { ...state.removedTurns }, turnRevisions: { ...state.turnRevisions }, deferredOps: [...state.deferredOps ?? []], turnOrder: [...state.turnOrder ?? []], nonAppendableItemIds: { ...state.nonAppendableItemIds } } + next.nonAppendableItemIds ??= {} + next.sessionId = patch.sessionId + for (const op of patch.ops) { + if (op.revision <= next.eventRevision) continue + next.eventRevision = op.revision + const nativeId = op.nativeTurnId + next.turnRevisions![nativeId] = op.revision + if (op.op === 'settleTurn') { + next.settledTurns![nativeId] = true + if (op.status === 'interrupted') { + const ids = new Set(next.turnRows![nativeId] ?? []) + next.turns = next.turns.map((row) => ids.has(row.turnId) ? { ...row, outputState: { provenance: 'retained', diagnostic: 'interrupted', refreshPending: false } } : row) + } + next.deferredOps = next.deferredOps?.filter((op) => op.nativeTurnId !== nativeId) + const prefix = JSON.stringify([nativeId]).slice(0, -1) + ',' + for (const key of Object.keys(next.closedItems!)) if (key.startsWith(prefix)) delete next.closedItems![key] + trimSettledTurns(next) + continue + } + if (next.removedTurns![nativeId]) continue + if (op.op === 'resetTurn') { + if (next.settledTurns![nativeId] && op.turns.length > 0) continue + next.turnOrder ??= [] + if (!next.turnOrder.includes(nativeId)) next.turnOrder.push(nativeId) + const previousIds = new Set(next.turnRows![nativeId] ?? []) + const insertion = next.turns.findIndex((row) => previousIds.has(row.turnId)) + next.turns = next.turns.filter((row) => !previousIds.has(row.turnId)) + next.turns.splice(insertion < 0 ? next.turns.length : insertion, 0, ...op.turns) + next.turnRows![nativeId] = op.turns.map((row) => row.turnId) + next.nonAppendableItemIds[nativeId] = op.nonAppendableItemIds ?? [] + if (op.turns.length === 0) next.removedTurns![nativeId] = true + for (const id of op.completedItemIds ?? []) next.closedItems![itemKey(nativeId, id)] = true + continue + } + if (next.settledTurns![nativeId]) continue + if (op.op === 'upsertItem') { + if (!next.turnRows![nativeId]) { + if (op.complete && next.previewTurns?.[nativeId]) next.turnRows![nativeId] = [] + else { deferOp(next, op); continue } + } + if (next.closedItems![itemKey(nativeId, op.item.id)]) continue + let index = next.turns.findIndex((row) => row.turnId === op.row.turnId) + if (index < 0) { + next.turns.push(op.row) + index = next.turns.length - 1 + next.turnRows![nativeId].push(op.row.turnId) + } + const row = next.turns[index] + const items = [...row.items] + const itemIndex = items.findIndex((item) => item.id === op.item.id) + if (itemIndex < 0) items.push(op.item) + else items[itemIndex] = op.item + next.turns[index] = { ...op.row, items } + next.nonAppendableItemIds[nativeId] = (next.nonAppendableItemIds[nativeId] ?? []).filter((id) => id !== op.item.id) + if (op.complete) next.closedItems![itemKey(nativeId, op.item.id)] = true + continue + } + if (next.closedItems![itemKey(nativeId, op.itemId)] || next.nonAppendableItemIds[nativeId]?.includes(op.itemId)) continue + if (!next.turnRows![nativeId]?.includes(op.rowId)) { deferOp(next, op); continue } + const rowIndex = next.turns.findIndex((row) => row.turnId === op.rowId) + if (rowIndex < 0) continue + const row = next.turns[rowIndex] + const index = row.items.findIndex((item) => item.id === op.itemId) + if (index < 0) continue + const item = row.items[index] + let updated: FreshAgentTranscriptItem | undefined + if (op.field === 'text' && (item.kind === 'text' || item.kind === 'thinking')) updated = { ...item, text: item.text + op.text } + else if (op.field === 'output' && (item.kind === 'command' || item.kind === 'file_change')) updated = { ...item, output: (item.output ?? '') + op.text } + else if ((op.field === 'summary' || op.field === 'content') && item.kind === 'reasoning' && typeof op.index === 'number') { + const parts = [...item[op.field]] + while (parts.length <= op.index) parts.push('') + parts[op.index] += op.text + const summary = op.field === 'summary' ? parts : item.summary + const content = op.field === 'content' ? parts : item.content + updated = { ...item, [op.field]: parts, text: (summary.length > 0 ? summary : content).join('\n') } + } + if (updated) { + const items = [...row.items]; items[index] = updated + next.turns[rowIndex] = { ...row, items } + } + } + return next +} + +function trimSettledTurns(state: TranscriptProjection): void { + const groups = Object.entries(state.turnRows ?? {}).filter(([id]) => state.settledTurns?.[id]) + .map(([id, ids]) => ({ id, ids: new Set(ids), revision: state.turnRevisions?.[id] ?? 0 })) + .sort((a, b) => a.revision - b.revision) + let bytes = groups.reduce((sum, group) => sum + JSON.stringify(state.turns.filter((row) => group.ids.has(row.turnId))).length, 0) + let count = groups.length + for (const group of groups) { + if (count <= 32 && bytes <= 8 * 1024 * 1024) break + bytes -= JSON.stringify(state.turns.filter((row) => group.ids.has(row.turnId))).length + state.turns = state.turns.filter((row) => !group.ids.has(row.turnId)) + delete state.turnRows![group.id] + count -= 1 + } +} + +export function freezeTranscriptProjection(state: TranscriptProjection): TranscriptProjection { + const interrupted = new Set() + for (const [id, rows] of Object.entries(state.turnRows ?? {})) { + if (!state.settledTurns?.[id]) for (const row of rows) interrupted.add(row) + } + const next: TranscriptProjection = { ...state, closedItems: {}, deferredOps: [], deferredBytes: 0, settledTurns: { ...state.settledTurns }, + turns: state.turns.map((row) => ({ ...row, outputState: { provenance: 'retained', diagnostic: interrupted.has(row.turnId) ? 'interrupted' : row.outputState?.diagnostic ?? 'unsaved', refreshPending: false } })), + } + for (const id of Object.keys(state.turnRows ?? {})) next.settledTurns![id] = true + trimSettledTurns(next) + return next +} + +// A reconnect can receive a delta during its first history RPC. Keep only +// these short-lived, unbased operations until an authoritative event prefix +// arrives. They never seed from a native preview or survive a terminal fence. +function deferOp(state: TranscriptProjection, op: FreshAgentTranscriptPatch['ops'][number]): void { + const ops = state.deferredOps ??= [] + const bytes = JSON.stringify(op).length + if (ops.length < 1024 && (state.deferredBytes ?? 0) + bytes <= 64 * 1024) { + ops.push(op) + state.deferredBytes = (state.deferredBytes ?? 0) + bytes + } else state.needsResnapshot = true +} + +export type ProjectionMetadata = { + id: string + eventRevision: number + historyGeneration: number + eventOwnedTurnIds: string[] + nativePreviewTurnIds: string[] + nativeFinalTurnIds: string[] + turnRows: Record + completedItems?: Record + nonAppendableItemIds?: Record + settledTurnIds?: string[] + turnOutputStates: Record +} + +export function bindTranscriptProjection(previous: TranscriptProjection | undefined, id: string, historyGeneration: number): TranscriptProjection { + if (previous?.id === id && previous.historyGeneration >= historyGeneration) return previous + const frozenTurns = previous && previous.id !== id + ? { ...previous.frozenTurns, ...Object.fromEntries(Object.entries(previous.turnRows ?? {}).map(([id, ids]) => [id, previous.turns.filter((row) => ids.includes(row.turnId))])) } + : undefined + if (frozenTurns) { + const entries = Object.entries(frozenTurns) + let bytes = entries.reduce((sum, [, rows]) => sum + JSON.stringify(rows).length, 0) + while (entries.length > 32 || bytes > 8 * 1024 * 1024) { + const oldest = entries.shift() + if (!oldest) break + bytes -= JSON.stringify(oldest[1]).length + delete frozenTurns[oldest[0]] + } + } + return { id, historyGeneration, eventRevision: 0, turns: [], turnRows: {}, sessionId: previous?.sessionId, ...(frozenTurns ? { frozenTurns } : {}) } +} + +const metadataCache = new WeakMap() + +export function readTranscriptProjection(snapshot: FreshAgentSnapshot): ProjectionMetadata | undefined { + const raw = snapshot.extensions?.codex?.projection + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const cached = metadataCache.get(raw) + if (cached) return cached + const value = raw as ProjectionMetadata + if (typeof value.id !== 'string' || !Number.isSafeInteger(value.eventRevision) || !Number.isSafeInteger(value.historyGeneration) + || ![value.eventOwnedTurnIds, value.nativePreviewTurnIds, value.nativeFinalTurnIds].every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === 'string')) + || !value.turnOutputStates || typeof value.turnOutputStates !== 'object' || Array.isArray(value.turnOutputStates)) return undefined + const ids = [...value.eventOwnedTurnIds, ...value.nativePreviewTurnIds, ...value.nativeFinalTurnIds] + const normalized = value.turnRows && typeof value.turnRows === 'object' && !Array.isArray(value.turnRows) ? value : { + ...value, + turnRows: Object.fromEntries(ids.map((id) => [id, snapshot.turns.filter((row) => rowBelongsToNativeTurn(row, id)).map((row) => row.turnId)])), + } + metadataCache.set(raw, normalized) + return normalized +} + +function rowBelongsToNativeTurn(row: FreshAgentTurn, id: string): boolean { + return row.nativeTurnId === id || row.turnId === id || (row.turnId.startsWith(`${id}:row-`) && /^\d+$/.test(row.turnId.slice(`${id}:row-`.length))) +} + +export function hydrateTranscriptProjection(state: TranscriptProjection, snapshot: FreshAgentSnapshot): TranscriptProjection { + const metadata = readTranscriptProjection(snapshot) + if (!metadata || metadata.id !== state.id || metadata.historyGeneration !== state.historyGeneration) return state + let next: TranscriptProjection = { ...state, turns: [...state.turns], turnRows: { ...state.turnRows }, turnRevisions: { ...state.turnRevisions }, closedItems: { ...state.closedItems }, settledTurns: { ...state.settledTurns }, nonAppendableItemIds: { ...state.nonAppendableItemIds }, previewTurns: Object.fromEntries(metadata.nativePreviewTurnIds.map((id) => [id, true as const])) } + for (const [id, items] of Object.entries(metadata.completedItems ?? {})) { + for (const item of items) next.closedItems![itemKey(id, item)] = true + } + for (const id of metadata.settledTurnIds ?? []) next.settledTurns![id] = true + for (const id of metadata.eventOwnedTurnIds) { + if ((state.turnRevisions?.[id] ?? 0) > metadata.eventRevision && (state.turnRows?.[id]?.length ?? 0) > 0) { + const readOnly = new Set(state.nonAppendableItemIds?.[id] ?? []) + const rows = new Set(metadata.turnRows[id] ?? []) + const saved = new Map(snapshot.turns.filter((row) => rows.has(row.turnId)).flatMap((row) => row.items.map((item) => [item.id, item] as const))) + const currentRows = new Set(state.turnRows?.[id] ?? []) + next.turns = next.turns.map((row) => currentRows.has(row.turnId) ? { ...row, items: row.items.map((item) => readOnly.has(item.id) ? saved.get(item.id) ?? item : item) } : row) + continue + } + const oldIds = new Set(next.turnRows![id] ?? []) + const ids = metadata.turnRows[id] ?? [] + const accepted = new Set(ids) + const rows = snapshot.turns.filter((row) => accepted.has(row.turnId)) + const at = next.turns.findIndex((row) => oldIds.has(row.turnId)) + next.turns = next.turns.filter((row) => !oldIds.has(row.turnId)) + next.turns.splice(at < 0 ? next.turns.length : at, 0, ...rows) + next.turnRows![id] = [...ids] + next.nonAppendableItemIds![id] = metadata.nonAppendableItemIds?.[id] ?? [] + next.turnRevisions![id] = metadata.eventRevision + } + const deferred = (state.deferredOps ?? []).filter((op) => op.revision > metadata.eventRevision && !next.settledTurns?.[op.nativeTurnId]) + if (deferred.length) next = applyFreshAgentTranscriptPatch({ ...next, eventRevision: metadata.eventRevision, deferredOps: [] }, { sessionId: snapshot.threadId, projectionId: state.id, historyGeneration: state.historyGeneration, ops: deferred }) + else next.deferredOps = [] + next.eventRevision = Math.max(state.eventRevision, metadata.eventRevision, next.eventRevision) + next.sessionId = snapshot.threadId + const groupOf = new Map(Object.entries(metadata.turnRows).flatMap(([id, rows]) => rows.map((row) => [row, id] as const))) + const order = [...new Set(snapshot.turns.map((row) => groupOf.get(row.turnId)).filter((id): id is string => Boolean(id)))] + const observed = state.turnOrder ?? [] + for (const [index, id] of observed.entries()) { + if (order.includes(id)) continue + const next = observed.slice(index + 1).find((candidate) => order.includes(candidate)) + order.splice(next ? order.indexOf(next) : order.length, 0, id) + } + next.turnOrder = order + if (metadata.eventRevision >= state.eventRevision) next.needsResnapshot = false + next.deferredBytes = JSON.stringify(next.deferredOps ?? []).length + return next +} + +function withOutputState(rows: FreshAgentTurn[], state: ProjectionMetadata['turnOutputStates'][string] | undefined): FreshAgentTurn[] { + if (!state || rows.length === 0 || (state.provenance === 'saved' && !state.diagnostic && !state.retainedTurns && !state.savedTurns)) return rows + const { retainedTurns, savedTurns, ...outputState } = state + return [{ ...rows[0], outputState: { ...outputState, + ...(retainedTurns ? { retainedItems: retainedTurns.flatMap((row) => row.items) } : {}), + ...(savedTurns ? { savedItems: savedTurns.flatMap((row) => row.items) } : {}), + } }, ...rows.slice(1)] +} + +/** Replace native-turn groups atomically. Read previews never consume event revisions. */ +export function mergeFreshAgentTranscriptProjection(snapshot: FreshAgentSnapshot, live?: TranscriptProjection): FreshAgentTurn[] { + const metadata = readTranscriptProjection(snapshot) + if (!metadata) return mergeSavedOnlyRetainedOutput(snapshot, live) + const currentLive = live?.id === metadata.id && live.historyGeneration === metadata.historyGeneration ? live : undefined + const liveRows = currentLive?.turnRows ?? {} + const nativeRows = metadata.turnRows ?? {} + const groupOf = new Map(Object.entries(nativeRows).flatMap(([id, rows]) => rows.map((row) => [row, id] as const))) + const savedGroups = new Map() + for (const row of snapshot.turns) { + const id = groupOf.get(row.turnId) + if (id) { const rows = savedGroups.get(id) ?? []; rows.push(row); savedGroups.set(id, rows) } + } + const result: FreshAgentTurn[] = [] + const seen = new Set() + const finals = new Set(metadata.nativeFinalTurnIds) + const previews = new Set(metadata.nativePreviewTurnIds) + const appendGroup = (id: string, saved: FreshAgentTurn[]) => { + if (seen.has(id)) return + seen.add(id) + const eventIds = new Set(liveRows[id] ?? []) + const eventRows = currentLive?.turns.filter((row) => eventIds.has(row.turnId)) ?? [] + const complete = eventRows.some((row) => row.items.some((item) => currentLive?.closedItems?.[itemKey(id, item.id)])) + const frozenState = eventRows.find((row) => row.outputState?.provenance === 'retained' && !row.outputState.refreshPending)?.outputState + const eventWins = eventRows.length > 0 && !finals.has(id) + && (saved.length === 0 || frozenState || ((currentLive!.turnRevisions?.[id] ?? 0) > metadata.eventRevision && (!previews.has(id) || complete))) + const rows = eventWins ? eventRows : saved + let state: ProjectionMetadata['turnOutputStates'][string] | undefined = (eventWins && frozenState ? frozenState : undefined) ?? metadata.turnOutputStates?.[id] + ?? (eventWins ? { provenance: 'retained' as const, refreshPending: Boolean(currentLive?.settledTurns?.[id]) } : undefined) + // Legacy recovery message ids may be reconstructed. Keep its unmatched + // context available without guessing which read item a live item replaces. + if (eventWins && complete && previews.has(id) && saved.length && !state?.savedTurns) { + state = { ...state!, savedTurns: saved } + } + const frozen = currentLive?.frozenTurns?.[id] + if (frozen?.length && (previews.has(id) || state?.diagnostic === 'interrupted') && !state?.retainedTurns) { + state = { provenance: 'saved-and-retained', diagnostic: 'interrupted', refreshPending: false, retainedTurns: frozen } + } + result.push(...withOutputState(rows, state)) + } + for (const row of snapshot.turns) { + const id = groupOf.get(row.turnId) + if (!id) { result.push(row); continue } + appendGroup(id, savedGroups.get(id) ?? []) + } + const observed = currentLive?.turnOrder ?? Object.keys(liveRows) + for (const [index, id] of observed.entries()) { + if (seen.has(id) || currentLive?.removedTurns?.[id] || finals.has(id)) continue + const before = result.length + appendGroup(id, []) + const added = result.splice(before) + const anchor = observed.slice(index + 1).find((candidate) => seen.has(candidate) && ((nativeRows[candidate]?.length ?? 0) > 0 || (liveRows[candidate]?.length ?? 0) > 0)) + const anchorRows = new Set(anchor ? nativeRows[anchor] ?? liveRows[anchor] ?? [] : []) + const insertion = result.findIndex((row) => anchorRows.has(row.turnId)) + result.splice(insertion < 0 ? result.length : insertion, 0, ...added) + } + return result +} + +function mergeSavedOnlyRetainedOutput(snapshot: FreshAgentSnapshot, live?: TranscriptProjection): FreshAgentTurn[] { + if (snapshot.provider !== 'codex' || live?.sessionId !== snapshot.threadId || snapshot.extensions?.codex?.nativeHistoryAvailable !== true || snapshot.extensions.codex.ownerKind !== 'vacant') return snapshot.turns + const rows = [...snapshot.turns] + const groups = { ...live.frozenTurns, ...Object.fromEntries(Object.entries(live.turnRows ?? {}).map(([id, ids]) => [id, live.turns.filter((row) => ids.includes(row.turnId))])) } + for (const [id, retained] of Object.entries(groups)) { + const index = rows.findIndex((row) => rowBelongsToNativeTurn(row, id)) + if (index < 0 || !retained.length || (rows[index].outputState?.diagnostic !== 'interrupted' && !retained.some((row) => row.outputState?.diagnostic === 'interrupted'))) continue + rows[index] = { ...rows[index], outputState: { provenance: 'saved-and-retained', diagnostic: 'interrupted', refreshPending: false, retainedItems: retained.flatMap((row) => row.items) } } + } + return rows +} diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index c2a4c65a9e..d4cd30d887 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -1,4 +1,5 @@ import type { AppDispatch } from '@/store/store' +import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '@shared/fresh-agent' import type { SessionRef } from '@shared/session-contract' import type { FreshAgentRecoveryStoppedMessage, ReadyMessage, SessionRuntimeOwnerMessage } from '@shared/ws-protocol' @@ -7,15 +8,18 @@ import { consumeCancelledCreate, consumeCreateRoute, rememberCreateRoute } from import { flushPersistedLayoutNow } from '@/store/persistControl' import { KILL_FAILED_MESSAGE } from '@/lib/kill-ack' import { materializeFreshAgentSession as materializeFreshAgentPaneSession } from '@/store/panesSlice' -import { applyFreshAgentCompletion, applyFreshAgentWaiting, observeFreshAgentSession, rekeyFreshAgentAttention } from '@/store/turnCompletionThunks' +import { applyFreshAgentCompletion, applyFreshAgentWaiting, applyFreshAgentQuestionAvailable, observeFreshAgentSession, rekeyFreshAgentAttention } from '@/store/turnCompletionThunks' import { revokeFreshAgentAttention } from '@/store/turnCompletionAttention' import type { ObservedOwnerFence } from '@/store/selectors/runtimeOwner' import { streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' import type { LiveBlockKind, LiveNotice, LiveNoticeLevel, LiveRateLimit, LiveRetry, LiveStreamOp } from '@/store/freshAgentLiveTypes' -import { assistantBlockBound, liveNoticeReceived, liveRetrySet, transcriptAnnounced } from '@/store/freshAgentLiveSlice' +import { assistantBlockBound, liveNoticeReceived, liveRetrySet, transcriptAnnounced, transcriptProjectionBound, transcriptPatchApplied, codexMetadataReceived, transcriptProjectionSnapshotReceived } from '@/store/freshAgentLiveSlice' +import { FreshAgentSnapshotSchema, FreshAgentTranscriptPatchSchema } from '@shared/fresh-agent-contract' import { addPermissionRequest, addQuestionRequest, + addElicitationRequest, + removeElicitation, applyRuntimeOwner, clearPendingCreateFailure, createFailed, @@ -469,6 +473,48 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh if (event.type !== 'freshAgent.stream') streamBatcherFor(dispatch).flush(locator) switch (event.type) { + case 'freshAgent.host.backpressure': + case 'freshAgent.host.journal.reset': { + const retained = event.retainedState + if (!isObject(retained) || !isObject(retained.source)) return true + const source = retained.source + const parsed = FreshAgentSnapshotSchema.safeParse(retained.snapshot) + if (!parsed.success || parsed.data.provider !== locator.provider || parsed.data.sessionType !== locator.sessionType + || parsed.data.threadId !== (source.nativeSessionId ?? sessionId) + || !isObject(parsed.data.extensions?.codex?.projection) + || parsed.data.extensions.codex.projection.id !== source.projectionId) return true + const snapshot = parsed.data + const sourceKey = JSON.stringify([source.soulId, source.incarnationId, source.hostBootId, source.projectionId]) + dispatch((innerDispatch, getState) => { + const root = getState() + const owner = root.freshAgent.runtimeOwners?.[`${locator.provider}:${sessionId}`] + if (owner && (owner.epoch !== source.ownerEpoch || owner.generation !== source.ownerGeneration || owner.ownerKind !== 'fresh-agent')) return + const soul = root.managedRuntime?.souls.find((soul) => soul.soulId === source.soulId) + if (soul && (soul.incarnationId !== source.incarnationId || soul.hostBootId && soul.hostBootId !== source.hostBootId)) return + innerDispatch(transcriptProjectionSnapshotReceived({ ...locator, snapshot, retainedSourceKey: sourceKey })) + const live = getState().freshAgentLive?.bySession[makeFreshAgentSessionKey(locator)] + if (live?.retainedSourceKey !== sourceKey || live.retainedSnapshot !== snapshot) return + const status = (['creating', 'starting', 'connected', 'running', 'idle', 'compacting', 'exited', 'stuck'] as const).find((status) => status === snapshot.status) + if (status) innerDispatch(sessionSnapshotReceived({ ...locator, status, latestTurnId: snapshot.latestTurnId ?? null, revision: snapshot.revision })) + for (const control of Array.isArray(retained.pendingControls) ? retained.pendingControls : []) { + if (isObject(control)) handleFreshAgentTransportEvent(innerDispatch, { ...msg, event: control }) + } + for (const mark of Array.isArray(retained.attentionMarks) ? retained.attentionMarks : []) { + if (!isObject(mark)) continue + for (const [field, apply] of [['lastTurnCompleteAt', applyFreshAgentCompletion], ['lastWaitingAt', applyFreshAgentWaiting], ['lastQuestionAvailableAt', applyFreshAgentQuestionAvailable]] as const) { + const at = mark[field] + if (typeof at === 'number' && Number.isFinite(at)) innerDispatch(apply({ provider: locator.provider, sessionId, at })) + } + } + }) + return true + } + case 'freshAgent.transcript.patch': { + const parsed = FreshAgentTranscriptPatchSchema.safeParse(event) + if (parsed.success && parsed.data.sessionId === sessionId) dispatch(transcriptPatchApplied({ ...locator, patch: parsed.data })) + else log.warn('dropping malformed native transcript patch', { sessionId }) + return true + } case 'freshAgent.session.snapshot': dispatch(observeFreshAgentSession(locator)) dispatch(sessionSnapshotReceived({ @@ -488,6 +534,10 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh } return true case 'freshAgent.session.init': + if (typeof event.projectionId === 'string' && Number.isSafeInteger(event.historyGeneration)) { + dispatch(transcriptProjectionBound({ ...locator, projectionId: event.projectionId, historyGeneration: event.historyGeneration as number })) + if (!['cliSessionId', 'model', 'effort', 'cwd', 'tools'].some((key) => key in event)) return true + } dispatch(sessionInit({ ...locator, cliSessionId: event.cliSessionId as string | undefined, @@ -498,6 +548,7 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh })) return true case 'freshAgent.session.metadata': + if (isObject(event.codex)) dispatch(codexMetadataReceived({ ...locator, metadata: event.codex })) dispatch(sessionMetadataReceived({ ...locator, cliSessionId: event.cliSessionId as string | undefined, @@ -505,6 +556,8 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh effort: event.effort as string | null | undefined, // Absent = no statement (as for model/effort); only a mode name states one. ...(typeof event.permissionMode === 'string' && event.permissionMode ? { permissionMode: event.permissionMode } : {}), + ...(['read-only', 'workspace-write', 'danger-full-access'].includes(event.sandbox as string) ? { sandbox: event.sandbox as 'read-only' | 'workspace-write' | 'danger-full-access' } : {}), + ...(event.collaborationMode === 'default' || event.collaborationMode === 'plan' ? { collaborationMode: event.collaborationMode } : {}), cwd: event.cwd as string | undefined, tools: event.tools as Array<{ name: string }> | undefined, })) @@ -530,6 +583,10 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh })) return true } + case 'freshAgent.question.available': { + if (typeof event.at === 'number' && Number.isFinite(event.at)) dispatch(applyFreshAgentQuestionAvailable({ provider: locator.provider, sessionId, at: event.at })) + return true + } case 'freshAgent.turn.waiting': { if (typeof event.at !== 'number' || !Number.isFinite(event.at)) { log.warn('dropping malformed freshAgent.turn.waiting without a numeric at', { sessionId, at: event.at }) @@ -613,6 +670,7 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh requestId: event.requestId as string, toolName: tool?.name, input: tool?.input, + ...(event.native && typeof event.native === 'object' ? { native: event.native as Record } : {}), providerRequest: { subtype: event.subtype, tool, @@ -631,9 +689,16 @@ export function handleFreshAgentTransportEvent(dispatch: AppDispatch, msg: Fresh ...locator, requestId: event.requestId as string, questions: event.questions as never, + ...(typeof event.isBlocking === 'boolean' ? { isBlocking: event.isBlocking } : {}), providerRequest: event, })) return true + case 'freshAgent.elicitation.request': + dispatch(addElicitationRequest({ ...locator, ...event, requestId: event.requestId as string } as never)) + return true + case 'freshAgent.elicitation.cancelled': + dispatch(removeElicitation({ ...locator, requestId: event.requestId as string })) + return true case 'freshAgent.question.cancelled': // Provider-originated cancellation: the Rust claude/kilroy slice forwards // the sidecar's question-cancel edge as this event type. Clear the card diff --git a/src/lib/ready-message-schema.ts b/src/lib/ready-message-schema.ts index 7a7969c983..84097e485c 100644 --- a/src/lib/ready-message-schema.ts +++ b/src/lib/ready-message-schema.ts @@ -31,6 +31,7 @@ export const ReadyMessageSchema = z.object({ sessionType: z.string().optional(), lastTurnCompleteAt: z.number().finite().optional(), lastWaitingAt: z.number().finite().optional(), + lastQuestionAvailableAt: z.number().finite().optional(), })).optional().catch(undefined), // kata b8ke: the handshake's runtime-owner replay (additive/optional — // old servers omit it). Same ready-frame doctrine: `.catch(undefined)` diff --git a/src/store/freshAgentLiveSlice.ts b/src/store/freshAgentLiveSlice.ts index 99fa62bfdd..071eae552c 100644 --- a/src/store/freshAgentLiveSlice.ts +++ b/src/store/freshAgentLiveSlice.ts @@ -1,6 +1,8 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' import { isLiveBlockLanded, landedTranscript, type LandedTranscript } from '@/lib/fresh-agent-landed-transcript' +import { applyFreshAgentTranscriptPatch, bindTranscriptProjection, freezeTranscriptProjection, hydrateTranscriptProjection, readTranscriptProjection } from '@/lib/fresh-agent-transcript-projection' +import type { FreshAgentSnapshot, FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' import { markSessionLost, materializeSession, @@ -44,6 +46,15 @@ function sessionFor(state: FreshAgentLiveState, locator: FreshAgentSessionLocato return state.bySession[key] } +function retireRetainedSource(session: FreshAgentLiveSession): void { + if (session.retainedSourceKey) { + session.retiredRetainedSourceKeys ??= {} + session.retiredRetainedSourceKeys[session.retainedSourceKey] = true + } + delete session.retainedSourceKey + delete session.retainedSnapshot +} + /** Session keys are `${sessionType}:${provider}:${sessionId}`; the id may itself contain ':'. */ function sessionIdOfKey(key: string): string { return key.split(':').slice(2).join(':') @@ -66,6 +77,9 @@ function clearTail(session: FreshAgentLiveSession): void { if (session.blocks.length > 0) session.blocks = [] if (session.expectedTurnIds.length > 0) session.expectedTurnIds = [] if (session.retry) delete session.retry + delete session.unboundTranscriptPatches + delete session.unboundTranscriptWatermarks + if (session.projection) session.projection = freezeTranscriptProjection(session.projection) } function clearTailsFor(state: FreshAgentLiveState, payload: SessionMutationPayload): void { @@ -83,7 +97,7 @@ function isViewed(state: FreshAgentLiveState, key: string): boolean { */ function endTurnsFor(state: FreshAgentLiveState, payload: SessionMutationPayload): void { for (const key of tailKeysFor(state, payload)) { - if (!isViewed(state, key)) { + if (!isViewed(state, key) && !state.bySession[key].projection) { delete state.bySession[key] continue } @@ -151,6 +165,94 @@ const freshAgentLiveSlice = createSlice({ name: 'freshAgentLive', initialState, reducers: { + /** Authoritative init/snapshot binding; arbitrary patches cannot change incarnation. */ + transcriptProjectionBound(state, action: PayloadAction) { + const { projectionId, historyGeneration, ...locator } = action.payload + const session = sessionFor(state, locator) + const previous = session.projection + // A later incarnation is accepted through the view's fenced snapshot + // request. Delayed init frames cannot replace an already-bound source. + if (previous && previous.id !== projectionId) return + if (previous?.id === projectionId && previous.historyGeneration >= historyGeneration) return + session.projection = bindTranscriptProjection(previous, projectionId, historyGeneration) + for (const patch of session.unboundTranscriptPatches ?? []) { + if (patch.projectionId === projectionId) session.projection = applyFreshAgentTranscriptPatch(session.projection, patch) + } + const watermark = session.unboundTranscriptWatermarks?.[JSON.stringify([projectionId, historyGeneration])] + if (watermark?.gap) { + session.projection.needsResnapshot = true + session.projection.eventRevision = Math.max(session.projection.eventRevision, watermark.revision) + } + delete session.unboundTranscriptPatches + delete session.unboundTranscriptWatermarks + }, + transcriptPatchApplied(state, action: PayloadAction) { + const { patch, ...locator } = action.payload + const session = sessionFor(state, locator) + if (session.projection?.id === patch.projectionId) session.projection = applyFreshAgentTranscriptPatch(session.projection, patch) + else { + // Buffer a bounded reconnect race without adopting its source. The + // authoritative init/snapshot chooses which incarnation may replay. + const pending = session.unboundTranscriptPatches ??= [] + const watermarks = session.unboundTranscriptWatermarks ??= {} + const key = JSON.stringify([patch.projectionId, patch.historyGeneration]) + const watermark = watermarks[key] ??= { revision: 0, gap: false } + watermark.revision = Math.max(watermark.revision, ...patch.ops.map((op) => op.revision)) + if (pending.length < 64 && JSON.stringify(pending).length + JSON.stringify(patch).length <= 64 * 1024) pending.push(patch) + else watermark.gap = true + } + }, + transcriptProjectionSnapshotReceived(state, action: PayloadAction) { + const { snapshot, retainedSourceKey, ...locator } = action.payload + const metadata = readTranscriptProjection(snapshot) + if (!metadata) return + const session = sessionFor(state, locator) + if (session.retiredProjectionIds?.[metadata.id] === true) return + if (retainedSourceKey) { + if (session.retiredRetainedSourceKeys?.[retainedSourceKey] === true) return + if (session.projection?.id === metadata.id && (session.projection.historyGeneration > metadata.historyGeneration + || session.projection.historyGeneration === metadata.historyGeneration && session.projection.eventRevision > metadata.eventRevision)) return + if (session.retainedSourceKey && session.retainedSourceKey !== retainedSourceKey) { + retireRetainedSource(session) + } + session.retainedSourceKey = retainedSourceKey + session.retainedSnapshot = snapshot + } + if (session.projection && session.projection.id !== metadata.id) { + session.retiredProjectionIds ??= {} + session.retiredProjectionIds[session.projection.id] = true + if (!retainedSourceKey) retireRetainedSource(session) + } + if (!session.projection || session.projection.id !== metadata.id || session.projection.historyGeneration < metadata.historyGeneration) { + session.projection = bindTranscriptProjection(session.projection, metadata.id, metadata.historyGeneration) + } + session.projection = hydrateTranscriptProjection(session.projection, snapshot) + for (const patch of session.unboundTranscriptPatches ?? []) { + if (patch.projectionId === metadata.id) session.projection = applyFreshAgentTranscriptPatch(session.projection, patch) + } + const watermark = session.unboundTranscriptWatermarks?.[JSON.stringify([metadata.id, metadata.historyGeneration])] + if (watermark?.gap && metadata.eventRevision < watermark.revision) { + session.projection.needsResnapshot = true + session.projection.eventRevision = Math.max(session.projection.eventRevision, watermark.revision) + } + delete session.unboundTranscriptPatches + delete session.unboundTranscriptWatermarks + const projection = session.projection + if (projection.historyGeneration !== metadata.historyGeneration) return + const accepted = new Set([...metadata.eventOwnedTurnIds, ...metadata.nativePreviewTurnIds, ...metadata.nativeFinalTurnIds]) + for (const id of Object.keys(projection.frozenTurns ?? {})) if (!accepted.has(id)) delete projection.frozenTurns![id] + for (const id of metadata.nativeFinalTurnIds ?? []) { + (projection.settledTurns ??= {})[id] = true + const rows = new Set(projection.turnRows?.[id] ?? []) + projection.turns = projection.turns.filter((row) => !rows.has(row.turnId)) + if (projection.turnRows) delete projection.turnRows[id] + if (metadata.turnOutputStates[id]?.diagnostic !== 'interrupted' && projection.frozenTurns) delete projection.frozenTurns[id] + } + }, + codexMetadataReceived(state, action: PayloadAction }>) { + const { metadata, ...locator } = action.payload + sessionFor(state, locator).codexMetadata = metadata + }, /** A batch of stream events for one session, in arrival order. */ streamOpsApplied(state, action: PayloadAction) { const { ops, ...locator } = action.payload @@ -297,6 +399,10 @@ const freshAgentLiveSlice = createSlice({ }) export const { + transcriptProjectionBound, + transcriptPatchApplied, + transcriptProjectionSnapshotReceived, + codexMetadataReceived, assistantBlockBound, liveNoticeReceived, liveRetrySet, diff --git a/src/store/freshAgentLiveTypes.ts b/src/store/freshAgentLiveTypes.ts index cd81f9f0a2..cf419381c6 100644 --- a/src/store/freshAgentLiveTypes.ts +++ b/src/store/freshAgentLiveTypes.ts @@ -1,9 +1,9 @@ /** * The live tail of a fresh-agent session (transcript-live plan §4.4): what a - * running turn shows before its transcript lines land in a snapshot. Durable - * content always comes from the snapshot (principle P1); this state holds - * only what the transcript cannot hold yet — the blocks being streamed, the - * API retry in progress, and the provider notices that never end a turn. It + * running turn shows before its transcript lines land in a snapshot. Claude + * blocks and Codex event-owned bodies stay here, with explicit provenance + * when native saving differs or is unavailable. API retries and provider + * notices never end a turn. This state * lives under its own root key so token-rate updates never touch * `state.freshAgent.sessions` (P3). */ @@ -82,7 +82,20 @@ export type LiveNotice = { rateLimit?: LiveRateLimit } +import type { TranscriptProjection } from '@/lib/fresh-agent-transcript-projection' +import type { FreshAgentSnapshot, FreshAgentTranscriptPatch } from '@shared/fresh-agent-contract' + export type FreshAgentLiveSession = { + retainedSnapshot?: FreshAgentSnapshot + retainedSourceKey?: string + /** Small identity tombstones live until this session scope is removed. + * Transcript bodies and patch buffers have their own finite bounds. */ + retiredRetainedSourceKeys?: Record + retiredProjectionIds?: Record + projection?: TranscriptProjection + codexMetadata?: Record + unboundTranscriptPatches?: FreshAgentTranscriptPatch[] + unboundTranscriptWatermarks?: Record blocks: LiveBlock[] retry?: LiveRetry /** Transcript line uuids announced by the CLI and not yet seen in a snapshot. */ diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 1e7b4bd355..a18d65da16 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -9,6 +9,7 @@ import { isValidClaudeSessionId } from '@/lib/claude-session-id' import type { SessionRuntimeOwnerMessage } from '@shared/ws-protocol' import type { FreshAgentPermissionRequest, + FreshAgentRequestId, FreshAgentQuestionRequest, FreshAgentSessionState, FreshAgentSessionStatus, @@ -73,6 +74,7 @@ function createSession(locator: FreshAgentSessionPayload, status: FreshAgentSess streamingActive: false, pendingPermissions: {}, pendingQuestions: {}, + pendingElicitations: {}, totalCostUsd: 0, totalInputTokens: 0, totalOutputTokens: 0, @@ -229,6 +231,8 @@ const freshAgentSlice = createSlice({ model?: string | null effort?: string | null permissionMode?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + collaborationMode?: 'default' | 'plan' cwd?: string tools?: Array<{ name: string }> }>) { @@ -253,6 +257,8 @@ const freshAgentSlice = createSlice({ if (action.payload.model !== undefined) session.model = action.payload.model if (action.payload.effort !== undefined) session.effort = action.payload.effort if (action.payload.permissionMode !== undefined) session.permissionMode = action.payload.permissionMode + if (action.payload.sandbox !== undefined) session.sandbox = action.payload.sandbox + if (action.payload.collaborationMode !== undefined) session.collaborationMode = action.payload.collaborationMode session.cwd = action.payload.cwd ?? session.cwd session.tools = action.payload.tools ?? session.tools if (metadataCliSessionId && !wouldDowngradeSnapshotIdentity) { @@ -377,6 +383,7 @@ const freshAgentSlice = createSlice({ session.pendingQuestions = Object.fromEntries( snapshot.pendingQuestions.map((question) => [String(question.requestId), question]), ) + session.pendingElicitations = Object.fromEntries((snapshot.pendingElicitations ?? []).map((request) => [String(request.requestId), request])) session.totalInputTokens = snapshot.tokenUsage.inputTokens session.totalOutputTokens = snapshot.tokenUsage.outputTokens session.totalCostUsd = snapshot.tokenUsage.costUsd ?? 0 @@ -426,6 +433,15 @@ const freshAgentSlice = createSlice({ delete state.sessions[key].pendingQuestions[String(action.payload.requestId)] }, + addElicitationRequest(state, action: PayloadAction[string]>) { + const session = resolveOrEnsureSession(state, action.payload) + if (session) (session.pendingElicitations ??= {})[String(action.payload.requestId)] = action.payload + }, + removeElicitation(state, action: PayloadAction) { + const key = resolveSessionKey(state, action.payload) + if (key) delete state.sessions[key].pendingElicitations?.[String(action.payload.requestId)] + }, + sessionError(state, action: PayloadAction) { const session = resolveOrEnsureSession(state, action.payload) if (!session) return @@ -712,6 +728,8 @@ const freshAgentSlice = createSlice({ export const { addPermissionRequest, addQuestionRequest, + addElicitationRequest, + removeElicitation, applyRuntimeOwner, applyRuntimeOwnerFenceRefresh, applyRefusalFence, diff --git a/src/store/freshAgentTypes.ts b/src/store/freshAgentTypes.ts index 855c23a8b6..35e569de49 100644 --- a/src/store/freshAgentTypes.ts +++ b/src/store/freshAgentTypes.ts @@ -5,6 +5,7 @@ import type { import type { FreshAgentPendingApproval, FreshAgentPendingQuestion, + FreshAgentPendingElicitation, FreshAgentRequestId, FreshAgentSnapshot, FreshAgentTurn, @@ -112,6 +113,8 @@ export type FreshAgentSessionState = FreshAgentSessionLocator & { * changed it itself (an approved ExitPlanMode leaves plan mode). Absent * while no metadata ever stated it. */ permissionMode?: string + sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + collaborationMode?: 'default' | 'plan' tools?: Array<{ name: string }> turns: FreshAgentTurn[] historyItems: FreshAgentTurn[] @@ -123,6 +126,7 @@ export type FreshAgentSessionState = FreshAgentSessionLocator & { streamingActive: boolean pendingPermissions: Record pendingQuestions: Record + pendingElicitations?: Record totalCostUsd: number totalInputTokens: number totalOutputTokens: number diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 04f9288750..cc5f7bfba2 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -495,6 +495,8 @@ export type FreshAgentPaneContent = { status: SdkSessionStatus resumeSessionId?: string sessionRef?: SessionLocator + /** Parent-scoped display selection; never a child runtime identity. */ + observedChildThreadId?: string /** Runtime-only server locality for same-server matching; never part of canonical durable identity. */ serverInstanceId?: string /** Explicit restore failure when no canonical durable target exists. */ @@ -511,6 +513,9 @@ export type FreshAgentPaneContent = { model?: string permissionMode?: string sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access' + collaborationMode?: 'default' | 'plan' + /** A stated native default differs from an older pane inheriting provider defaults. */ + effortIsNativeDefault?: boolean effort?: string /** Effort levels for the current model as known at selection time (static * or probed catalog), stamped by the settings selector's model switch. An diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index a9017b4e05..ba5fbc4a4b 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -207,6 +207,7 @@ function normalizePaneContent( const modelEffortLevels = normalizeFreshAgentModelEffortLevels(rawFreshAgent.modelEffortLevels) const freshHandoffError = normalizeHandoffError(rawFreshAgent.handoffError) const checkpointWorkspace = normalizeCheckpointWorkspace(rawFreshAgent.checkpointWorkspace) + const observedChildThreadId = input.provider === 'codex' && typeof rawFreshAgent.observedChildThreadId === 'string' && rawFreshAgent.observedChildThreadId.trim() ? rawFreshAgent.observedChildThreadId : undefined const outcomeConversationRequestId = typeof rawFreshAgent.createRequestId === 'string' && rawFreshAgent.createRequestId ? rawFreshAgent.createRequestId @@ -274,6 +275,7 @@ function normalizePaneContent( initialCwd: input.initialCwd, ...(checkpointWorkspace ? { checkpointWorkspace } : {}), ...(checkpointOutcomes ? { checkpointOutcomes } : {}), + ...(observedChildThreadId ? { observedChildThreadId } : {}), createError: input.createError, closeError, modelSelection: normalizeFreshAgentModelSelection( @@ -284,6 +286,8 @@ function normalizePaneContent( ...(modelLabel ? { modelLabel } : {}), permissionMode: input.permissionMode, sandbox: input.sandbox, + collaborationMode: input.collaborationMode === 'plan' || input.collaborationMode === 'default' ? input.collaborationMode : undefined, + effortIsNativeDefault: typeof input.effortIsNativeDefault === 'boolean' ? input.effortIsNativeDefault : undefined, effort: normalizeFreshAgentEffortOverride(input.effort), ...(modelEffortLevels ? { modelEffortLevels } : {}), plugins: input.plugins, @@ -361,6 +365,7 @@ function normalizePaneContent( initialCwd: input.initialCwd, ...(checkpointWorkspace ? { checkpointWorkspace } : {}), ...(checkpointOutcomes ? { checkpointOutcomes } : {}), + ...(observedChildThreadId ? { observedChildThreadId } : {}), createError: input.createError, modelSelection: normalizeFreshAgentModelSelection( (input as { modelSelection?: unknown }).modelSelection, @@ -370,6 +375,8 @@ function normalizePaneContent( ...(modelLabel ? { modelLabel } : {}), permissionMode: input.permissionMode, sandbox: input.sandbox, + collaborationMode: input.collaborationMode === 'plan' || input.collaborationMode === 'default' ? input.collaborationMode : undefined, + effortIsNativeDefault: typeof input.effortIsNativeDefault === 'boolean' ? input.effortIsNativeDefault : undefined, effort: normalizeFreshAgentEffortOverride(input.effort), ...(modelEffortLevels ? { modelEffortLevels } : {}), plugins: input.plugins, diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index be8e12c06a..f6529bf9f9 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -351,6 +351,21 @@ function normalizeTerminalContent(content: Record): Record): Record { + if ('collaborationMode' in content && content.collaborationMode !== 'default' && content.collaborationMode !== 'plan') { + const { collaborationMode: _invalidMode, ...rest } = content + content = rest + } + if ('effortIsNativeDefault' in content && typeof content.effortIsNativeDefault !== 'boolean') { + const { effortIsNativeDefault: _invalidEffortIntent, ...rest } = content + content = rest + } + + // Only the native child selection is durable. Bodies and ownership fences + // are read again through the parent's current provider after reload. + if ('observedChildThreadId' in content && (content.provider !== 'codex' || typeof content.observedChildThreadId !== 'string' || !content.observedChildThreadId.trim())) { + const { observedChildThreadId: _invalidChild, ...rest } = content + content = rest + } const checkpointOutcomes = normalizeFreshAgentCheckpointOutcomes( content.checkpointOutcomes, typeof content.createRequestId === 'string' ? content.createRequestId : undefined, diff --git a/src/store/turnCompletionThunks.ts b/src/store/turnCompletionThunks.ts index 47ef6a952f..f1bc006dc2 100644 --- a/src/store/turnCompletionThunks.ts +++ b/src/store/turnCompletionThunks.ts @@ -16,8 +16,10 @@ function completionDedupeKey(provider: string, sessionId: string): string { return `${provider}:${sessionId}` } -function waitingDedupeKey(provider: string, sessionId: string): string { - return `${completionDedupeKey(provider, sessionId)}#waiting` +type FreshAgentAttentionKind = 'complete' | 'waiting' | 'question' +const FRESH_AGENT_ATTENTION_KINDS = ['complete', 'waiting', 'question'] as const +function attentionDedupeKey(provider: string, sessionId: string, kind: FreshAgentAttentionKind): string { + return `${completionDedupeKey(provider, sessionId)}${kind === 'complete' ? '' : `#${kind}`}` } type FreshAgentPaneLocation = { @@ -30,13 +32,13 @@ function attentionDedupeKeys( provider: string, sessionId: string, location: FreshAgentPaneLocation | undefined, - waiting: boolean, + kind: FreshAgentAttentionKind, ): string[] { const sessionKeys = new Set([ ...(location?.sessionKeys ?? []), completionDedupeKey(provider, sessionId), ]) - return [...sessionKeys].map((key) => waiting ? `${key}#waiting` : key) + return [...sessionKeys].map((key) => kind === 'complete' ? key : `${key}#${kind}`) } function newestKnownAt(state: RootState, terminalIds: string[]): number | undefined { @@ -60,8 +62,8 @@ export function observeFreshAgentSession(payload: { provider: string; sessionId: const location = findFreshAgentPaneBySessionKey( getState(), completionDedupeKey(payload.provider, payload.sessionId), ) - for (const waiting of [false, true]) { - const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting) + for (const kind of FRESH_AGENT_ATTENTION_KINDS) { + const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, kind) for (const terminalId of terminalIds) { dispatch(advanceCompletionBaseline({ terminalId, at: 0 })) } @@ -77,10 +79,10 @@ export function rekeyFreshAgentAttention(payload: { provider: string; previousSe const nextKey = completionDedupeKey(payload.provider, payload.sessionId) const location = findFreshAgentPaneBySessionKey(state, previousKey) ?? findFreshAgentPaneBySessionKey(state, nextKey) - for (const waiting of [false, true]) { + for (const kind of FRESH_AGENT_ATTENTION_KINDS) { const terminalIds = [...new Set([ - ...attentionDedupeKeys(payload.provider, payload.previousSessionId, location, waiting), - ...attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting), + ...attentionDedupeKeys(payload.provider, payload.previousSessionId, location, kind), + ...attentionDedupeKeys(payload.provider, payload.sessionId, location, kind), ])] const at = newestKnownAt(state, terminalIds) ?? 0 advanceAttentionAliases(dispatch, terminalIds, at) @@ -96,11 +98,12 @@ export function foldReadyFreshAgentAttention(marks: ReadyMessage['freshAgentAtte const location = findFreshAgentPaneBySessionKey( getState(), completionDedupeKey(mark.provider, mark.sessionId), ) - for (const [waiting, at, apply] of [ - [false, mark.lastTurnCompleteAt, applyFreshAgentCompletion], - [true, mark.lastWaitingAt, applyFreshAgentWaiting], + for (const [kind, at, apply] of [ + ['complete', mark.lastTurnCompleteAt, applyFreshAgentCompletion], + ['waiting', mark.lastWaitingAt, applyFreshAgentWaiting], + ['question', mark.lastQuestionAvailableAt, applyFreshAgentQuestionAvailable], ] as const) { - const terminalIds = attentionDedupeKeys(mark.provider, mark.sessionId, location, waiting) + const terminalIds = attentionDedupeKeys(mark.provider, mark.sessionId, location, kind) const known = newestKnownAt(getState(), terminalIds) if (known === undefined) { advanceAttentionAliases(dispatch, terminalIds, at ?? 0) @@ -123,14 +126,14 @@ export function foldReadyFreshAgentAttention(marks: ReadyMessage['freshAgentAtte function applyFreshAgentAttentionEdge( payload: ApplyFreshAgentCompletionPayload, - waiting: boolean, + kind: FreshAgentAttentionKind, ) { return (dispatch: AppDispatch, getState: () => RootState): void => { dispatch(observeFreshAgentSession(payload)) const state = getState() const sessionKey = completionDedupeKey(payload.provider, payload.sessionId) const location = findFreshAgentPaneBySessionKey(state, sessionKey) - const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, waiting) + const terminalIds = attentionDedupeKeys(payload.provider, payload.sessionId, location, kind) const known = newestKnownAt(state, terminalIds) if (!location) { @@ -145,7 +148,7 @@ function applyFreshAgentAttentionEdge( dispatch(recordTurnComplete({ tabId: location.tabId, paneId: location.paneId, - terminalId: waiting ? waitingDedupeKey(payload.provider, payload.sessionId) : sessionKey, + terminalId: attentionDedupeKey(payload.provider, payload.sessionId, kind), at: payload.at, })) advanceAttentionAliases(dispatch, terminalIds, payload.at) @@ -239,7 +242,7 @@ export type ApplyFreshAgentCompletionPayload = { * values are dropped by the same baseline as live edges. */ export function applyFreshAgentCompletion(payload: ApplyFreshAgentCompletionPayload) { - return applyFreshAgentAttentionEdge(payload, false) + return applyFreshAgentAttentionEdge(payload, 'complete') } export type ApplyFreshAgentWaitingPayload = { @@ -260,7 +263,12 @@ export type ApplyFreshAgentWaitingPayload = { * waiting edge, and prevents a reconnect from ringing the same approval twice. */ export function applyFreshAgentWaiting(payload: ApplyFreshAgentWaitingPayload) { - return applyFreshAgentAttentionEdge(payload, true) + return applyFreshAgentAttentionEdge(payload, 'waiting') +} + +/** Actionable async content has its own replay mark and never means native work is paused. */ +export function applyFreshAgentQuestionAvailable(payload: ApplyFreshAgentWaitingPayload) { + return applyFreshAgentAttentionEdge(payload, 'question') } function findFreshAgentPaneBySessionKey( diff --git a/test/e2e-browser/helpers/managed-runtime.ts b/test/e2e-browser/helpers/managed-runtime.ts index 1445853973..5bd92f9af2 100644 --- a/test/e2e-browser/helpers/managed-runtime.ts +++ b/test/e2e-browser/helpers/managed-runtime.ts @@ -522,10 +522,9 @@ export class ManagedRuntimeBrowserRig { const features = this.fixtureFreshAgentModes.length > 0 ? 'managed-fresh-agent-fixtures' : qualification.length ? 'managed-provider-qualification' : 'managed-runtime-v1' - const prepared = features === 'managed-runtime-v1' ? undefined - : preparedRuntimeBinary(this.repoRoot, `server-${features}`) + const prepared = preparedRuntimeBinary(this.repoRoot, `server-${features}`) if (!prepared) execFileSync(mise, [ - 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-server', + 'exec', 'rust@1.96.0', '--', 'cargo', 'build', '--locked', '--release', '-p', 'freshell-server', '--features', features, ], { cwd: this.repoRoot, stdio: 'inherit' }) const source = prepared ?? path.join(this.repoRoot, 'target', 'release', 'freshell-server') @@ -540,7 +539,7 @@ export class ManagedRuntimeBrowserRig { const mise = path.join(os.homedir(), '.local', 'bin', 'mise') const prepared = preparedRuntimeBinary(this.repoRoot, 'supervisor-qualification') if (!prepared) execFileSync(mise, [ - 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-supervisor', + 'exec', 'rust@1.96.0', '--', 'cargo', 'build', '--locked', '--release', '-p', 'freshell-supervisor', '--features', 'provider-qualification', ], { cwd: this.repoRoot, stdio: 'inherit' }) const target = path.join(this.runtime.buildDir, 'freshell-supervisor-qualification') diff --git a/test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts index c61a695dc7..00255059e8 100644 --- a/test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts @@ -42,7 +42,7 @@ async function sendFromAnotherConnection(baseUrl: string, token: string, session socket.on('message', (raw) => { const frame = JSON.parse(raw.toString()) if (frame.type === 'ready') socket.send(JSON.stringify({ - type: 'freshAgent.send', provider: 'claude', sessionType, sessionId, + type: 'freshAgent.send', provider: sessionType === 'freshcodex' ? 'codex' : 'claude', sessionType, sessionId, requestId: 'offline-turn', text: 'finish while the browser is offline', })) if (frame.type === 'error' && frame.requestId === 'offline-turn') finish(new Error(JSON.stringify(frame))) @@ -57,14 +57,29 @@ async function sendFromAnotherConnection(baseUrl: string, token: string, session } } -for (const flavour of ['freshclaude', 'kilroy'] as const) { +for (const flavour of ['freshclaude', 'kilroy', 'freshcodex'] as const) { test(`${flavour}: reconnect replays missed attention once and reload seeds silently`, async ({ page }) => { test.setTimeout(120_000) const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-attention-reconnect-')) const project = path.join(root, 'project') await fs.mkdir(project) + const codex = path.join(root, 'codex.mjs') + const nativeLog = path.join(root, 'codex-native.jsonl') + if (flavour === 'freshcodex') { + const fixture = path.resolve(directory, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs') + await fs.writeFile(codex, `#!/usr/bin/env node +import {randomUUID} from 'node:crypto' +import path from 'node:path' +const threadId=randomUUID() +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR=JSON.stringify({recordTurns:true,appendNativeNotificationLogPath:${JSON.stringify(nativeLog)},threadStartThreadId:threadId, +writeRolloutOnMethods:{'turn/start':{path:path.join(process.env.CODEX_HOME,'sessions','rollout-'+threadId+'.jsonl'),record:{type:'session_meta',payload:{id:threadId,cwd:process.cwd()}}}}}) +await import(${JSON.stringify(fixture)}) +`) + await fs.chmod(codex, 0o755) + } const server = new RustServer({ env: { + ...(flavour === 'freshcodex' ? { CODEX_CMD: codex, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1' } : {}), FRESHELL_CLAUDE_SIDECAR: sidecar, FRESHELL_CLAUDE_NODE: process.execPath, FRESHELL_CLAUDE_SDK_QUERY_MODULE: simulator, @@ -74,7 +89,7 @@ for (const flavour of ['freshclaude', 'kilroy'] as const) { setupHome: async (home) => { await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) await fs.writeFile(path.join(home, '.freshell', 'config.json'), JSON.stringify({ - version: 1, settings: { codingCli: { enabledProviders: ['claude'] }, freshAgent: { enabled: true } }, + version: 1, settings: { codingCli: { enabledProviders: [flavour === 'freshcodex' ? 'codex' : 'claude'] }, freshAgent: { enabled: true } }, })) }, }) @@ -112,11 +127,11 @@ for (const flavour of ['freshclaude', 'kilroy'] as const) { // picker, or it can return the fading boot picker and miss the click. await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) await page.evaluate(() => (window as any).__FRESHELL_TEST_HARNESS__.dispatch({ - type: 'connection/setAvailableClis', payload: { claude: true }, + type: 'connection/setAvailableClis', payload: { claude: true, codex: true }, })) const tabId = (await harness.getActiveTabId())! const picker = await openPanePicker(page) - const label = flavour === 'kilroy' ? 'Kilroy' : 'Freshclaude' + const label = flavour === 'kilroy' ? 'Kilroy' : flavour === 'freshcodex' ? 'Freshcodex' : 'Freshclaude' await picker.getByRole('button', { name: new RegExp(`^${label}$`) }).click({ force: true }) const cwd = page.getByLabel(new RegExp(`^Starting directory for ${label}$`, 'i')) await cwd.fill(project) @@ -151,7 +166,11 @@ for (const flavour of ['freshclaude', 'kilroy'] as const) { expect((await completion()).attentionByTab[tabId]).toBe(true) expect((await completion()).attentionByPane[leaf.id]).toBe(true) await expect(page.locator(`[data-context="tab"][data-tab-id="${tabId}"]`)).toHaveClass(/bg-emerald-100/) - await expect(page.getByTestId('sidebar-session-list').locator(`[data-session-id="${sessionRef.sessionId}"][data-provider="claude"]`)).toHaveClass(/bg-emerald-50|border-l-emerald-500/) + await expect(page.getByTestId('sidebar-session-list').locator(`[data-session-id="${sessionRef.sessionId}"][data-provider="${flavour === 'freshcodex' ? 'codex' : 'claude'}"]`)).toHaveClass(/bg-emerald-50|border-l-emerald-500/) + if (flavour === 'freshcodex') { + const notifications = (await fs.readFile(nativeLog, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + expect(notifications.filter(frame => frame.method === 'turn/completed')).toHaveLength(2) + } await harness.forceDisconnect() await harness.waitForConnection() diff --git a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts index 062723ccf1..120038b00d 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -422,7 +422,7 @@ async function sendComposerText(page: Page, text: string): Promise { const paneRoot = page.locator('[data-context="fresh-agent"]').last() const composer = paneRoot.getByRole('textbox', { name: 'Chat message input' }) await composer.fill(text) - await paneRoot.getByRole('button', { name: 'Send' }).click() + await paneRoot.getByRole('button', { name: 'Send', exact: true }).click() } /** Capture the pane's durable sessionRef id once it exists. */ @@ -1289,6 +1289,7 @@ async function bootCodexLane( projectDir: string opLogPath: string responseLogPath: string + requestLogPath: string tabId: string }> { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-agentctl-codex-')) @@ -1297,6 +1298,7 @@ async function bootCodexLane( await fs.mkdir(projectDir, { recursive: true }) const opLogPath = path.join(sharedRoot, 'codex-ops.jsonl') const responseLogPath = path.join(sharedRoot, 'codex-responses.jsonl') + const requestLogPath = path.join(sharedRoot, 'codex-requests.jsonl') const { server, info, harness } = await bootWall(page, { env: { // Whitespace-split by spawn_sidecar (codex.rs): interpreter + script. @@ -1307,6 +1309,8 @@ async function bootCodexLane( // answers the recorded history, so forks provably diverge at the pin. recordTurns: true, appendClientResponseLogPath: responseLogPath, + appendRequestLogPath: requestLogPath, + echoRequestedModel: true, ...behavior, }), ...extraEnv, @@ -1319,7 +1323,7 @@ async function bootCodexLane( const tabId = (await harness.getActiveTabId())! expect(tabId).toBeTruthy() await createFreshAgentPane(page, /^Freshcodex$/, 'Freshcodex', projectDir) - return { server, info, harness, sharedRoot, projectDir, opLogPath, responseLogPath, tabId } + return { server, info, harness, sharedRoot, projectDir, opLogPath, responseLogPath, requestLogPath, tabId } } catch (error) { await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) throw error @@ -1330,6 +1334,66 @@ function readCodexOps(opLogPath: string): any[] { return readJsonl(opLogPath) } +/** Bounded fixture-only diagnostics; never include control bodies or answers. */ +async function captureCodexLaneDiagnostics( + page: Page, + lane: Awaited>, +): Promise { + const rows = (turns: any[] = []) => turns.map((turn) => ({ + id: turn.turnId, nativeTurnId: turn.nativeTurnId, role: turn.role, + items: (turn.items ?? []).map((item: any) => ({ id: item.id, kind: item.kind, textBytes: typeof item.text === 'string' ? item.text.length : undefined })), + })) + const summarizeMetadata = (metadata: any) => metadata && ({ + ...metadata, + turnOutputStates: Object.fromEntries(Object.entries(metadata.turnOutputStates ?? {}).map(([id, output]: [string, any]) => [id, { + provenance: output.provenance, diagnostic: output.diagnostic, refreshPending: output.refreshPending, + savedTurns: rows(output.savedTurns), retainedTurns: rows(output.retainedTurns), + }])), + }) + const summarizeSnapshot = (snapshot: any) => snapshot && ({ + threadId: snapshot.threadId, status: snapshot.status, revision: snapshot.revision, + turns: rows(snapshot.turns), + projection: summarizeMetadata(snapshot.extensions?.codex?.projection), + }) + const result = await Promise.allSettled([ + fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1'), + lane.harness.getReceivedWsMessages(), + lane.harness.getState(), + page.locator('[data-context="fresh-agent"]').last().getByRole('button').allTextContents(), + ]) + const value = (index: number): any => { + const item = result[index] + return item.status === 'fulfilled' ? item.value : { diagnosticError: String(item.reason) } + } + const state = value(2) + const live = Object.values(state?.freshAgentLive?.bySession ?? {}).map((session: any) => ({ + sessionId: session.sessionId, + retainedSourceKey: session.retainedSourceKey, + retainedSnapshot: summarizeSnapshot(session.retainedSnapshot), + projection: session.projection && { + id: session.projection.id, historyGeneration: session.projection.historyGeneration, + eventRevision: session.projection.eventRevision, needsResnapshot: session.projection.needsResnapshot, + turns: rows(session.projection.turns), turnRows: session.projection.turnRows, + }, + })) + const frames = Array.isArray(value(1)) ? value(1).slice(-100).map((frame: any) => ({ + type: frame.type, sessionId: frame.sessionId, provider: frame.provider, + event: frame.event && { + type: frame.event.type, status: frame.event.status, + projectionId: frame.event.projectionId, historyGeneration: frame.event.historyGeneration, + afterRevision: frame.event.afterRevision, + ops: frame.event.ops?.map((op: any) => ({ op: op.op, revision: op.revision, nativeTurnId: op.nativeTurnId })), + source: frame.event.retainedState?.source, + snapshot: summarizeSnapshot(frame.event.retainedState?.snapshot), + }, + })) : value(1) + console.log('TASK006_CODEX_STATE_DIAGNOSTIC', JSON.stringify({ + test: test.info().title, nativeOperations: readCodexOps(lane.opLogPath).slice(-100), + restSnapshot: summarizeSnapshot(value(0)), forwardedFrames: frames, + live, sessions: Object.values(state?.freshAgent?.sessions ?? {}).map((session: any) => ({ sessionId: session.sessionId, status: session.status })), visibleButtonLabels: value(3), + })) +} + /** Read a freshAgent session's status from the harness store — the exact * `agentSession.status` the stuck card's `effectiveStatus` renders from. */ async function readFreshAgentSessionStatus( @@ -1371,12 +1435,13 @@ async function sendCodexTurnAndWaitRows( harness: TestHarness, expectedRowCount: number, text: string, + threadId = 'thread-new-1', ): Promise { const sentBefore = await harness.getSentWsMessages() const matchingSendsBefore = (sentBefore as any[]).filter( (message) => message?.type === 'freshAgent.send' && message?.provider === 'codex' - && message?.sessionId === 'thread-new-1' + && message?.sessionId === threadId && message?.text === text, ).length await sendComposerText(page, text) @@ -1385,7 +1450,7 @@ async function sendCodexTurnAndWaitRows( async () => (await harness.getSentWsMessages() as any[]).filter( (message) => message?.type === 'freshAgent.send' && message?.provider === 'codex' - && message?.sessionId === 'thread-new-1' + && message?.sessionId === threadId && message?.text === text, ).length, { @@ -1406,7 +1471,7 @@ async function sendCodexTurnAndWaitRows( await expect .poll( async () => { - const snapshot = await fetchSnapshot(info, 'freshcodex', 'codex', 'thread-new-1') + const snapshot = await fetchSnapshot(info, 'freshcodex', 'codex', threadId) return { rows: snapshot?.turns?.length ?? 0, status: snapshot?.status ?? null, @@ -1423,7 +1488,7 @@ async function sendCodexTurnAndWaitRows( // provider snapshot alone. await expect .poll( - () => readFreshAgentSessionStatus(harness, 'thread-new-1'), + () => readFreshAgentSessionStatus(harness, threadId), { timeout: 30_000, message: `the browser did not settle its Codex session after "${text}"`, @@ -1461,6 +1526,185 @@ async function readRollout(homeDir: string, threadId: string): Promise { test.setTimeout(240_000) + test('Freshcodex preserves pre-ack Default and typed exact effort through native sends', async ({ page }) => { + const gateRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-create-gate-')) + const releasePath = path.join(gateRoot, 'release') + await fs.writeFile(releasePath, '') + const lane = await bootCodexLane(page, { uniqueThreadIds: true, waitForThreadStartReleasePath: releasePath }) + try { + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + const controlId = (await paneLeaf(lane.harness, lane.tabId)).content.sessionId as string + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'Pre-ack readiness control', controlId) + const settings = page.getByRole('dialog', { name: 'Agent settings' }) + const modelDialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await page.getByRole('button', { name: 'Agent settings' }).click() + await settings.getByRole('button', { name: /Change/ }).click() + await modelDialog.getByRole('option', { name: 'Provider Current', exact: true }).click() + await modelDialog.getByRole('listbox', { name: 'Thinking levels for Provider Current' }).getByRole('option', { name: /^ultra\b/ }).click() + await modelDialog.getByRole('button', { name: 'Use Provider Current · ultra' }).click() + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 4, 'Pre-ack explicit ultra control', controlId) + const starts = () => readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start') + expect(starts()[1].params).toMatchObject({ model: 'provider-current', effort: 'ultra' }) + console.log('TASK008_PREACK_NATIVE_READY', JSON.stringify({ controlId, completedControl: true, explicitEffort: 'ultra' })) + + await fs.unlink(releasePath) + await page.getByRole('button', { name: 'New shell tab', exact: true }).click() + await selectShellIfPickerShowing(page) + await expect(page.locator('.xterm:visible').first()).toBeVisible({ timeout: 30_000 }) + const tabId = (await lane.harness.getActiveTabId())! + await createFreshAgentPane(page, /^Freshcodex$/, 'Freshcodex', lane.projectDir) + await expect.poll(() => readJsonl(lane.requestLogPath).filter((row) => row.method === 'thread/start').length).toBe(2) + const create = (await lane.harness.getSentWsMessages() as any[]).filter((row) => row.type === 'freshAgent.create').at(-1) + expect(create.effort).toBe('ultra') + expect((await paneLeaf(lane.harness, tabId)).content.sessionId).toBeUndefined() + await page.getByRole('button', { name: 'Agent settings' }).click() + await settings.getByRole('button', { name: /Change/ }).click() + await modelDialog.getByRole('option', { name: 'Default', exact: true }).click() + await modelDialog.getByRole('button', { name: /^Use .* · Default$/ }).click() + expect((await paneLeaf(lane.harness, tabId)).content).toMatchObject({ effortIsNativeDefault: true }) + expect((await paneLeaf(lane.harness, tabId)).content.effort).toBeUndefined() + await page.keyboard.press('Escape') + await fs.writeFile(releasePath, '') + await expect.poll(async () => (await paneLeaf(lane.harness, tabId))?.content?.sessionId ?? null).not.toBe(null) + const threadId = (await paneLeaf(lane.harness, tabId)).content.sessionId as string + await expect.poll(async () => (await fetchSnapshot(lane.info, 'freshcodex', 'codex', threadId))?.settings?.effort).toBe('ultra') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'Pre-ack Default next send', threadId) + const afterAck = (await paneLeaf(lane.harness, tabId)).content + const defaultStart = starts().at(-1).params + + await page.getByRole('button', { name: 'Agent settings' }).click() + await settings.getByRole('button', { name: /Change/ }).click() + await expect(modelDialog.getByRole('listbox', { name: 'Thinking levels for Provider Current' })).toBeVisible() + await modelDialog.getByRole('textbox', { name: 'Reasoning effort', exact: true }).fill('future-effort') + const commitButton = modelDialog.getByRole('button', { name: /^Use / }) + const typedLabel = await commitButton.textContent() + await commitButton.click() + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 4, 'Typed exact effort next send', threadId) + expect({ + afterAck: { effort: afterAck.effort ?? null, nativeDefault: afterAck.effortIsNativeDefault }, + defaultNative: defaultStart.effort ?? null, + defaultCollaboration: defaultStart.collaborationMode.settings.reasoning_effort, + typedLabel, typedNative: starts().at(-1).params.effort ?? null, + }).toEqual({ + afterAck: { effort: null, nativeDefault: true }, defaultNative: null, defaultCollaboration: null, + typedLabel: 'Use Provider Current · future-effort', typedNative: 'future-effort', + }) + } finally { + await fs.writeFile(releasePath, '') + await lane.server.stop() + await fs.rm(lane.sharedRoot, { recursive: true, force: true }) + await fs.rm(gateRoot, { recursive: true, force: true }) + } + }) + + test('Freshcodex explicitly clears effort with Default and keeps native settings after reload', async ({ page }) => { + const lane = await bootCodexLane(page, { uniqueThreadIds: true }) + try { + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + const threadId = (await paneLeaf(lane.harness, lane.tabId)).content.sessionId as string + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'Default readiness control', threadId) + const starts = () => readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start') + const settings = page.getByRole('dialog', { name: 'Agent settings' }) + const modelDialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await page.getByRole('button', { name: 'Agent settings' }).click() + await settings.getByRole('button', { name: /Change/ }).click() + await modelDialog.getByRole('option', { name: 'Provider Current', exact: true }).click() + await modelDialog.getByRole('listbox', { name: 'Thinking levels for Provider Current' }).getByRole('option', { name: /^ultra\b/ }).click() + await modelDialog.getByRole('button', { name: 'Use Provider Current · ultra' }).click() + await settings.getByRole('combobox', { name: 'File access', exact: true }).selectOption('read-only') + await settings.getByRole('combobox', { name: 'Work mode', exact: true }).selectOption('plan') + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 4, 'Explicit effort before Default', threadId) + expect(starts()[1].params).toMatchObject({ model: 'provider-current', effort: 'ultra', sandboxPolicy: { type: 'readOnly' }, collaborationMode: { mode: 'plan', settings: { reasoning_effort: 'ultra' } } }) + const providerDefaults = async () => { + const response = await page.request.get(`${lane.info.baseUrl}/api/settings`, { headers: { 'x-auth-token': lane.info.token } }) + expect(response.ok()).toBe(true) + return (await response.json()).freshAgent?.providers?.freshcodex + } + await expect.poll(async () => (await providerDefaults())?.effort).toBe('ultra') + console.log('TASK008_DEFAULT_NATIVE_READY', JSON.stringify({ threadId, completedControl: true, explicitEffort: 'ultra', savedEffort: 'ultra' })) + await page.getByRole('button', { name: 'Agent settings' }).click() + await settings.getByRole('button', { name: /Change/ }).click() + const levels = modelDialog.getByRole('listbox', { name: 'Thinking levels for Provider Current' }) + await expect(levels.getByRole('option', { name: /^ultra\b/ })).toBeVisible() + await expect(levels.getByRole('option', { name: 'Default', exact: true })).toBeVisible({ timeout: 10_000 }) + await levels.getByRole('option', { name: 'Default', exact: true }).click() + await modelDialog.getByRole('button', { name: 'Use Provider Current · Default' }).click() + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 6, 'Native Default next send', threadId) + expect(starts()[2].params).not.toHaveProperty('effort') + expect(starts()[2].params).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, approvalPolicy: 'on-request', collaborationMode: { mode: 'plan', settings: { model: 'provider-current', reasoning_effort: null, developer_instructions: null } } }) + expect((await lane.harness.getSentWsMessages() as any[]).some((frame) => frame.type === 'freshAgent.configure' && frame.settings?.effort === null)).toBe(true) + await expect.poll(async () => (await providerDefaults())?.effort ?? null).toBe(null) + await page.reload() + await lane.harness.waitForHarness() + await lane.harness.waitForConnection() + await expect.poll(async () => (await paneLeaf(lane.harness, lane.tabId))?.content?.effortIsNativeDefault).toBe(true) + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 8, 'Native Default after reload', threadId) + expect(starts()[3].params).not.toHaveProperty('effort') + expect(starts()[3].params).toMatchObject({ model: 'provider-current', sandboxPolicy: { type: 'readOnly' }, collaborationMode: { mode: 'plan', settings: { reasoning_effort: null } } }) + } finally { + await lane.server.stop() + await fs.rm(lane.sharedRoot, { recursive: true, force: true }) + } + }) + + test('Freshcodex file access and Plan settings reach native turns and survive reload', async ({ page }) => { + const lane = await bootCodexLane(page, { serverRequestsByPrompt: { + 'Plan the change': { id: 'plan-question', method: 'item/tool/requestUserInput', params: { + isBlocking: true, questions: [{ id: 'approach', header: 'Approach', question: 'Choose an approach', options: [{ label: 'Small change', description: 'Keep the change focused' }] }], + } }, + } }) + try { + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'Settings readiness control') + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start')).toHaveLength(1) + console.log('TASK008_SETTINGS_NATIVE_READY', JSON.stringify({ threadId: 'thread-new-1', status: 'idle', completedControl: true })) + + await page.getByRole('button', { name: 'Agent settings' }).click() + const settings = page.getByRole('dialog', { name: 'Agent settings' }) + await settings.getByRole('combobox', { name: 'File access', exact: true }).selectOption('read-only') + await settings.getByRole('combobox', { name: 'Work mode', exact: true }).selectOption('plan') + await page.keyboard.press('Escape') + await sendComposerText(page, 'Plan the change') + const question = page.getByRole('region', { name: 'Question from Codex' }) + await expect(question).toBeVisible() + const starts = () => readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start') + await expect.poll(() => starts().length).toBe(2) + const plan = starts()[1].params + expect(plan).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, approvalPolicy: 'on-request', collaborationMode: { + mode: 'plan', settings: { model: plan.model, reasoning_effort: plan.effort ?? null, developer_instructions: null }, + } }) + await question.getByRole('button', { name: 'Small change', exact: true }).click() + await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 'plan-question', result: { answers: { approach: { answers: ['Small change'] } } } }) + await expect.poll(async () => (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1'))?.status).toBe('idle') + await expect.poll(() => readFreshAgentSessionStatus(lane.harness, 'thread-new-1')).toBe('idle') + + await page.getByRole('button', { name: 'Agent settings' }).click() + await settings.getByRole('combobox', { name: 'Work mode', exact: true }).selectOption('default') + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 6, 'Build the change') + expect(starts()[2].params).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, approvalPolicy: 'on-request', collaborationMode: { + mode: 'default', settings: { model: plan.model, reasoning_effort: plan.effort ?? null, developer_instructions: null }, + } }) + + await page.reload() + await lane.harness.waitForHarness() + await lane.harness.waitForConnection() + await page.getByRole('button', { name: 'Agent settings' }).click() + await expect(settings.getByRole('combobox', { name: 'File access', exact: true })).toHaveValue('read-only') + await expect(settings.getByRole('combobox', { name: 'Work mode', exact: true })).toHaveValue('default') + await page.keyboard.press('Escape') + await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 8, 'After settings reload') + expect(starts()[3].params).toMatchObject({ cwd: lane.projectDir, sandboxPolicy: { type: 'readOnly' }, collaborationMode: { mode: 'default' } }) + } finally { + await lane.server.stop() + await fs.rm(lane.sharedRoot, { recursive: true, force: true }) + } + }) + test('Codex approvals and questions survive reload and send user decisions to the provider', async ({ page }) => { const lane = await bootCodexLane(page, { serverRequestsByPrompt: { 'Approve tests': { id: 501, method: 'item/commandExecution/requestApproval', params: { command: 'npm test', reason: 'Run project tests' } }, @@ -1476,7 +1720,8 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await page.reload() await expect(approval).toHaveCount(1) await expect(approval).toBeVisible() - await approval.getByRole('button', { name: 'Allow tool use', exact: true }).click() + expect(readJsonl(lane.responseLogPath)).toHaveLength(0) + await approval.getByRole('button', { name: 'Allow', exact: true }).click() await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 501, result: { decision: 'accept' } }) await expect(approval).toBeHidden() await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1490,43 +1735,76 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await sendComposerText(page, 'Deny an edit') const edit = page.getByRole('alert', { name: 'Permission request for Edit' }) await expect(edit).toBeVisible() - await edit.getByRole('button', { name: 'Deny tool use', exact: true }).click() + await edit.getByRole('button', { name: 'Decline', exact: true }).click() await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 502, result: { decision: 'decline' } }) await expect(edit).toBeHidden() await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + console.log('TASK006_NATIVE_APPROVAL_CONTRACT', JSON.stringify({ test: test.info().title, normalizedNativeResponses: readJsonl(lane.responseLogPath) })) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) } }) - // Decision D3 (client-alerts G20): codex keeps its client-only bypass, - // labelled for what it is. (The claude lane's former "Always Allow" case is - // replaced by Claude's own "Allow and don't ask again", pinned with no - // client auto-answer in freshclaude-approvals-rust.spec.ts.) - test('Always allow (pane) auto-answers the second raise for that tool', async ({ page }) => { + // Native grants belong to Codex's offered decision, rather than a generic + // pane-local tool bypass. A subsequent native request still needs a choice. + test('Codex native session approval forwards an offered decision and keeps later requests user-gated', async ({ page }) => { const lane = await bootCodexLane(page, { serverRequestsByPrompt: { - 'Approve tests': { id: 601, method: 'item/commandExecution/requestApproval', params: { command: 'npm test', reason: 'Run project tests' } }, - 'Approve a build': { id: 602, method: 'item/commandExecution/requestApproval', params: { command: 'npm run build', reason: 'Build the project' } }, + 'Approve tests': { id: 601, method: 'item/commandExecution/requestApproval', params: { command: 'npm test', reason: 'Run project tests', availableDecisions: ['accept', 'acceptForSession', 'decline', 'cancel'] } }, + 'Approve a build': { id: 602, method: 'item/commandExecution/requestApproval', params: { command: 'npm run build', reason: 'Build the project', availableDecisions: ['decline', 'cancel'] } }, } }) try { await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await sendComposerText(page, 'Approve tests') const approval = page.getByRole('alert', { name: 'Permission request for Bash' }) await expect(approval).toBeVisible() - const always = approval.getByRole('button', { name: 'Always allow Bash in this pane', exact: true }) - await expect(always).toHaveAttribute('title', 'Until this pane reloads') - await always.click() - await expect.poll(() => readJsonl(lane.responseLogPath)).toContainEqual({ id: 601, result: { decision: 'accept' } }) + const beforeReload = (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1')).pendingApprovals[0] + expect(beforeReload).toMatchObject({ providerRequest: { id: 601, method: 'item/commandExecution/requestApproval' }, native: { availableDecisions: ['accept', 'acceptForSession', 'decline', 'cancel'] } }) + expect(readJsonl(lane.responseLogPath)).toEqual([]) + await expect(approval.getByRole('button', { name: 'Always allow Bash in this pane', exact: true })).toHaveCount(0) + expect((await lane.harness.getSentWsMessages() as any[]).filter((frame) => frame.type === 'freshAgent.send' && frame.text === 'Approve tests')).toHaveLength(1) + await page.reload() + await expect(approval).toHaveCount(1) + await expect(approval).toBeVisible() + const afterReload = (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1')).pendingApprovals[0] + expect(afterReload.requestId).toBe(beforeReload.requestId) + expect(afterReload.native.availableDecisions).toEqual(['accept', 'acceptForSession', 'decline', 'cancel']) + expect(readJsonl(lane.responseLogPath)).toEqual([]) + await approval.getByRole('button', { name: 'Allow for this session', exact: true }).click() + await expect.poll(() => readJsonl(lane.responseLogPath)).toEqual([{ id: 601, result: { decision: 'acceptForSession' } }]) await expect(approval).toBeHidden() await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - // The second request for Bash is answered by the pane, with no click. + // The provider raises a different command with a narrower offer. The + // first session choice does not authorize a client-side automatic reply. await sendComposerText(page, 'Approve a build') - await expect.poll(() => readJsonl(lane.responseLogPath), { timeout: 15_000, message: 'the second raise is answered without a click' }) - .toContainEqual({ id: 602, result: { decision: 'accept' } }) + await expect(approval).toHaveCount(1) + await expect(approval).toBeVisible() + const second = (await fetchSnapshot(lane.info, 'freshcodex', 'codex', 'thread-new-1')).pendingApprovals[0] + expect(second).toMatchObject({ providerRequest: { id: 602, method: 'item/commandExecution/requestApproval' }, native: { availableDecisions: ['decline', 'cancel'] } }) + await expect(approval.getByRole('button', { name: 'Allow', exact: true })).toHaveCount(0) + await expect(approval.getByRole('button', { name: 'Allow for this session', exact: true })).toHaveCount(0) + await expect(approval.getByRole('button', { name: 'Always allow Bash in this pane', exact: true })).toHaveCount(0) + expect(readJsonl(lane.responseLogPath)).toEqual([{ id: 601, result: { decision: 'acceptForSession' } }]) + expect((await lane.harness.getSentWsMessages() as any[]).filter((frame) => frame.type === 'freshAgent.approval.respond' && frame.requestId === second.requestId)).toEqual([]) + await approval.getByRole('button', { name: 'Decline', exact: true }).click() + await expect.poll(() => readJsonl(lane.responseLogPath)).toEqual([{ id: 601, result: { decision: 'acceptForSession' } }, { id: 602, result: { decision: 'decline' } }]) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await expect(approval).toBeHidden() + expect((await lane.harness.getSentWsMessages() as any[]).filter((frame) => frame.type === 'freshAgent.send' && frame.text === 'Approve a build')).toHaveLength(1) + const recorded = JSON.parse(await fs.readFile(path.join(lane.info.homeDir, '.codex', 'fake-turns', 'thread-new-1.json'), 'utf8')) + expect(recorded).toHaveLength(2) + console.log('TASK006_NATIVE_APPROVAL_CONTRACT', JSON.stringify({ + test: test.info().title, + normalizedRequests: [beforeReload, afterReload, second].map((request) => ({ requestId: request.requestId, providerRequest: request.providerRequest, availableDecisions: request.native.availableDecisions })), + normalizedNativeResponses: readJsonl(lane.responseLogPath), recordedTurns: recorded.length, + })) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1659,6 +1937,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await expect(pane).toContainText('review-1') await expect(pane).toContainText('pending') await expect(pane).toContainText('thread-parent-1') + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1666,12 +1947,16 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { }) test('compact: thread/compact/start, never a turn; pane returns usable', async ({ page }) => { - const lane = await bootCodexLane(page) + const compactRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-codex-compact-')) + const notificationLogPath = path.join(compactRoot, 'native-notifications.jsonl') + const lane = await bootCodexLane(page, { appendNativeNotificationLogPath: notificationLogPath }) try { await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'codex turn one') // Typed slash gesture → freshAgent.compact → thread/compact/start. + const notificationFrom = readJsonl(notificationLogPath).length + const receivedFrom = (await lane.harness.getReceivedWsMessages()).length const paneRoot = page.locator('[data-context="fresh-agent"]').last() await paneRoot.getByRole('textbox', { name: 'Chat message input' }).fill('/compact') await paneRoot.getByRole('textbox', { name: 'Chat message input' }).press('Enter') @@ -1682,8 +1967,28 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { 'thread/compact/start op', ) expect(compactOp.params.threadId).toBe('thread-new-1') - // Busy→idle ride the probed notification sequence; the pane settles. + // A prior idle snapshot can still be visible when the compact RPC lands. + // Join this native compaction turn's terminal and its owning Rust + // consumption before the next send is allowed to be an idle start. + let compactTurnId: string | undefined + await expect.poll(() => { + compactTurnId = readJsonl(notificationLogPath).slice(notificationFrom) + .find((row) => row.method === 'turn/started' && row.threadId === 'thread-new-1')?.turnId + return compactTurnId + }).toBeTruthy() + await expect.poll(async () => { + const nativeCompleted = readJsonl(notificationLogPath).slice(notificationFrom).some((row) => + row.method === 'turn/completed' && row.threadId === 'thread-new-1' && row.turnId === compactTurnId) + const frames = (await lane.harness.getReceivedWsMessages() as any[]).slice(receivedFrom) + const settled = frames.findIndex((frame) => frame.type === 'freshAgent.event' && frame.sessionId === 'thread-new-1' + && frame.event?.type === 'freshAgent.transcript.patch' + && frame.event.ops?.some((op: any) => op.op === 'settleTurn' && op.nativeTurnId === compactTurnId)) + const consumed = settled >= 0 && frames.slice(settled + 1).some((frame) => + frame.type === 'freshAgent.event' && frame.sessionId === 'thread-new-1' && frame.event?.type === 'freshAgent.turn.complete') + return nativeCompleted && consumed + }).toBe(true) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start')).toHaveLength(1) // AGENT-04's crucial shape pin: compact minted NO turn. The fixture's // recorded-turns store is the ground truth — after the compact the @@ -1696,9 +2001,15 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { // Usable after compact: a follow-up prompt mints exactly the next turn // (two recorded turns -> four display rows). await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 4, 'codex post-compact turn') + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/start')).toHaveLength(2) + expect(readJsonl(lane.requestLogPath).filter((row) => row.method === 'turn/steer')).toHaveLength(0) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) + await fs.rm(compactRoot, { recursive: true, force: true }).catch(() => {}) } }) @@ -1770,6 +2081,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { ) expect(mutationsAgainstParent, 'no post-fork mutation against the source thread').toEqual([]) expect(await readRollout(lane.info.homeDir, 'thread-new-1')).toBe(parentRolloutBefore) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1939,6 +2253,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { }, { timeout: 30_000, message: 'post-restart resume+read evidence for BOTH source and child' }) .toBe(true) sourceRestartWs.close() + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -2033,6 +2350,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { await expect.poll(async () => (await fetchSnapshot(lane.info, 'freshcodex', 'codex', threadId))?.turns?.length, { timeout: 15_000 }).toBe(turnsAfter.length + 2) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -2067,6 +2387,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { // exactly the false positive the bound exists to prevent. await page.waitForTimeout(Math.max(0, 11_000 - (Date.now() - sentAt))) await expect(stuckAlert).toHaveCount(0) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -2074,10 +2397,24 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { }) test('per-send settings alter the turn payload against the Rust server (freshcodex)', async ({ page }) => { - const lane = await bootCodexLane(page) + const lane = await bootCodexLane(page, { modelCatalogPages: [{ data: [ + { id: 'astra-record', model: 'gpt-6-astra', displayName: 'GPT-6 Astra', + supportedReasoningEfforts: [{ reasoningEffort: 'high' }, { reasoningEffort: 'xhigh' }], defaultReasoningEffort: 'xhigh' }, + { id: 'sol-record', model: 'gpt-5.6-sol', displayName: 'GPT-5.6 Sol', + supportedReasoningEfforts: [{ reasoningEffort: 'low' }, { reasoningEffort: 'high' }], defaultReasoningEffort: 'high' }, + ], nextCursor: null }] }) try { await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - // Turn 1 rides the pane's untouched defaults. + // Omission is native no-override. Deliberately select legacy max so the + // original first-turn xhigh assertion exercises the advertised alias. + await page.getByRole('button', { name: 'Agent settings' }).click() + await page.getByRole('dialog', { name: 'Agent settings' }).getByRole('button', { name: /Change/ }).click() + const initialDialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect(initialDialog.getByRole('listbox', { name: 'Thinking levels for GPT-6 Astra' })).toBeVisible() + await initialDialog.getByRole('textbox', { name: 'Reasoning effort', exact: true }).fill('max') + await initialDialog.getByRole('button', { name: 'Use GPT-6 Astra · max' }).click() + await page.keyboard.press('Escape') + expect((await paneLeaf(lane.harness, lane.tabId))?.content?.effort).toBe('max') await sendCodexTurnAndWaitRows(page, lane.info, lane.harness, 2, 'codex turn one') const threadId = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId as string expect(threadId, 'the durable codex thread id must be known before turn two').toBeTruthy() @@ -2114,9 +2451,8 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { // Ground truth: the fake's recorded-turns file under the lane's isolated // CODEX_HOME (/.codex/fake-turns/.json). Each recorded // turn carries its captured turn/start params additively under `start` — - // turn 2 carries the per-send selection while turn 1 kept the defaults - // (gpt-6-astra / the default 'max' effort, wire-mapped 'xhigh' by - // to_codex_reasoning_effort). + // Turn 1 keeps the explicit max intent, wire-mapped to xhigh only because + // this selected native model advertises xhigh and no distinct max. const turnsPath = path.join(lane.info.homeDir, '.codex', 'fake-turns', `${threadId}.json`) await expect(async () => { const turns = JSON.parse(await fs.readFile(turnsPath, 'utf8')) as any[] @@ -2128,6 +2464,9 @@ test.describe('fresh-agent control surfaces — codex lane (rust)', () => { expect(turns[0]?.start?.model).not.toBe(turns[1]?.start?.model) expect(turns[0]?.start?.effort).not.toBe(turns[1]?.start?.effort) }).toPass({ timeout: 15_000 }) + } catch (error) { + await captureCodexLaneDiagnostics(page, lane).catch((diagnosticError) => console.log('TASK006_CODEX_DIAGNOSTIC_FAILED', String(diagnosticError))) + throw error } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) diff --git a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts index abd18290d3..feb11d00e5 100644 --- a/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-model-dialog-parity.spec.ts @@ -2,22 +2,143 @@ import type { Page } from '@playwright/test' import { test, expect } from '../helpers/fixtures.js' import { FRESHCODEX_DEFAULT_MODEL } from '@shared/fresh-agent-models.js' -async function installPane(page: Page, provider: 'claude' | 'codex') { +const codexCatalog = { + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1234, + models: [{ id: 'provider-current', displayName: 'Provider Current', provider: 'codex', + supportsEffort: true, supportedEffortLevels: ['none', 'ultra'], defaultEffort: 'ultra', + inputModalities: ['text', 'image'], supportsAdaptiveThinking: false }], +} + +test('Codex discovers only the bound runtime and preserves search and exact settings across rebind', async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + let releaseOld!: () => void + const oldHeld = new Promise((resolve) => { releaseOld = resolve }) + const contexts: URLSearchParams[] = [] + await page.route('**/api/fresh-agent/model-capabilities/freshcodex**', async (route) => { + const query = new URL(route.request().url()).searchParams + contexts.push(query) + if (contexts.length === 1) { + await oldHeld + await route.fulfill({ json: { ...codexCatalog, models: [{ ...codexCatalog.models[0], id: 'stale-model', displayName: 'Stale Provider' }] } }) + } else await route.fulfill({ json: codexCatalog }) + }) + await installPane(page, 'codex') + await page.getByRole('button', { name: /^Model:.*change model$/ }).click() + const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect.poll(() => contexts.length).toBe(1) + await dialog.getByRole('searchbox').fill('provider') + await page.evaluate(() => { + const harness = window.__FRESHELL_TEST_HARNESS__! + const state = harness.getState() + const tabId = state.tabs.activeTabId! + const paneId = state.panes.activePane[tabId] + harness.dispatch({ type: 'panes/mergePaneContent', payload: { tabId, paneId, + updates: { sessionId: 'replacement-native', sessionRef: { provider: 'codex', sessionId: 'replacement-native' } } } }) + }) + await dialog.getByRole('option', { name: 'Provider Current', exact: true }).click() + releaseOld() + await expect(dialog.getByRole('searchbox')).toHaveValue('provider') + await expect(dialog.getByRole('option', { name: 'Stale Provider' })).toHaveCount(0) + await expect.poll(() => contexts.length).toBe(2) + expect(contexts[0].get('sessionId')).toBe('d4430000-0000-4444-8444-000000000001') + expect(contexts[1].get('sessionId')).toBe('replacement-native') + await dialog.getByRole('button', { name: 'Use Provider Current · ultra' }).click() + await expect.poll(() => page.evaluate(() => { + const state = window.__FRESHELL_TEST_HARNESS__!.getState() + const layout = state.panes.layouts[state.tabs.activeTabId!] + return layout?.type === 'leaf' ? { model: layout.content.model, effort: layout.content.effort } : null + })).toEqual({ model: 'provider-current', effort: 'ultra' }) +}) + +test('Codex picker waits for ordinary create and shows honest failure with exact saved input', async ({ freshellPage, page, terminal }) => { + await terminal.waitForTerminal() + const contexts: Record[] = [] + await page.route('**/api/fresh-agent/model-capabilities/freshcodex**', async route => { + contexts.push(Object.fromEntries(new URL(route.request().url()).searchParams)) + await route.fulfill({ json: codexCatalog }) + }) + const creates = () => page.evaluate(() => + (window.__FRESHELL_TEST_HARNESS__!.getSentWsMessages?.() ?? []) + .filter((frame: any) => frame?.type === 'freshAgent.create') + .map((frame: any) => ({ requestId: frame.requestId, model: frame.model, effort: frame.effort }))) + const begin = (requestId: string) => installPane(page, 'codex', { requestId, model: 'saved-custom', effort: 'xhigh' }) + const open = async () => { + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('button', { name: /saved-custom.*Change/ }).click() + } + const firstRequest = 'model-dialog-codex-ack-create' + await begin(firstRequest) + await expect.poll(creates).toEqual([{ requestId: firstRequest, model: 'saved-custom', effort: 'xhigh' }]) + await open() + const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect(dialog.getByRole('status')).toContainText(/waiting.*runtime/i) + await dialog.getByRole('textbox', { name: 'Exact model ID' }).fill('staged-custom') + await dialog.getByRole('textbox', { name: 'Reasoning effort' }).fill('ultra') + await dialog.getByRole('searchbox').fill('provider') + expect(contexts).toHaveLength(0) + expect(await creates()).toHaveLength(1) + await page.evaluate(requestId => { + const harness = window.__FRESHELL_TEST_HARNESS__! + if (!harness.receiveWsMessage) throw new Error('Public receive-message fixture is required') + harness.receiveWsMessage({ type: 'freshAgent.created', requestId, + sessionId: 'acknowledged-native', sessionRef: { provider: 'codex', sessionId: 'acknowledged-native' }, + sessionType: 'freshcodex', provider: 'codex', runtimeProvider: 'codex' }) + }, firstRequest) + await expect(dialog.getByRole('option', { name: 'Provider Current', exact: true })).toBeVisible() + await expect(dialog.getByRole('searchbox')).toHaveValue('provider') + await expect(dialog.getByRole('button', { name: 'Use staged-custom · ultra' })).toBeEnabled() + expect(contexts).toEqual([{ sessionId: 'acknowledged-native', + sessionRef: JSON.stringify({ provider: 'codex', sessionId: 'acknowledged-native' }), cwd: '/tmp' }]) + expect(await creates()).toHaveLength(1) + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() + await page.keyboard.press('Escape') + + const failedRequest = 'model-dialog-codex-failed-create' + await begin(failedRequest) + await expect.poll(creates).toEqual([ + { requestId: firstRequest, model: 'saved-custom', effort: 'xhigh' }, + { requestId: failedRequest, model: 'saved-custom', effort: 'xhigh' }, + ]) + await open() + await expect(dialog.getByRole('status')).toContainText(/waiting.*runtime/i) + await dialog.getByRole('searchbox').fill('saved') + await page.evaluate(requestId => { + const harness = window.__FRESHELL_TEST_HARNESS__! + if (!harness.receiveWsMessage) throw new Error('Public receive-message fixture is required') + harness.receiveWsMessage({ type: 'freshAgent.create.failed', requestId, + code: 'FRESH_AGENT_CREATE_FAILED', message: 'Controlled create failure', retryable: false }) + }, failedRequest) + await expect(dialog.getByRole('status')).toContainText(/catalog unavailable/i) + await expect(dialog.getByRole('searchbox')).toHaveValue('saved') + await dialog.getByRole('searchbox').fill('') + await expect(dialog.getByRole('button', { name: /Use saved-custom.*xhigh/ })).toBeEnabled() + expect(contexts).toHaveLength(1) + expect(await creates()).toHaveLength(2) +}) + +async function installPane( + page: Page, + provider: 'claude' | 'codex', + creation?: { requestId: string; model: string; effort: string }, +) { const sessionType = provider === 'claude' ? 'freshclaude' : 'freshcodex' - const sessionId = 'd4430000-0000-4444-8444-000000000001' - const model = provider === 'claude' ? 'opus[1m]' : FRESHCODEX_DEFAULT_MODEL - await page.route('**/api/fresh-agent/threads/**', (route) => route.fulfill({ - json: { - sessionType, provider, sessionId, threadId: sessionId, + const sessionId = creation ? undefined : 'd4430000-0000-4444-8444-000000000001' + const model = creation?.model ?? (provider === 'claude' ? 'opus[1m]' : FRESHCODEX_DEFAULT_MODEL) + const effort = creation?.effort ?? 'low' + const createRequestId = creation?.requestId ?? `model-dialog-${provider}` + await page.route('**/api/fresh-agent/threads/**', route => { + const requestedSessionId = decodeURIComponent(new URL(route.request().url()).pathname.split('/').at(-1)!) + return route.fulfill({ json: { + sessionType, provider, sessionId: requestedSessionId, threadId: requestedSessionId, revision: 1, latestTurnId: null, status: 'idle', capabilities: { send: true, interrupt: true, approvals: true, questions: true, fork: false }, - settings: { model, effort: 'low' }, + settings: { model, effort }, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, pendingApprovals: [], pendingQuestions: [], turns: [], - extensions: provider === 'claude' ? { claude: { liveSessionId: sessionId, cliSessionId: sessionId } } : {}, - }, - })) - await page.evaluate(({ sessionType, provider, sessionId, model }) => { + extensions: provider === 'claude' ? { claude: { liveSessionId: requestedSessionId, cliSessionId: requestedSessionId } } : {}, + } }) + }) + await page.evaluate(({ sessionType, provider, sessionId, model, effort, createRequestId }) => { const harness = window.__FRESHELL_TEST_HARNESS__! const state = harness.getState() const tabId = state.tabs.activeTabId! @@ -25,13 +146,13 @@ async function installPane(page: Page, provider: 'claude' | 'codex') { harness.setFreshAgentNetworkEffectsSuppressed(paneId, true) harness.dispatch({ type: 'panes/updatePaneContent', payload: { tabId, paneId, content: { - kind: 'fresh-agent', sessionType, provider, sessionId, - sessionRef: { provider, sessionId }, resumeSessionId: sessionId, - createRequestId: `model-dialog-${provider}`, status: 'idle', - model, effort: 'low', initialCwd: '/tmp', settingsDismissed: true, + kind: 'fresh-agent', sessionType, provider, + ...(sessionId ? { sessionId, sessionRef: { provider, sessionId }, resumeSessionId: sessionId } : {}), + createRequestId, status: sessionId ? 'idle' : 'creating', + model, effort, initialCwd: '/tmp', settingsDismissed: true, }, } }) - }, { sessionType, provider, sessionId, model }) + }, { sessionType, provider, sessionId, model, effort, createRequestId }) } test('Claude model search survives catalog loading and remembers a selected thinking level', async ({ freshellPage, page, terminal }) => { @@ -64,12 +185,16 @@ test('Claude model search survives catalog loading and remembers a selected thin test('Codex cancels a staged model change and preserves its current thinking level', async ({ freshellPage, page, terminal }) => { await terminal.waitForTerminal() + await page.route('**/api/fresh-agent/model-capabilities/freshcodex**',route=>route.fulfill({json:{...codexCatalog,models:[ + {id:FRESHCODEX_DEFAULT_MODEL,displayName:'GPT-6 Astra',provider:'codex',supportsEffort:true,supportedEffortLevels:['low','high','xhigh'],defaultEffort:'high',supportsAdaptiveThinking:false}, + ...codexCatalog.models, + ]}})) await installPane(page, 'codex') const chip = page.getByRole('button', { name: /^Model:.*change model$/ }) await chip.click() const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) await expect(dialog.getByRole('button', { name: 'Use GPT-6 Astra · low' })).toBeVisible() - await dialog.getByRole('option', { name: /GPT-5.6 Sol/ }).click() + await dialog.getByRole('option', { name: 'Provider Current', exact:true }).click() await dialog.getByRole('button', { name: 'Cancel', exact: true }).click() await chip.click() await dialog.getByRole('button', { name: 'Use GPT-6 Astra · low' }).click() diff --git a/test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts new file mode 100644 index 0000000000..d2c18af70b --- /dev/null +++ b/test/e2e-browser/specs/fresh-agent-rest-codex-rust.spec.ts @@ -0,0 +1,204 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { test, expect, createFreshE2ePage } from '../helpers/fixtures.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' +import { ensureMcpServerBuilt, McpStdioClient, REPO_ROOT } from '../helpers/mcp-stdio-client.js' + +const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) +const runFile = promisify(execFile) + +function findLeaf(node: any, id: string): any { + if (node?.type === 'leaf') return node.id === id ? node : undefined + return (node?.children ?? []).map((child: any) => findLeaf(child, id)).find(Boolean) +} + +test('REST creates and splits Freshcodex through the browser owning native runtime without moving focus', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-rest-')) + const wrapper = path.join(root, 'codex.mjs') + const requests = path.join(root, 'requests.jsonl') + await fs.writeFile(wrapper, `#!/usr/bin/env node +import { randomUUID } from 'node:crypto' +import path from 'node:path' +const threadId = randomUUID() +const now = new Date() +const rollout = path.join(process.env.CODEX_HOME, 'sessions', String(now.getUTCFullYear()), String(now.getUTCMonth() + 1).padStart(2, '0'), String(now.getUTCDate()).padStart(2, '0'), \`rollout-\${threadId}.jsonl\`) +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR = JSON.stringify({ + ...JSON.parse(process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR || '{}'), + threadStartThreadId: threadId, + writeRolloutOnMethods: { 'turn/start': { path: rollout, record: { type: 'session_meta', payload: { id: threadId, cwd: process.cwd() } } } }, +}) +await import(${JSON.stringify(source)}) +`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, echoRequestedModel: true, appendRequestLogPath: requests }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { + freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] }, + } })) + } }) + let context: Awaited>['context'] | undefined + let mcp: McpStdioClient | undefined + try { + const mcpPath = ensureMcpServerBuilt(REPO_ROOT).path + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + const headers = { 'x-auth-token': info.token } + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + const input = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(input).toBeEnabled({ timeout: 30_000 }) + const controlPrompt = 'Task009 browser native readiness' + await input.fill(controlPrompt) + await pane.getByRole('button', { name: 'Send' }).click() + await expect(pane.getByText('Fixture turn', { exact: true })).toBeVisible({ timeout: 30_000 }) + const nativeRequests = () => fs.readFile(requests, 'utf8').then((text) => text.trim().split('\n').map((line) => JSON.parse(line))) + await expect.poll(async () => (await nativeRequests()).filter((row) => row.method === 'turn/start').length).toBe(1) + const nativeTurn = (await nativeRequests()).find((row) => row.method === 'turn/start') + const sessionId = nativeTurn.params.threadId + expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + await expect.poll(async () => { + const response = await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${sessionId}`, { headers }) + const snapshot = await response.json() + return { status: snapshot.status, rows: snapshot.turns?.length } + }, { timeout: 30_000 }).toEqual({ status: 'idle', rows: 2 }) + console.log('TASK009_BROWSER_NATIVE_READY', sessionId) + const originalTab = await harness.getActiveTabId() + const originalPane = await pane.getAttribute('data-pane-id') + await expect.poll(async () => { + const response = await page.request.get(`${info.baseUrl}/api/panes`, { headers }) + return JSON.stringify(await response.json()).includes(originalPane!) + }).toBe(true) + await input.focus() + const createBody = { agent: 'codex', cwd: root, model: 'provider-current', effort: 'xhigh', collaborationMode: 'plan', sandbox: 'workspace-write', permissionMode: 'on-request' } + const created = await page.request.post(`${info.baseUrl}/api/tabs`, { headers, data: createBody }) + const split = await page.request.post(`${info.baseUrl}/api/panes/${originalPane}/split`, { headers, data: { ...createBody, direction: 'vertical' } }) + const createdBody = await created.json() + const splitBody = await split.json() + expect({ create: created.status(), split: split.status() }, JSON.stringify({ create: createdBody, split: splitBody })).toEqual({ create: 200, split: 200 }) + const createdPane = createdBody.data.paneId as string + const createdTab = createdBody.data.tabId as string + const createdRef = createdBody.data.sessionRef + const splitPane = splitBody.data.paneId as string + await expect.poll(async () => findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content?.sessionRef).toEqual(createdRef) + const splitView = page.locator(`[data-context="fresh-agent"][data-pane-id="${splitPane}"]`) + await expect(splitView.getByRole('textbox', { name: 'Chat message input' })).toBeEnabled({ timeout: 30_000 }) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + await expect.poll(() => harness.getActiveTabId()).toBe(originalTab) + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${originalPane}"]`).getByRole('textbox', { name: 'Chat message input' })).toBeFocused() + expect(await page.evaluate(() => document.activeElement?.closest('[data-pane-id]')?.getAttribute('data-pane-id'))).toBe(originalPane) + + const sent = await page.request.post(`${info.baseUrl}/api/panes/${createdPane}/send-keys`, { headers, data: { data: 'REST native Task009\r', requestId: 'browser-rest-send' } }) + const sentBody = await sent.json() + expect(sent.ok(), JSON.stringify(sentBody)).toBe(true) + expect(sentBody.data.status).toBe('idle') + expect(sentBody.data.submittedTurnId).toBe('turn-1') + const capture = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/capture`, { headers }) + expect(capture.ok()).toBe(true) + expect(await capture.text()).toContain('user: REST native Task009') + expect(await capture.text()).toContain('assistant: Fixture turn') + const bounded = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/capture?maxBytes=10`, { headers }) + expect(Buffer.byteLength(await bounded.text())).toBeLessThanOrEqual(10) + const waiting = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/wait-for?pattern=Fixture%20turn&T=0`, { headers }) + expect((await waiting.json()).data.matched).toBe(true) + const absent = await page.request.get(`${info.baseUrl}/api/panes/${createdPane}/wait-for?pattern=not-present-Task009&T=0`, { headers }) + expect(await absent.json()).toMatchObject({ status: 'approx', data: { matched: false } }) + + mcp = new McpStdioClient({ command: process.execPath, args: [mcpPath], env: { ...process.env, FRESHELL_URL: info.baseUrl, FRESHELL_TOKEN: info.token }, requestTimeoutMs: 60_000 }) + await mcp.initialize() + const mcpCreated = await mcp.callFreshellAction('new-tab', { agent: 'codex', cwd: root, model: 'provider-current', effort: 'xhigh', prompt: 'MCP native Task009' }) + expect(mcpCreated.status, JSON.stringify(mcpCreated)).toBe('ok') + expect(mcpCreated.data.sessionRef.provider).toBe('codex') + expect(await mcp.callFreshellAction('capture-pane', { target: mcpCreated.data.paneId })).toContain('user: MCP native Task009') + const startsBeforeCli = (await nativeRequests()).filter((row) => row.method === 'thread/start').length + const cli = await runFile(process.execPath, [path.join(REPO_ROOT, 'dist/tools/freshell-cli/index.js'), 'new-tab', '--agent', 'codex', '--resume', mcpCreated.data.sessionRef.sessionId, '--prompt', 'CLI native Task009'], { + env: { ...process.env, FRESHELL_URL: info.baseUrl, FRESHELL_TOKEN: info.token }, encoding: 'utf8', timeout: 60_000, + }) + const cliCreated = JSON.parse(cli.stdout) + expect(cliCreated.status, cli.stdout).toBe('ok') + expect(cliCreated.data.sessionRef).toEqual(mcpCreated.data.sessionRef) + expect((await nativeRequests()).filter((row) => row.method === 'thread/start')).toHaveLength(startsBeforeCli) + expect((await nativeRequests()).filter((row) => row.method === 'thread/resume')).toHaveLength(0) + expect(await mcp.callFreshellAction('capture-pane', { target: cliCreated.data.paneId })).toContain('user: CLI native Task009') + expect(await mcp.callFreshellAction('wait-for', { target: cliCreated.data.paneId, pattern: 'CLI native Task009', timeout: 0 })).toMatchObject({ status: 'ok', data: { matched: true } }) + const turnsBeforeNoop = (await nativeRequests()).filter((row) => row.method === 'turn/start').length + expect(await mcp.callFreshellAction('send-keys', { target: splitPane, keys: '\r', literal: true })).toMatchObject({ status: 'ok', data: { status: 'noop' } }) + expect((await nativeRequests()).filter((row) => row.method === 'turn/start')).toHaveLength(turnsBeforeNoop) + + const savedName = 'Task009 saved Codex' + const namingContent = findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content + const sessionsRoot = path.join(info.homeDir, '.codex', 'sessions') + const nativeFile = (await fs.readdir(sessionsRoot, { recursive: true })) + .find((name) => name.endsWith(`rollout-${createdRef.sessionId}.jsonl`)) + const nativeMeta = nativeFile ? JSON.parse((await fs.readFile(path.join(sessionsRoot, nativeFile), 'utf8')).split('\n')[0]) : undefined + const nameDocument = JSON.parse(await fs.readFile(path.join(info.homeDir, '.freshell', 'session-names.json'), 'utf8')) + console.log('TASK009_NAME_BINDING_CONTEXT', JSON.stringify({ + sessionId: createdRef.sessionId, + pane: { sessionId: namingContent?.sessionId, sessionRef: namingContent?.sessionRef, nameRef: namingContent?.nameRef, namingHandle: namingContent?.namingHandle }, + nativeMeta: { type: nativeMeta?.type, id: nativeMeta?.payload?.id, cwd: nativeMeta?.payload?.cwd }, + records: Object.values(nameDocument.records).map((record: any) => ({ ref: record.ref, name: record.name, source: record.source })), + redirects: Object.values(nameDocument.redirects), + })) + expect(nativeMeta?.type).toBe('session_meta') + expect(nativeMeta?.payload?.id).toBe(createdRef.sessionId) + expect(namingContent?.sessionRef).toEqual(createdRef) + const renamed = await mcp.callFreshellAction('rename-pane', { target: createdPane, name: savedName, nameIntent: 'user' }) + expect(renamed.status, JSON.stringify(renamed)).toBe('ok') + const readName = async () => { + const response = await page.request.post(`${info.baseUrl}/api/session-names/read`, { headers, data: { refs: [{ kind: 'session', provider: 'codex', sessionId: createdRef.sessionId }] } }) + return (await response.json()).names[0]?.record + } + const observedName = await readName() + console.log('TASK009_NAME_EVIDENCE', JSON.stringify({ + sessionId: createdRef.sessionId, renameStatus: renamed.status, + renameNameRef: renamed.data?.nameRef, + readNameRef: observedName?.ref, name: observedName?.name, source: observedName?.source, + })) + await expect.poll(readName, { timeout: 30_000 }).toMatchObject({ name: savedName, source: 'manual' }) + await expect.poll(async () => findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content?.sessionRef).toEqual(createdRef) + const startsBeforeReload = (await nativeRequests()).filter((row) => row.method === 'thread/start').length + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect.poll(async () => findLeaf(await harness.getPaneLayout(createdTab), createdPane)?.content?.sessionRef).toEqual(createdRef) + await mcp.callFreshellAction('select-tab', { target: createdTab }) + const restored = page.locator(`[data-context="fresh-agent"][data-pane-id="${createdPane}"]`) + await expect(restored.getByRole('textbox', { name: 'Chat message input' })).toBeEnabled({ timeout: 30_000 }) + await expect(restored).toContainText('REST native Task009') + await expect(restored).toContainText('Fixture turn') + await expect(page.locator(`[data-context="tab"][data-tab-id="${createdTab}"]`)).toContainText(savedName) + expect(await readName()).toMatchObject({ name: savedName, source: 'manual' }) + expect(await mcp.callFreshellAction('capture-pane', { target: createdPane })).toContain('user: REST native Task009') + expect((await nativeRequests()).filter((row) => row.method === 'thread/start')).toHaveLength(startsBeforeReload) + const snapshot = await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${createdRef.sessionId}`, { headers }) + expect((await snapshot.json()).settings).toMatchObject({ model: 'provider-current', effort: 'xhigh', collaborationMode: 'plan', sandbox: 'workspace-write', permissionMode: 'on-request', cwd: root }) + const prompts = (await nativeRequests()).filter((row) => row.method === 'turn/start').map((row) => row.params.input.map((part: any) => part.text ?? '').join('')) + for (const prompt of [controlPrompt, 'REST native Task009', 'MCP native Task009', 'CLI native Task009']) expect(prompts.filter((value) => value === prompt)).toHaveLength(1) + } finally { + await mcp?.close().catch(() => {}) + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts b/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts index 1328a3d3c3..e8ff7afcc9 100644 --- a/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts @@ -10,10 +10,8 @@ // configure lane (the fake's sdk.configured receipt) and broadcasts the // metadata frame; the simulator ledger records the SDK settings call. // -// The codex lane's wire contract is pinned in-crate -// (crates/freshell-freshagent/src/codex.rs configure tests against the real -// fake app-server) — the e2e app-server fixture path used by sibling specs -// is stale, so no codex leg here. +// Codex uses the same real Rust configure and metadata path, with only its +// native executable replaced by the current app-server fixture. // // Rust-only: raw-WS + owned RustServer per test (the rust-chromium project). // The client-side UI halves (dialog commit → configure frame; metadata fold → @@ -142,6 +140,54 @@ function seedWallConfig(input: { } test.describe('fresh-agent live model convergence (rust)', () => { + test('codex: two connections converge settings and the next native turn carries the exact pair', async () => { + test.setTimeout(120_000) + const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-conv-codex-')) + const wrapper = path.join(root, 'codex.mjs') + const log = path.join(root, 'requests.jsonl') + const fixture = path.resolve(__dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs') + await fsp.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(fixture)}\n`) + await fsp.chmod(wrapper, 0o755) + const server = new RustServer({ env: { CODEX_CMD: wrapper, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, echoRequestedModel: true, appendRequestLogPath: log }) }, + setupHome: seedWallConfig({ providers: ['codex'], freshAgent: true }) }) + const wires: WsCapture[] = [] + try { + const info = await server.start() + const first = new WsCapture(info.baseUrl, info.token) + const second = new WsCapture(info.baseUrl, info.token) + wires.push(first, second) + await Promise.all(wires.map(wire => wire.ready())) + first.send({ type: 'freshAgent.create', provider: 'codex', sessionType: 'freshcodex', requestId: 'codex-converge-create', cwd: root, + model: 'initial-native-model', effort: 'low' }) + const created = await first.waitFor(frame => frame.requestId === 'codex-converge-create' + && ['freshAgent.created', 'freshAgent.create.failed'].includes(frame.type), 30_000, 'Codex creation') + expect(created.type, JSON.stringify(created)).toBe('freshAgent.created') + const sessionId = created.sessionId + first.send({ type: 'freshAgent.configure', provider: 'codex', sessionType: 'freshcodex', sessionId, requestId: 'codex-converge-configure', + settings: { model: 'catalog-native-model', effort: 'ultra', collaborationMode: 'plan' } }) + for (const wire of wires) { + const metadata = await wire.waitFor(frame => frame.type === 'freshAgent.event' && frame.sessionId === sessionId + && frame.event?.type === 'freshAgent.session.metadata' && frame.event.model === 'catalog-native-model', 30_000, 'shared Codex metadata') + expect(metadata.event).toMatchObject({ model: 'catalog-native-model', effort: 'ultra', collaborationMode: 'plan' }) + } + second.send({ type: 'freshAgent.send', provider: 'codex', sessionType: 'freshcodex', sessionId, requestId: 'codex-converge-send', text: 'Use the configured native model' }) + await expect.poll(() => readJsonl(log).filter(row => row.method === 'turn/start').length).toBe(1) + expect(readJsonl(log).find(row => row.method === 'turn/start').params).toMatchObject({ threadId: sessionId, + model: 'catalog-native-model', effort: 'ultra', collaborationMode: { mode: 'plan', settings: { model: 'catalog-native-model', reasoning_effort: 'ultra' } } }) + await second.waitFor(frame => frame.type === 'freshAgent.event' && frame.sessionId === sessionId + && frame.event?.type === 'freshAgent.turn.complete', 30_000, 'Codex native completion') + const response = await fetch(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${sessionId}`, { headers: { 'x-auth-token': info.token } }) + expect(response.ok).toBe(true) + expect((await response.json()).settings).toMatchObject({ model: 'catalog-native-model', effort: 'ultra', collaborationMode: 'plan' }) + expect(readJsonl(log).filter(row => row.method === 'thread/start')).toHaveLength(1) + } finally { + wires.forEach(wire => wire.close()) + await server.stop() + await fsp.rm(root, { recursive: true, force: true }) + } + }) + test('opencode: configure broadcasts session metadata and the next turn carries the pair', async () => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-conv-opencode-')) diff --git a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts index ed6d96cbf4..044f4248a4 100644 --- a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts @@ -313,10 +313,11 @@ test.describe('fresh-agent settings survive restart (rust)', () => { } }) - test('codex: create-shaped resume after restart carries the recorded model', async () => { + test('codex: configured xhigh, file access, Plan and explicit Default survive actual server restarts', async () => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-settings-codex-')) const opLogPath = path.join(sharedRoot, 'codex-ops.jsonl') + const notificationLogPath = path.join(sharedRoot, 'codex-notifications.jsonl') const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const server = new RustServer({ @@ -325,7 +326,10 @@ test.describe('fresh-agent settings survive restart (rust)', () => { CODEX_CMD: `${process.execPath} ${CODEX_FIXTURE}`, // STATIC per server boot (V4/A6): resume succeeds by default. FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ - appendThreadOperationLogPath: opLogPath, + appendRequestLogPath: opLogPath, + appendNativeNotificationLogPath: notificationLogPath, + echoRequestedModel: true, + recordTurns: true, }), }, }) @@ -353,22 +357,23 @@ test.describe('fresh-agent settings survive restart (rust)', () => { ) const threadId = created.sessionId as string expect(threadId).toBeTruthy() + const sendAndConsumeTerminal = async (requestId: string, text: string) => { + const capture = ws! + const from = capture.frames.length + const notificationFrom = readJsonl(notificationLogPath).length + capture.send({type:'freshAgent.send',requestId,provider:'codex',sessionType:'freshcodex',sessionId:threadId,text}) + const accepted = await capture.waitFor(f=>f.type==='freshAgent.send.accepted' && f.requestId===requestId,30_000,`accepted ${requestId}`) + expect(accepted.submittedTurnId).toBeTruthy() + await capture.waitFor(f=>capture.frames.indexOf(f)>=from && f.type==='freshAgent.event' && f.sessionId===threadId && f.event?.type==='freshAgent.turn.complete',30_000,`consumed terminal ${requestId}`) + expect(readJsonl(notificationLogPath).slice(notificationFrom).some(row=>row.method==='turn/completed' && row.threadId===threadId && row.turnId===accepted.submittedTurnId)).toBe(true) + } - // One turn. `freshAgent.send.accepted` is broadcast after `turn/start` - // resolves (codex.rs handle_send), and the fake answers turn/start with a - // completed turn -- so this IS the turn's completion edge on this fixture. - ws.send({ - type: 'freshAgent.send', - provider: 'codex', - sessionId: threadId, - sessionType: 'freshcodex', - text: 'settings-resume first turn', - }) - await ws.waitFor( - (f) => f.type === 'freshAgent.send.accepted' && f.sessionId === threadId, - 30_000, - 'freshAgent.send.accepted (codex first turn)', - ) + ws.send({type:'freshAgent.configure',requestId:'codex-settings-xhigh',provider:'codex',sessionType:'freshcodex',sessionId:threadId,settings:{effort:'xhigh'}}) + await ws.waitFor(f=>f.type==='freshAgent.event' && f.event?.type==='freshAgent.session.metadata' && f.event.effort==='xhigh',30_000,'exact xhigh metadata after configure') + + // Join native acceptance to its matching terminal notification and the + // owning Rust consumer's completion edge before restarting the server. + await sendAndConsumeTerminal('codex-settings-before-restart','settings-resume first turn') ws.close() ws = null @@ -398,6 +403,35 @@ test.describe('fresh-agent settings survive restart (rust)', () => { expect(resume, 'restart must resume the durable thread').toBeTruthy() expect(resume.params.model, 'resume must carry the recorded model, not null').toBe('gpt-5.3-codex-spark') expect(resume.params.sandbox, 'resume must carry the recorded sandbox').toBe('workspace-write') + await sendAndConsumeTerminal('codex-settings-after-restart','settings-resume second turn') + const turns=readJsonl(opLogPath).filter(op=>op.method==='turn/start') + expect(turns).toHaveLength(2) + expect(turns.map(turn=>turn.params.model)).toEqual(['gpt-5.3-codex-spark','gpt-5.3-codex-spark']) + expect(turns.map(turn=>turn.params.effort)).toEqual(['xhigh','xhigh']) + // Partial changes retain xhigh; explicit Default clears it before a + // second actual server restart. The resumed create states no settings. + const nestedCwd = path.join(projectDir, 'nested') + await fsp.mkdir(nestedCwd) + ws.send({type:'freshAgent.configure',requestId:'codex-settings-scoped',provider:'codex',sessionType:'freshcodex',sessionId:threadId,settings:{cwd:nestedCwd,sandbox:'read-only',permissionMode:'never',collaborationMode:'plan'}}) + await ws.waitFor(f=>f.type==='freshAgent.event' && f.event?.type==='freshAgent.session.metadata' && f.event.requestId==='codex-settings-scoped' && f.event.effort==='xhigh' && f.event.cwd===nestedCwd && f.event.collaborationMode==='plan',30_000,'complete staged defaults retain untouched effort') + ws.send({type:'freshAgent.configure',requestId:'codex-settings-default',provider:'codex',sessionType:'freshcodex',sessionId:threadId,settings:{effort:null}}) + await ws.waitFor(f=>f.type==='freshAgent.event' && f.event?.type==='freshAgent.session.metadata' && f.event.requestId==='codex-settings-default' && f.event.effort===null,30_000,'explicit native Default metadata') + ws.close() + ws = null + await server.restartAbrupt() + await enableFreshAgent(server.info.baseUrl, server.info.token) + ws = new WsCapture(server.info.baseUrl, server.info.token) + await ws.ready() + ws.send({type:'freshAgent.create',requestId:'codex-settings-default-resume',provider:'codex',sessionType:'freshcodex',sessionRef:{provider:'codex',sessionId:threadId}}) + await ws.waitFor(f=>f.type==='freshAgent.created' && f.requestId==='codex-settings-default-resume',30_000,'settings-free create resumes cleared defaults from the ledger') + const response = await fetch(`${server.info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${encodeURIComponent(threadId)}`,{headers:{'x-auth-token':server.info.token}}) + expect(response.ok).toBe(true) + expect((await response.json() as any).settings).toEqual({model:'gpt-5.3-codex-spark',effort:null,cwd:nestedCwd,sandbox:'read-only',permissionMode:'never',collaborationMode:'plan'}) + await sendAndConsumeTerminal('codex-default-after-second-restart','native Default after actual restart') + const afterDefault = readJsonl(opLogPath).filter(op=>op.method==='turn/start') + expect(afterDefault).toHaveLength(3) + expect(afterDefault[2].params).not.toHaveProperty('effort') + expect(afterDefault[2].params).toMatchObject({cwd:nestedCwd,sandboxPolicy:{type:'readOnly'},approvalPolicy:'never',collaborationMode:{mode:'plan',settings:{model:'gpt-5.3-codex-spark',reasoning_effort:null,developer_instructions:null}}}) } finally { ws?.close() await server.stop().catch(() => {}) diff --git a/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts new file mode 100644 index 0000000000..b57e6313ff --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-child-observer-rust.spec.ts @@ -0,0 +1,120 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } from '../helpers/fixtures.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' + +const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) +const parentId = '11111111-1111-4111-8111-111111111111' +const childId = '22222222-2222-4222-8222-222222222222' + +test('Freshcodex observes the exact child read-only and restores the selection after reload', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-child-')) + const wrapper = path.join(root, 'codex.mjs') + const operations = path.join(root, 'operations.jsonl') + const outputImage = path.join(root, 'generated-output.png') + const imageBytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1UAAAAASUVORK5CYII=', 'base64') + await fs.writeFile(outputImage, imageBytes) + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const parent = { id: parentId, ephemeral: false, status: { type: 'idle' }, turns: [{ id: 'parent-turn', status: 'completed', items: [ + { id: 'parent-answer', type: 'agentMessage', text: 'Parent conversation remains available', delivery: 'async', questions: [{ title: 'Parent question?', options: ['Parent choice'] }] }, + { id: 'denied', type: 'commandExecution', command: 'touch blocked', status: 'declined', aggregatedOutput: 'User declined', exitCode: null }, + { id: 'image-output', type: 'imageGeneration', status: 'completed', result: outputImage, savedPath: outputImage }, + { id: 'spawn', type: 'collabAgentToolCall', tool: 'spawnAgent', status: 'completed', senderThreadId: parentId, receiverThreadIds: [childId], agentsStates: { [childId]: { status: 'completed', message: 'Child finished', model: 'child-model' } } }, + ] }] } + const child = { id: childId, parentThreadId: parentId, ephemeral: false, status: { type: 'idle' }, turns: [ + { id: 'child-turn', status: 'completed', itemsView: 'full', items: [{ id: 'child-answer', type: 'agentMessage', text: 'Exact observed child answer', delivery: 'async', questions: [{ title: 'Child question?', options: ['Child choice'] }] }] }, + { id: 'preview-turn', status: 'interrupted', itemsView: 'summary', items: [{ id: 'child-preview', type: 'agentMessage', summary: 'Partial child preview' }] }, + ] } + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ threadStartThreadId: parentId, appendThreadOperationLogPath: operations, threadReadResults: { [parentId]: parent, [childId]: child }, overrides: { 'thread/read': { result: { thread: parent } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + const inputs: Array> = [] + page.on('websocket', (socket) => socket.on('framesent', (frame) => { + try { const value = JSON.parse(String(frame.payload)); if (value.type === 'freshAgent.send' || value.type === 'freshAgent.question.respond') inputs.push(value) } catch { /* unrelated frame */ } + })) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + await expect(pane.getByText('Parent conversation remains available', { exact: true })).toBeVisible({ timeout: 30_000 }) + await pane.getByRole('button', { name: 'Parent choice', exact: true }).click() + await expect(pane.getByRole('textbox', { name: 'Chat message input' })).toHaveValue(/parent-answer/) + const before = await fs.readFile(operations, 'utf8') + const open = pane.getByRole('button', { name: `Observe child ${childId}`, exact: true }).first() + await open.click({ timeout: 15_000 }) + const observer = page.getByRole('dialog', { name: 'Child conversation' }) + await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible() + await expect(observer.getByText('Partial child preview', { exact: true })).toBeVisible() + await expect(observer.getByRole('note', { name: 'Output persistence' })).toHaveText('Native preview. Interrupted.') + await expect(observer.getByText('Child question?', { exact: true })).toBeVisible() + await expect(observer.getByText('Child choice', { exact: true })).toBeVisible() + await expect(observer.getByRole('button', { name: 'Child choice', exact: true })).toHaveCount(0) + await expect(observer.getByRole('textbox')).toHaveCount(0) + await expect(observer.getByRole('button', { name: /send|stop|configure|resume/i })).toHaveCount(0) + const close = observer.getByRole('button', { name: 'Close child conversation' }) + await expect(close).toBeFocused() + await page.keyboard.press('Shift+Tab') + await expect(observer.getByRole('button', { name: 'Copy turn', exact: true }).last()).toBeFocused() + await page.keyboard.press('Tab') + await expect(close).toBeFocused() + await page.keyboard.press('Escape') + await expect(observer).toHaveCount(0) + await expect(open).toBeFocused() + await open.click() + await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible() + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect(observer.getByText('Exact observed child answer', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(observer.getByRole('note', { name: 'Output persistence' })).toHaveText('Native preview. Interrupted.') + await expect(pane.getByText('Parent conversation remains available', { exact: true })).toHaveCount(1) + await observer.getByRole('button', { name: 'Close child conversation' }).click() + await expect(observer).toHaveCount(0) + await pane.getByRole('button', { name: 'Toggle activity details' }).first().click() + await expect(pane.getByLabel('declined')).toBeVisible() + await pane.getByRole('button', { name: 'ImageGeneration tool call' }).click() + const output = pane.getByRole('link', { name: outputImage, exact: true }) + await expect(output).toBeVisible() + const image = await page.request.get(new URL((await output.getAttribute('href'))!, info.baseUrl).href) + expect(image.status()).toBe(200) + expect(await image.body()).toEqual(imageBytes) + const rows = (await fs.readFile(operations, 'utf8')).split('\n').filter(Boolean).map((line) => JSON.parse(line)) + const prior = before.split('\n').filter(Boolean).map((line) => JSON.parse(line)) + expect(rows.filter((row) => row.method === 'thread/start')).toHaveLength(prior.filter((row) => row.method === 'thread/start').length) + expect(rows.filter((row) => row.params?.threadId === childId && row.method !== 'thread/read')).toEqual([]) + expect(inputs).toEqual([]) + await expect(pane.getByRole('textbox', { name: 'Chat message input' })).toHaveValue(/parent-answer/) + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await expect.poll(() => inputs.length).toBe(1) + expect(inputs[0]).toMatchObject({ type: 'freshAgent.send', text: expect.stringContaining('Parent choice') }) + await expect(pane.getByText('Answer accepted as a message', { exact: true })).toBeVisible() + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/e2e-browser/specs/freshcodex-controls-rust.spec.ts b/test/e2e-browser/specs/freshcodex-controls-rust.spec.ts new file mode 100644 index 0000000000..62d35585d8 --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-controls-rust.spec.ts @@ -0,0 +1,187 @@ +import type { Browser, Locator, Page } from '@playwright/test' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } from '../helpers/fixtures.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' + +const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + + +function controlReplies(page: Page) { + const frames: Array> = [] + page.on('websocket', (socket) => socket.on('framesent', (frame) => { + try { + const message = JSON.parse(String(frame.payload)) + if (message.type === 'freshAgent.question.respond' || message.type === 'freshAgent.elicitation.respond') frames.push(message) + } catch { /* Non-JSON frames are unrelated to controls. */ } + })) + return frames +} + +function controlRequests(page: Page) { + const requests: Array> = [] + page.on('websocket', (socket) => socket.on('framereceived', (frame) => { + try { + const message = JSON.parse(String(frame.payload)) + if (['freshAgent.question.request', 'freshAgent.elicitation.request'].includes(message.event?.type)) requests.push(message.event) + } catch { /* Non-JSON frames are unrelated to controls. */ } + })) + return requests +} + +test('Freshcodex retains secret native question metadata after reload and sends its exact answer', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-controls-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, serverRequestsByPrompt: { 'Ask for token': { id: 51, method: 'item/tool/requestUserInput', params: { isBlocking: false, questions: [{ id: 'token', header: 'Token', question: 'Enter token', options: null, isOther: false, isSecret: true }] } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + const replies = controlReplies(page) + const requests = controlRequests(page) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + await pane.getByRole('textbox', { name: 'Chat message input' }).fill('Ask for token') + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await expect(pane.getByRole('region', { name: 'Question from Codex' })).toBeVisible() + const other = pane.getByRole('button', { name: 'Other', exact: true }) + if (await other.count()) await other.click() + const secret = pane.getByLabel('Enter token') + await expect(secret).toHaveAttribute('type', 'password') + await secret.fill('unsent secret') + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect(secret).toHaveAttribute('type', 'password') + await expect(secret).toHaveValue('') + await expect(pane.getByRole('button', { name: 'Other', exact: true })).toHaveCount(0) + await secret.fill(' token, punctuation! ') + await pane.getByRole('button', { name: 'Submit', exact: true }).click() + await expect(secret).toHaveCount(0) + expect(requests[0]?.providerRequest).toMatchObject({ id: 51, method: 'item/tool/requestUserInput' }) + expect(replies).toEqual([{ type: 'freshAgent.question.respond', sessionId: 'thread-new-1', sessionType: 'freshcodex', provider: 'codex', requestId: requests[0]?.requestId, answers: { token: ' token, punctuation! ' } }]) + await expect(pane.getByRole('button', { name: 'Send', exact: true })).toBeVisible() + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) + + +async function withNativeControl(browser: Browser, params: Record, run: (page: Page, pane: Locator, replies: Array>, requests: Array>) => Promise) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-elicitation-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, serverRequestsByPrompt: { 'Ask fixture': { id: 41, method: 'mcpServer/elicitation/request', params: { serverName: 'contract', message: 'A native MCP request', ...params } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + const replies = controlReplies(page) + const requests = controlRequests(page) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + await pane.getByRole('textbox', { name: 'Chat message input' }).fill('Ask fixture') + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await run(page, pane, replies, requests) + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +} + +for (const mode of ['form', 'openai/form', 'openaiForm']) { + test(`Freshcodex answers native ${mode} with typed values after reload`, async ({ browser }) => { + test.setTimeout(180_000) + await withNativeControl(browser, { mode, requestedSchema: { type: 'object', properties: { + token: { type: 'string', title: 'Token', writeOnly: true }, + count: { type: 'integer', title: 'Count', minimum: 1, maximum: 5 }, + enabled: { type: 'boolean', title: 'Enabled' }, + color: { type: 'string', title: 'Color', enum: ['blue', 'green'] }, + colors: { type: 'array', title: 'Colors', items: { type: 'string', enum: ['blue', 'green'] } }, + }, required: ['token', 'count', 'enabled', 'color', 'colors'] } }, async (page, pane, replies, requests) => { + const banner = pane.getByRole('region', { name: 'Request from contract' }) + await expect(banner).toBeVisible() + await expect(banner.getByLabel('Token', { exact: true })).toHaveAttribute('type', 'password') + await banner.getByLabel('Token', { exact: true }).fill('unsent fixture value') + await page.reload() + await expect(banner).toBeVisible({ timeout: 30_000 }) + await expect(banner.getByLabel('Token', { exact: true })).toHaveValue('') + expect(replies).toEqual([]) + await banner.getByLabel('Token', { exact: true }).fill(' exact, form answer! ') + await banner.getByLabel('Count', { exact: true }).fill('3') + await banner.getByLabel('Enabled', { exact: true }).selectOption('false') + await banner.getByLabel('Color', { exact: true }).selectOption('blue') + await banner.getByLabel('Colors', { exact: true }).selectOption(['blue', 'green']) + await banner.getByRole('button', { name: 'Continue', exact: true }).click() + await expect(banner).toHaveCount(0) + expect(requests[0]?.providerRequest).toMatchObject({ id: 41, method: 'mcpServer/elicitation/request' }) + expect(replies).toEqual([{ type: 'freshAgent.elicitation.respond', sessionId: 'thread-new-1', sessionType: 'freshcodex', provider: 'codex', requestId: requests[0]?.requestId, action: 'accept', content: { token: ' exact, form answer! ', count: 3, enabled: false, color: 'blue', colors: ['blue', 'green'] } }]) + }) + }) +} + +test('Freshcodex opens native URL elicitation without accepting until Continue is clicked', async ({ browser }) => { + test.setTimeout(180_000) + await withNativeControl(browser, { mode: 'url', turnId: null, url: 'https://elicitation.example.test/confirm', elicitationId: 'public-url-id' }, async (page, pane, replies, requests) => { + await page.context().route('https://elicitation.example.test/**', (route) => route.fulfill({ body: 'Controlled fixture page' })) + const banner = pane.getByRole('region', { name: 'Request from contract' }) + await expect(banner).toBeVisible() + const popupPromise = page.waitForEvent('popup') + await banner.getByRole('link', { name: 'Open contract page' }).click() + const popup = await popupPromise + expect(replies).toEqual([]) + await expect(banner).toBeVisible() + await popup.close() + await banner.getByRole('button', { name: 'Continue', exact: true }).click() + await expect(banner).toHaveCount(0) + expect(requests[0]?.providerRequest).toMatchObject({ id: 41, method: 'mcpServer/elicitation/request' }) + expect(replies).toEqual([{ type: 'freshAgent.elicitation.respond', sessionId: 'thread-new-1', sessionType: 'freshcodex', provider: 'codex', requestId: requests[0]?.requestId, action: 'accept', content: null }]) + }) +}) diff --git a/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts b/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts new file mode 100644 index 0000000000..10d7b95164 --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-live-transcript-rust.spec.ts @@ -0,0 +1,483 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } from '../helpers/fixtures.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' +import { createHash, randomUUID } from 'node:crypto' + +const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + +/** Match the qualified native contract: initial session metadata exists before + * completion. Only the executable is replaced; every server and UI door is real. */ +async function installNativeWrapper(file: string, processSetup = ''): Promise { + await fs.writeFile(file, `#!/usr/bin/env node +import { randomUUID } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +const threadId=randomUUID() +let behavior=JSON.parse(process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR) +${processSetup} +process.env.FAKE_CODEX_APP_SERVER_BEHAVIOR=JSON.stringify({ + ...behavior,threadStartThreadId:threadId,requestLogContext:{nativeThreadId:threadId}, + writeRolloutOnMethods:{'turn/start':{path:path.join(process.env.CODEX_HOME,'sessions','rollout-'+threadId+'.jsonl'),record:{type:'session_meta',payload:{id:threadId,cwd:process.cwd()}}}} +}) +await import(${JSON.stringify(source)}) +`) + await fs.chmod(file, 0o755) +} + +for (const terminalItemsView of ['summary', 'notLoaded']) test(`Freshcodex shows native text and command progress before ${terminalItemsView} completion, isolates hidden panes, then restores one saved answer`, async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-live-')) + const wrapper = path.join(root, 'codex.mjs') + const release = path.join(root, 'release') + const nativeLog = path.join(root, 'native.jsonl') + const nativeRequests = path.join(root, 'native-requests.jsonl') + const nativeFileReads = path.join(root, 'native-file-reads.jsonl') + await installNativeWrapper(wrapper) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, echoRequestedModel: true, liveTranscript: true, liveTranscriptPrompts: ['Show native live progress'], terminalItemsView, + waitForTurnReleasePath: release, appendNativeNotificationLogPath: nativeLog, appendRequestLogPath: nativeRequests, + appendInputFileReadLogPath: nativeFileReads, + serverRequestsByPrompt: { SECOND_PANE_WAIT: { id: 'second-pane-question', method: 'item/tool/requestUserInput', params: { isBlocking: true, + questions: [{ id: 'second', header: 'Second', question: 'Second pane question?', options: [{ label: 'Second pane choice', description: 'Only this pane' }] }] } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { + freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] }, + } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pendingPane = page.locator('[data-context="fresh-agent"]').last() + await expect(pendingPane).toBeVisible() + const paneId = await pendingPane.getAttribute('data-pane-id') + expect(paneId).toBeTruthy() + const pane = page.locator(`[data-context="fresh-agent"][data-pane-id="${paneId}"]`) + const input = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(input).toBeEnabled({ timeout: 30_000 }) + const uploadedImage = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR1UAAAAASUVORK5CYII=', 'base64') + const uploadedNotes = Buffer.from('Uploaded fixture notes') + await pane.locator('input[type="file"]').setInputFiles([ + { name: 'native-upload.png', mimeType: 'image/png', buffer: uploadedImage }, + { name: 'native-notes.txt', mimeType: 'text/plain', buffer: uploadedNotes }, + ]) + await expect(pane.getByRole('list', { name: 'Attachments' }).getByText('native-upload.png')).toBeVisible() + await expect(pane.getByRole('list', { name: 'Attachments' }).getByText('native-notes.txt')).toBeVisible() + await expect(pane.getByLabel('uploading')).toHaveCount(0) + const attachments = pane.getByRole('list', { name: 'Attachments' }) + const imagePath = await attachments.getByRole('listitem').filter({ hasText: 'native-upload.png' }).getAttribute('title') + const notesPath = await attachments.getByRole('listitem').filter({ hasText: 'native-notes.txt' }).getAttribute('title') + expect(imagePath && path.isAbsolute(imagePath)).toBe(true) + expect(notesPath && path.isAbsolute(notesPath)).toBe(true) + await input.fill('Show native live progress') + await pane.getByRole('button', { name: 'Send' }).click() + await expect.poll(async () => (await fs.readFile(nativeLog, 'utf8')).includes('item/agentMessage/delta')).toBe(true) + expect(await fs.readFile(nativeLog, 'utf8')).not.toContain('turn/completed') + await expect(pane.getByText('Native streaming fragment', { exact: true })).toBeVisible({ timeout: 20_000 }) + await expect(pane.getByText('Inspect progress', { exact: true })).toBeVisible() + await expect(pane.getByText('Old plan', { exact: true })).toHaveCount(0) + await expect(pane.getByText('Latest usage 20 tokens', { exact: true })).toBeVisible() + await expect(pane.getByRole('meter', { name: 'Context window used' })).toHaveCount(0) + await pane.getByRole('button', { name: 'Diff: Turn changes' }).click() + await expect(pane.getByText('+replacement', { exact: true })).toBeVisible() + await expect(pane.getByText('+previous', { exact: true })).toHaveCount(0) + await pane.getByRole('button', { name: 'Toggle activity details' }).first().click() + await pane.getByRole('button', { name: 'Bash tool call' }).click() + await expect(pane.getByText('command progress before completion', { exact: true })).toBeVisible() + const reasoning = pane.getByText('Native reasoning in progress', { exact: true }) + if (!await reasoning.isVisible()) await pane.getByRole('button', { name: 'Thinking', exact: true }).last().click() + await expect(reasoning).toBeVisible() + expect(await fs.readFile(nativeLog, 'utf8')).not.toContain('turn/completed') + await expect(pane.getByRole('button', { name: 'Stop' })).toBeVisible() + const originalTab = await harness.getActiveTabId() + const opened = await page.request.post(`${info.baseUrl}/api/tabs`, { headers: { 'x-auth-token': info.token }, + data: { agent: 'codex', cwd: root, model: 'other-pane-model', effort: 'low' } }) + expect(opened.ok(), await opened.text()).toBe(true) + const body = await opened.json() + const otherTab = body.data?.tabId ?? body.tabId + const otherPaneId = body.data?.paneId ?? body.paneId + expect(otherTab).not.toBe(originalTab) + expect(otherPaneId).not.toBe(paneId) + await expect.poll(() => harness.getActiveTabId()).toBe(originalTab) + await page.locator(`[data-context="tab"][data-tab-id="${otherTab}"]`).click() + const otherPane = page.locator(`[data-context="fresh-agent"][data-pane-id="${otherPaneId}"]`) + const otherInput = otherPane.getByRole('textbox', { name: 'Chat message input' }) + await expect(otherInput).toBeEnabled({ timeout: 30_000 }) + await otherInput.fill('SECOND_PANE_WAIT') + await otherInput.press('Enter') + const otherChoice = otherPane.getByRole('button', { name: 'Second pane choice', exact: true }) + await expect(otherChoice).toBeVisible({ timeout: 30_000 }) + const starts = (await fs.readFile(nativeRequests, 'utf8')).trim().split('\n').map(line => JSON.parse(line)).filter(row => row.method === 'turn/start') + expect(starts).toHaveLength(2) + const firstNativeText = starts[0].params.input.filter((part: any) => part.type === 'text').map((part: any) => part.text).join('\n') + expect(firstNativeText).toContain(imagePath!) + expect(firstNativeText).toContain(notesPath!) + expect(new Set(starts.map(row => row.params.threadId)).size).toBe(2) + expect(starts[1].params).toMatchObject({ model: 'other-pane-model', effort: 'low' }) + await expect(pane).toBeHidden() + await expect(otherPane.getByText('Native streaming fragment', { exact: true })).toHaveCount(0) + await otherChoice.focus() + await fs.writeFile(release, 'release') + await expect.poll(async () => (await fs.readFile(nativeLog, 'utf8')).includes('turn/completed')).toBe(true) + await expect.poll(() => harness.getActiveTabId()).toBe(otherTab) + await expect(otherChoice).toBeFocused() + await page.locator(`[data-context="tab"][data-tab-id="${originalTab}"]`).click() + await expect(pane.getByText('Second pane question?', { exact: true })).toHaveCount(0) + await expect(pane.getByText('Native streaming fragment and final answer', { exact: true })).toHaveCount(1, { timeout: 30_000 }) + await page.locator(`[data-context="tab"][data-tab-id="${otherTab}"]`).click() + await otherChoice.click() + await expect(otherChoice).toHaveCount(0) + await page.locator(`[data-context="tab"][data-tab-id="${originalTab}"]`).click() + await expect(pane.getByRole('button', { name: 'Stop' })).toHaveCount(0, { timeout: 30_000 }) + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + await expect(page.getByText('Native streaming fragment and final answer', { exact: true })).toHaveCount(1, { timeout: 30_000 }) + await expect(page.getByLabel('You transcript turn').filter({ hasText: 'native-upload.png' })).toHaveCount(1) + await expect(page.getByLabel('You transcript turn').filter({ hasText: 'native-notes.txt' })).toHaveCount(1) + for (const [savedPath, bytes] of [[imagePath!, uploadedImage], [notesPath!, uploadedNotes]] as const) { + const response = await page.request.get(`${info.baseUrl}/api/files/raw?path=${encodeURIComponent(savedPath)}`, + { headers: { 'x-auth-token': info.token } }) + expect(response.status(), `Saved attachment resource ${savedPath}`).toBe(200) + expect(await response.body()).toEqual(bytes) + const reads = (await fs.readFile(nativeFileReads, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + expect(reads.filter(read => read.path === savedPath)).toEqual([ + expect.objectContaining({ threadId: starts[0].params.threadId, pid: expect.any(Number), uid: expect.any(Number), + bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }), + ]) + } + } finally { + await fs.writeFile(release, 'release').catch(() => {}) + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('Freshcodex discovers the owning native catalog and converges exact settings and names across browsers and reload', async ({ browser }) => { + test.setTimeout(240_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-catalog-live-')) + const cwd = path.join(root, 'project') + await fs.mkdir(cwd) + const wrapper = path.join(root, 'codex.mjs') + const release = path.join(root, 'create-release') + const nativeLog = path.join(root, 'native.jsonl') + const sourceCounter = path.join(root, 'catalog-runtime-counter') + await installNativeWrapper(wrapper, ` +const counter=${JSON.stringify(sourceCounter)} +const ordinal=fs.existsSync(counter)?Number(fs.readFileSync(counter,'utf8'))+1:1 +fs.writeFileSync(counter,String(ordinal)) +if(ordinal>1)behavior={...behavior,modelCatalogPagesByCwd:{[process.cwd()]:[{data:[{id:'second-runtime-record',model:'second-runtime-model',displayName:'Second runtime model',supportedReasoningEfforts:[{reasoningEffort:'xhigh'}],defaultReasoningEffort:'xhigh',inputModalities:['text']}],nextCursor:null}]}} +`) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, echoRequestedModel: true, + waitForThreadStartReleasePath: release, appendRequestLogPath: nativeLog, + modelCatalogPagesByCwd: { [cwd]: [{ data: [{ id: 'owning-runtime-record', model: 'catalog-only-model', + displayName: 'Owning runtime model', supportedReasoningEfforts: [{ reasoningEffort: 'low' }, { reasoningEffort: 'ultra' }], + defaultReasoningEffort: 'ultra', inputModalities: ['text', 'image'] }], nextCursor: null }] } }), + }, setupHome: async home => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { + freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] }, + } })) + } }) + const contexts: Array>['context']> = [] + const readNative = async (): Promise => { + try { return (await fs.readFile(nativeLog, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) } + catch (error: any) { if (error.code === 'ENOENT') return []; throw error } + } + try { + const info = await server.start() + const a = await createFreshE2ePage(browser, info) + contexts.push(a.context) + const page = a.page + const catalogRequests: URL[] = [] + page.on('request', request => { if (request.url().includes('/api/fresh-agent/model-capabilities/freshcodex')) catalogRequests.push(new URL(request.url())) }) + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(cwd) + await directory.press('Enter') + const pendingPane = page.locator('[data-context="fresh-agent"]').last() + await expect(pendingPane).toBeVisible() + const paneId = await pendingPane.getAttribute('data-pane-id') + expect(paneId).toBeTruthy() + const pane = page.locator(`[data-context="fresh-agent"][data-pane-id="${paneId}"]`) + await expect.poll(async () => (await readNative()).filter(row => row.method === 'thread/start').length).toBe(1) + await pane.getByRole('button', { name: /^Model:.*change model$/ }).click() + const dialog = page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect(dialog.getByRole('status')).toContainText(/waiting.*runtime/i) + await dialog.getByRole('textbox', { name: 'Exact model ID' }).fill('custom-omitted-model') + await dialog.getByRole('textbox', { name: 'Reasoning effort' }).fill('ultra') + expect(catalogRequests).toHaveLength(0) + expect((await readNative()).filter(row => row.method === 'model/list')).toHaveLength(0) + await fs.writeFile(release, 'release') + await expect(dialog.getByRole('option', { name: 'Owning runtime model', exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(dialog.getByRole('button', { name: 'Use custom-omitted-model · ultra' })).toBeEnabled() + await dialog.getByRole('button', { name: 'Use custom-omitted-model · ultra' }).click() + const composer = pane.getByRole('textbox', { name: 'Chat message input' }) + const send = async (prompt: string) => { + await composer.fill(prompt) + await composer.press('Enter') + await expect.poll(async () => (await readNative()).filter(row => row.method === 'turn/start' + && row.params.input.some((item: any) => item.text === prompt)).length).toBe(1) + await expect(pane.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0, { timeout: 30_000 }) + } + await send('Validate the custom model omitted from the native list') + const first = (await readNative()).find(row => row.method === 'turn/start') + const nativeId = first.params.threadId + expect(first.params).toMatchObject({ model: 'custom-omitted-model', effort: 'ultra' }) + expect(catalogRequests.length).toBeGreaterThan(0) + for (const request of catalogRequests) { + expect(request.searchParams.get('sessionId')).toBe(nativeId) + expect(request.searchParams.get('cwd')).toBe(cwd) + } + expect((await readNative()).filter(row => row.method === 'model/list').every(row => row.cwd === cwd)).toBe(true) + expect((await readNative()).filter(row => row.method === 'thread/start')).toHaveLength(1) + const originalTab = await harness.getActiveTabId() + expect(originalTab).toBeTruthy() + + const b = await createFreshE2ePage(browser, info) + contexts.push(b.context) + await b.page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const otherHarness = new TestHarness(b.page) + await otherHarness.waitForHarness() + await otherHarness.waitForConnection() + // A new browser has its own layout. Explicitly open the same canonical + // conversation through the public headless-create/resume path, then select it. + const opened = await b.page.request.post(`${info.baseUrl}/api/tabs`, { headers: { 'x-auth-token': info.token }, + data: { agent: 'codex', cwd, sessionRef: { provider: 'codex', sessionId: nativeId } } }) + expect(opened.ok(), await opened.text()).toBe(true) + const openedBody = await opened.json() + const openedTab = openedBody.data?.tabId ?? openedBody.tabId + await expect.poll(() => harness.getActiveTabId()).toBe(originalTab) + await b.page.locator(`[data-context="tab"][data-tab-id="${openedTab}"]`).click() + await expect(b.page.getByText('Fixture turn', { exact: true })).toBeVisible({ timeout: 30_000 }) + console.info(JSON.stringify({ event: 'task010.catalog.second_browser_open', originalTab, activeTab: await harness.getActiveTabId(), paneId, + panes: await page.locator('[data-context="fresh-agent"]').evaluateAll(roots => roots.map(root => ({ + paneId: root.getAttribute('data-pane-id'), visibility: getComputedStyle(root).visibility, + modelButtons: [...root.querySelectorAll('button')].map(button => button.getAttribute('aria-label')).filter(label => label?.includes('Model:')), + }))) })) + // Continue the first browser's interaction on its explicitly selected tab. + await page.bringToFront() + await page.locator(`[data-context="tab"][data-tab-id="${originalTab}"]`).click() + await expect(pane).toBeVisible() + // Exact custom IDs without a display name intentionally have no chip. + await expect(pane.getByRole('button', { name: /^Model:.*change model$/ })).toHaveCount(0) + await composer.fill('/model') + await composer.press('Enter') + await expect(dialog).toBeVisible() + await dialog.getByRole('option', { name: 'Owning runtime model', exact: true }).click() + await dialog.getByRole('listbox', { name: 'Thinking levels for Owning runtime model', exact: true }).getByRole('option', { name: /^ultra(?:\s|$)/ }).click() + await dialog.getByRole('button', { name: 'Use Owning runtime model · ultra' }).click() + // Settings belongs to the pane header, alongside the content view. + const settingsButton = page.locator(`[data-context="pane-header"][data-pane-id="${paneId}"]`) + .getByRole('button', { name: 'Agent settings', exact: true }) + await expect(settingsButton).toBeVisible() + await settingsButton.click() + await page.getByRole('combobox', { name: 'File access' }).selectOption('read-only') + await page.getByRole('combobox', { name: 'Work mode' }).selectOption('plan') + await page.getByRole('combobox', { name: 'Permission mode' }).selectOption('never') + await page.keyboard.press('Escape') + const otherComposer = b.page.getByRole('textbox', { name: 'Chat message input' }) + await otherComposer.fill('/model') + await otherComposer.press('Enter') + const otherDialog = b.page.getByRole('dialog', { name: 'Model and thinking level' }) + const peerCurrentRows = otherDialog.getByRole('option', { name: 'Owning runtime model current', exact: true }) + await expect(peerCurrentRows.and(otherDialog.locator('[data-group="catalog"]'))).toBeVisible({ timeout: 30_000 }) + const peerSnapshotResponse = await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${nativeId}`, + { headers: { 'x-auth-token': info.token } }) + const peerSnapshot = peerSnapshotResponse.ok() ? await peerSnapshotResponse.json() : undefined + console.info(JSON.stringify({ event: 'task010.catalog.peer_selection', nativeId, status: peerSnapshotResponse.status(), + settings: peerSnapshot?.settings, options: await otherDialog.getByRole('option').allTextContents() })) + // Exact model ID is a cleared manual-entry draft. The current catalog + // row and selected pair express the second browser's effective choice. + // Recent and catalog can both contain the current model. Exactly one + // row is highlighted, while the native catalog row stays available. + await expect(peerCurrentRows.and(otherDialog.locator('[aria-selected="true"]'))).toHaveCount(1) + await expect(otherDialog.getByRole('textbox', { name: 'Reasoning effort' })).toHaveValue('ultra') + await expect(otherDialog.getByRole('button', { name: 'Use Owning runtime model · ultra', exact: true })).toBeEnabled() + await otherDialog.getByRole('button', { name: 'Cancel', exact: true }).click() + const otherModel = b.page.getByRole('button', { name: 'Model: Owning runtime model — change model', exact: true }) + await expect(otherModel).toBeVisible({ timeout: 30_000 }) + await expect(otherModel).toHaveAttribute('title', 'catalog-only-model · effort ultra') + await send('Use the catalog-only model in Plan') + const configured = (await readNative()).filter(row => row.method === 'turn/start').at(-1) + expect(configured.params).toMatchObject({ model: 'catalog-only-model', effort: 'ultra', approvalPolicy: 'never', + sandboxPolicy: { type: 'readOnly' }, collaborationMode: { mode: 'plan', settings: { model: 'catalog-only-model', reasoning_effort: 'ultra' } } }) + const name = `Catalog journey ${randomUUID().slice(0, 8)}` + const headers = { 'x-auth-token': info.token } + const renamed = await page.request.patch(`${info.baseUrl}/api/session-names`, { headers, + data: { target: { kind: 'session', provider: 'codex', sessionId: nativeId }, name, nameIntent: 'user' } }) + expect(renamed.ok(), await renamed.text()).toBe(true) + await expect(b.page.locator('[data-context="pane-header"]').filter({ hasText: name })).toBeVisible({ timeout: 30_000 }) + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + const restoredModel = pane.getByRole('button', { name: 'Model: Owning runtime model — change model', exact: true }) + await expect(restoredModel).toBeVisible({ timeout: 30_000 }) + await expect(restoredModel).toHaveAttribute('title', 'catalog-only-model · effort ultra') + await send('Keep the exact pair after a document reload') + const last = (await readNative()).filter(row => row.method === 'turn/start').at(-1) + expect(last.params).toMatchObject({ threadId: nativeId, model: 'catalog-only-model', effort: 'ultra', + collaborationMode: { mode: 'plan', settings: { model: 'catalog-only-model', reasoning_effort: 'ultra' } } }) + const snapshot = await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${nativeId}`, { headers }) + expect((await snapshot.json()).settings).toMatchObject({ model: 'catalog-only-model', effort: 'ultra', collaborationMode: 'plan', sandbox: 'read-only', permissionMode: 'never', cwd }) + expect((await readNative()).filter(row => row.method === 'thread/start')).toHaveLength(1) + expect((await readNative()).filter(row => row.method === 'turn/start')).toHaveLength(3) + // A second native source in the SAME cwd offers a different catalog. + // Every request still traverses browser HTTP, owning Rust runtime and native. + const secondRequests: URL[] = [] + const secondCatalogs: Array<{ url: string; models: string[] }> = [] + const secondCatalogErrors: string[] = [] + b.page.on('response', response => { if (response.ok() && response.url().includes('/api/fresh-agent/model-capabilities/freshcodex')) void response.json().then(body => secondCatalogs.push({ url: response.url(), models: body.models.map((model: any) => model.id) })).catch(error => secondCatalogErrors.push(String(error))) }) + b.page.on('request', request => { if (request.url().includes('/api/fresh-agent/model-capabilities/freshcodex')) secondRequests.push(new URL(request.url())) }) + const second = await b.page.request.post(`${info.baseUrl}/api/tabs`, { headers, data: { agent: 'codex', cwd } }) + expect(second.ok(), await second.text()).toBe(true) + const secondBody = await second.json() + const secondTab = secondBody.data?.tabId ?? secondBody.tabId + const secondPaneId = secondBody.data?.paneId ?? secondBody.paneId + await b.page.locator(`[data-context="tab"][data-tab-id="${secondTab}"]`).click() + const secondPane = b.page.locator(`[data-context="fresh-agent"][data-pane-id="${secondPaneId}"]`) + const secondComposer = secondPane.getByRole('textbox', { name: 'Chat message input' }) + await expect(secondComposer).toBeEnabled({ timeout: 30_000 }) + await secondComposer.fill('/model') + await secondComposer.press('Enter') + const secondDialog = b.page.getByRole('dialog', { name: 'Model and thinking level' }) + await expect(secondDialog.getByRole('option', { name: 'Second runtime model', exact: true })).toBeVisible({ timeout: 30_000 }) + // Recent custom choices remain eligible; the owning runtime's actual + // catalog response, rather than the recent-choice list, is source-specific. + await expect.poll(() => secondCatalogs.length).toBeGreaterThan(0) + expect(secondCatalogErrors).toEqual([]) + expect(secondCatalogs.every(reply => reply.models.length === 1 && reply.models[0] === 'second-runtime-model')).toBe(true) + expect(secondRequests.length).toBeGreaterThan(0) + const secondId = secondRequests.at(-1)!.searchParams.get('sessionId') + expect(secondId).toBeTruthy() + expect(secondId).not.toBe(nativeId) + expect(secondRequests.every(request => request.searchParams.get('sessionId') === secondId && request.searchParams.get('cwd') === cwd)).toBe(true) + expect((await readNative()).some(row => row.method === 'model/list' && row.context.nativeThreadId === secondId)).toBe(true) + await b.page.keyboard.press('Escape') + await composer.fill('/model') + await composer.press('Enter') + const returnedCatalog = dialog.locator('[data-group="catalog"]') + await expect(dialog.getByRole('option', { name: 'Owning runtime model current', exact: true }).and(returnedCatalog)).toBeVisible() + await expect(dialog.getByRole('option', { name: /^Second runtime model(?: current)?$/ }).and(returnedCatalog)).toHaveCount(0) + await page.keyboard.press('Escape') + expect((await readNative()).filter(row => row.method === 'thread/start')).toHaveLength(2) + expect((await readNative()).filter(row => row.method === 'turn/start')).toHaveLength(3) + } finally { + await fs.writeFile(release, 'release').catch(() => {}) + await Promise.allSettled(contexts.map(context => context.close())) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('Freshcodex runs !command in its selected directory, keeps file effects through conversation undo, and restores its code checkpoint', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-checkpoint-command-')) + const cwd = path.join(root, 'selected-project') + await fs.mkdir(cwd) + const stateFile = path.join(cwd, 'state.txt') + await fs.writeFile(stateFile, 'before command\n') + const wrapper = path.join(root, 'codex.mjs') + const nativeLog = path.join(root, 'native.jsonl') + await installNativeWrapper(wrapper) + const server = new RustServer({ env: { CODEX_CMD: wrapper, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ recordTurns: true, appendRequestLogPath: nativeLog }) }, + setupHome: async home => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { + freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] }, + } })) + } }) + let context: Awaited>['context'] | undefined + const native = async (): Promise => (await fs.readFile(nativeLog, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(cwd) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + const composer = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(composer).toBeEnabled({ timeout: 30_000 }) + const prompt = 'Create a checkpoint in this selected project' + await composer.fill(prompt) + await composer.press('Enter') + const firstTurn = pane.locator('[data-turn-role="user"]', { hasText: prompt }) + await expect(firstTurn.getByRole('status', { name: 'Code rewind available for this message' })).toBeVisible({ timeout: 30_000 }) + await expect(pane.getByText('Fixture turn', { exact: true })).toHaveCount(1, { timeout: 30_000 }) + const command = "pwd; printf 'after command\\n' > state.txt; printf command-output-canary" + const executed = page.waitForResponse(response => response.url().includes('/api/fresh-agent/exec') && response.request().method() === 'POST') + await composer.fill(`!${command}`) + await composer.press('Enter') + const response = await executed + expect(response.ok(), await response.text()).toBe(true) + expect(response.request().postDataJSON()).toEqual({ command, cwd }) + expect(await response.json()).toEqual({ output: `${cwd}\ncommand-output-canary`, exitCode: 0, truncated: false }) + await expect(pane.getByText('Fixture turn', { exact: true })).toHaveCount(2, { timeout: 30_000 }) + expect(await fs.readFile(stateFile, 'utf8')).toBe('after command\n') + const starts = (await native()).filter(row => row.method === 'turn/start') + expect(starts).toHaveLength(2) + expect(starts[1].params.cwd).toBe(cwd) + const forwarded = starts[1].params.input.find((item: any) => item.type === 'text').text + expect(forwarded).toContain(`in ${cwd}. Output:`) + expect(forwarded).toContain(`${cwd}\ncommand-output-canary`) + await composer.fill('/undo') + await composer.press('Enter') + await expect(composer).toHaveValue(forwarded, { timeout: 30_000 }) + expect(await fs.readFile(stateFile, 'utf8')).toBe('after command\n') + expect((await native()).filter(row => row.method === 'thread/revert')).toHaveLength(1) + await composer.fill('/redo') + await composer.press('Enter') + await expect(pane.getByText(/Redo is not available for Codex sessions/)).toBeVisible() + expect((await native()).filter(row => row.method === 'turn/start')).toHaveLength(2) + await firstTurn.hover() + const accepted = page.waitForEvent('dialog').then(dialog => dialog.accept()) + await firstTurn.getByRole('button', { name: 'Rewind code to here' }).click() + await accepted + await expect.poll(() => fs.readFile(stateFile, 'utf8'), { timeout: 30_000 }).toBe('before command\n') + expect((await native()).filter(row => row.method === 'thread/start')).toHaveLength(1) + expect((await native()).filter(row => row.method === 'turn/start')).toHaveLength(2) + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) diff --git a/test/e2e-browser/specs/freshcodex-retained-recovery-rust.spec.ts b/test/e2e-browser/specs/freshcodex-retained-recovery-rust.spec.ts new file mode 100644 index 0000000000..09ffaac4ae --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-retained-recovery-rust.spec.ts @@ -0,0 +1,73 @@ +/** Hosted loss is injected at the browser's transport boundary; native server + * ownership/history remain real. Task006 Rust tests saturate the actual host + * bridge. This slice proves immediate retained rendering while HTTP is held. */ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } from '../helpers/fixtures.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' + +const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + +test('Freshcodex renders retained hosted output before native HTTP recovery and keeps composer focus', async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-retained-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { CODEX_CMD: wrapper, FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES:'1', FAKE_CODEX_APP_SERVER_BEHAVIOR:JSON.stringify({recordTurns:true}) }, setupHome:async(home) => { + await fs.mkdir(path.join(home,'.freshell'),{recursive:true}) + await fs.writeFile(path.join(home,'.freshell/config.json'),JSON.stringify({version:1,settings:{freshAgent:{enabled:true},codingCli:{enabledProviders:['codex']}}})) + } }) + let context: Awaited>['context'] | undefined + let release: (() => void) | undefined + const held = new Promise((resolve) => {release=resolve}) + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser,info) + context=owned.context + const page=owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness=new TestHarness(page) + await harness.waitForHarness(); await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({type:'connection/setAvailableClis',payload:{claude:false,codex:true}})) + const picker=await openPanePicker(page) + await picker.getByRole('button',{name:/^Freshcodex$/i}).click({force:true}) + const directory=page.getByRole('combobox',{name:'Starting directory for Freshcodex'}) + await directory.fill(root); await directory.press('Enter') + const pane=page + const composer=pane.getByRole('textbox',{name:'Chat message input'}) + await expect(composer).toBeEnabled() + const sessionId=await page.evaluate(() => { + const layouts=window.__FRESHELL_TEST_HARNESS__!.getState().panes.layouts + const leaf=Object.values(layouts).find((node) => node.type==='leaf' && node.content.kind==='fresh-agent') + if (!leaf || leaf.type!=='leaf' || leaf.content.kind!=='fresh-agent' || !leaf.content.sessionId) throw new Error('Freshcodex not created') + return leaf.content.sessionId + }) + const owner = await page.evaluate((id) => window.__FRESHELL_TEST_HARNESS__!.getState().freshAgent.runtimeOwners[`codex:${id}`], sessionId) + const response=await page.request.get(`${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${sessionId}`,{headers:{'x-auth-token':info.token}}) + expect(response.ok()).toBe(true) + const snapshot=await response.json() + snapshot.turns=[{id:'recovered-turn',turnId:'recovered-turn',role:'assistant',summary:'',items:[{id:'recovered-answer',kind:'text',text:'Final hosted output recovered without another token'}]}] + snapshot.extensions={codex:{projection:{id:'browser-source',historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['recovered-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'recovered-turn':['recovered-turn']},turnOutputStates:{'recovered-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['recovered-turn']}}} + let refreshes=0 + await page.route('**/api/fresh-agent/threads/freshcodex/codex/**',async(route) => {refreshes++; await held; await route.fulfill({json:snapshot})}) + await composer.fill('Draft stays local') + for (const type of ['freshAgent.host.backpressure','freshAgent.host.journal.reset']) { + await harness.receiveWsMessage({type:'freshAgent.event',provider:'codex',sessionType:'freshcodex',sessionId,event:{type,retainedState:{source:{projectionId:'browser-source',ownerEpoch:owner?.epoch ?? 0,ownerGeneration:owner?.generation ?? 0,soulId:'browser-soul',incarnationId:'browser-incarnation',hostBootId:'browser-host'},snapshot,pendingControls:[],attentionMarks:[]}}}) + } + await expect(pane.getByText('Final hosted output recovered without another token',{exact:true})).toBeVisible() + await expect.poll(() => refreshes).toBeGreaterThan(0) + await expect(composer).toHaveValue('Draft stays local') + await expect(composer).toBeFocused() + await expect(pane.getByRole('note',{name:'Output persistence'})).toHaveText('Retained output. Saved output could not be confirmed.') + } finally { + release?.() + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root,{recursive:true,force:true}) + } +}) diff --git a/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts b/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts new file mode 100644 index 0000000000..868488afd1 --- /dev/null +++ b/test/e2e-browser/specs/freshcodex-send-delivery-rust.spec.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { test, expect, createFreshE2ePage } from '../helpers/fixtures.js' +import { RustServer } from '../helpers/rust-server.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' + +const source = fileURLToPath(new URL('../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', import.meta.url)) + +for (const delivery of [ + { name: 'refused', code: -32602, message: 'This input was refused before admission' }, + { name: 'ambiguous', code: -32603, message: 'The native response was lost after possible admission' }, +] as const) { +test(`Freshcodex retains a ${delivery.name} prompt with its delivery reason`, async ({ browser }) => { + test.setTimeout(180_000) + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshcodex-delivery-')) + const wrapper = path.join(root, 'codex.mjs') + await fs.writeFile(wrapper, `#!/usr/bin/env node\nimport ${JSON.stringify(source)}\n`) + await fs.chmod(wrapper, 0o755) + const server = new RustServer({ env: { + CODEX_CMD: wrapper, + FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES: '1', + FAKE_CODEX_APP_SERVER_BEHAVIOR: JSON.stringify({ overrides: { 'turn/start': { error: { code: delivery.code, message: delivery.message } } } }), + }, setupHome: async (home) => { + await fs.mkdir(path.join(home, '.freshell'), { recursive: true }) + await fs.writeFile(path.join(home, '.freshell/config.json'), JSON.stringify({ version: 1, settings: { freshAgent: { enabled: true }, codingCli: { enabledProviders: ['codex'] } } })) + } }) + let context: Awaited>['context'] | undefined + try { + const info = await server.start() + const owned = await createFreshE2ePage(browser, info) + context = owned.context + const page = owned.page + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await page.evaluate(() => window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', payload: { claude: false, codex: true } })) + const picker = await openPanePicker(page) + await picker.getByRole('button', { name: /^Freshcodex$/i }).click({ force: true }) + const directory = page.getByRole('combobox', { name: 'Starting directory for Freshcodex' }) + await directory.fill(root) + await directory.press('Enter') + const pane = page.locator('[data-context="fresh-agent"]').last() + const input = pane.getByRole('textbox', { name: 'Chat message input' }) + await expect(input).toBeEnabled({ timeout: 30_000 }) + await input.fill('Keep the original prompt') + await pane.getByRole('button', { name: 'Send', exact: true }).click() + await expect(input).toHaveValue('Keep the original prompt', { timeout: 15_000 }) + if (delivery.name === 'refused') { + await expect(pane.getByText(/Message not sent:.*refused before admission/)).toBeVisible() + await expect(pane.getByRole('button', { name: 'Send', exact: true })).toBeEnabled() + } else { + await expect(pane.getByText(/Delivery uncertain \(.+\):.*native response was lost/)).toBeVisible() + await expect(pane.getByRole('button', { name: 'Stop', exact: true })).toBeVisible() + } + } finally { + await context?.close().catch(() => {}) + await server.stop() + await fs.rm(root, { recursive: true, force: true }) + } +}) +} diff --git a/test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts b/test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts index bade88d9a4..48d19a9acc 100644 --- a/test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts +++ b/test/e2e-browser/specs/managed-recovery-contextual-ui.spec.ts @@ -478,10 +478,18 @@ test('legacy provider fixture: injected managed projection preserves the convers rollout = path.join(sessions, `rollout-${SESSION_ID}.jsonl`) transcript = (await fs.readFile('test/fixtures/coding-cli/codex/task-events.sanitized.jsonl', 'utf8')) .replace('session-activity', SESSION_ID).replace('Sanitized completion', 'Fixture turn') + .split('\n').map((line) => { + if (!line) return line + const record = JSON.parse(line) + if (record.type !== 'session_meta') return line + return JSON.stringify({ ...record, payload: { ...record.payload, cwd: homeDir } }) + }).join('\n') await fs.writeFile(rollout, transcript) operations = path.join(homeDir, 'codex-operations.jsonl') // This is the existing provider protocol fixture, reached through the real Rust backend. + // Native cold resume restores the saved cwd even when its request omits cwd. fixtureEnv.FAKE_CODEX_APP_SERVER_BEHAVIOR = JSON.stringify({ threadStartThreadId: SESSION_ID, + effectiveThreadSettings: { cwd: homeDir }, appendThreadOperationLogPath: operations }) }, }) @@ -494,6 +502,12 @@ test('legacy provider fixture: injected managed projection preserves the convers client.sendJson({ type: 'freshAgent.create', requestId: CREATE_REQUEST_ID, sessionType: 'freshcodex', provider: 'codex', cwd: info.homeDir }) const created = await client.nextJsonMessage<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }>('freshAgent.created', 20_000) + const snapshot = await page.request.get( + `${info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${created.sessionRef?.sessionId ?? created.sessionId}`, + { headers: { 'x-auth-token': info.token } }, + ) + expect(snapshot.ok()).toBe(true) + expect((await snapshot.json()).settings).toMatchObject({ cwd: info.homeDir }) const sent: Array> = [] const received: Array> = [] const held: Array = [] diff --git a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs index f51f24333e..0692c03ce4 100644 --- a/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs +++ b/test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs @@ -2,6 +2,7 @@ import { WebSocketServer } from 'ws' import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' import fs from 'node:fs' import net from 'node:net' import os from 'node:os' @@ -330,6 +331,15 @@ function successResult(method, params) { platformOs: 'linux', } } + if (method === 'configRequirements/read') return { requirements: behavior.configRequirements ?? null } + if (method === 'model/list') { + const pages = behavior.modelCatalogPagesByCwd?.[process.cwd()] ?? behavior.modelCatalogPages ?? [{ data: [ + { id: 'native-record', model: 'provider-current', displayName: 'Provider Current', + supportedReasoningEfforts: [{ reasoningEffort: 'none' }, { reasoningEffort: 'ultra' }], + defaultReasoningEffort: 'ultra', inputModalities: ['text', 'image'] }, + ], nextCursor: null }] + return pages[params?.cursor ? Number(params.cursor) : 0] + } if (method === 'thread/start') { const threadId = behavior.threadStartThreadId || (behavior.uniqueThreadIds === true @@ -344,12 +354,13 @@ function successResult(method, params) { return { thread, cwd: params?.cwd ?? process.cwd(), - model: 'fixture-model', + model: behavior.echoRequestedModel === true ? params?.model ?? 'fixture-model' : 'fixture-model', modelProvider: 'openai', instructionSources: [], - approvalPolicy: 'never', + approvalPolicy: behavior.effectiveApprovalPolicy ?? params?.approvalPolicy ?? 'never', approvalsReviewer: 'user', sandbox: params?.sandbox ?? 'danger-full-access', + ...behavior.effectiveThreadSettings, } } if (method === 'thread/resume') { @@ -363,14 +374,20 @@ function successResult(method, params) { return { thread, cwd: params?.cwd ?? process.cwd(), - model: 'fixture-model', + model: behavior.echoRequestedModel === true ? params?.model ?? 'fixture-model' : 'fixture-model', modelProvider: 'openai', instructionSources: [], - approvalPolicy: 'never', + approvalPolicy: behavior.effectiveApprovalPolicy ?? params?.approvalPolicy ?? 'never', approvalsReviewer: 'user', sandbox: params?.sandbox ?? 'danger-full-access', + ...behavior.effectiveThreadSettings, } } + if (method === 'turn/steer') { + // Native 0.164 active input adds to the expected turn; it does not + // manufacture a second turn or apply turn/start setting overrides. + return { turnId: params?.expectedTurnId } + } if (method === 'turn/start') { if (behavior.recordTurns) { const threadId = params?.threadId || 'thread-new-1' @@ -379,6 +396,10 @@ function successResult(method, params) { ? params.input.map((part) => (part && typeof part.text === 'string' ? part.text : '')).filter(Boolean).join('\n') : '' const turn = makeRecordedTurn(`turn-${turns.length + 1}`, promptText, params) + if (behavior.liveTranscript) { + turn.status = 'inProgress' + turn.items = [turn.items[0]] + } turns.push(turn) recordedTurnsByThread.set(threadId, turns) persistRecordedTurns(threadId, turns) @@ -414,7 +435,7 @@ function successResult(method, params) { model: params?.model ?? 'fixture-model', modelProvider: 'openai', instructionSources: [], - approvalPolicy: 'never', + approvalPolicy: behavior.effectiveApprovalPolicy ?? params?.approvalPolicy ?? 'never', approvalsReviewer: 'user', sandbox: params?.sandbox ?? 'danger-full-access', } @@ -459,6 +480,8 @@ function successResult(method, params) { return {} } if (method === 'thread/read') { + const scriptedThread = behavior.threadReadResults?.[params?.threadId] + if (scriptedThread) return { thread: scriptedThread } const thread = makeThread(params?.threadId, { ...params, includeTurns: params?.includeTurns === true, @@ -524,6 +547,22 @@ function maybeWriteRolloutForMethod(method, params) { } } +function recordInputFileReads(method, params) { + if (method !== 'turn/start' || !behavior.appendInputFileReadLogPath) return + const text = (params?.input ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n') + for (const match of text.matchAll(/^- (.+)$/gm)) { + const file = match[1] + if (!path.isAbsolute(file)) continue + const size = fs.statSync(file).size + if (size > 10 * 1024 * 1024) throw new Error(`Native fixture attachment exceeds its bounded read: ${file}`) + const bytes = fs.readFileSync(file) + fs.appendFileSync(behavior.appendInputFileReadLogPath, JSON.stringify({ + threadId: params.threadId, pid: process.pid, uid: process.getuid?.(), gid: process.getgid?.(), + path: file, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), + }) + '\n') + } +} + const listenUrl = parseListenUrl(process.argv.slice(2)) const behavior = loadBehavior() if (process.env.FAKE_CODEX_APP_SERVER_ARG_LOG) { @@ -619,6 +658,9 @@ const paginatedThreadIds = new Set() let forkCounter = 0 function broadcastNotification(method, params) { + if (behavior.appendNativeNotificationLogPath) { + fs.appendFileSync(behavior.appendNativeNotificationLogPath, JSON.stringify({ method, threadId: params?.threadId, turnId: params?.turnId ?? params?.turn?.id }) + '\n') + } const payload = JSON.stringify({ jsonrpc: '2.0', method, @@ -669,7 +711,9 @@ function appendThreadOperation(method, params, result) { method, threadId, params, - listenUrl, + listenUrl: portFile ? `ws://${host}:${wss.address().port}` : listenUrl, + sidecarOwnershipId: process.env.FRESHELL_CODEX_SIDECAR_ID ?? null, + pid: process.pid, at: new Date().toISOString(), }) + '\n', 'utf8') } @@ -791,6 +835,9 @@ wss.on('connection', (socket) => { return } const method = message.method + if (behavior.appendRequestLogPath) { + fs.appendFileSync(behavior.appendRequestLogPath, JSON.stringify({ method, params: message.params, codexHome: getCodexHome(), cwd: process.cwd(), ...(behavior.requestLogContext ? { context: behavior.requestLogContext } : {}) }) + '\n') + } if ( behavior.requireInitializeBeforeOtherMethods @@ -895,6 +942,13 @@ wss.on('connection', (socket) => { return } + // Opt-in native requirements refusal. Unrelated fixture consumers retain + // their normal resume behavior and effective defaults. + if (behavior.rejectResumeOverrides === true && method === 'thread/resume' + && (message.params?.sandbox != null || message.params?.approvalPolicy != null)) { + socket.send(JSON.stringify({ id: message.id, error: { code: -32600, message: 'Native test requirements refuse requested resume sandbox or approval overrides' } })) + return + } const override = behavior.overrides?.[method] const delayMs = Number(behavior.delayMethodsMs?.[method] || 0) const floodStdoutBytes = Number(behavior.floodStdoutBeforeMethodsBytes?.[method] || 0) @@ -910,9 +964,20 @@ wss.on('connection', (socket) => { } setTimeout(async () => { + // Opt-in creation interleaving: the real browser may stage settings + // after its native thread/start request but before its acknowledgement. + if (method === 'thread/start' && behavior.waitForThreadStartReleasePath) { + const deadline = Date.now() + 120_000 + while (!fs.existsSync(behavior.waitForThreadStartReleasePath)) { + if (Date.now() > deadline) throw new Error('Timed out waiting for test thread/start release') + await new Promise((resolve) => setTimeout(resolve, 25)) + } + } await writeBytes(process.stdout, floodStdoutBytes) await writeBytes(process.stderr, floodStderrBytes) - const result = override?.result ?? successResult(method, message.params) + const result = method === 'thread/read' && behavior.threadReadResults?.[message.params?.threadId] + ? successResult(method, message.params) + : override?.result ?? successResult(method, message.params) // Durable witnesses land BEFORE the RPC response (kata rb5h). socket.send copies // the frame into the kernel synchronously, so on a loaded multi-core host a client // on another core can observe the response and assert the witness files while THIS @@ -922,6 +987,7 @@ wss.on('connection', (socket) => { // response implies the witness entry is already on disk. appendThreadOperation(method, message.params, result) maybeWriteRolloutForMethod(method, message.params) + recordInputFileReads(method, message.params) socket.send(JSON.stringify({ id: message.id, result, @@ -958,6 +1024,45 @@ wss.on('connection', (socket) => { turn: { id: result.turn.id, status: 'inProgress' }, }) const prompt = (message.params?.input ?? []).filter((part) => part.type === 'text').map((part) => part.text).join('\n') + if (behavior.liveTranscript && (!behavior.liveTranscriptPrompts || behavior.liveTranscriptPrompts.some(marker => prompt.includes(marker)))) { + const threadId = message.params.threadId + const turnId = result.turn.id + const user = result.turn.items[0] + broadcastNotification('item/started', { threadId, turnId, item: user }) + broadcastNotification('item/completed', { threadId, turnId, item: user }) + const item = { type: 'agentMessage', id: `${turnId}:answer`, text: '' } + broadcastNotification('item/started', { threadId, turnId, item }) + broadcastNotification('item/agentMessage/delta', { threadId, turnId, itemId: item.id, delta: 'Native streaming fragment' }) + const command = { type: 'commandExecution', id: `${turnId}:command`, command: 'echo progress', status: 'inProgress', aggregatedOutput: '' } + broadcastNotification('item/started', { threadId, turnId, item: command }) + broadcastNotification('item/commandExecution/outputDelta', { threadId, turnId, itemId: command.id, delta: 'command progress before completion' }) + const reasoning = { type: 'reasoning', id: `${turnId}:reasoning`, summary: [], content: [] } + broadcastNotification('item/started', { threadId, turnId, item: reasoning }) + broadcastNotification('item/reasoning/summaryTextDelta', { threadId, turnId, itemId: reasoning.id, summaryIndex: 0, delta: 'Native reasoning in progress' }) + broadcastNotification('turn/plan/updated', { threadId, turnId, plan: [{ step: 'Old plan', status: 'pending' }] }) + broadcastNotification('turn/plan/updated', { threadId, turnId, explanation: 'Latest native plan', plan: [{ step: 'Inspect progress', status: 'inProgress' }, { step: 'Verify saved result', status: 'pending' }] }) + broadcastNotification('turn/diff/updated', { threadId, turnId, diff: '@@ -1 +1 @@\n-old\n+previous' }) + broadcastNotification('turn/diff/updated', { threadId, turnId, diff: '@@ -1 +1 @@\n-old\n+replacement' }) + broadcastNotification('thread/tokenUsage/updated', { threadId, turnId, tokenUsage: { last: { totalTokens: 20, inputTokens: 15, cachedInputTokens: 8, outputTokens: 5, reasoningOutputTokens: 3 }, total: { totalTokens: 200, inputTokens: 150, cachedInputTokens: 80, outputTokens: 50 }, modelContextWindow: 258400 } }) + const release = behavior.waitForTurnReleasePath + const deadline = Date.now() + 120_000 + while (release && !fs.existsSync(release)) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for test release: ${threadId}/${turnId}`) + await new Promise((resolve) => setTimeout(resolve, 25)) + } + item.text = 'Native streaming fragment and final answer' + command.status = 'completed' + command.aggregatedOutput = 'command progress before completion' + reasoning.summary = ['Native reasoning in progress'] + broadcastNotification('item/completed', { threadId, turnId, item }) + broadcastNotification('item/completed', { threadId, turnId, item: command }) + broadcastNotification('item/completed', { threadId, turnId, item: reasoning }) + const turns = loadRecordedTurns(threadId) + const turn = turns.find((candidate) => candidate.id === turnId) + turn.items.push(item, command, reasoning) + turn.status = 'completed' + persistRecordedTurns(threadId, turns) + } const userRequest = behavior.serverRequestsByPrompt?.[prompt] let interrupted = false if (userRequest) { @@ -979,7 +1084,9 @@ wss.on('connection', (socket) => { }) broadcastNotification('turn/completed', { threadId: message.params?.threadId, - turn: { id: result.turn.id, status: interrupted ? 'interrupted' : 'completed' }, + turn: behavior.terminalItemsView + ? projectTurnItemsView({ ...loadRecordedTurns(message.params.threadId).find(turn => turn.id === result.turn.id), status: interrupted ? 'interrupted' : 'completed' }, behavior.terminalItemsView) + : { id: result.turn.id, status: interrupted ? 'interrupted' : 'completed' }, }) } if (method === 'initialize') { @@ -1059,6 +1166,20 @@ wss.on('connection', (socket) => { exitProcessAfterMethodsOnce.delete(method) && claimCrossProcessOnce(behavior.exitProcessAfterMethodsOnceMarkerPath, `exit-process:${method}`) ) { + if (behavior.processExitGate) { + const { readyPath, releasePath } = behavior.processExitGate + fs.writeFileSync(`${readyPath}.tmp`, JSON.stringify({ + pid: process.pid, + ownershipId: process.env.FRESHELL_CODEX_SIDECAR_ID ?? null, + method, + threadId: result?.thread?.id ?? message.params?.threadId ?? null, + rolloutPath: result?.thread?.path ?? null, + }) + '\n', { encoding: 'utf8', flag: 'wx' }) + fs.renameSync(`${readyPath}.tmp`, readyPath) + while (!fs.existsSync(releasePath)) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + } setTimeout(() => { if (behavior.stdoutBeforeExit) { process.stdout.write(String(behavior.stdoutBeforeExit)) diff --git a/test/fixtures/coding-cli/codex-native-artifact.json b/test/fixtures/coding-cli/codex-native-artifact.json new file mode 100644 index 0000000000..d5afc39e7a --- /dev/null +++ b/test/fixtures/coding-cli/codex-native-artifact.json @@ -0,0 +1,12 @@ +{ + "version": "0.164.0", + "packageName": "@openai/codex", + "packageVersion": "0.164.0-linux-x64", + "platform": "linux", + "arch": "x64", + "url": "https://registry.npmjs.org/@openai/codex/-/codex-0.164.0-linux-x64.tgz", + "integrity": "sha512-XLAoaWsGOT2khzl+P4FYDPTkzAr9W0VFXFaVkqlFdkM0soa1gH/4S0wSD57Ek3HHwkirAEGz2/Mwehseh+l5Mg==", + "unpackedBytes": 444349626, + "fileCount": 45, + "executable": "vendor/x86_64-unknown-linux-musl/bin/codex" +} diff --git a/test/fixtures/fresh-agent/codex/native-children-0.164.json b/test/fixtures/fresh-agent/codex/native-children-0.164.json new file mode 100644 index 0000000000..73bdbded91 --- /dev/null +++ b/test/fixtures/fresh-agent/codex/native-children-0.164.json @@ -0,0 +1,381 @@ +{ + "source": { + "scenario": "parent-child-read", + "version": "0.164.0", + "receipt": "5768b31f-6e97-4aff-81bc-cb0ded210f0c.json", + "sha256": "6eb99ed11fc98523c961fbcf07aed8ad5c909260e8c8fc6d7ca906eb47b794a1" + }, + "parent": { + "thread": { + "id": "01a17436-80b5-7d81-a429-133d30114b56", + "environments": [ + { + "environmentId": "local", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "runtimeWorkspaceRoots": [ + "/tmp/codex-contract-ooDX6r/workspace" + ] + } + ], + "extra": null, + "sessionId": "01a17436-80b5-7d81-a429-133d30114b56", + "forkedFromId": null, + "parentThreadId": null, + "preview": "Run the controlled contract scenario.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "idle" + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80b5-7d81-a429-133d30114b56.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": "vscode", + "canAcceptDirectInput": true, + "threadSource": null, + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-80da-7843-a626-57d4c7c88671", + "clientId": null, + "content": [ + { + "type": "text", + "text": "Run the controlled contract scenario.", + "text_elements": [] + } + ] + }, + { + "type": "collabAgentToolCall", + "id": "call_native_child", + "tool": "spawnAgent", + "status": "completed", + "senderThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "receiverThreadIds": [ + "01a17436-80f3-7f33-84a6-319fdd160006" + ], + "prompt": "freshell-child-native-probe: return the controlled child answer.", + "model": "contract-model", + "reasoningEffort": "low", + "agentsStates": { + "01a17436-80f3-7f33-84a6-319fdd160006": { + "status": "pendingInit", + "message": null + } + } + }, + { + "type": "agentMessage", + "id": "msg_contract_1", + "text": "First fragment and final fragment", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + }, + { + "type": "agentMessage", + "id": "msg_contract_3", + "text": "First fragment and final fragment", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": 1792951091, + "completedAt": 1792951091, + "durationMs": 119 + } + ] + }, + "readState": { + "firstUnread": { + "type": "turn", + "turnId": "01a17436-80c2-7f80-9531-66f31f25b1f9" + }, + "revision": "dc18d800-0e12-45c5-9a59-fe87256b4fe3" + } + }, + "active": { + "thread": { + "id": "01a17436-80f3-7f33-84a6-319fdd160006", + "environments": [ + { + "environmentId": "local", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "runtimeWorkspaceRoots": [ + "/tmp/codex-contract-ooDX6r/workspace" + ] + } + ], + "extra": null, + "sessionId": "01a17436-80b5-7d81-a429-133d30114b56", + "forkedFromId": null, + "parentThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "preview": "freshell-child-native-probe: return the controlled child answer.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "active", + "activeFlags": [] + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80f3-7f33-84a6-319fdd160006.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": { + "subAgent": { + "thread_spawn": { + "parent_thread_id": "01a17436-80b5-7d81-a429-133d30114b56", + "depth": 1, + "agent_path": null, + "agent_nickname": "Sartre", + "agent_role": null + } + } + }, + "canAcceptDirectInput": true, + "threadSource": "subagent", + "agentNickname": "Sartre", + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80f5-7bf3-bd56-9801e21e9047", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-8101-7842-8b60-468e2881a86a", + "clientId": null, + "content": [ + { + "type": "text", + "text": "freshell-child-native-probe: return the controlled child answer.", + "text_elements": [] + } + ] + } + ], + "itemsView": "full", + "status": "inProgress", + "error": null, + "startedAt": 1792951091, + "completedAt": null, + "durationMs": null + } + ] + }, + "readState": null + }, + "final": { + "thread": { + "id": "01a17436-80f3-7f33-84a6-319fdd160006", + "environments": [ + { + "environmentId": "local", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "runtimeWorkspaceRoots": [ + "/tmp/codex-contract-ooDX6r/workspace" + ] + } + ], + "extra": null, + "sessionId": "01a17436-80b5-7d81-a429-133d30114b56", + "forkedFromId": null, + "parentThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "preview": "freshell-child-native-probe: return the controlled child answer.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "active", + "activeFlags": [] + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80f3-7f33-84a6-319fdd160006.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": { + "subAgent": { + "thread_spawn": { + "parent_thread_id": "01a17436-80b5-7d81-a429-133d30114b56", + "depth": 1, + "agent_path": null, + "agent_nickname": "Sartre", + "agent_role": null + } + } + }, + "canAcceptDirectInput": true, + "threadSource": "subagent", + "agentNickname": "Sartre", + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80f5-7bf3-bd56-9801e21e9047", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-8101-7842-8b60-468e2881a86a", + "clientId": null, + "content": [ + { + "type": "text", + "text": "freshell-child-native-probe: return the controlled child answer.", + "text_elements": [] + } + ] + }, + { + "type": "agentMessage", + "id": "msg_contract_2", + "text": "Exact child final answer.", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "inProgress", + "error": null, + "startedAt": 1792951091, + "completedAt": null, + "durationMs": null + } + ] + }, + "readState": null + }, + "saved": { + "thread": { + "id": "01a17436-80f3-7f33-84a6-319fdd160006", + "environments": null, + "extra": null, + "sessionId": "01a17436-80f3-7f33-84a6-319fdd160006", + "forkedFromId": null, + "parentThreadId": "01a17436-80b5-7d81-a429-133d30114b56", + "preview": "freshell-child-native-probe: return the controlled child answer.", + "ephemeral": false, + "section": null, + "sectionEnteredAt": null, + "projectId": null, + "historyMode": "paginated", + "modelProvider": "contract-loopback", + "model": "contract-model", + "reasoningEffort": "low", + "createdAt": 1792951091, + "updatedAt": 1792951091, + "recencyAt": 1792951091, + "status": { + "type": "notLoaded" + }, + "path": "/tmp/codex-contract-ooDX6r/codex/sessions/2026/10/25/rollout-2026-10-25T17-58-11-01a17436-80f3-7f33-84a6-319fdd160006.jsonl", + "cwd": "/tmp/codex-contract-ooDX6r/workspace", + "cliVersion": "0.164.0", + "originator": "freshell_codex_contract", + "source": { + "subAgent": { + "thread_spawn": { + "parent_thread_id": "01a17436-80b5-7d81-a429-133d30114b56", + "depth": 1, + "agent_path": null, + "agent_nickname": "Sartre", + "agent_role": null + } + } + }, + "canAcceptDirectInput": null, + "threadSource": "subagent", + "agentNickname": "Sartre", + "agentRole": null, + "gitInfo": null, + "name": null, + "daybreakEnabled": null, + "turns": [ + { + "id": "01a17436-80f5-7bf3-bd56-9801e21e9047", + "rootTurnId": "01a17436-80c2-7f80-9531-66f31f25b1f9", + "items": [ + { + "type": "userMessage", + "id": "01a17436-8101-7842-8b60-468e2881a86a", + "clientId": null, + "content": [ + { + "type": "text", + "text": "freshell-child-native-probe: return the controlled child answer.", + "text_elements": [] + } + ] + }, + { + "type": "agentMessage", + "id": "msg_contract_2", + "text": "Exact child final answer.", + "phase": null, + "memoryCitation": null, + "delivery": null, + "questions": null + } + ], + "itemsView": "full", + "status": "completed", + "error": null, + "startedAt": 1792951091, + "completedAt": 1792951091, + "durationMs": 43 + } + ] + }, + "readState": null + } +} diff --git a/test/fixtures/fresh-agent/codex/native-projection-0.164.json b/test/fixtures/fresh-agent/codex/native-projection-0.164.json new file mode 100644 index 0000000000..03ce8b7da3 --- /dev/null +++ b/test/fixtures/fresh-agent/codex/native-projection-0.164.json @@ -0,0 +1 @@ +{"cases":[{"scenario":"approval-policy-restricted-exec","parameters":{"approvalControl":"stdin"},"source":{"file":"13-approval-policy-restricted-exec.json","sha256":"15fad3a674315290dc50722da031d93a6198c72fdae89c8e8f8f9151ce314b9d","version":"0.164.0"},"notifications":[{"method":"turn/started","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turn":{"id":"01a17472-59f5-7a31-ba4b-14174b0019cc","rootTurnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1792955013,"completedAt":null,"durationMs":null}}},{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a17472-5a50-7060-a5a9-88ca4bbe21e7","clientId":null,"content":[{"type":"text","text":"Grant the narrow owned directory and open a real sandboxed PTY.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","startedAtMs":1792955013714}},{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a17472-5a50-7060-a5a9-88ca4bbe21e7","clientId":null,"content":[{"type":"text","text":"Grant the narrow owned directory and open a real sandboxed PTY.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","completedAtMs":1792955013714}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","tokenUsage":{"total":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"item/started","params":{"item":{"type":"commandExecution","id":"call_open_first","pluginId":null,"scriptPath":null,"command":"/usr/bin/sh -c '/bin/bash --noprofile --norc'","cwd":"/probe-state/codex-contract-aFAvDr/workspace","processId":"88913","source":"unifiedExecStartup","status":"inProgress","commandActions":[{"type":"unknown","command":"/bin/bash --noprofile --norc"}],"aggregatedOutput":null,"exitCode":null,"durationMs":null},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","startedAtMs":1792955014085}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","tokenUsage":{"total":{"totalTokens":30,"inputTokens":20,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":10,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"item/started","params":{"item":{"type":"agentMessage","id":"msg_contract_2","text":"","phase":null,"memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","startedAtMs":1792955014385}},{"method":"item/agentMessage/delta","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","itemId":"msg_contract_2","delta":"First fragment"}},{"method":"item/agentMessage/delta","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","itemId":"msg_contract_2","delta":" and final fragment"}},{"method":"item/completed","params":{"item":{"type":"agentMessage","id":"msg_contract_2","text":"First fragment and final fragment","phase":null,"memoryCitation":null,"delivery":null,"questions":null},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","completedAtMs":1792955014385}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","tokenUsage":{"total":{"totalTokens":45,"inputTokens":30,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":15,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"turn/completed","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turn":{"id":"01a17472-59f5-7a31-ba4b-14174b0019cc","rootTurnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","items":[{"type":"agentMessage","id":"msg_contract_2","text":"First fragment and final fragment","phase":null,"memoryCitation":null,"delivery":null,"questions":null}],"itemsView":"summary","status":"completed","error":null,"startedAt":1792955013,"completedAt":1792955014,"durationMs":774}}},{"method":"turn/started","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turn":{"id":"01a17472-5d09-7b80-bbc0-742127c507f0","rootTurnId":"01a17472-5d09-7b80-bbc0-742127c507f0","items":[],"itemsView":"notLoaded","status":"inProgress","error":null,"startedAt":1792955014,"completedAt":null,"durationMs":null}}},{"method":"item/started","params":{"item":{"type":"userMessage","id":"01a17472-5d1d-7452-91d4-cf12c705cd42","clientId":null,"content":[{"type":"text","text":"Poll the terminal, deny one input, then approve a separate input.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","startedAtMs":1792955014430}},{"method":"item/completed","params":{"item":{"type":"userMessage","id":"01a17472-5d1d-7452-91d4-cf12c705cd42","clientId":null,"content":[{"type":"text","text":"Poll the terminal, deny one input, then approve a separate input.","text_elements":[]}]},"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","completedAtMs":1792955014431}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","tokenUsage":{"total":{"totalTokens":60,"inputTokens":40,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":20,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"thread/tokenUsage/updated","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-5d09-7b80-bbc0-742127c507f0","tokenUsage":{"total":{"totalTokens":75,"inputTokens":50,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":25,"reasoningOutputTokens":0},"last":{"totalTokens":15,"inputTokens":10,"cachedInputTokens":0,"cacheWriteInputTokens":0,"outputTokens":5,"reasoningOutputTokens":0},"modelContextWindow":121600}}},{"method":"item/commandExecution/outputDelta","params":{"threadId":"01a17472-59d7-7133-a91c-51c797e80d63","turnId":"01a17472-59f5-7a31-ba4b-14174b0019cc","itemId":"call_open_first","delta":"\r void) | undefined + let ended = false + const query = { + async *[Symbol.asyncIterator]() { + while (!ended || queue.length) { + if (queue.length) yield queue.shift() + else await new Promise(resolve => { wake = resolve }) + } + }, + close: vi.fn(() => { ended = true; wake?.() }), + } + const events: any[] = [] + const session = new Session({ + query: () => query, + fixture: { cwd: '/fixture/workspace', env: () => ({}) }, + provider: { url: 'http://127.0.0.1/fixture' }, + trace: { add: (event: unknown) => events.push(event) }, + uuid: (label: string) => label, + }, 'S') + const deliver = async (...messages: any[]) => { + for (const message of messages) { + queue.push(message) + wake?.() + await session.waitFor((candidate: unknown) => candidate === message) + } + } + return { session, query, events, deliver } +} diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 629195f7e2..2b865b2d1c 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -66,6 +66,16 @@ function results(result: ScenarioResult, session: string) { return messagesOf(result, session).filter(isResult) } +function expectSuccessfulClose(result: ScenarioResult, session: string, uuid: string) { + const events = eventsOf(result, session) + const close = at(events, event => event.k === 'close') + const terminal = events.slice(0, close).filter(event => event.k === 'msg') + .map(event => event.m).filter(message => isResult(message) || message.type === 'command_lifecycle' || isState('idle')(message)) + .slice(-3) + expect(terminal.map(message => isResult(message) ? 'result' : message.state)).toEqual(['result', 'completed', 'idle']) + expect(terminal[1].command_uuid).toBe(uuid) +} + describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback provider)`, () => { it('the pinned SDK and its bundled CLI are installed and report the pinned versions', async () => { const pinned = await resolvePinnedClaude(REPO_ROOT) @@ -141,6 +151,7 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p it('T4: a fresh boot with an id whose transcript exists is refused: "Session ID X is already in use." and exit code 1', async () => { const r = await real('session-in-use') + expectSuccessfulClose(r, 'seed', 'u1') const events = eventsOf(r, 'S') expect(r.observations.rejected.stderr).toContain('Session ID X is already in use.') expect(r.observations.rejected.childExit).toEqual({ code: 1, signal: null }) @@ -515,6 +526,9 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p }) expect(results(r, 'S').map((m) => m.subtype)).toEqual(['success']) expect(results(r, 'M').map((m) => m.subtype)).toEqual(['success']) + for (const [session, uuid] of [['S', 'u1'], ['M', 'u2']]) { + expectSuccessfulClose(r, session, uuid) + } }, SCENARIO_TIMEOUT) }) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index cc96600f96..00f69c5da2 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -116,7 +116,7 @@ function createInput() { } } -class Session { +export class Session { constructor(ctx, label, { options = {}, env = {}, permissions } = {}) { this.ctx = ctx this.label = label @@ -311,8 +311,29 @@ class Session { return this.child?.pid } - async close() { - if (!this.closed) this.ctx.trace.add({ s: this.label, k: 'close' }) + close() { + this.closing ??= this.closeAfterSuccessfulDrain() + return this.closing + } + + async closeAfterSuccessfulDrain() { + // A successful result is followed by command completion and the queue's + // idle frame on a later pipe delivery. Keep that tail inside the capture. + // Cancellation/error results and deliberate mid-turn closes retain their + // immediate-close semantics; they do not promise this successful drain. + const resultAt = this.frames.findLastIndex((m) => m.type === 'result' && !m.parent_tool_use_id) + const result = this.frames[resultAt] + const startedAt = this.frames.findLastIndex((m) => !m.parent_tool_use_id && ( + (m.type === 'command_lifecycle' && m.state === 'started') + || (m.type === 'system' && (m.subtype === 'init' || (m.subtype === 'session_state_changed' && m.state === 'running'))))) + let drainError + if (result?.subtype === 'success' && result.is_error === false + && (result.terminal_reason === undefined || result.terminal_reason === 'completed') && startedAt < resultAt) { + const drained = await this.waitFor((m) => isStateIdle(m) && this.frames.indexOf(m) > resultAt, + 10_000, 'successful result idle') + if (!drained) drainError = new Error(`successful result for session ${result.session_id} did not reach idle within 10000ms`) + } + this.ctx.trace.add({ s: this.label, k: 'close' }) this.closed = true this.input.end() try { this.query.close() } catch { /* already closed */ } @@ -321,6 +342,7 @@ class Session { this.child.kill('SIGKILL') await Promise.race([new Promise((resolve) => this.child.once('exit', resolve)), sleep(3000)]) } + if (drainError) throw drainError } } @@ -372,7 +394,7 @@ function transcriptShape(lines) { })) } -const SCENARIOS = { +export const SCENARIOS = { /** T1 T2 T3 T7 T8: fresh boot with our session id, settings before the first prompt, two turns. */ async boot(ctx) { const X = ctx.uuid('X') @@ -389,13 +411,14 @@ const SCENARIOS = { const resultAt = Date.now() const appeared = await ctx.pollTranscript(X, 4000) const msAfterResult = appeared ? Date.now() - resultAt : null - const assistantUuid = s.messages((m) => m.type === 'assistant' && !m.parent_tool_use_id).at(-1)?.uuid - ctx.need(assistantUuid, 'first turn assistant UUID') - // Appearance can be a valid partial flush. Compare the first turn's - // shape only once the assistant we already observed is durable. - const completed = await ctx.pollTranscript(X, 4000, 1, (lines) => lines.some((line) => line.type === 'assistant' && line.uuid === assistantUuid)) - ctx.need(completed, 'first turn assistant in durable transcript') - ctx.observe('transcriptAfterFirstResult', { msAfterResult, shape: transcriptShape(completed) }) + const assistantUuid = ctx.need(s.messages(message => message.type === 'assistant' && !message.parent_tool_use_id).at(-1)?.uuid, 'first-turn assistant UUID') + // File appearance can precede the final assistant append. Preserve that + // timing observation, but compare history only once this exact turn saved. + const saved = ctx.need( + await ctx.pollTranscript(X, 4000, 1, lines => lines.some(line => line.type === 'assistant' && line.uuid === assistantUuid)), + `first-turn assistant ${assistantUuid} saved for session ${X} within 4000ms`, + ) + ctx.observe('transcriptAfterFirstResult', { msAfterResult, shape: transcriptShape(saved) }) s.push('second prompt', 'u2') await s.waitForResults(2) await s.waitForCount(isStateIdle, 2, 3000, 'idle') @@ -864,6 +887,21 @@ const SCENARIOS = { export const SCENARIO_NAMES = Object.keys(SCENARIOS) +/** Poll until the saved lines satisfy the requested count and optional predicate. */ +export async function pollTranscript(fixture, trace, id, ms, minLines = 1, predicate) { + const started = Date.now() + while (Date.now() - started <= ms) { + const lines = await fixture.readTranscript(id) + if (lines && lines.length >= minLines && (!predicate || predicate(lines))) { + trace.add({ k: 'transcript-seen', id, lines: lines.length }) + return lines + } + await sleep(50) + } + trace.add({ k: 'transcript-missing', id }) + return null +} + /** Load the `query` export of the target. */ async function loadTarget(target, repoRoot) { if (target === 'real') { @@ -920,20 +958,7 @@ export async function runScenario(target, name, { repoRoot = DEFAULT_REPO_ROOT } return value }, files: () => fixture.transcripts(), - /** Poll until the saved lines satisfy the requested count and optional predicate. */ - async pollTranscript(id, ms, minLines = 1, predicate) { - const started = Date.now() - while (Date.now() - started <= ms) { - const lines = await fixture.readTranscript(id) - if (lines && lines.length >= minLines && (!predicate || predicate(lines))) { - trace.add({ k: 'transcript-seen', id, lines: lines.length }) - return lines - } - await sleep(50) - } - trace.add({ k: 'transcript-missing', id }) - return null - }, + pollTranscript: (...args) => pollTranscript(fixture, trace, ...args), } let failure try { diff --git a/test/integration/providers/claude-freshell-mcp.mjs b/test/integration/providers/claude-freshell-mcp.mjs index dad524c416..dead41909f 100644 --- a/test/integration/providers/claude-freshell-mcp.mjs +++ b/test/integration/providers/claude-freshell-mcp.mjs @@ -67,7 +67,7 @@ try { }) failureStage = 'sidecar start' - sidecar = spawn(process.execPath, [path.join(sidecarDir, 'index.mjs')], { + sidecar = fixture.ownProcess(spawn(process.execPath, [path.join(sidecarDir, 'index.mjs')], { cwd: sidecarDir, env: fixture.env(provider.url, { CLAUDE_CMD: pinned.nativeCli, @@ -78,7 +78,7 @@ try { FRESHELL_TOKEN: token, }), stdio: ['pipe', 'pipe', 'pipe'], - }) + })) let stderr = '' sidecar.stderr.setEncoding('utf8').on('data', (chunk) => { stderr = (stderr + chunk).slice(-4000) }) sidecar.stdin.on('error', () => {}) @@ -111,6 +111,7 @@ try { const created = await waitFor(frame => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 45_000, 'created') if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) if (created.sessionId !== nativeSessionId) throw new Error('the sidecar changed the requested native session identity') + await fixture.ownProcessId(created.cliPid) failureStage = 'cli argv' const argv = (await fs.readFile(`/proc/${created.cliPid}/cmdline`, 'utf8')).split('\0') @@ -155,6 +156,7 @@ try { for (let waited = 0; waited < 10_000 && receipt.probe === null; waited += 50) { try { receipt.probe = JSON.parse(await fs.readFile(reportFile, 'utf8')) } catch { await sleep(50) } } + if (receipt.probe?.pid) await fixture.ownProcessId(receipt.probe.pid) const tokenDigest = createHash('sha256').update(token).digest('hex') const probeOk = receipt.probe !== null diff --git a/test/integration/providers/claude-local-provider.mjs b/test/integration/providers/claude-local-provider.mjs index 1039c53157..769b33b759 100644 --- a/test/integration/providers/claude-local-provider.mjs +++ b/test/integration/providers/claude-local-provider.mjs @@ -128,6 +128,7 @@ export async function createClaudeFixture({ parent = os.tmpdir(), prefix = 'fres const tmp = path.join(root, 'tmp') for (const directory of [home, cwd, tmp]) await fs.mkdir(directory, { recursive: true, mode: 0o700 }) const claudeConfigDir = path.join(home, '.claude') + const ownedProcessLifetimes = new Map() return { root, home, @@ -186,16 +187,84 @@ export async function createClaudeFixture({ parent = os.tmpdir(), prefix = 'fres const complete = text.slice(0, text.lastIndexOf('\n') + 1) return complete.split('\n').filter(Boolean).map((line) => JSON.parse(line)) }, + /** Register immediately after spawn so even an early close is retained. */ + ownProcess(child) { + const closed = new Promise(resolve => child.once('close', resolve)) + ownedProcessLifetimes.set(child.pid, () => waitForFixtureProcess(closed, child.pid)) + return child + }, + /** The sidecar's created frame identifies its separately owned native CLI. */ + async ownProcessId(pid) { + if (!Number.isInteger(pid) || pid <= 0) throw new Error('fixture process PID must be a positive integer') + if (ownedProcessLifetimes.has(pid)) return + const identity = await liveFixtureProcessIdentity(pid) + if (identity) ownedProcessLifetimes.set(pid, () => waitForFixturePid(pid, identity)) + }, async cleanup() { - // maxRetries makes fs.rm retry ENOTEMPTY/EBUSY/EPERM: a native CLI that - // is still draining into its fixture (HOME/TMPDIR live under root) can - // re-create a file between the unlink sweep and the final rmdir, and - // with the default maxRetries=0 that race rejects the whole cleanup. + // A sidecar exit/query.close() initiates CLI shutdown; it does not join + // the native writer. Never remove HOME until all registered owners have + // settled. A missing exit retains the fixture and fails diagnostically. + const outcomes = await Promise.allSettled([...ownedProcessLifetimes.values()].map(wait => wait())) + const failures = outcomes.filter(result => result.status === 'rejected').map(result => result.reason) + if (failures.length) throw new AggregateError(failures, `Claude fixture process cleanup incomplete; retained ${root}`) + // Retain the main-branch retries for callers without registered writers + // and transient filesystem contention after all registered owners settle. await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }, } } +const FIXTURE_PROCESS_EXIT_BUDGET_MS = 5_000 + +async function liveFixtureProcessIdentity(pid) { + let stat + try { stat = await fs.readFile(`/proc/${pid}/stat`, 'utf8') } + catch (error) { if (error.code === 'ENOENT' || error.code === 'ESRCH') return null; throw error } + const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ') + if (!/^\d+$/.test(fields[19] ?? '')) throw new Error(`cannot establish fixture process identity for PID ${pid}`) + // Zombies cannot write; start time distinguishes the captured process from + // a later process reusing its PID. No signal is sent by fixture cleanup. + return fields[0] === 'Z' || fields[0] === 'X' ? null : fields[19] +} + +function processExitError(pid) { + return new Error(`fixture-owned process ${pid} did not settle within ${FIXTURE_PROCESS_EXIT_BUDGET_MS}ms`) +} + +async function waitForFixtureProcess(closed, pid) { + let timer + try { + await Promise.race([closed, new Promise((_, reject) => { + timer = setTimeout(() => reject(processExitError(pid)), FIXTURE_PROCESS_EXIT_BUDGET_MS) + })]) + } finally { clearTimeout(timer) } +} + +function waitForFixturePid(pid, identity) { + return new Promise((resolve, reject) => { + let settled = false + let reading = false + const finish = error => { + if (settled) return + settled = true + clearInterval(poll) + clearTimeout(deadline) + if (error) reject(error) + else resolve() + } + const check = async () => { + if (reading || settled) return + reading = true + try { if (await liveFixtureProcessIdentity(pid) !== identity) finish() } + catch (error) { finish(error) } + finally { reading = false } + } + const poll = setInterval(check, 10) + const deadline = setTimeout(() => finish(processExitError(pid)), FIXTURE_PROCESS_EXIT_BUDGET_MS) + void check() + }) +} + function textOfContent(content) { if (typeof content === 'string') return content if (!Array.isArray(content)) return '' diff --git a/test/integration/providers/claude-sidecar-shutdown.mjs b/test/integration/providers/claude-sidecar-shutdown.mjs index 58f9e0f896..41da2d1131 100644 --- a/test/integration/providers/claude-sidecar-shutdown.mjs +++ b/test/integration/providers/claude-sidecar-shutdown.mjs @@ -9,13 +9,13 @@ // Modes (argv[3]): // - `shutdown` (default): the {"type":"shutdown"} line, then stdin EOF. // - `stdout-closed`: first the sidecar's stdout reader goes away (Rust aborts the session's -// stdout consumer before its teardown), then, 50 ms later (the teardown's ownership scan), +// stdout consumer before its teardown), then, 50 ms later, // the same shutdown line and stdin EOF. The provider streams a text delta every 25 ms, so // the sidecar is writing into the closed pipe before the shutdown line can arrive. import fs from 'node:fs/promises' import path from 'node:path' import http from 'node:http' -import { spawn, execFileSync } from 'node:child_process' +import { spawn } from 'node:child_process' import { createInterface } from 'node:readline' import { randomUUID } from 'node:crypto' import { createClaudeFixture, FAKE_MODEL, resolvePinnedClaude } from './claude-local-provider.mjs' @@ -55,6 +55,7 @@ let sidecarExitedAt let sidecarExit let cliExitedAt let shutdownSentAt +let sidecarStderr = '' try { failureStage = 'local provider' @@ -71,7 +72,7 @@ try { response.writeHead(404).end() return } - response.on('close', () => { providerRequestClosedAt ??= Date.now() }) + response.on('close', () => { providerRequestClosedAt ??= performance.now() }) response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', 'request-id': randomUUID() }) const send = (type, data) => response.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`) send('message_start', { message: { id: `msg_${randomUUID().replaceAll('-', '')}`, type: 'message', role: 'assistant', model: FAKE_MODEL, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } }) @@ -104,11 +105,12 @@ try { }), stdio: ['pipe', 'pipe', 'pipe'], }) - sidecar.stderr.on('data', () => {}) + fixture.ownProcess(sidecar) + sidecar.stderr.on('data', chunk => { sidecarStderr = (sidecarStderr + chunk.toString()).slice(-16000) }) // The shutdown line may reach a sidecar that already stopped on its own (`stdout-closed` // mode); that write's EPIPE is expected, as it is for Rust's teardown. sidecar.stdin.on('error', () => {}) - sidecar.once('exit', (code, signal) => { sidecarExitedAt = Date.now(); sidecarExit = { code, signal } }) + sidecar.once('exit', (code, signal) => { sidecarExitedAt = performance.now(); sidecarExit = { code, signal } }) const frames = [] const waiters = [] createInterface({ input: sidecar.stdout }).on('line', (line) => { @@ -135,11 +137,12 @@ try { write({ type: 'send', sessionId: created.sessionId, uuid: randomUUID(), text: 'hello' }) await withTimeout(streaming, 45_000, 'the provider to receive the turn') await waitFor(frame => frame.type === 'sdk.stream' && frame.event?.type === 'content_block_delta', 30_000, 'the streamed delta') - cliPid = Number(execFileSync('pgrep', ['-P', String(sidecar.pid)]).toString().trim().split('\n')[0]) - if (!Number.isInteger(cliPid) || !alive(cliPid)) throw new Error('the native CLI child was not found') + cliPid = created.cliPid + if (!Number.isInteger(cliPid) || !alive(cliPid)) throw new Error('the created native CLI child was not found') + await fixture.ownProcessId(cliPid) failureStage = 'shutdown' - shutdownSentAt = Date.now() + shutdownSentAt = performance.now() if (mode === 'stdout-closed') { sidecar.stdout.destroy() await sleep(50) @@ -147,11 +150,11 @@ try { write({ type: 'shutdown' }) sidecar.stdin.end() const deadline = shutdownSentAt + 10_000 - while (Date.now() < deadline && (sidecarExitedAt === undefined || alive(cliPid) || providerRequestClosedAt === undefined)) { - if (cliExitedAt === undefined && !alive(cliPid)) cliExitedAt = Date.now() + while (performance.now() < deadline && (sidecarExitedAt === undefined || alive(cliPid) || providerRequestClosedAt === undefined)) { + if (cliExitedAt === undefined && !alive(cliPid)) cliExitedAt = performance.now() await sleep(5) } - if (cliExitedAt === undefined && !alive(cliPid)) cliExitedAt = Date.now() + if (cliExitedAt === undefined && !alive(cliPid)) cliExitedAt = performance.now() if (sidecarExitedAt !== undefined && cliExitedAt !== undefined && providerRequestClosedAt !== undefined) verdict = 'PASS' } catch (error) { process.stderr.write(`${JSON.stringify({ @@ -178,10 +181,12 @@ process.stdout.write(`${JSON.stringify({ mode, sdkVersion: pinned.sdkVersion, bundledCliVersion: pinned.cliVersion, + nodeVersion: process.version, sidecarExit, sidecarExitMs: since(sidecarExitedAt), nativeCliExitMs: since(cliExitedAt), providerRequestClosedMs: since(providerRequestClosedAt), fixtureRemoved, + sidecarStderr, })}\n`) if (verdict !== 'PASS' || !fixtureRemoved) process.exitCode = 1 diff --git a/test/integration/providers/claude-sidecar-shutdown.test.ts b/test/integration/providers/claude-sidecar-shutdown.test.ts index 564f223ea4..2918ef6927 100644 --- a/test/integration/providers/claude-sidecar-shutdown.test.ts +++ b/test/integration/providers/claude-sidecar-shutdown.test.ts @@ -63,6 +63,7 @@ describe('Claude sidecar shutdown against the pinned CLI and a local provider', const lines = stdout.trim().split('\n').filter(Boolean) expect(lines, stderr).toHaveLength(1) const receipt = JSON.parse(lines[0]!) as Record + console.log(JSON.stringify({ event: 'claude.shutdown.receipt', receipt })) expect(receipt).toMatchObject({ verdict: 'PASS', mode, diff --git a/test/integration/providers/codex-cli-contract.mjs b/test/integration/providers/codex-cli-contract.mjs new file mode 100644 index 0000000000..194228e42e --- /dev/null +++ b/test/integration/providers/codex-cli-contract.mjs @@ -0,0 +1,1400 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict' +import fs from 'node:fs' +import fsp from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import http from 'node:http' +import { spawn, spawnSync } from 'node:child_process' +import { createInterface } from 'node:readline' +import { createHash, randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { artifactMetadata, repoRoot, verifyArtifact, ambientCodex, verifySandbox, nativeSandboxPolicy, verifyPolicyClientProbe } from '../../../scripts/testing/prepare-codex-contract.mjs' +import { createCodexLocalProvider, MCP_FORM_CONTENT, MCP_LARGE_TEXT, CODEX_NETWORK_REQUIREMENTS } from './codex-local-provider.mjs' + +const scenarios = ['text-history', 'active-input-stop', 'parent-child-read', 'plan-question-controls', 'mcp-form-url', 'model-effort-usage', 'approval-policy-restricted-exec'] +export function nativeContractCases() { + const cases = [] + for (const historyMode of ['legacy', 'paginated']) { + cases.push(['text-history', { historyMode }], ['text-history', { historyMode, duplicateBodies: true }]) + } + for (const name of scenarios.filter(name => name !== 'text-history')) cases.push([name, {}]) + for (const sandboxMode of ['read-only', 'workspace-write']) cases.push(['approval-policy-restricted-exec', { sandboxMode, probeApprovalPolicy: 'never', probePolicyEffects: true }]) + cases.push(['approval-policy-restricted-exec', { requirementsGuard: true }]) + for (const approvalControl of ['stdin', 'network']) cases.push(['approval-policy-restricted-exec', { approvalControl }]) + cases.push(['active-input-stop', { startupCancellation: true }], ['parent-child-read', { ephemeralParent: true }], ['parent-child-read', { childApproval: true }]) + for (const historyMode of ['legacy', 'paginated']) { + cases.push(['mcp-form-url', { historyMode, largeOutput: true }]) + for (const persistenceMode of ['delayed', 'missing']) cases.push(['text-history', { historyMode, persistenceMode }]) + } + return cases +} +const deadline = (promise, ms, label) => { + let timer + return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`CODEX_CONTRACT_TIMEOUT: ${label}`)), ms) })]).finally(() => clearTimeout(timer)) +} +function deferred() { + let resolve + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} +function sanitizedCapture(value, secrets, key = '') { + if (typeof value === 'string') { + if (/^(?:authorization|api_?key|access_?token|password|secret)$/i.test(key)) return '[redacted]' + let result = value.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[a-zA-Z0-9+/=]+/g, '[uploaded image omitted]') + for (const secret of secrets) if (secret) result = result.replaceAll(secret, '[redacted]') + return result + } + if (Array.isArray(value)) return value.map(item => sanitizedCapture(item, secrets)) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, sanitizedCapture(item, secrets, name)])) + return value +} + +function exportCloudCapture(capture, json) { + const id = randomUUID() + const partSize = 16_000 + const parts = Math.ceil(json.length / partSize) + for (let part = 0; part < parts; part++) process.stdout.write(`${JSON.stringify({ severity: 'INFO', event: 'codex_native_capture_part', id, scenario: capture.scenario, part, parts, payload: json.slice(part * partSize, (part + 1) * partSize) })}\n`) + process.stdout.write(`${JSON.stringify({ severity: 'INFO', event: 'codex_native_capture_complete', id, scenario: capture.scenario, parts, bytes: Buffer.byteLength(json), sha256: createHash('sha256').update(json).digest('hex'), passed: capture.passed, version: capture.binary.version, uid: capture.environment.uid, gid: capture.environment.gid, platform: capture.binary.platform, requestCount: capture.requests.length, fixtureRequestCount: capture.fixtureTraffic.length })}\n`) +} + +async function roots(options) { + const metadata = options.probeMetadata ?? artifactMetadata + let staged + if (fs.existsSync(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'))) staged = JSON.parse(await fsp.readFile(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'), 'utf8')) + const managed = process.env.FRESHELL_CODEX_MANAGED_CONTRACT === '1' ? JSON.parse(await fsp.readFile(nativeSandboxPolicy().native.layoutFile, 'utf8')) : undefined + const artifactRoot = managed?.platformPackageRoot ?? options.artifactRoot ?? process.env.FRESHELL_CODEX_CONTRACT_ROOT ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.artifactRoot : undefined) + if (!artifactRoot) throw new Error('CODEX_CONTRACT_PREREQUISITE: explicit prepared artifact root is missing; no PATH fallback') + const artifact = await verifyArtifact(artifactRoot, metadata, managed ? '/native-reference.json' : undefined) + let policyClientProbe + if (!options.probeMetadata && !options.seccompProbeReceipt) { + const file = options.policyClientProbe ?? process.env.FRESHELL_CODEX_POLICY_CLIENT_PROBE ?? staged?.policyClientProbe?.path + assert(file, 'CODEX_CONTRACT_PREREQUISITE: required real-client policy probe is missing') + policyClientProbe = await verifyPolicyClientProbe(file, staged?.policyClientProbe?.path === file ? staged.policyClientProbe.sha256 : undefined) + } + const stateBase = options.stateRoot ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.stateRoot : undefined) ?? os.tmpdir() + const stateRoot = await fsp.mkdtemp(path.join(stateBase, 'codex-contract-')) + const receiptRoot = path.resolve(options.receiptDir ?? (process.env.FRESHELL_SANDBOX_MODE === 'runtime-suite' ? staged?.receiptRoot : undefined) ?? path.join(stateRoot, 'receipts')) + await fsp.mkdir(receiptRoot, { recursive: true, mode: 0o700 }) + for (const directory of ['home', 'codex', 'xdg-config', 'xdg-cache', 'xdg-data', 'workspace', 'tmp']) await fsp.mkdir(path.join(stateRoot, directory), { mode: 0o700 }) + const systemConfig = ['/etc/codex/config.toml', '/etc/codex/requirements.toml'].map(file => ({ path: file, present: fs.existsSync(file) })) + if (systemConfig.some(file => file.present && (file.path !== '/etc/codex/requirements.toml' || fs.readFileSync(file.path, 'utf8') !== CODEX_NETWORK_REQUIREMENTS))) throw new Error('CODEX_CONTRACT_PREREQUISITE: unexpected system Codex configuration in fixture environment') + const environment = { PATH: process.env.PATH, HOME: path.join(stateRoot, 'home'), CODEX_HOME: await fsp.realpath(path.join(stateRoot, 'codex')), XDG_CONFIG_HOME: path.join(stateRoot, 'xdg-config'), XDG_CACHE_HOME: path.join(stateRoot, 'xdg-cache'), XDG_DATA_HOME: path.join(stateRoot, 'xdg-data'), TMPDIR: path.join(stateRoot, 'tmp'), LANG: 'C.UTF-8', RUST_LOG: 'warn' } + if (!managed && (!options.probeMetadata || options.preferBundledBwrap)) { + const bundled = artifact.files.filter(file => file.path.endsWith('/codex-resources/bwrap')) + assert.equal(bundled.length, 1, 'fixture PATH selection needs the single verified bundled bubblewrap resource') + environment.PATH = `${path.dirname(path.join(artifact.path, bundled[0].path))}:${environment.PATH}` + } + return { artifact, metadata, managed, stateRoot, receiptRoot, environment, workspace: path.join(stateRoot, 'workspace'), systemConfig, staged, policyClientProbe } +} + +export class NativeClient { + constructor(binary, context, capture, args = ['app-server']) { + this.capture = capture + this.pending = new Map() + this.waiters = new Set() + this.nextId = 1 + this.secretControls = new Map() + this.child = spawn(binary, args, { cwd: context.workspace, env: context.environment, stdio: ['pipe', 'pipe', 'pipe'] }) + this.connectionId = (capture.connections ??= []).length + capture.connections.push({ id: this.connectionId, pid: this.child.pid, binary, args }) + if (!capture.probeOnly || capture.environment.seccompProbe || capture.environment.nativeSandboxPolicy) { + const observed = new Map((capture.observations.executedSandboxHelpers ?? []).map(helper => [helper.pid, helper])) + this.helperWatcher = setInterval(() => { + const visit = pid => { + try { + const executable = fs.readlinkSync(`/proc/${pid}/exe`) + if (executable.endsWith('/bwrap')) observed.set(pid, { pid, executable, argv0: fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0')[0] }) + } catch {} + try { + const children = new Set() + for (const task of fs.readdirSync(`/proc/${pid}/task`)) { + try { for (const child of fs.readFileSync(`/proc/${pid}/task/${task}/children`, 'utf8').trim().split(/\s+/).filter(Boolean)) children.add(Number(child)) } catch {} + } + for (const child of children) visit(child) + } catch {} + } + if (this.child.exitCode === null) visit(this.child.pid) + capture.observations.executedSandboxHelpers = [...observed.values()] + }, 5) + } + this.exit = new Promise(resolve => this.child.once('exit', (code, signal) => { + capture.processExit = { code, signal } + ;(capture.processExits ??= []).push({ connectionId: this.connectionId, pid: this.child.pid, code, signal }) + clearInterval(this.helperWatcher) + for (const entry of this.pending.values()) entry.reject(new Error(`owned Codex exited: ${code}/${signal}`)) + resolve({ code, signal }) + })) + this.closed = new Promise(resolve => this.child.once('close', (code, signal) => { + ;(capture.processCloses ??= []).push({ connectionId: this.connectionId, pid: this.child.pid, code, signal }) + resolve({ code, signal }) + })) + this.child.stderr.on('data', bytes => { capture.stderr = `${capture.stderr ?? ''}${bytes.toString()}`.slice(-64 * 1024) }) + createInterface({ input: this.child.stdout }).on('line', line => { + let frame + try { frame = JSON.parse(line) } catch { return } + capture.frames.push({ index: capture.frames.length, connectionId: this.connectionId, direction: 'native', frame }) + if (frame.id !== undefined && !frame.method) { + capture.responses.push(frame) + const entry = this.pending.get(frame.id) + if (entry?.delayDelivery) { entry.nativeReply = frame; entry.receivedIndex = capture.frames.length - 1 } + else if (entry) { this.pending.delete(frame.id); if (frame.error) entry.reject(Object.assign(new Error(frame.error.message), { nativeError: frame.error })); else entry.resolve(frame.result) } + } else if (frame.method && frame.id !== undefined) { + capture.serverRequests.push(frame) + if (frame.method === 'item/tool/requestUserInput') this.secretControls.set(frame.id, { questions: new Set((frame.params.questions ?? []).filter(question => question.isSecret).map(question => question.id)) }) + if (frame.method === 'mcpServer/elicitation/request') this.secretControls.set(frame.id, { fields: new Set(Object.entries(frame.params.requestedSchema?.properties ?? {}).filter(([, schema]) => schema.format === 'password' || schema.writeOnly === true).map(([name]) => name)) }) + this.onServerRequest?.(frame) + } + else capture.notifications.push(frame) + for (const waiter of [...this.waiters]) if (waiter.predicate(frame)) { this.waiters.delete(waiter); waiter.resolve(frame) } + }) + } + send(frame) { + const secret = this.secretControls.get(frame.id) + for (const question of secret?.questions ?? []) for (const value of frame.result?.answers?.[question]?.answers ?? []) if (typeof value === 'string' && value) this.capture.secretValues.add(value) + for (const field of secret?.fields ?? []) { const value = frame.result?.content?.[field]; if (typeof value === 'string' && value) this.capture.secretValues.add(value) } + if (frame.id !== undefined && frame.result !== undefined) this.secretControls.delete(frame.id) + this.capture.frames.push({ index: this.capture.frames.length, connectionId: this.connectionId, direction: 'client', frame }) + this.child.stdin.write(`${JSON.stringify(frame)}\n`) + } + request(method, params = {}, timeoutMs = 30_000, delayDelivery = false) { + const id = this.nextId++ + const frame = { id, method, params } + this.capture.requests.push(frame) + return deadline(new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject, delayDelivery }); this.send(frame) }), timeoutMs, method) + } + releaseReply(id) { + const entry = this.pending.get(id) + assert(entry?.nativeReply, 'only an actually received native response can be delivered') + this.pending.delete(id) + ;(this.capture.observations.delayedControllerReplies ??= []).push({ connectionId: this.connectionId, requestId: id, nativeReceiptIndex: entry.receivedIndex, controllerReleaseIndex: this.capture.frames.length }) + if (entry.nativeReply.error) entry.reject(Object.assign(new Error(entry.nativeReply.error.message), { nativeError: entry.nativeReply.error })) + else entry.resolve(entry.nativeReply.result) + } + wait(predicate, timeoutMs = 30_000, minimumIndex = 0) { + const existing = this.capture.frames.find(entry => entry.connectionId === this.connectionId && entry.index >= minimumIndex && entry.direction === 'native' && predicate(entry.frame)) + if (existing) return Promise.resolve(existing.frame) + let waiter + return deadline(new Promise(resolve => { waiter = { predicate, resolve }; this.waiters.add(waiter) }), timeoutMs, 'native notification').finally(() => this.waiters.delete(waiter)) + } + async close() { + this.child.stdin.end() + if (this.child.exitCode === null && this.child.signalCode === null) this.child.kill('SIGTERM') + try { await deadline(this.exit, 5000, 'owned process exit') } catch { this.child.kill('SIGKILL'); await deadline(this.exit, 5000, 'owned process kill') } + // The bubblewrap launcher can exit before its PID namespace finishes + // teardown. Its descendants inherit these pipes; join their closure too. + await deadline(this.closed, 5000, `owned process stdio close pid=${this.child.pid} exit=${this.child.exitCode} signal=${this.child.signalCode}`) + } +} + +async function nativeRequirementsGuard(context, capture) { + const { path: file } = context.policyClientProbe + capture.observations.policyClientProbe = context.policyClientProbe + const requirementFile = path.join(context.stateRoot, 'strict-requirements.toml') + await fsp.writeFile(requirementFile, `allowed_approval_policies = ["never"]\n${CODEX_NETWORK_REQUIREMENTS}`, { mode: 0o600 }) + const reservation = http.createServer() + await new Promise(resolve => reservation.listen(0, '127.0.0.1', resolve)) + const port = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + const readiness = () => new Promise(resolve => { + const request = http.get(`http://127.0.0.1:${port}/readyz`, response => { response.resume(); resolve(response.statusCode === 200) }) + request.on('error', () => resolve(false)); request.setTimeout(1000, () => request.destroy()) + }) + const helper = path.join(context.artifact.path, 'vendor/x86_64-unknown-linux-musl/codex-resources/bwrap') + // This matches native's proc-preflight fallback: retain the fresh user/PID/ + // IPC namespaces and inherited recursive root procfs, omit only --proc. + const args = ['--unshare-user', '--unshare-pid', '--unshare-ipc', '--die-with-parent', '--new-session', + '--ro-bind', '/', '/', '--bind', context.stateRoot, context.stateRoot, + '--ro-bind', requirementFile, '/etc/codex/requirements.toml', '--dev', '/dev', + '--chdir', context.workspace, '--cap-drop', 'ALL', '--uid', String(process.getuid()), '--gid', String(process.getgid()), + '--', capture.binary.path, 'app-server', '--listen', `ws://127.0.0.1:${port}`] + const native = new NativeClient(helper, context, capture, args) + try { + await deadline((async () => { + while (!await readiness()) { + assert.equal(native.child.exitCode, null, capture.stderr) + await new Promise(resolve => setTimeout(resolve, 50)) + } + })(), 15_000, 'isolated native requirements listener') + const receipt = path.join(context.stateRoot, 'policy-client-proof.json') + const probe = spawn(file, ['--exact', 'app_server::tests::native_requirements_refuse_untrusted_before_work', '--nocapture'], { + cwd: context.workspace, env: { ...context.environment, FRESHELL_CODEX_POLICY_PROBE_URL: `ws://127.0.0.1:${port}`, FRESHELL_CODEX_POLICY_PROBE_RECEIPT: receipt }, stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '', stderr = '' + probe.stdout.on('data', bytes => { stdout += bytes }); probe.stderr.on('data', bytes => { stderr += bytes }) + const exited = new Promise((resolve, reject) => { probe.once('exit', (code, signal) => resolve({ code, signal })); probe.once('error', reject) }) + let result + try { result = await deadline(exited, 30_000, 'real production policy client') } + finally { if (probe.exitCode === null && probe.signalCode === null) { probe.kill('SIGKILL'); await exited } } + assert.equal(result.code, 0, `${stdout}\n${stderr}`) + assert.match(stdout, /1 passed; 0 failed/) + const proof = JSON.parse(await fsp.readFile(receipt, 'utf8')) + assert.deepEqual(proof.refusedRoutes, ['create', 'resume', 'fork', 'send']) + assert.equal(proof.workRequests, 0) + for (const entry of proof.frames) { + const direction = entry.direction === 'controller' ? 'client' : 'native' + const frame = entry.frame + capture.frames.push({ index: capture.frames.length, connectionId: native.connectionId, direction, frame }) + if (direction === 'client' && frame.method && frame.id !== undefined) capture.requests.push(frame) + else if (direction === 'native' && frame.id !== undefined) capture.responses.push(frame) + else if (direction === 'native' && frame.method) capture.notifications.push(frame) + } + capture.observations.requirementsGuard = { ...proof, frames: undefined, result, stdout, stderr, requirementsSha256: createHash('sha256').update(await fsp.readFile(requirementFile)).digest('hex') } + assert(capture.requests.every(frame => ['initialize', 'configRequirements/read'].includes(frame.method))) + assert.equal(capture.requests.filter(frame => frame.method === 'configRequirements/read').length, 4) + } finally { await native.close() } + assert.equal(await readiness(), false, 'owned PID namespace and native listener must settle after launcher exit') + capture.observations.requirementsGuard.ownedListenerSettled = true +} + +async function nativeCatalogProbe(context,capture,fixture) { + const trafficStart=fixture.traffic.length + const reservation=http.createServer() + await new Promise(resolve=>reservation.listen(0,'127.0.0.1',resolve)) + const port=reservation.address().port + await new Promise(resolve=>reservation.close(resolve)) + const native=new NativeClient(capture.binary.path,context,capture,['app-server','--listen',`ws://127.0.0.1:${port}`]) + const ready=()=>new Promise(resolve=>{ + const request=http.get(`http://127.0.0.1:${port}/readyz`,response=>{response.resume();resolve(response.statusCode===200)}) + request.on('error',()=>resolve(false));request.setTimeout(1000,()=>request.destroy()) + }) + try { + await deadline((async()=>{ + while (!await ready()) { + assert.equal(native.child.exitCode,null,capture.stderr) + await new Promise(resolve=>setTimeout(resolve,50)) + } + })(),15_000,'owned model-catalog native listener') + const receipt=path.join(context.stateRoot,'catalog-client-proof.json') + const probe=spawn(context.policyClientProbe.path,['--exact','catalog::native_tests::native_catalog_and_opaque_effort_use_initialized_client','--nocapture'],{ + cwd:context.workspace,env:{...context.environment,FRESHELL_CODEX_CATALOG_PROBE_URL:`ws://127.0.0.1:${port}`,FRESHELL_CODEX_CATALOG_PROBE_RECEIPT:receipt},stdio:['ignore','pipe','pipe'], + }) + let stdout='',stderr='' + probe.stdout.on('data',bytes=>{stdout+=bytes});probe.stderr.on('data',bytes=>{stderr+=bytes}) + const exited=new Promise((resolve,reject)=>{probe.once('exit',(code,signal)=>resolve({code,signal}));probe.once('error',reject)}) + let result + try { result=await deadline(exited,120_000,'real production catalog client') } + finally { if (probe.exitCode===null && probe.signalCode===null) {probe.kill('SIGKILL');await exited} } + assert.equal(result.code,0,`${stdout}\n${stderr}`) + assert.match(stdout,/1 passed; 0 failed/) + const proof=JSON.parse(await fsp.readFile(receipt,'utf8')) + assert(proof.models.length>0) + assert.equal(proof.nativeAcceptedFutureEffort,true) + assert(proof.futureEffortCompletedTurn) + assert.equal(proof.nativeExactLifecycleAccepted,true) + assert.equal(proof.completedTurns.length,4) + const traffic=fixture.traffic.slice(trafficStart) + assert.equal(traffic.length,7,'four lifecycle turns, one future-effort turn and two advertised-setting turns must each reach the controlled provider once') + assert(traffic.slice(0,5).every(request=>request.body.model==='provider-current'),'custom native model work must use the exact model omitted from the catalog') + // Record both boundaries: native receives exact public effort strings, + // and its downstream HTTP reasoning can depend on the selected model. + const nativeTurns=proof.frames.filter(entry=>entry.direction==='controller' && entry.frame.method==='turn/start') + assert.equal(nativeTurns.length,7) + assert(nativeTurns.slice(0,4).every(entry=>entry.frame.params.effort==='ultra')) + assert.equal(nativeTurns[4].frame.params.effort,'future-unadvertised-native-effort') + assert.equal(traffic[4].body.reasoning.effort,'future-unadvertised-native-effort') + const advertised=proof.advertisedSelection + assert(proof.models.some(model=>model.id===advertised.model.id)) + assert.equal(advertised.turns.length,2) + const matchedAdvertisedSettings=advertised.turns.map((turn,index)=>{ + const nativeRequest=nativeTurns[index+5].frame.params + const http=traffic[index+5].body + assert(advertised.model.supportedEffortLevels.includes(turn.effort)) + assert.equal(nativeRequest.model,advertised.model.id) + assert.equal(nativeRequest.effort,turn.effort) + assert.equal(nativeRequest.approvalPolicy,'never') + assert.equal(http.model,advertised.model.id) + assert(turn.turnId) + return {...turn,nativeRequest,http} + }) + const methods=proof.frames.filter(entry=>entry.direction==='controller' && entry.frame.id!==undefined).map(entry=>entry.frame.method) + assert.equal(methods.filter(method=>method==='initialize').length,1) + const firstWork=methods.indexOf('thread/start') + assert(firstWork>0 && methods.slice(0,firstWork).every(method=>['initialize','model/list'].includes(method))) + capture.observations.productionCatalogClient={...proof,result,stdout,stderr,fixtureRequestCount:traffic.length,nativeHttpEfforts:traffic.map(request=>request.body.reasoning.effort),matchedAdvertisedSettings} + } finally {await native.close()} + assert.equal(await ready(),false,'owned native catalog listener must close') +} + +function assistantText(read, nativeTurnId) { + return read.thread.turns.filter(turn => !nativeTurnId || turn.id === nativeTurnId).flatMap(turn => turn.items.filter(item => item.type === 'agentMessage').map(item => item.text)).join('\n') +} + +async function nativeRefusal(client, method, params) { + try { await client.request(method, params) } catch (error) { assert(error.nativeError, 'refusal must come from the native provider'); return error.nativeError } + assert.fail(`native ${method} unexpectedly accepted invalid input`) +} + +async function configure(context, baseUrl, extra = '') { + await fsp.writeFile(path.join(context.environment.CODEX_HOME, 'config.toml'), `model = "contract-model"\nmodel_provider = "contract-loopback"\nmodel_reasoning_effort = "low"\n${extra}\n[model_providers.contract-loopback]\nname = "Codex contract loopback"\nbase_url = ${JSON.stringify(baseUrl)}\nwire_api = "responses"\nrequires_openai_auth = false\nsupports_websockets = false\nrequest_max_retries = 0\nstream_max_retries = 0\n`) +} + +function advertisedCall(record, name, args, id) { + let namespace + let tool = record.body.tools.find(tool => tool.type === 'function' && tool.name === name) + if (!tool) { + const group = record.body.tools.find(tool => tool.type === 'namespace' && tool.tools?.some(member => member.name === name)) + tool = group?.tools.find(member => member.name === name) + namespace = group?.name + } + assert(tool, `actual native model request did not advertise ${name}`) + return { items: [{ id: `fc_${id}`, type: 'function_call', call_id: `call_${id}`, ...(namespace ? { namespace } : {}), name, arguments: JSON.stringify(args), status: 'completed' }] } +} + +async function questionResponse(record, flow, capture) { + const current = flow.current + assert(current, 'question model work must belong to the selected native case') + const step = current.step++ + if (current.kind === 'contextual-reply') { + capture.observations.contextualAnswerModelInput = record.body.input + return { text: 'First fragment and final fragment' } + } + if (step === 0) { + if (current.kind === 'async') return advertisedCall(record, 'request_user_input_async', { questions: [{ title: 'Which fixture environment should be used?', options: ['Staging', 'Production'] }, { title: 'What deadline should be used?' }] }, `async_${current.id}`) + return advertisedCall(record, 'request_user_input', { questions: [{ id: 'fixture_choice', header: 'Choice', question: 'Which fixture result should be used?', options: [{ label: 'Alpha (Recommended)', description: 'Use the first result.' }, { label: 'Beta', description: 'Use the second result.' }] }] }, `question_${current.id}`) + } + assert(record.body.input.some(item => item.type === 'function_call_output' && item.call_id === `call_${current.kind === 'async' ? 'async' : 'question'}_${current.id}`), 'continued model work must receive the real native question result') + if (current.kind === 'async') { + capture.observations.asyncContinuedBeforeAnswer = true + return { text: 'Work continued after the asynchronous question.' } + } + return { text: current.mode === 'plan' ? 'Preface\n\n# Fixture plan\n- Inspect the fixture.\n- Return the result.\n\nPostscript' : 'First fragment and final fragment' } +} + +async function driveQuestions(client, capture, flow) { + capture.observations.questionCases = [] + for (const mode of ['plan', 'default']) { + for (const cancelled of [false, true]) { + const current = { id: `${mode}_${cancelled ? 'cancelled' : 'answered'}`, kind: 'awaited', mode, cancelled, step: 0 } + flow.current = current + const turn = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: `Ask the controlled ${mode} question.` }], collaborationMode: { mode, settings: { model: 'contract-model', reasoning_effort: 'low', developer_instructions: null } } }) + const request = await client.wait(frame => frame.method === 'item/tool/requestUserInput' && frame.params?.turnId === turn.turn.id) + assert.equal(request.params.isBlocking, mode === 'plan') + assert.equal(request.params.questions[0].id, 'fixture_choice') + const responsesBeforeAnswer = current.step + if (cancelled) await client.request('turn/interrupt', { threadId: capture.threadId, turnId: turn.turn.id }) + else client.send({ id: request.id, result: { answers: { fixture_choice: { answers: ['Alpha (Recommended)'] } } } }) + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id) + assert.equal(terminal.params.turn.status, cancelled ? 'interrupted' : 'completed') + assert.equal(responsesBeforeAnswer, 1, 'both true and false isBlocking requests await their actual reply') + const read = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: current.id, body: read }) + if (mode === 'plan' && !cancelled) { + assert(capture.notifications.some(frame => frame.method === 'item/plan/delta' && frame.params?.turnId === turn.turn.id), 'Plan mode must emit actual native plan deltas') + assert(read.thread.turns.some(nativeTurn => nativeTurn.id === turn.turn.id && nativeTurn.items.some(item => item.type === 'plan'))) + } + capture.observations.questionCases.push({ id: current.id, turnId: turn.turn.id, requestId: request.id, isBlocking: request.params.isBlocking, cancelled }) + } + } + flow.current = { id: 'async', kind: 'async', mode: 'default', step: 0 } + const asynchronous = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'Ask the true asynchronous fixture questions and continue work.' }], collaborationMode: { mode: 'default', settings: { model: 'contract-model', reasoning_effort: 'low', developer_instructions: null } } }) + const asyncItem = await client.wait(frame => frame.params?.turnId === asynchronous.turn.id && frame.params.item?.type === 'agentMessage' && frame.params.item.delivery === 'async') + capture.observations.asyncQuestionItem = asyncItem.params.item + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === asynchronous.turn.id) + assert.equal(capture.observations.asyncContinuedBeforeAnswer, true) + assert(!capture.serverRequests.some(frame => frame.method === 'item/tool/requestUserInput' && frame.params?.turnId === asynchronous.turn.id), 'true async questions must not create a pending reply RPC') + flow.current = { id: 'contextual-reply', kind: 'contextual-reply', step: 0 } + const answer = 'Question: Which fixture environment should be used? Answer: Staging. Question: What deadline should be used? Answer: Friday.' + const reply = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: answer }] }) + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === reply.turn.id) + assert(JSON.stringify(capture.observations.contextualAnswerModelInput).includes(answer), 'ordinary contextual answer must reach the real next model request') + return reply +} + +function mcpResponse(record, flow) { + const current = flow.current + assert(current, 'MCP model work must belong to the selected native case') + if (current.step++ === 0) return advertisedCall(record, 'elicit', { route: current.route, sequence: current.hold ? 'hold' : 'matrix' }, `mcp_${current.id}`) + assert(record.body.input.some(item => item.type === 'function_call_output' && item.call_id === `call_mcp_${current.id}` && JSON.stringify(item.output).includes('Fixture sequence completed')), 'MCP result must traverse native tools/call and return to the real model request') + return { text: 'First fragment and final fragment' } +} + +function largeOutputResponse(record, flow, context) { + const current = flow.current + const step = current.step++ + if (step === 0) return advertisedCall(record, 'exec_command', { cmd: `python3 '${context.largeCommand}'`, shell: '/bin/sh', login: false, max_output_tokens: 100000, yield_time_ms: 1000 }, 'large_command') + if (!current.commandDone) { + const output = toolOutput(record, current.polls ? `call_large_poll_${current.polls}` : 'call_large_command') + const running = output.match(/Process running with session ID (\d+)/) + if (running) { + current.polls = (current.polls ?? 0) + 1 + return advertisedCall(record, 'write_stdin', { session_id: Number(running[1]), chars: '', max_output_tokens: 100000, yield_time_ms: 1000 }, `large_poll_${current.polls}`) + } + assert.match(output, /Process exited with code 0/) + current.commandDone = true + return advertisedCall(record, 'large_result', {}, 'large_mcp') + } + assert.match(toolOutput(record, 'call_large_mcp'), /F1_MCP_STRUCTURED/) + return { text: 'First fragment and final fragment' } +} + +function largeSavedItems(read, turnId) { + return read.thread.turns.filter(turn => turn.id === turnId).flatMap(turn => turn.items) +} +async function verifyLargeOutput(capture, read, context, turnId, label, historyMode) { + const live = capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.turnId === turnId).map(frame => frame.params.item) + const command = live.find(item => item.type === 'commandExecution' && item.id === 'call_large_command') + assert.equal(command?.exitCode, 0) + assert.equal(Buffer.byteLength(command.aggregatedOutput), 128 * 1024) + assert(command.aggregatedOutput.startsWith('F1_COMMAND_HEAD\n')) + assert(command.aggregatedOutput.endsWith('\nF1_COMMAND_TAIL\n')) + const mcp = live.find(item => item.type === 'mcpToolCall') + assert.equal(mcp?.status, 'completed') + assert.equal(mcp.result.content[0].text, MCP_LARGE_TEXT) + assert.equal(mcp.result.structuredContent.fixture, 'F1_MCP_STRUCTURED') + assert.equal(mcp.result._meta['fixture/result'], 'F1_MCP_META') + const items = largeSavedItems(read, turnId) + const savedCommand = items.find(item => item.type === 'commandExecution') + const savedMcp = items.find(item => item.type === 'mcpToolCall') + assert(savedMcp, 'native history must preserve its actual ordinary MCP result') + const order = items.map(item => ({ id: item.id, type: item.type })) + assert.equal(order[0].type, 'userMessage') + if (savedCommand) assert(order.findIndex(item => item.type === 'commandExecution') < order.findIndex(item => item.type === 'mcpToolCall')) + assert(order.findIndex(item => item.type === 'mcpToolCall') < order.findIndex(item => item.type === 'agentMessage')) + if (historyMode === 'paginated') { + assert(savedCommand, 'paginated canonical command history must be present') + assert.equal(savedCommand.id, command.id) + assert(Buffer.byteLength(savedCommand.aggregatedOutput) <= 65536) + assert(savedCommand.aggregatedOutput.includes('\n... command output truncated for persistence ...\n')) + assert(savedCommand.aggregatedOutput.startsWith('F1_COMMAND_HEAD\n')) + assert(savedCommand.aggregatedOutput.endsWith('\nF1_COMMAND_TAIL\n')) + assert.match(savedMcp.result.content[0].text, /…\d+ chars truncated…/u) + assert.equal(savedMcp.result.structuredContent, null) + assert.equal(savedMcp.result._meta, null) + assert.notDeepEqual(savedMcp.result, mcp.result) + const rollout = read.thread.path + assert(rollout.startsWith(context.environment.CODEX_HOME + '/sessions/'), 'read only the actual owned native rollout') + const records = (await fsp.readFile(rollout, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + const core = records.filter(record => record.type === 'event_msg' && record.payload?.type === 'item_completed').map(record => record.payload.item) + const coreCommand = core.find(item => item.id === command.id && item.type === 'CommandExecution') + const coreMcp = core.find(item => item.id === mcp.id && item.type === 'McpToolCall') + assert(coreCommand && coreMcp, 'actual paginated canonical tool records are required') + assert(Buffer.byteLength(coreCommand.aggregated_output) <= 65536) + assert(Buffer.byteLength(JSON.stringify(coreMcp.result)) <= 65536) + assert.equal(coreMcp.result.isError, false) + assert(!('structuredContent' in coreMcp.result)) + assert(!('_meta' in coreMcp.result)) + capture.observations.canonicalSavedTools = { command: coreCommand, mcp: coreMcp } + } else { + assert.deepEqual(savedMcp.result, mcp.result, 'Legacy ordinary MCP result retains its actual full native form') + const rollout = read.thread.path + assert(rollout.startsWith(context.environment.CODEX_HOME + '/sessions/')) + const records = (await fsp.readFile(rollout, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + capture.observations.legacySavedToolRecords = records.filter(record => record.type === 'event_msg' && /exec|mcp/i.test(record.payload?.type ?? '')) + capture.observations.legacyCommandHistoryAvailability = savedCommand ? 'present' : 'native-history-item-unavailable-with-live-output-retained' + } + ;(capture.observations.largeOutputReads ??= []).push({ label, historyMode, order, retained: { command, mcp }, saved: { command: savedCommand, mcp: savedMcp }, state: historyMode === 'paginated' ? 'saved-provider-truncated-with-richer-retained-output' : 'actual-legacy-saved-representation' }) +} + +async function driveMcp(client, capture, flow, context) { + capture.observations.elicitationCases = [] + capture.observations.urlOpenCases = [] + let last + for (const route of ['form', 'openai/form', 'openaiForm', 'url']) { + const current = { id: route.replaceAll('/', '_'), route, step: 0 } + flow.current = current + last = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: `Run the controlled ${route} MCP matrix.` }] }) + for (const action of ['accept', 'decline', 'cancel']) { + const already = new Set(capture.observations.elicitationCases.filter(record => record.turnId === last.turn.id).map(record => record.requestId)) + const request = await client.wait(frame => frame.method === 'mcpServer/elicitation/request' && frame.params?.turnId === last.turn.id && !already.has(frame.id)) + assert.equal(request.params.mode, route) + if (route === 'url') { + const opened = await fetch(request.params.url) + assert.equal(opened.status, 200, 'the owned URL must actually open before a separate native decision') + await opened.text() + const pending = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: `url-opened-awaiting-${action}`, body: pending }) + const turn = pending.thread.turns.find(turn => turn.id === last.turn.id) + assert.equal(turn.status, 'inProgress', 'opening the URL must not complete the native pending control') + assert(!capture.notifications.some(frame => frame.method === 'serverRequest/resolved' && frame.params?.requestId === request.id && frame.params.threadId === capture.threadId)) + capture.observations.urlOpenCases.push({ requestId: request.id, elicitationId: request.params.elicitationId, turnId: last.turn.id, openedStatus: opened.status, pendingTurnStatus: turn.status, explicitActionStillRequired: action }) + } + const result = { action, ...(action === 'accept' && route !== 'url' ? { content: MCP_FORM_CONTENT, _meta: { 'fixture/response': 'accepted' } } : {}) } + client.send({ id: request.id, result }) + capture.observations.elicitationCases.push({ route, action, requestId: request.id, turnId: last.turn.id, elicitationId: request.params.elicitationId ?? null }) + } + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === last.turn.id) + assert.equal(terminal.params.turn.status, 'completed') + capture.reads.push({ label: `mcp-${route}`, body: await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) }) + } + flow.current = { id: 'held', route: 'form', hold: true, step: 0 } + const held = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'Leave the owned MCP form pending for a controlled Stop.' }] }) + const pending = await client.wait(frame => frame.method === 'mcpServer/elicitation/request' && frame.params?.turnId === held.turn.id) + await client.request('turn/interrupt', { threadId: capture.threadId, turnId: held.turn.id }) + const cancelled = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === held.turn.id) + assert.equal(cancelled.params.turn.status, 'interrupted') + const resolved = await client.wait(frame => frame.method === 'serverRequest/resolved' && frame.params?.requestId === pending.id && frame.params.threadId === capture.threadId) + capture.observations.cancelledMcpControl = { requestId: pending.id, turnId: held.turn.id, resolved: resolved.params } + const trace = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) + capture.observations.mcpTraffic = trace + assert.equal(trace.filter(record => record.event === 'elicitation_result' && record.sequence === 'matrix').length, 12) + assert(!trace.some(record => record.event === 'fixture_error'), 'ordinary MCP route must return actual native responses without fixture errors') + const initialized = trace.find(record => record.direction === 'native' && record.frame?.method === 'initialize') + assert(initialized, 'configured MCP server must actually initialize') + capture.observations.mcpInitialize = initialized.frame.params + assert.equal(capture.observations.elicitationCases.length, 12) + return last +} + +function toolOutput(record, callId) { + const output = record.body.input.findLast(item => item.type === 'function_call_output' && item.call_id === callId)?.output + assert(output !== undefined, `actual native tool result missing for ${callId}`) + return typeof output === 'string' ? output : output.filter(item => item.type === 'input_text').map(item => item.text).join('\n') +} + +function stdinResponse(record, flow, capture, context) { + const current = flow.current + assert(current, 'stdin model exchange must belong to an actual selected turn') + const step = current.step++ + if (current.kind === 'stdin-open') { + if (step === 0) return advertisedCall(record, 'request_permissions', { reason: 'Allow writes only to the fixture terminal result directory.', permissions: { file_system: { write: [path.join(context.workspace, 'allowed')] } } }, `permissions_${current.id}`) + if (step === 1) { + toolOutput(record, `call_permissions_${current.id}`) + return advertisedCall(record, 'exec_command', { cmd: '/bin/bash --noprofile --norc', shell: '/bin/sh', login: false, tty: true, yield_time_ms: 250 }, `open_${current.id}`) + } + const output = toolOutput(record, `call_open_${current.id}`) + const match = output.match(/Process running with session ID (\d+)/) + assert(match, 'PTY session handle must come from actual native execution') + flow.sessionId = Number(match[1]) + return { text: 'First fragment and final fragment' } + } + if (current.kind === 'stdin-cancel') return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: 'printf cancelled > allowed/cancelled-marker\n', yield_time_ms: 1000 }, 'stdin_cancel') + if (step === 0) return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: '', yield_time_ms: 1000 }, 'stdin_empty') + if (step === 1) { + toolOutput(record, 'call_stdin_empty') + current.emptyPollReturned = true + return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: 'REJECTED=1; printf denied > allowed/denied-marker\n', yield_time_ms: 1000 }, 'stdin_denied') + } + if (step === 2) { + capture.observations.deniedStdinResult = toolOutput(record, 'call_stdin_denied') + return advertisedCall(record, 'write_stdin', { session_id: flow.sessionId, chars: "if test -v REJECTED || test -e allowed/denied-marker; then exit 19; fi; printf accepted > allowed/accepted-marker; printf 'STDIN_ACCEPTED\\n'; exit\n", yield_time_ms: 1000 }, 'stdin_accepted') + } + const output = toolOutput(record, 'call_stdin_accepted') + assert.match(output, /Process exited with code 0/) + capture.observations.acceptedStdinResult = output + return { text: 'First fragment and final fragment' } +} + +async function driveStdin(client, capture, flow, context) { + capture.observations.stdinCases = [] + const open = async id => { + flow.current = { id, kind: 'stdin-open', step: 0 } + const turn = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', input: [{ type: 'text', text: 'Grant the narrow owned directory and open a real sandboxed PTY.' }] }) + const permission = await client.wait(frame => frame.method === 'item/permissions/requestApproval' && frame.params?.turnId === turn.turn.id) + const requested = permission.params.permissions + assert(JSON.stringify(requested).includes(path.join(context.workspace, 'allowed'))) + client.send({ id: permission.id, result: { permissions: requested, scope: 'turn', strictAutoReview: false } }) + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id) + assert(Number.isSafeInteger(flow.sessionId)) + const item = capture.notifications.find(frame => frame.method === 'item/started' && frame.params?.turnId === turn.turn.id && frame.params.item?.type === 'commandExecution')?.params.item + assert(item, 'actual PTY command item identity is required') + capture.observations.stdinCases.push({ kind: 'open', turnId: turn.turn.id, sessionId: flow.sessionId, itemId: item.id, permissionRequestId: permission.id }) + return { turn, item } + } + const first = await open('first') + flow.current = { id: 'write', kind: 'stdin-write', step: 0 } + const write = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', input: [{ type: 'text', text: 'Poll the terminal, deny one input, then approve a separate input.' }] }) + const denied = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.kind === 'writeStdin' && frame.params.turnId === write.turn.id) + assert.equal(flow.current.emptyPollReturned, true, 'empty polling must finish without stdin review') + assert.equal(denied.params.itemId, first.item.id) + assert(!fs.existsSync(path.join(context.workspace, 'allowed/denied-marker'))) + client.send({ id: denied.id, result: { decision: 'decline' } }) + const accepted = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.kind === 'writeStdin' && frame.params.turnId === write.turn.id && frame.id !== denied.id) + assert.equal(accepted.params.itemId, first.item.id) + assert.notEqual(accepted.params.approvalId, denied.params.approvalId) + assert(!fs.existsSync(path.join(context.workspace, 'allowed/accepted-marker'))) + assert(!fs.existsSync(path.join(context.workspace, 'allowed/denied-marker'))) + client.send({ id: accepted.id, result: { decision: 'accept' } }) + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === write.turn.id) + assert.equal(terminal.params.turn.status, 'completed') + assert.equal(await fsp.readFile(path.join(context.workspace, 'allowed/accepted-marker'), 'utf8'), 'accepted') + assert(!fs.existsSync(path.join(context.workspace, 'allowed/denied-marker'))) + capture.observations.stdinCases.push({ kind: 'write', turnId: write.turn.id, itemId: first.item.id, deniedRequestId: denied.id, acceptedRequestId: accepted.id, deniedApprovalId: denied.params.approvalId, acceptedApprovalId: accepted.params.approvalId, acceptedFile: 'accepted', deniedFilePresent: false }) + const second = await open('cancel') + flow.current = { id: 'cancel', kind: 'stdin-cancel', step: 0 } + const cancelling = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', input: [{ type: 'text', text: 'Cancel the separate pending stdin write without changing the owned terminal.' }] }) + const pending = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.kind === 'writeStdin' && frame.params.turnId === cancelling.turn.id) + client.send({ id: pending.id, result: { decision: 'cancel' } }) + const cancelled = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === cancelling.turn.id) + assert.equal(cancelled.params.turn.status, 'interrupted') + assert(!fs.existsSync(path.join(context.workspace, 'allowed/cancelled-marker'))) + capture.observations.stdinCases.push({ kind: 'cancel', turnId: cancelling.turn.id, requestId: pending.id, sessionId: flow.sessionId, filePresent: false, terminalExitInferred: false }) + return second.turn +} + +async function driveNativeRaces(client, capture, flow) { + const cases = capture.observations.stopRaceCases = [] + const terminalFor = id => client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === id) + const input = text => ({ threadId: capture.threadId, input: [{ type: 'text', text }] }) + const outcome = promise => promise.then(result => ({ result }), error => { assert(error.nativeError); return { nativeError: error.nativeError } }) + const hold = () => { const release = deferred(); flow.current = { kind: 'race', release }; return release } + const frameIndex = (id, direction) => capture.frames.find(entry => entry.connectionId === client.connectionId && entry.direction === direction && entry.frame.id === id && !entry.frame.method)?.index + let release = hold() + const pendingRequest = client.nextId + const start = client.request('turn/start', input('Pending-start Stop native race.')) + const startupStopId = client.nextId + const emptyStop = outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: '' })) + const accepted = await start + const startupStop = await emptyStop + // Empty-ID success is submission only. Bind the actual admitted ID and + // obtain its own terminal; a second explicit cancellation is attributed. + const knownTerminal = capture.notifications.find(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === accepted.turn.id) + const followupStop = knownTerminal ? null : await outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: accepted.turn.id })) + const pendingTerminal = knownTerminal ?? await terminalFor(accepted.turn.id) + release.resolve() + cases.push({ kind: 'start-outstanding-at-stop-issuance', startRequestId: pendingRequest, emptyStopRequestId: startupStopId, startReplyIndex: frameIndex(pendingRequest, 'native'), stopReplyIndex: frameIndex(startupStopId, 'native'), turnId: accepted.turn.id, startupStop, followupStop, terminal: pendingTerminal.params.turn }) + let previous = accepted.turn.id + for (const ordering of ['release-then-stop', 'stop-then-release']) { + release = hold() + const turn = await client.request('turn/start', input(`Natural completion/Stop race: ${ordering}.`)) + await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === turn.turn.id) + let stopping + if (ordering === 'release-then-stop') { release.resolve(); stopping = outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: turn.turn.id })) } + else { stopping = outcome(client.request('turn/interrupt', { threadId: capture.threadId, turnId: turn.turn.id })); release.resolve() } + const stop = await stopping + const terminal = await terminalFor(turn.turn.id) + assert(['completed', 'interrupted'].includes(terminal.params.turn.status)) + cases.push({ kind: 'native-natural-completion-race', ordering, turnId: turn.turn.id, stop, terminal: terminal.params.turn }) + previous = turn.turn.id + } + release = hold() + const current = await client.request('turn/start', input('A stale Stop must not affect this distinct active native turn.')) + await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === current.turn.id) + assert.notEqual(current.turn.id, previous) + const stale = await nativeRefusal(client, 'turn/interrupt', { threadId: capture.threadId, turnId: previous }) + const steer = await client.request('turn/steer', { threadId: capture.threadId, expectedTurnId: current.turn.id, input: [{ type: 'text', text: 'Keep the actual new native turn active.' }] }) + assert.equal(steer.turnId, current.turn.id) + await client.request('turn/interrupt', { threadId: capture.threadId, turnId: current.turn.id }) + const currentTerminal = await terminalFor(current.turn.id) + release.resolve() + cases.push({ kind: 'stale-stop-after-turnover', staleTurnId: previous, currentTurnId: current.turn.id, nativeRefusal: stale, steer, terminal: currentTerminal.params.turn }) + release = hold() + const since = capture.frames.length + const delayedStartId = client.nextId + const delayedStart = client.request('turn/start', input('Real native output before delayed controller acceptance delivery.'), 30_000, true) + const started = await client.wait(frame => frame.method === 'turn/started', 30_000, since) + const delta = await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === started.params.turn.id, 30_000, since) + assert(delta.params.delta.length > 0) + client.releaseReply(delayedStartId) + const delayedAccepted = await delayedStart + assert.equal(delayedAccepted.turn.id, started.params.turn.id) + const stopId = client.nextId + const ambiguous = client.request('turn/interrupt', { threadId: capture.threadId, turnId: delayedAccepted.turn.id }, 50, true).then(() => assert.fail('controller response is deliberately withheld'), error => ({ message: error.message })) + const terminal = await terminalFor(delayedAccepted.turn.id) + const timeout = await ambiguous + assert.match(timeout.message, /TIMEOUT/) + assert.equal(capture.requests.at(-1).method, 'turn/interrupt', 'a local deadline must not replay or issue successor input') + client.releaseReply(stopId) + release.resolve() + cases.push({ kind: 'controller-delivery-loss-with-progressing-native-consumer', turnId: delayedAccepted.turn.id, startRequestId: delayedStartId, stopRequestId: stopId, timeout, terminal: terminal.params.turn, promptReplayCount: 0, nativeReceiptOrderPreserved: true }) + flow.current = null + const final = await client.request('turn/start', input('New work only after exact native terminal resolution.')) + const completed = await terminalFor(final.turn.id) + assert.equal(completed.params.turn.status, 'completed') + const lateStop = await nativeRefusal(client, 'turn/interrupt', { threadId: capture.threadId, turnId: final.turn.id }) + cases.push({ kind: 'completed-then-late-stop', turnId: final.turn.id, nativeRefusal: lateStop, terminal: completed.params.turn }) + capture.observations.nativeStartResponseOrder = capture.requests.filter(frame => frame.method === 'turn/start').map(request => { + const reply = capture.frames.find(entry => entry.connectionId === client.connectionId && entry.direction === 'native' && entry.frame.id === request.id && entry.frame.result?.turn) + const turnId = reply?.frame.result.turn.id + const firstEvent = capture.frames.find(entry => entry.connectionId === client.connectionId && entry.direction === 'native' && entry.frame.method && (entry.frame.params?.turnId === turnId || entry.frame.params?.turn?.id === turnId)) + return { requestId: request.id, turnId, rawReplyIndex: reply?.index, firstNativeEventIndex: firstEvent?.index, nativeEventBeforeReply: firstEvent?.index < reply?.index } + }) + return final +} + +async function networkFixture(context) { + const hits = [] + const listeners = [] + for (let index = 0; index < 3; index++) { + const server = http.createServer((request, response) => { + hits.push({ listener: index, path: request.url, peer: request.socket.remoteAddress }) + response.end('NATIVE_NETWORK_ALLOWED') + }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) }) + listeners.push(server) + } + const reservation = http.createServer() + await new Promise((resolve, reject) => { reservation.once('error', reject); reservation.listen(0, '127.0.0.1', resolve) }) + const proxyPort = reservation.address().port + await new Promise(resolve => reservation.close(resolve)) + const helper = path.join(context.workspace, 'network-canary.py') + await fsp.writeFile(helper, `import os, socket, sys, urllib.parse +proxy = urllib.parse.urlparse(os.environ['HTTP_PROXY']) +target = sys.argv[1] +s = socket.create_connection((proxy.hostname, proxy.port), timeout=20) +s.settimeout(20) +s.sendall(('GET ' + target + ' HTTP/1.1\\r\\nHost: ' + urllib.parse.urlparse(target).netloc + '\\r\\nConnection: close\\r\\n\\r\\n').encode()) +chunks = [] +while True: + data = s.recv(65536) + if not data: break + chunks.append(data) +body = b''.join(chunks) +print(body.decode(errors='replace')) +sys.exit(0 if b'NATIVE_NETWORK_ALLOWED' in body else 23) +`) + return { hits, helper, proxyPort, urls: listeners.map(server => `http://127.0.0.1:${server.address().port}/canary/${randomUUID()}`), close: () => Promise.all(listeners.map(server => new Promise(resolve => { server.closeAllConnections(); server.close(resolve) }))) } +} + +function networkResponse(record, flow, capture, context) { + const current = flow.current + assert(current, 'network command must belong to the actual selected turn') + const step = current.step++ + if (step === 0) return advertisedCall(record, 'exec_command', { cmd: `python3 '${context.network.helper}' '${current.url}'`, shell: '/bin/sh', login: false, yield_time_ms: 1000, max_output_tokens: 2000 }, `network_${current.id}`) + const callId = current.polls ? `call_network_poll_${current.id}_${current.polls}` : `call_network_${current.id}` + const output = toolOutput(record, callId) + const running = output.match(/Process running with session ID (\d+)/) + if (running) { + current.polls = (current.polls ?? 0) + 1 + return advertisedCall(record, 'write_stdin', { session_id: Number(running[1]), chars: '', yield_time_ms: 1000, max_output_tokens: 2000 }, `network_poll_${current.id}_${current.polls}`) + } + current.output = output + if (['once', 'session', 'cached'].includes(current.id)) { assert.match(output, /Process exited with code 0/); assert.match(output, /NATIVE_NETWORK_ALLOWED/) } + return { text: 'First fragment and final fragment' } +} + +async function driveNetwork(client, capture, flow, context) { + capture.observations.networkCases = [] + let last + for (const [index, id, decision] of [[0, 'once', 'accept'], [0, 'session', 'acceptForSession'], [0, 'cached', null], [1, 'declined', 'decline'], [2, 'cancelled', 'cancel']]) { + const beforeHits = context.network.hits.length + const target = new URL(context.network.urls[index]) + target.pathname = `/canary/${id}/${randomUUID()}` + const current = { id, step: 0, url: target.href } + flow.current = current + const turn = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: 'on-request', permissions: 'fixture_network', input: [{ type: 'text', text: `Run the controlled ${id} native network decision.` }] }) + let approval + if (decision) { + approval = await client.wait(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.turnId === turn.turn.id && frame.params.networkApprovalContext) + assert.equal(approval.params.kind, 'command') + assert.equal(approval.params.networkApprovalContext.host, '127.0.0.1') + assert.equal(approval.params.networkApprovalContext.protocol, 'http') + assert.equal(context.network.hits.length, beforeHits, 'network traffic must wait for the actual native decision') + if (decision !== 'decline') assert(approval.params.availableDecisions.includes(decision), 'decision must be offered by native Codex') + // Native supports Decline as a controller response while its network UI + // list intentionally omits it. Preserve both facts without inventing a UI choice. + client.send({ id: approval.id, result: { decision } }) + } + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id) + assert.equal(terminal.params.turn.status, decision === 'cancel' ? 'interrupted' : 'completed') + if (decision === 'decline') assert.match(current.output, /rejected by user/i) + const requests = capture.serverRequests.filter(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.turnId === turn.turn.id) + assert.equal(requests.length, decision ? 1 : 0, 'session grant must prevent only the matching repeated native request') + assert.equal(context.network.hits.length - beforeHits, ['decline', 'cancel'].includes(decision) ? 0 : 1) + capture.observations.networkCases.push({ id, turnId: turn.turn.id, requestId: approval?.id ?? null, approvalId: approval?.params.approvalId ?? null, listener: index, decision, decisionSource: decision === 'decline' ? 'supported-controller-response-not-advertised' : 'offered-native-decision', availableDecisions: approval?.params.availableDecisions ?? [], hits: context.network.hits.length - beforeHits, output: current.output ?? null, terminal: terminal.params.turn.status }) + if (decision !== 'cancel') last = turn + } + capture.observations.networkCanaryHits = context.network.hits + return last +} + +export async function captureNativeScenario(name, options = {}) { + assert(scenarios.includes(name), `unknown native scenario ${name}`) + const context = await roots(options) + const capture = { scenario: name, parameters: Object.fromEntries(['historyMode', 'duplicateBodies', 'persistenceMode', 'largeOutput', 'startupCancellation', 'ephemeralParent', 'childApproval', 'approvalControl', 'probeApprovalPolicy', 'probePolicyEffects', 'sandboxMode', 'requirementsGuard'].filter(key => options[key] !== undefined).map(key => [key, options[key]])), probeOnly: options.probeMetadata?.probeOnly === true, binary: { path: context.managed?.launchWrapper ?? path.join(context.artifact.path, context.metadata.executable), version: null, platform: `${process.platform}-${process.arch}`, integrity: context.artifact.integrity }, threadId: null, requests: [], responses: [], serverRequests: [], notifications: [], reads: [], fixtureTraffic: [], observations: {}, frames: [], environment: { uid: process.getuid?.(), gid: process.getgid?.(), groups: process.getgroups?.(), artifact: context.artifact, systemConfig: context.systemConfig, policyClientProbe: context.policyClientProbe, writableRoots: { state: context.stateRoot, receipts: context.receiptRoot }, prerequisites: context.staged ?? null, context: context.environment, ambientBefore: ambientCodex() } } + Object.defineProperty(capture, 'secretValues', { value: new Set(), enumerable: false }) + capture.environment.cloud = Object.fromEntries(['CLOUD_RUN_JOB', 'CLOUD_RUN_EXECUTION', 'CLOUD_RUN_TASK_INDEX', 'CLOUD_RUN_TASK_ATTEMPT'].filter(key => process.env[key] !== undefined).map(key => [key, process.env[key]])) + if (options.seccompProbeReceipt) capture.environment.seccompProbe = JSON.parse(await fsp.readFile(options.seccompProbeReceipt, 'utf8')) + if (context.managed) { + capture.environment.managedLayout = context.managed + capture.environment.nativeSandboxPolicy = nativeSandboxPolicy() + delete capture.environment.nativeSandboxPolicy.profile + delete capture.environment.nativeSandboxPolicy.json + } + if (process.env.FRESHELL_CODEX_CONTRACT_POLICY === 'prepared') { + const declared = nativeSandboxPolicy() + assert.equal(context.staged?.nativeSandboxPolicy?.id, declared.id) + assert.equal(context.staged.nativeSandboxPolicy.profileSha256, declared.profileSha256) + capture.environment.nativeSandboxPolicy = context.staged.nativeSandboxPolicy + } + capture.environment.bwrapCandidates = [] + for (const directory of context.environment.PATH.split(path.delimiter)) { + const candidate = path.join(directory, 'bwrap') + try { + fs.accessSync(candidate, fs.constants.X_OK) + const absolute = fs.realpathSync(candidate) + const version = spawnSync(absolute, ['--version'], { env: context.environment, encoding: 'utf8', timeout: 5000 }) + capture.environment.bwrapCandidates.push({ path: absolute, status: version.status, stdout: version.stdout, stderr: version.stderr }) + } catch {} + } + const readable = file => { try { return fs.readFileSync(file, 'utf8').trim() } catch (error) { return { unavailable: error.code } } } + const status = readable('/proc/self/status') + capture.environment.kernelBoundary = { + appArmorProfile: readable('/proc/self/attr/current'), + status: typeof status === 'string' ? status.split('\n').filter(line => /^(Uid|Gid|Groups|CapInh|CapPrm|CapEff|CapBnd|NoNewPrivs|Seccomp|Seccomp_filters):/.test(line)) : status, + sysctls: Object.fromEntries(['kernel/unprivileged_userns_clone', 'kernel/apparmor_restrict_unprivileged_userns', 'kernel/apparmor_restrict_unprivileged_unconfined', 'user/max_user_namespaces'].map(key => [key, readable(`/proc/sys/${key}`)])), + } + let client + let fixture + let modelStep = 0 + let childModelStep = 0 + let releaseStream + const childRequested = deferred() + const childStream = deferred() + const parentStream = deferred() + const controlFlow = { current: null } + try { + assert.equal(process.platform, 'linux'); assert.equal(process.arch, 'x64') + const version = spawnSync(capture.binary.path, ['--version'], { env: context.environment, encoding: 'utf8', timeout: 15_000 }) + assert.equal(version.status, 0, version.stderr) + capture.binary.version = version.stdout.trim().replace(/^codex-cli /, '') + assert.equal(capture.binary.version, context.metadata.version) + fixture = await createCodexLocalProvider(async record => { + const step = modelStep++ + if (name === 'plan-question-controls') return questionResponse(record, controlFlow, capture) + if (name === 'mcp-form-url') return options.largeOutput ? largeOutputResponse(record, controlFlow, context) : mcpResponse(record, controlFlow) + if (options.approvalControl === 'stdin') return stdinResponse(record, controlFlow, capture, context) + if (options.approvalControl === 'network') return networkResponse(record, controlFlow, capture, context) + if (name === 'parent-child-read') { + const childInput = record.body.input.some(item => item.role === 'user' && JSON.stringify(item.content).includes('freshell-child-native-probe')) + if (childInput) { + childRequested.resolve() + if (options.childApproval) { + if (childModelStep++ === 0) { + const names = record.body.tools.flatMap(tool => tool.type === 'namespace' ? tool.tools.map(member => member.name) : [tool.name]) + const name = ['exec_command', 'shell_command', 'shell'].find(name => names.includes(name)) + assert(name, 'actual native child must advertise an execution tool') + const args = name === 'exec_command' ? { cmd: 'cat contract-input.txt', shell: '/bin/sh', login: false, max_output_tokens: 1000 } : name === 'shell' ? { command: ['sh', '-c', 'cat contract-input.txt'] } : { command: 'cat contract-input.txt' } + return advertisedCall(record, name, args, 'child_contract_command') + } + const output = toolOutput(record, 'call_child_contract_command') + assert.match(output, /codex-child-untrusted-command-ran/) + capture.observations.childCommandOutput = output + } + return { text: 'Exact child final answer.', pauseAfterDelta: () => childStream.promise } + } + if (step === 0) { + const namespace = record.body.tools.find(tool => tool.type === 'namespace' && tool.tools?.some(member => member.name === 'spawn_agent')) + assert(namespace, 'native parent must actually advertise spawn_agent') + capture.observations.advertisedSpawn = namespace.tools.find(member => member.name === 'spawn_agent') + return { items: [{ id: 'fc_native_child', type: 'function_call', call_id: 'call_native_child', namespace: namespace.name, name: 'spawn_agent', arguments: JSON.stringify({ message: 'freshell-child-native-probe: return the controlled child answer.', fork_context: false }), status: 'completed' }] } + } + await parentStream.promise + return { text: 'First fragment and final fragment' } + } + if (name === 'active-input-stop' && controlFlow.current?.kind === 'race') { const current = controlFlow.current; return { text: 'First fragment and final fragment', pauseAfterDelta: () => current.release.promise } } + if (name === 'active-input-stop' && step === 0) return { text: 'Interrupted native fragment', pauseAfterDelta: () => new Promise(resolve => { releaseStream = resolve }) } + if (name === 'text-history' && options.duplicateBodies) return { items: [0, 1].map(index => ({ id: `msg_duplicate_${index}`, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'First fragment and final fragment', annotations: [] }] })) } + const commands = [options.sandboxMode === 'workspace-write' ? 'cat contract-input.txt && printf codex-workspace-write > contract-output.txt' : 'cat contract-input.txt'] + if (options.seccompProbeReceipt) commands[0] += '; sleep 0.3' + if (options.probeNetworkEnabled) { + const script = `import socket; s=socket.create_connection(('127.0.0.1', ${new URL(fixture.baseUrl).port}), timeout=2); s.sendall(b'GET /native-permitted-network-canary HTTP/1.0\\r\\n\\r\\n'); print(s.recv(128))` + commands[0] += `; python3 -c '${script.replaceAll("'", "'\\''")}'` + } + if (options.probePolicyEffects) { + const quote = value => `'${value.replaceAll("'", "'\\''")}'` + const networkScript = `import socket; s=socket.create_connection(('127.0.0.1', ${new URL(fixture.baseUrl).port}), timeout=2); s.sendall(b'GET /native-network-canary HTTP/1.0\\r\\n\\r\\n'); print(s.recv(128))` + commands.push(`printf forbidden-outside > ${quote(path.join(context.stateRoot, 'outside/forbidden.txt'))}`, 'printf forbidden-git > .git/freshell-contract-denied', 'printf forbidden-codex > .codex/freshell-contract-denied', `python3 -c ${quote(networkScript)}`) + commands.push('printf forbidden-agents > .agents/freshell-contract-denied', `printf forbidden-home > ${quote(path.join(context.environment.HOME, 'forbidden.txt'))}`, `printf forbidden-state > ${quote(path.join(context.environment.CODEX_HOME, 'forbidden.txt'))}`, `printf forbidden-tmpdir > ${quote(path.join(context.environment.TMPDIR, 'forbidden.txt'))}`, 'printf forbidden-global-temp > /tmp/freshell-codex-excluded-temp', ...(options.sandboxMode === 'read-only' ? ['printf forbidden-workspace > contract-readonly-forbidden.txt'] : [])) + } + const deniedStep = options.probePolicyEffects && step === commands.length + 1 + if (name === 'approval-policy-restricted-exec' && (step < commands.length || deniedStep)) { + const tools = record.body.tools ?? [] + const tool = tools.find(tool => tool.name === 'exec_command') ?? tools.find(tool => tool.name === 'shell') ?? tools.find(tool => tool.name === 'shell_command') + assert(tool, `native command tool not advertised: ${tools.map(tool => tool.name).join(',')}`) + capture.observations.advertisedCommandTool = tool + const command = deniedStep ? 'printf denied-by-user > denied-by-user.txt' : commands[step] + const args = tool.name === 'exec_command' ? { cmd: command, max_output_tokens: 1000, shell: '/bin/sh', login: false } : tool.name === 'shell' ? { command: ['sh', '-c', command] } : { command } + const callId = step === 0 ? 'call_contract_command' : `call_contract_${step}` + return { items: [{ id: `fc_contract_${step}`, type: 'function_call', call_id: callId, name: tool.name, arguments: JSON.stringify(args), status: 'completed' }] } + } + if (name === 'approval-policy-restricted-exec') assert(record.body.input.some(item => item.type === 'function_call_output' && item.call_id === 'call_contract_command'), 'follow-up model response requires the actual native command result') + return { text: 'First fragment and final fragment' } + }) + let extraConfig = options.legacyLandlock ? '[features]\nuse_legacy_landlock = true\n' : '' + if (['plan-question-controls', 'mcp-form-url'].includes(name) || options.approvalControl || options.startupCancellation) { + const catalogPath = path.join(context.stateRoot, 'models.json') + await fsp.writeFile(catalogPath, JSON.stringify({ models: [{ slug: 'contract-model', display_name: 'Contract model', description: 'Owned loopback native contract', default_reasoning_level: 'low', supported_reasoning_levels: [{ effort: 'low', description: 'Contract effort' }], shell_type: options.approvalControl || options.largeOutput ? 'unified_exec' : 'disabled', visibility: 'list', supported_in_api: true, priority: 0, support_verbosity: false, truncation_policy: { mode: 'tokens', limit: 10000 }, context_window: 128000, experimental_supported_tools: ['request_user_input_async'], supports_search_tool: false, tool_mode: 'direct', use_responses_lite: false, model_messages: { instructions_template: "Follow the user's request using the available tools." } }] })) + extraConfig = `model_catalog_json = ${JSON.stringify(catalogPath)}\nweb_search = "disabled"\n[features]\napps = false\ndefault_mode_request_user_input = true\nauth_elicitation = true\nnon_prefixed_mcp_tool_names = false\n[tools.experimental_request_user_input]\nenabled = true\n` + if (options.approvalControl || options.largeOutput) extraConfig = extraConfig.replace('[tools.experimental_request_user_input]', 'unified_exec = true\nunified_exec_tty = true\nwrite_stdin_approval = true\nexec_permission_approvals = true\nrequest_permissions_tool = true\n[tools.experimental_request_user_input]') + if (name === 'mcp-form-url' || options.startupCancellation) { + context.mcpTrace = path.join(context.stateRoot, 'mcp-traffic.jsonl') + await fsp.writeFile(context.mcpTrace, '', { mode: 0o600 }) + extraConfig += `\n[mcp_servers.contract]\ncommand = ${JSON.stringify(process.execPath)}\nargs = ${JSON.stringify([path.join(repoRoot, 'test/integration/providers/codex-local-provider.mjs'), options.startupCancellation ? '--mcp-startup-fixture' : '--mcp-fixture', context.mcpTrace, fixture.baseUrl.replace(/\/v1$/, '')])}\nenabled = true\nrequired = ${!options.startupCancellation}\nstartup_readiness = "connection"\nstartup_timeout_sec = ${options.startupCancellation ? 90 : 15}\ntool_timeout_sec = 120\n` + } + } + if (options.approvalControl === 'network') { + context.network = await networkFixture(context) + extraConfig = `default_permissions = "fixture_network"\napproval_policy = "on-request"\napprovals_reviewer = "user"\n${extraConfig}\n[permissions.fixture_network.filesystem]\n":minimal" = "read"\n${JSON.stringify(context.workspace)} = "read"\n${JSON.stringify(context.artifact.path)} = "read"\n[permissions.fixture_network.network]\nenabled = true\nmode = "limited"\nallow_local_binding = true\n[features.network_proxy]\nenabled = true\nproxy_url = "http://127.0.0.1:${context.network.proxyPort}"\nenable_socks5 = false\nenable_socks5_udp = false\nallow_upstream_proxy = false\nallow_local_binding = true\nmode = "limited"\ncredential_broker = false\n` + } + await configure(context, fixture.baseUrl, extraConfig) + if (options.childApproval) await fsp.writeFile(path.join(context.workspace, 'contract-input.txt'), 'codex-child-untrusted-command-ran\n') + const restricted = name === 'approval-policy-restricted-exec' && !options.approvalControl + if (options.largeOutput) { + context.largeCommand = path.join(context.workspace, 'large-output.py') + await fsp.writeFile(context.largeCommand, "import sys\nhead=b'F1_COMMAND_HEAD\\n'\ntail=b'\\nF1_COMMAND_TAIL\\n'\nsys.stdout.buffer.write(head+b'x'*(128*1024-len(head)-len(tail))+tail)\nsys.stdout.buffer.flush()\n") + controlFlow.current = { kind: 'large-output', step: 0 } + } + if (options.approvalControl === 'stdin') await fsp.mkdir(path.join(context.workspace, 'allowed')) + if (restricted) { + await fsp.writeFile(path.join(context.workspace, 'contract-input.txt'), 'codex-restricted-command-ran\n') + if (options.probePolicyEffects) { + const initialized = spawnSync('git', ['init', '--quiet'], { cwd: context.workspace, env: context.environment, encoding: 'utf8', timeout: 15_000 }) + assert.equal(initialized.status, 0, initialized.stderr) + await fsp.mkdir(path.join(context.workspace, '.codex')) + await fsp.mkdir(path.join(context.workspace, '.agents')) + await fsp.mkdir(path.join(context.stateRoot, 'outside'), { mode: 0o700 }) + capture.observations.gitCommonDir = spawnSync('git', ['rev-parse', '--git-common-dir'], { cwd: context.workspace, env: context.environment, encoding: 'utf8', timeout: 15_000 }).stdout.trim() + { + const mountRoot = path.join(context.stateRoot, 'raw-mount-attempt') + await fsp.mkdir(mountRoot) + const mounted = spawnSync('mount', ['-t', 'tmpfs', 'tmpfs', mountRoot], { env: context.environment, encoding: 'utf8', timeout: 15_000 }) + capture.observations.outerMountAttempt = { status: mounted.status, stderr: mounted.stderr, stdout: mounted.stdout, error: mounted.error?.message } + assert.notEqual(mounted.status, 0, 'outer mount without a user namespace must remain denied') + } + } + if (!options.probeMetadata) { + await configure(context, fixture.baseUrl, `approval_policy = "untrusted"\n${extraConfig}`) + const rejection = spawnSync(capture.binary.path, ['exec', '--skip-git-repo-check', '--json', 'Do not perform work; public config rejection probe.'], { env: context.environment, cwd: context.workspace, encoding: 'utf8', timeout: 15_000 }) + capture.observations.publicUntrustedRejection = { status: rejection.status, stdout: rejection.stdout, stderr: rejection.stderr, fixtureRequests: fixture.traffic.length } + assert.notEqual(rejection.status, 0) + assert.match(rejection.stderr, /approval_policy\s*=\s*"untrusted" is no longer supported/) + assert.equal(fixture.traffic.length, 0, 'public config rejection must precede model work') + } + // Only this owned fixture configuration is rewritten. Production source + // copying is qualified separately through the production source-precedence path. + await configure(context, fixture.baseUrl, extraConfig) + } + if (options.requirementsGuard) { + await nativeRequirementsGuard(context, capture) + assert.equal(fixture.traffic.length, 0, 'a refused policy cannot reach model work') + capture.passed = true + return capture + } + if (name === 'model-effort-usage') await nativeCatalogProbe(context,capture,fixture) + client = new NativeClient(capture.binary.path, context, capture) + client.onServerRequest = frame => { + if (frame.method === 'item/commandExecution/requestApproval' && !options.approvalControl) { + const decision = frame.params.command?.includes('denied-by-user') ? 'cancel' : 'accept' + const approval = { id: frame.id, params: frame.params, decision } + capture.observations.commandApproval ??= approval + ;(capture.observations.commandApprovals ??= []).push(approval) + client.send({ id: frame.id, result: { decision } }) + } + } + const capabilities = { experimentalApi: true, ...(name === 'mcp-form-url' ? { extensions: { 'openai/form': {}, 'openai/elicitation': { form: {} }, 'openai/standard-form-input': {} } } : {}) } + capture.observations.initialize = await client.request('initialize', { clientInfo: { name: 'freshell_codex_contract', title: 'Freshell isolated native contract', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + capture.observations.effectiveConfig = await client.request('config/read', { includeLayers: true }) + if (name === 'model-effort-usage') { + capture.observations.models = await client.request('model/list', { includeHidden: false, limit: 100 }) + assert(capture.observations.models.data.length > 0, 'release model discovery must return actual metadata') + } + const policy = options.probeApprovalPolicy ?? (restricted || options.childApproval ? 'untrusted' : name === 'mcp-form-url' || options.approvalControl ? 'on-request' : 'never') + const sandboxMode = options.sandboxMode ?? 'read-only' + const started = await client.request('thread/start', { cwd: context.workspace, model: 'contract-model', modelProvider: 'contract-loopback', approvalPolicy: policy, ...(options.approvalControl === 'network' ? { permissions: 'fixture_network' } : { sandbox: sandboxMode }), experimentalRawEvents: false, persistExtendedHistory: true, ephemeral: options.ephemeralParent ?? false, ...(options.historyMode ? { historyMode: options.historyMode } : {}) }) + capture.threadId = started.thread.id + capture.observations.threadStart = started + if (options.persistenceMode) { + assert.equal(name, 'text-history') + context.persistenceBlocker = path.join(context.environment.CODEX_HOME, 'sessions') + assert(!fs.existsSync(context.persistenceBlocker), 'persistence fault requires the native first-materialization parent to be absent') + await fsp.writeFile(context.persistenceBlocker, 'owned native recorder materialization blocker', { flag: 'wx', mode: 0o600 }) + capture.observations.persistenceFault = { kind: 'owned-file-blocks-native-directory', path: context.persistenceBlocker, mode: options.persistenceMode } + } + assert.equal(started.approvalPolicy, policy, 'typed native policy must remain effective') + if (options.startupCancellation) { + const starting = await client.wait(frame => frame.method === 'mcpServer/startupStatus/updated' && frame.params?.threadId === capture.threadId && frame.params.name === 'contract' && frame.params.status === 'starting') + const initialized = await deadline((async () => { + for (;;) { + const records = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) + const held = records.find(record => record.event === 'startup_initialize_held') + if (held) return held + await new Promise(resolve => setTimeout(resolve, 10)) + } + })(), 15_000, 'actual held MCP initialize') + const ack = await client.request('turn/interrupt', { threadId: capture.threadId, turnId: '' }) + const settled = await client.wait(frame => frame.method === 'mcpServer/startupStatus/updated' && frame.params?.threadId === capture.threadId && frame.params.name === 'contract' && frame.params.status === 'cancelled') + assert.equal(fixture.traffic.length, 0, 'startup cancellation must precede any model work') + assert(!capture.requests.some(frame => frame.method === 'turn/start')) + assert(!capture.notifications.some(frame => frame.method === 'turn/completed'), 'startup has its own native result, never an invented turn terminal') + capture.observations.startupCancellation = { starting: starting.params, actualInitialize: initialized, acknowledgement: ack, nativeStartupResult: settled.params, modelRequestCount: fixture.traffic.length } + capture.passed = true + return capture + } + const sandboxPolicy = sandboxMode === 'workspace-write' ? { type: 'workspaceWrite', writableRoots: [context.workspace], networkAccess: options.probeNetworkEnabled ?? false, excludeTmpdirEnvVar: true, excludeSlashTmp: true } : { type: 'readOnly', networkAccess: options.probeNetworkEnabled ?? false } + let turn = options.approvalControl === 'network' ? await driveNetwork(client, capture, controlFlow, context) : options.approvalControl === 'stdin' ? await driveStdin(client, capture, controlFlow, context) : name === 'plan-question-controls' ? await driveQuestions(client, capture, controlFlow) : name === 'mcp-form-url' && !options.largeOutput ? await driveMcp(client, capture, controlFlow, context) : await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: policy, ...(restricted && !options.probeDefaultPolicy ? { sandboxPolicy } : {}), input: [{ type: 'text', text: 'Run the controlled contract scenario.' }] }) + capture.observations.startedTurnId = turn.turn.id + if (name === 'parent-child-read') { + const spawned = await client.wait(frame => frame.method === 'item/completed' && frame.params?.item?.type === 'collabAgentToolCall' && frame.params.item.receiverThreadIds?.length > 0) + const childId = spawned.params.item.receiverThreadIds[0] + capture.observations.childId = childId + await deadline(childRequested.promise, 30_000, 'actual child model request') + const activeMetadata = await client.request('thread/read', { threadId: childId, includeTurns: false }) + capture.reads.push({ label: 'child-active-metadata', body: activeMetadata }) + assert.equal(activeMetadata.thread.parentThreadId, capture.threadId, 'read child must carry the exact native parent relationship') + assert.equal(activeMetadata.thread.ephemeral, options.ephemeralParent ?? false) + if (options.ephemeralParent) { + capture.observations.ephemeralChildReadRefusal = await nativeRefusal(client, 'thread/read', { threadId: childId, includeTurns: true }) + assert.match(capture.observations.ephemeralChildReadRefusal.message, /ephemeral/i) + capture.observations.childAvailability = 'ephemeral_history_unavailable' + } else { + const activeRead = await client.request('thread/read', { threadId: childId, includeTurns: true }) + capture.reads.push({ label: 'child-active-history', body: activeRead }) + } + childStream.resolve() + const childSettled = await deadline((async () => { + for (;;) { + const body = await client.request('thread/read', { threadId: childId, includeTurns: !options.ephemeralParent }) + if (options.ephemeralParent ? body.thread.status.type === 'idle' : assistantText(body) === 'Exact child final answer.') return body + await new Promise(resolve => setTimeout(resolve, 25)) + } + })(), 30_000, 'child native completion/saving') + capture.reads.push({ label: 'child-settled', body: childSettled }) + capture.observations.childFinalText = options.ephemeralParent ? null : assistantText(childSettled) + if (options.childApproval) { + const request = capture.serverRequests.find(frame => frame.method === 'item/commandExecution/requestApproval' && frame.params?.itemId === 'call_child_contract_command') + assert(request, 'spawned child must actually retain the parent untrusted approval policy') + assert.equal(request.params.threadId, childId, 'approval must identify the actual native child') + capture.observations.childApprovalPolicyProof = { parentPolicy: policy, childId, requestId: request.id, approvalId: request.params.approvalId ?? null, request: request.params, actualToolOutput: capture.observations.childCommandOutput } + } + parentStream.resolve() + } + if (name === 'active-input-stop') { + const firstTurnId = turn.turn.id + const delta = await client.wait(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === firstTurnId) + assert(delta.params.delta.length > 0, 'interruption must follow actual nonempty native output') + capture.observations.mismatchedSteer = await nativeRefusal(client, 'turn/steer', { threadId: capture.threadId, expectedTurnId: 'not-the-active-turn', input: [{ type: 'text', text: 'Must be refused.' }] }) + const steered = await client.request('turn/steer', { threadId: capture.threadId, expectedTurnId: firstTurnId, input: [{ type: 'text', text: 'Controlled same-turn steering input.' }] }) + assert.equal(steered.turnId, firstTurnId) + capture.observations.acceptedSteer = steered + capture.observations.stopAcknowledgement = await client.request('turn/interrupt', { threadId: capture.threadId, turnId: firstTurnId }) + const terminal = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === firstTurnId) + capture.observations.interruptedTurn = terminal.params.turn + assert.equal(terminal.params.turn.status, 'interrupted') + capture.observations.retainedPartial = capture.notifications.filter(frame => frame.method === 'item/agentMessage/delta' && frame.params?.turnId === firstTurnId).map(frame => frame.params.delta).join('') + assert.equal(capture.observations.retainedPartial, 'Interrupted native fragment') + const interruptedRead = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'interrupted-warm-native-read', body: interruptedRead }) + capture.observations.idleSteer = await nativeRefusal(client, 'turn/steer', { threadId: capture.threadId, expectedTurnId: firstTurnId, input: [{ type: 'text', text: 'No active native turn; must be refused.' }] }) + releaseStream?.() + turn = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'New work after resolved Stop.' }] }) + assert.notEqual(turn.turn.id, firstTurnId) + capture.observations.followingTurnId = turn.turn.id + } + const completed = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === turn.turn.id, options.timeoutMs ?? 120_000) + capture.observations.completedTurn = completed.params.turn + if (name === 'active-input-stop') turn = await driveNativeRaces(client, capture, controlFlow) + if (name === 'parent-child-read' && options.ephemeralParent) { + capture.observations.ephemeralParentReadRefusal = await nativeRefusal(client, 'thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.observations.childObserverMethods = capture.requests.filter(frame => frame.params.threadId === capture.observations.childId).map(frame => frame.method) + assert(capture.observations.childObserverMethods.every(method => method === 'thread/read')) + capture.passed = true + return capture + } + if (options.persistenceMode) { + const event = await client.wait(frame => frame.method === 'item/completed' && frame.params?.turnId === turn.turn.id && frame.params.item?.type === 'agentMessage') + assert.equal(event.params.item.text, 'First fragment and final fragment') + capture.observations.retainedCompletedText = event.params.item.text + capture.observations.boundedRefresh = [] + for (let attempt = 0; attempt < 3; attempt++) { + try { + const body = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }, 5000) + capture.reads.push({ label: `blocked-refresh-${attempt}`, body }) + capture.observations.boundedRefresh.push({ attempt, status: 'read', savedText: assistantText(body, turn.turn.id) }) + } catch (error) { + capture.observations.boundedRefresh.push({ attempt, status: error.nativeError ? 'native-error' : 'timeout-or-exit', error: error.nativeError ?? error.message }) + } + } + assert((await fsp.stat(context.persistenceBlocker)).isFile(), 'native recorder must still be unable to create its rollout directory') + capture.observations.persistenceState = 'retained-unsaved-refresh-stopped' + if (options.persistenceMode === 'missing') { + await client.close() + capture.observations.persistenceOwnedExit = capture.processExit + await fsp.unlink(context.persistenceBlocker) + capture.observations.coldMissingFaultRemoved = true + client = new NativeClient(capture.binary.path, context, capture) + await client.request('initialize', { clientInfo: { name: 'freshell_codex_missing_persistence', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + capture.observations.coldMissingRead = await nativeRefusal(client, 'thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.observations.coldRecoveryState = 'saved-history-unavailable-no-invented-fragments' + capture.passed = true + return capture + } + await fsp.unlink(context.persistenceBlocker) + { + const flush = await client.request('turn/start', { threadId: capture.threadId, input: [{ type: 'text', text: 'Flush the actual pending native recorder after its owned parent becomes available.' }] }) + await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === flush.turn.id) + capture.observations.persistenceFlushTurnId = flush.turn.id + } + try { + const recovered = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'released-blocker-warm-read', body: recovered }) + capture.observations.releasedWarmText = assistantText(recovered, turn.turn.id) + } catch (error) { + assert(error.nativeError, 'recovery availability failure must come from native') + capture.observations.releasedWarmReadError = error.nativeError + } + capture.observations.persistenceState = 'owned-blocker-released-native-recovery-pending' + await client.close() + client = new NativeClient(capture.binary.path, context, capture) + await client.request('initialize', { clientInfo: { name: 'freshell_codex_delayed_persistence', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + try { + await client.request('thread/resume', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, sandbox: sandboxMode, persistExtendedHistory: true }) + const recovered = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'released-blocker-cold-read', body: recovered }) + assert.equal(assistantText(recovered, turn.turn.id), event.params.item.text) + capture.observations.persistenceState = 'saved-after-owned-blocker-release-and-cold-recovery' + } catch (error) { + assert(error.nativeError, 'recovery must either supply the exact native body or an actual native availability error') + capture.observations.releasedColdReadError = error.nativeError + capture.observations.persistenceState = 'retained-unsaved-provider-unavailable-refresh-stopped' + } + capture.passed = true + return capture + } + const read = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'completed', body: read }) + capture.observations.finalAssistantText = assistantText(read, turn.turn.id) + const parentCompletedMessages = name === 'parent-child-read' ? capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.turnId === turn.turn.id && frame.params.item?.type === 'agentMessage').map(frame => frame.params.item.text) : null + if (parentCompletedMessages) { + assert(parentCompletedMessages.length > 0) + assert(parentCompletedMessages.every(text => text === 'First fragment and final fragment')) + capture.observations.parentCompletedMessages = parentCompletedMessages + } + const expectedAssistantText = parentCompletedMessages?.join('\n') ?? Array(options.duplicateBodies ? 2 : 1).fill('First fragment and final fragment').join('\n') + assert.equal(capture.observations.finalAssistantText, expectedAssistantText) + if (options.largeOutput) await verifyLargeOutput(capture, read, context, turn.turn.id, 'warm', options.historyMode) + capture.observations.finalItems = read.thread.turns.filter(nativeTurn => nativeTurn.id === turn.turn.id).flatMap(nativeTurn => nativeTurn.items) + if (name === 'model-effort-usage') { + const usage = capture.notifications.filter(frame => frame.method === 'thread/tokenUsage/updated' && frame.params?.turnId === turn.turn.id).at(-1)?.params.tokenUsage + assert.equal(usage?.last?.totalTokens, 15, 'usage must come from the actual model completion') + capture.observations.latestUsage = usage.last + capture.observations.usageLabel = 'Latest usage' + const primaryFirst=fixture.traffic[capture.observations.productionCatalogClient.fixtureRequestCount] + assert.equal(primaryFirst.body.model, 'contract-model') + assert.equal(primaryFirst.body.reasoning.effort, 'low') + // Default clears Freshell's override. Record Codex's actual later + // behavior instead of assuming omission resets its native effort. + const observations = [] + for (const [label, selection] of [ + ['explicit', { effort: 'xhigh' }], + ['native-default', { collaborationMode: { mode: 'default', settings: { model: 'contract-model', reasoning_effort: null, developer_instructions: null } } }], + ]) { + const before = fixture.traffic.length + const params = { threadId: capture.threadId, input: [{ type: 'text', text: `Observe the ${label} effort.` }], ...selection } + const submitted = await client.request('turn/start', params) + const completed = await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === submitted.turn.id) + assert.equal(completed.params.turn.status, 'completed') + const current = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + assert.equal(assistantText(current, submitted.turn.id), 'First fragment and final fragment') + const traffic = fixture.traffic.slice(before) + assert(traffic.length > 0, `${label} must reach the actual native model wire`) + observations.push({ label, params, turnId: submitted.turn.id, completed, traffic, current }) + } + assert.equal(observations[0].traffic[0].body.reasoning.effort, 'xhigh') + assert.equal(Object.hasOwn(observations[1].params, 'effort'), false) + assert.equal(observations[1].params.collaborationMode.settings.reasoning_effort, null) + capture.observations.sameThreadNativeDefault = observations + } + if (restricted) { + const commandItems = capture.notifications.filter(frame => frame.method === 'item/completed' && frame.params?.item?.type === 'commandExecution').map(frame => frame.params.item) + capture.observations.commandItems = commandItems + capture.observations.workspaceWritePresent = fs.existsSync(path.join(context.workspace, 'contract-output.txt')) + if (policy === 'untrusted') assert(capture.observations.commandApproval, 'untrusted command must produce an actual approval request') + capture.observations.restrictedCommandExecuted = commandItems.some(item => item.exitCode === 0 && item.aggregatedOutput?.includes('codex-restricted-command-ran')) + if (options.probeNetworkEnabled) { + capture.observations.permittedNetworkRequests = fixture.traffic.filter(request => request.path === '/native-permitted-network-canary').length + assert.equal(capture.observations.permittedNetworkRequests, 1, 'native network-enabled policy must actually reach the parent loopback canary') + } + if (options.probePolicyEffects) { + capture.observations.policyEffects = { + outsideWrite: fs.existsSync(path.join(context.stateRoot, 'outside/forbidden.txt')), + gitWrite: fs.existsSync(path.join(context.workspace, '.git/freshell-contract-denied')), + codexWrite: fs.existsSync(path.join(context.workspace, '.codex/freshell-contract-denied')), + networkRequests: fixture.traffic.filter(request => request.path === '/native-network-canary').length, + } + Object.assign(capture.observations.policyEffects, { + agentsWrite: fs.existsSync(path.join(context.workspace, '.agents/freshell-contract-denied')), + homeWrite: fs.existsSync(path.join(context.environment.HOME, 'forbidden.txt')), + stateWrite: fs.existsSync(path.join(context.environment.CODEX_HOME, 'forbidden.txt')), + excludedTmpdirWrite: fs.existsSync(path.join(context.environment.TMPDIR, 'forbidden.txt')), + excludedGlobalTempWrite: fs.existsSync('/tmp/freshell-codex-excluded-temp'), + ...(sandboxMode === 'read-only' ? { workspaceWrite: fs.existsSync(path.join(context.workspace, 'contract-readonly-forbidden.txt')) } : {}), + }) + if (policy === 'untrusted') { + const denial = await client.request('turn/start', { threadId: capture.threadId, approvalPolicy: policy, ...(!options.probeDefaultPolicy ? { sandboxPolicy } : {}), input: [{ type: 'text', text: 'Run the separate user-denied command probe.' }] }) + capture.observations.deniedTurn = (await client.wait(frame => frame.method === 'turn/completed' && frame.params?.turn?.id === denial.turn.id, options.timeoutMs ?? 120_000)).params.turn + capture.observations.policyEffects.userDeniedWrite = fs.existsSync(path.join(context.workspace, 'denied-by-user.txt')) + } + } + } else if (!['text-history', 'active-input-stop', 'model-effort-usage', 'parent-child-read', 'plan-question-controls', 'mcp-form-url'].includes(name) && !options.approvalControl) { + throw new Error(`CODEX_CONTRACT_UNIMPLEMENTED: ${name}; text-only execution does not establish this scenario`) + } + await client.close() + client = new NativeClient(capture.binary.path, context, capture) + await client.request('initialize', { clientInfo: { name: 'freshell_codex_contract_resume', version: '1.0.0' }, capabilities }) + client.send({ method: 'initialized', params: {} }) + const resumedThread = await client.request('thread/resume', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, ...(options.approvalControl === 'network' ? { permissions: 'fixture_network' } : { sandbox: sandboxMode }), persistExtendedHistory: true }) + capture.observations.resumeApprovalPolicy = resumedThread.approvalPolicy + assert.equal(resumedThread.approvalPolicy, policy) + const resumed = await client.request('thread/read', { threadId: capture.threadId, includeTurns: true }) + capture.reads.push({ label: 'cold-resume', body: resumed }) + if (options.largeOutput) await verifyLargeOutput(capture, resumed, context, turn.turn.id, 'cold-resume', options.historyMode) + capture.observations.resumedAssistantText = assistantText(resumed, turn.turn.id) + assert.equal(capture.observations.resumedAssistantText, capture.observations.finalAssistantText) + if (name === 'parent-child-read') { + const savedChild = await client.request('thread/read', { threadId: capture.observations.childId, includeTurns: true }) + capture.reads.push({ label: 'child-cold-saved-history', body: savedChild }) + assert.equal(savedChild.thread.parentThreadId, capture.threadId) + assert.equal(assistantText(savedChild), 'Exact child final answer.') + capture.observations.childObserverMethods = capture.requests.filter(frame => frame.params.threadId === capture.observations.childId).map(frame => frame.method) + assert(capture.observations.childObserverMethods.every(method => method === 'thread/read'), 'observer must not create/resume/send/stop/configure the child') + const pending = await client.request('thread/start', { cwd: context.workspace, model: 'contract-model', modelProvider: 'contract-loopback', approvalPolicy: 'never', sandbox: 'read-only', ephemeral: false }) + capture.observations.unmaterializedRead = await nativeRefusal(client, 'thread/read', { threadId: pending.thread.id, includeTurns: true }) + } + if (restricted) { + const fork = await client.request('thread/fork', { threadId: capture.threadId, cwd: context.workspace, approvalPolicy: policy, sandbox: sandboxMode, ephemeral: false, persistExtendedHistory: true }) + capture.observations.fork = { threadId: fork.thread.id, approvalPolicy: fork.approvalPolicy, sandbox: fork.sandbox } + assert.equal(fork.approvalPolicy, policy) + assert(capture.observations.restrictedCommandExecuted, `restricted command did not execute: ${JSON.stringify(capture.observations.commandItems)}`) + if (sandboxMode === 'workspace-write') assert(capture.observations.workspaceWritePresent, 'permitted workspace write must actually exist') + if (options.probePolicyEffects) { + // Native untrusted acceptance can authorize retry without sandboxing. + // Only never-policy isolates filesystem/network enforcement; a real + // user cancellation remains an absence-of-effect check for untrusted. + if (policy === 'never') for (const [effect, value] of Object.entries(capture.observations.policyEffects)) assert.equal(value, effect === 'networkRequests' ? 0 : false, `restricted policy allowed forbidden effect: ${effect}`) + if (policy === 'untrusted') assert.equal(capture.observations.policyEffects.userDeniedWrite, false, 'native-cancelled command must have no effect') + if (policy === 'untrusted') assert(capture.observations.commandApprovals.some(approval => approval.decision === 'cancel'), 'denied command must receive an actual native cancellation') + } + } + if (options.approvalControl === 'network') assert.equal(capture.observations.networkCases?.length, 5, 'native network once/session/no-repeat/decline/cancel evidence is required') + capture.passed = true + } catch (error) { + capture.passed = false + capture.failure = { message: error.message, nativeError: error.nativeError } + error.capture = capture + throw error + } finally { + if (client) await client.close() + releaseStream?.() + childStream.resolve() + parentStream.resolve() + if (context.network) await context.network.close() + if (fixture) { capture.fixtureTraffic = fixture.traffic; await fixture.close() } + if (context.mcpTrace && fs.existsSync(context.mcpTrace)) capture.observations.mcpTraffic = (await fsp.readFile(context.mcpTrace, 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) + capture.environment.ambientAfter = ambientCodex() + assert.equal(capture.environment.ambientAfter, capture.environment.ambientBefore) + const persisted = sanitizedCapture(capture, capture.secretValues) + const json = JSON.stringify(persisted) + await fsp.writeFile(path.join(context.receiptRoot, `${name}-${randomUUID()}.json`), `${json}\n`, { mode: 0o600 }) + if (options.emitReceipt) process.stdout.write(`${JSON.stringify({ nativeCapture: true, capture: persisted })}\n`) + if (process.env.FRESHELL_CODEX_CONTRACT_CAPTURE_STDOUT === '1') exportCloudCapture(persisted, json) + } + return capture +} + +async function managedVersion(options) { + const { register } = await import('tsx/esm/api') + const unregister = register() + const { RuntimeHarness } = await import('../../../scripts/testing/runtime-sandbox.ts') + const harness = new RuntimeHarness(repoRoot, `codex-contract-${randomUUID()}`, 3) + const receiptDir = path.join(path.resolve(options.receiptDir ?? path.join(repoRoot, '.runtime-build/codex-contract/managed')), `run-${randomUUID()}`) + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_receipt_root', path: receiptDir })) + await fsp.mkdir(receiptDir, { recursive: true, mode: 0o700 }) + await fsp.mkdir(harness.evidenceDir, { recursive: true, mode: 0o700 }) + try { + // Existing exact run-owned image seam; no supervisor or broker needed for + // this initial version gate, and no global tag is adopted or overwritten. + if (options.probeSeccomp && options.probeUid === '1000:1000') { + const prerequisite = await verifySandbox() + harness.imageRef = prerequisite.manifest.image + } else harness.ensureRuntimeImage() + let output + if (options.probeUid !== '1000:1000') { + output = spawnSync('docker', ['run', '--rm', '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', '65534:0', '--tmpfs', '/tmp:rw,nosuid,nodev,size=64m', harness.imageRef, '/usr/local/bin/codex', '--version'], { encoding: 'utf8', timeout: 30_000 }) + const receipt = { imageDigest: harness.imageRef, architecture: process.arch, uid: 65534, gid: 0, command: '/usr/local/bin/codex --version', status: output.status, stdout: output.stdout, stderr: output.stderr, requiredVersion: artifactMetadata.version } + await fsp.writeFile(path.join(receiptDir, 'binary.json'), `${JSON.stringify(receipt, null, 2)}\n`) + assert.equal(output.status, 0, output.stderr) + } + if (options.providerIdentityProbe) { + const artifactRoot = options.probeArtifactRoot ?? path.join(repoRoot, '.runtime-build/codex-contract/artifact') + await verifyArtifact(artifactRoot, options.probeMetadata ?? artifactMetadata) + const name = `freshcodex-provider-probe-${randomUUID()}` + const command = ['node', '/contract/test/integration/providers/codex-cli-contract.mjs', '--scenario', 'approval-policy-restricted-exec', '--receipt-dir', '/tmp/native-receipts', '--sandbox-mode', options.sandboxMode ?? 'read-only', '--emit-receipt'] + if (options.probeMetadataPath) command.push('--probe-metadata', `/contract/${path.relative(repoRoot, options.probeMetadataPath)}`) + if (options.legacyLandlock) command.push('--legacy-landlock') + if (options.probePolicyEffects) command.push('--probe-policy-effects') + if (options.probeApprovalPolicy) command.push('--probe-approval-policy', options.probeApprovalPolicy) + if (options.probeDefaultPolicy) command.push('--probe-default-policy') + if (options.probePolicyEffects) command.push('--state-root', '/probe-state') + if (options.probeNetworkEnabled) command.push('--probe-network-enabled', '--state-root', '/probe-state') + if (options.probeSeccomp) command.push('--probe-seccomp-receipt', `/contract/${path.relative(repoRoot, path.join(path.dirname(options.probeSeccomp), 'receipt.json'))}`) + if (options.preferBundledBwrap) command.push('--probe-bundled-bwrap') + const identity = options.probeUid ?? '65534:0' + assert(['1000:1000', '65534:0'].includes(identity), 'only the two declared native probe identities are allowed') + const [uid, gid] = identity.split(':') + const createArgs = ['create', '--name', name, '--label', `org.freshell.codex-contract=${name}`, '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', ...(options.probeSeccomp ? ['--security-opt', `seccomp=${options.probeSeccomp}`] : []), '--user', identity, '--pids-limit', '512', '--memory', '1g', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m,mode=1777', '--tmpfs', `/probe-state:rw,nosuid,nodev,size=128m,mode=700,uid=${uid},gid=${gid}`, '--mount', `type=bind,source=${repoRoot},target=/contract,readonly`, '--mount', `type=bind,source=${artifactRoot},target=/opt/freshell-codex-contract,readonly`, '--env', 'FRESHELL_CODEX_CONTRACT_ROOT=/opt/freshell-codex-contract', '--entrypoint', '/usr/local/bin/node', harness.imageRef, ...command.slice(1)] + const created = spawnSync('docker', createArgs, { encoding: 'utf8', timeout: 30_000 }) + assert.equal(created.status, 0, created.stderr) + const id = created.stdout.trim() + assert.match(id, /^[a-f0-9]{64}$/) + try { + const inspection = spawnSync('docker', ['inspect', id], { encoding: 'utf8', timeout: 15_000 }) + assert.equal(inspection.status, 0, inspection.stderr) + const observed = JSON.parse(inspection.stdout)[0] + assert.equal(observed.Config.Labels['org.freshell.codex-contract'], name) + await fsp.writeFile(path.join(receiptDir, 'container.json'), JSON.stringify({ id, image: observed.Image, appArmorProfile: observed.AppArmorProfile, config: observed.Config, hostConfig: observed.HostConfig }, null, 2)) + const result = spawnSync('docker', ['start', '--attach', id], { encoding: 'utf8', timeout: 180_000, maxBuffer: 32 * 1024 * 1024 }) + await fsp.writeFile(path.join(receiptDir, 'execution.json'), JSON.stringify({ status: result.status, stderr: result.stderr, error: result.error?.message }, null, 2)) + const captured = result.stdout.split('\n').filter(Boolean).map(line => JSON.parse(line)).find(frame => frame.nativeCapture === true)?.capture + assert(captured, 'provider-identity probe must preserve an actual native receipt') + await fsp.writeFile(path.join(receiptDir, 'native.json'), JSON.stringify(captured, null, 2), { mode: 0o600 }) + assert.equal(result.status, 0, result.stderr) + } finally { + const removal = spawnSync('docker', ['rm', '--force', id], { encoding: 'utf8', timeout: 30_000 }) + assert.equal(removal.status, 0, removal.stderr) + } + return + } + assert.equal(output.stdout.trim(), `codex-cli ${artifactMetadata.version}`, 'managed runtime must execute the required Codex release') + const { runManagedContract } = await import('./codex-managed-contract.mjs') + await runManagedContract(harness, receiptDir, options) + } finally { const cleanup = await harness.cleanup(); unregister(); assert.equal(cleanup.ok, true, JSON.stringify(cleanup.errors)) } +} + +async function main(args) { + let scenario = 'all' + const options = {} + let managed = false + for (let index = 0; index < args.length; index++) { + if (args[index] === '--scenario') scenario = args[++index] + else if (args[index] === '--receipt-dir') options.receiptDir = path.resolve(args[++index]) + else if (args[index] === '--managed-runtime') managed = true + else if (args[index] === '--sandbox-mode') options.sandboxMode = args[++index] + else if (args[index] === '--provider-identity-probe') { managed = true; options.providerIdentityProbe = true } + else if (args[index] === '--emit-receipt') options.emitReceipt = true + else if (args[index] === '--probe-metadata') { options.probeMetadataPath = path.resolve(args[++index]); options.probeMetadata = JSON.parse(await fsp.readFile(options.probeMetadataPath, 'utf8')); assert.equal(options.probeMetadata.probeOnly, true) } + else if (args[index] === '--probe-artifact-root') options.probeArtifactRoot = path.resolve(args[++index]) + else if (args[index] === '--legacy-landlock') options.legacyLandlock = true + else if (args[index] === '--probe-policy-effects') options.probePolicyEffects = true + else if (args[index] === '--probe-approval-policy') options.probeApprovalPolicy = args[++index] + else if (args[index] === '--probe-default-policy') options.probeDefaultPolicy = true + else if (args[index] === '--state-root') options.stateRoot = path.resolve(args[++index]) + else if (args[index] === '--probe-seccomp') options.probeSeccomp = path.resolve(args[++index]) + else if (args[index] === '--probe-seccomp-receipt') options.seccompProbeReceipt = path.resolve(args[++index]) + else if (args[index] === '--probe-uid') options.probeUid = args[++index] + else if (args[index] === '--probe-bundled-bwrap') options.preferBundledBwrap = true + else if (args[index] === '--history-mode') { options.historyMode = args[++index]; assert(['legacy', 'paginated'].includes(options.historyMode)) } + else if (args[index] === '--persistence-mode') { options.persistenceMode = args[++index]; assert(['delayed', 'missing'].includes(options.persistenceMode)) } + else if (args[index] === '--duplicate-bodies') options.duplicateBodies = true + else if (args[index] === '--large-output') options.largeOutput = true + else if (args[index] === '--startup-cancellation') options.startupCancellation = true + else if (args[index] === '--missing-policy-client-probe') options.policyClientProbe = '/freshell-missing-policy-client-probe' + else if (args[index] === '--requirements-guard') options.requirementsGuard = true + else if (args[index] === '--child-approval') options.childApproval = true + else if (args[index] === '--ephemeral-parent') options.ephemeralParent = true + else if (args[index] === '--probe-network-enabled') options.probeNetworkEnabled = true + else if (args[index] === '--approval-control') { options.approvalControl = args[++index]; assert(['stdin', 'network'].includes(options.approvalControl)) } + else throw new Error(`unknown argument ${args[index]}`) + } + if (options.probePolicyEffects || options.probeApprovalPolicy || options.probeDefaultPolicy || options.legacyLandlock) assert(managed || process.env.FRESHELL_CODEX_MANAGED_CONTRACT === '1' || options.probeMetadata || options.probeSeccomp || options.seccompProbeReceipt, 'probe options require an explicit candidate manifest or declared seccomp probe') + if (managed) return managedVersion({ ...options, scenario }) + for (const [name, selected] of scenario === 'all' ? nativeContractCases() : [[scenario, {}]]) { + const capture = await captureNativeScenario(name, { ...options, ...selected }) + console.log(JSON.stringify({ severity: 'info', event: 'codex_native_scenario_passed', scenario: name, parameters: capture.parameters, version: capture.binary.version, threadId: capture.threadId })) + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2)).catch(error => { console.error(JSON.stringify({ severity: 'error', event: 'codex_native_contract_failed', message: error.message })); process.exitCode = 1 }) diff --git a/test/integration/providers/codex-cli-contract.test.ts b/test/integration/providers/codex-cli-contract.test.ts new file mode 100644 index 0000000000..9c214364dc --- /dev/null +++ b/test/integration/providers/codex-cli-contract.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +// @ts-expect-error Native executable harness is shared with the offline runner. +import { captureNativeScenario } from './codex-cli-contract.mjs' + +describe('Codex 0.164.0 release contract through an isolated Responses provider', () => { + it('refuses a missing real-client prerequisite before opening a native thread', async () => { + await expect(captureNativeScenario('text-history', { policyClientProbe: '/freshell-missing-policy-client-probe' })) + .rejects.toThrow(/freshell-missing-policy-client-probe/) + }, 180_000) + + it.each(['legacy', 'paginated'])('retains the actual final answer through %s history and a cold resume', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, timeoutMs: 120_000 }) + expect(capture.binary.version).toBe('0.164.0') + expect(capture.observations.finalAssistantText).toBe('First fragment and final fragment') + expect(capture.observations.resumedAssistantText).toBe(capture.observations.finalAssistantText) + expect(capture.notifications.some((frame: any) => frame.method === 'item/agentMessage/delta')).toBe(true) + expect(capture.fixtureTraffic.length).toBeGreaterThan(0) + expect(capture.fixtureTraffic.every((request: any) => request.loopback)).toBe(true) + }, 180_000) + + it.each(['legacy', 'paginated'])('preserves duplicate equal assistant bodies in %s history', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, duplicateBodies: true, timeoutMs: 120_000 }) + expect(capture.observations.finalAssistantText).toBe('First fragment and final fragment\nFirst fragment and final fragment') + expect(capture.observations.resumedAssistantText).toBe(capture.observations.finalAssistantText) + }, 180_000) + + it.each(['active-input-stop', 'parent-child-read', 'plan-question-controls', 'mcp-form-url', 'model-effort-usage', 'approval-policy-restricted-exec'])( + 'executes the complete native %s scenario', async scenario => { + const capture = await captureNativeScenario(scenario, { timeoutMs: 120_000 }) + expect(capture.passed).toBe(true) + expect(capture.fixtureTraffic.length).toBeGreaterThan(0) + if (scenario === 'model-effort-usage') { + const [explicit, nativeDefault] = capture.observations.sameThreadNativeDefault + expect(explicit.traffic[0].body.reasoning.effort).toBe('xhigh') + expect(nativeDefault.params).not.toHaveProperty('effort') + expect(nativeDefault.params.collaborationMode.settings.reasoning_effort).toBeNull() + expect(nativeDefault.completed.params.turn.status).toBe('completed') + expect(nativeDefault.traffic.length).toBeGreaterThan(0) + expect(nativeDefault.current.thread.id).toBe(explicit.current.thread.id) + } + }, 180_000, + ) + it.each(['read-only', 'workspace-write'])('executes restricted never policy with actual %s effects', async sandboxMode => { + const capture = await captureNativeScenario('approval-policy-restricted-exec', { sandboxMode, probeApprovalPolicy: 'never', probePolicyEffects: true, timeoutMs: 120_000 }) + expect(capture.observations.restrictedCommandExecuted).toBe(true) + expect(capture.observations.policyEffects).toEqual(expect.objectContaining({ outsideWrite: false, gitWrite: false, codexWrite: false, agentsWrite: false, homeWrite: false, stateWrite: false, excludedTmpdirWrite: false, excludedGlobalTempWrite: false, networkRequests: 0 })) + expect(capture.observations.workspaceWritePresent).toBe(sandboxMode === 'workspace-write') + }, 180_000) + + it('refuses untrusted work through the real Rust client under actual native requirements', async () => { + const capture = await captureNativeScenario('approval-policy-restricted-exec', { requirementsGuard: true, timeoutMs: 120_000 }) + expect(capture.observations.requirementsGuard.refusedRoutes).toEqual(['create', 'resume', 'fork', 'send']) + expect(capture.observations.requirementsGuard.workRequests).toBe(0) + expect(capture.observations.requirementsGuard.ownedListenerSettled).toBe(true) + expect(capture.processCloses).toEqual(capture.processExits) + expect(capture.fixtureTraffic).toHaveLength(0) + }, 180_000) + + it.each(['stdin', 'network'])('executes actual %s approval effects and cancellation', async approvalControl => { + const capture = await captureNativeScenario('approval-policy-restricted-exec', { approvalControl, timeoutMs: 120_000 }) + expect(capture.passed).toBe(true) + expect(approvalControl === 'stdin' ? capture.observations.stdinCases.length : capture.observations.networkCases.length).toBe(approvalControl === 'stdin' ? 4 : 5) + }, 180_000) + + it('cancels genuine MCP startup without inventing a turn terminal', async () => { + const capture = await captureNativeScenario('active-input-stop', { startupCancellation: true, timeoutMs: 120_000 }) + expect(capture.observations.startupCancellation.nativeStartupResult.status).toBe('cancelled') + expect(capture.fixtureTraffic).toHaveLength(0) + }, 180_000) + + it('reads ephemeral child metadata without taking a writer role', async () => { + const capture = await captureNativeScenario('parent-child-read', { ephemeralParent: true, timeoutMs: 120_000 }) + expect(capture.observations.childAvailability).toBe('ephemeral_history_unavailable') + expect(capture.observations.childObserverMethods.every((method: string) => method === 'thread/read')).toBe(true) + }, 180_000) + + it.each(['legacy', 'paginated'])('captures actual large command and MCP persistence in %s history', async historyMode => { + const capture = await captureNativeScenario('mcp-form-url', { historyMode, largeOutput: true, timeoutMs: 120_000 }) + expect(capture.observations.largeOutputReads.map((read: any) => read.label)).toEqual(['warm', 'cold-resume']) + }, 180_000) + + it.each(['legacy', 'paginated'])('captures delayed persistence and finite failed refresh in %s history', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, persistenceMode: 'delayed', timeoutMs: 120_000 }) + expect(capture.observations.boundedRefresh).toHaveLength(3) + expect(capture.observations.retainedCompletedText).toBe('First fragment and final fragment') + expect(['saved-after-owned-blocker-release-and-cold-recovery', 'retained-unsaved-provider-unavailable-refresh-stopped']).toContain(capture.observations.persistenceState) + }, 180_000) + + it.each(['legacy', 'paginated'])('captures genuinely missing persistence after owned exit in %s history', async historyMode => { + const capture = await captureNativeScenario('text-history', { historyMode, persistenceMode: 'missing', timeoutMs: 120_000 }) + expect(capture.observations.coldMissingFaultRemoved).toBe(true) + expect(capture.observations.coldRecoveryState).toBe('saved-history-unavailable-no-invented-fragments') + }, 180_000) + + it('preserves native untrusted intent in the spawned child without a writer takeover', async () => { + const capture = await captureNativeScenario('parent-child-read', { childApproval: true, timeoutMs: 120_000 }) + expect(capture.observations.childApprovalPolicyProof.parentPolicy).toBe('untrusted') + expect(capture.observations.childApprovalPolicyProof.request.threadId).toBe(capture.observations.childId) + expect(capture.observations.childObserverMethods.every((method: string) => method === 'thread/read')).toBe(true) + }, 180_000) + +}) diff --git a/test/integration/providers/codex-local-provider.mjs b/test/integration/providers/codex-local-provider.mjs new file mode 100644 index 0000000000..739ad1c890 --- /dev/null +++ b/test/integration/providers/codex-local-provider.mjs @@ -0,0 +1,188 @@ +// Model-wire fixture only: all app-server events originate in the release CLI. +import http from 'node:http' +import fs from 'node:fs' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' + +export const MCP_LARGE_TEXT = 'F1_MCP_HEAD\n' + 'm'.repeat(128 * 1024) + '\nF1_MCP_TAIL' +export const MCP_FORM_CONTENT = { name: 'fixture', count: 2.5, enabled: true, color: 'blue' } +export const CODEX_NETWORK_REQUIREMENTS = '[experimental_network]\nenabled = true\nallow_local_binding = true\nallow_upstream_proxy = false\n' +const FORM_SCHEMA = { type: 'object', properties: { name: { type: 'string', title: 'Name' }, count: { type: 'number', title: 'Count' }, enabled: { type: 'boolean', title: 'Enabled' }, color: { type: 'string', title: 'Color', enum: ['blue', 'green'] } }, required: ['name', 'count', 'enabled', 'color'] } + +function serveMcpFixture(tracePath, url, holdStartup = false) { + const pending = new Map() + let nextId = 1 + const trace = record => fs.appendFileSync(tracePath, `${JSON.stringify(record)}\n`, { mode: 0o600 }) + const send = frame => { trace({ direction: 'server', frame }); process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`) } + const request = (method, params) => { + const number = nextId++ + const id = number % 2 ? number : `mcp-${number}` + return new Promise((resolve, reject) => { pending.set(id, { resolve, reject }); send({ id, method, params }) }) + } + const handle = async frame => { + trace({ direction: 'native', frame }) + if (!frame.method && frame.id !== undefined) { + const waiter = pending.get(frame.id) + if (waiter) { pending.delete(frame.id); if (frame.error) waiter.reject(new Error(frame.error.message)); else waiter.resolve(frame.result) } + return + } + if (frame.method === 'notifications/cancelled') { + for (const [id, waiter] of pending) { pending.delete(id); waiter.resolve({ action: 'cancel', cancelledByNative: true }) } + return + } + if (frame.id === undefined) return + if (frame.method === 'initialize' && holdStartup) { trace({ event: 'startup_initialize_held', id: frame.id }); return } + if (frame.method === 'initialize') send({ id: frame.id, result: { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'freshell-contract', version: '1' } } }) + else if (frame.method === 'ping') send({ id: frame.id, result: {} }) + else if (frame.method === 'tools/list') send({ id: frame.id, result: { tools: [{ name: 'large_result', description: 'Return a bounded native persistence fixture.', inputSchema: { type: 'object', properties: {}, additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, idempotentHint: true } }, { name: 'elicit', description: 'Run the selected native elicitation fixture.', inputSchema: { type: 'object', properties: { route: { type: 'string', enum: ['form', 'openai/form', 'openaiForm', 'url'] }, sequence: { type: 'string', enum: ['matrix', 'hold'] } }, required: ['route', 'sequence'], additionalProperties: false }, annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false, idempotentHint: true } }] } }) + else if (frame.method === 'tools/call' && frame.params.name === 'large_result') { + send({ id: frame.id, result: { content: [{ type: 'text', text: MCP_LARGE_TEXT }], structuredContent: { fixture: 'F1_MCP_STRUCTURED', count: 1 }, isError: false, _meta: { 'fixture/result': 'F1_MCP_META' } } }) + } + else if (frame.method === 'tools/call') { + const { route, sequence } = frame.params.arguments + const results = [] + for (const action of sequence === 'hold' ? ['cancel'] : ['accept', 'decline', 'cancel']) { + const method = route === 'openai/form' ? 'openai/form' : route === 'openaiForm' ? 'openai/elicitation/create' : 'elicitation/create' + const params = route === 'url' ? { mode: 'url', message: 'Open the fixture page, then choose an action.', url: `${url}/elicitation/example`, elicitationId: `fixture-url-${nextId}` } : { ...(route === 'openai/form' ? {} : { mode: 'form' }), message: 'Fixture form', requestedSchema: FORM_SCHEMA } + const result = await request(method, params) + trace({ event: 'elicitation_result', route, sequence, expectedAction: action, result }) + if (result.action !== action) throw new Error(`MCP route ${route} returned the wrong action`) + if (action === 'accept' && route !== 'url') assert.deepEqual(result.content, MCP_FORM_CONTENT, 'MCP form content must preserve typed native answers') + results.push(result) + } + send({ id: frame.id, result: { content: [{ type: 'text', text: 'Fixture sequence completed' }], isError: false } }) + } else send({ id: frame.id, error: { code: -32601, message: 'Unsupported fixture method' } }) + } + createInterface({ input: process.stdin }).on('line', line => { + let frame + try { frame = JSON.parse(line) } catch { return } + handle(frame).catch(error => { + trace({ event: frame.params?.arguments?.sequence === 'hold' ? 'fixture_request_cancelled' : 'fixture_error', message: error.message }) + if (frame.id !== undefined) send({ id: frame.id, error: { code: -32603, message: error.message } }) + }) + }) +} + +export async function createCodexLocalProvider(handler, { port = 0 } = {}) { + const traffic = [] + let sequence = 0 + const sockets = new Set() + const server = http.createServer(async (request, response) => { + try { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + const body = Buffer.concat(chunks).toString('utf8') + const parsed = body ? JSON.parse(body) : null + const record = { index: traffic.length, method: request.method, path: request.url, loopback: request.socket.remoteAddress === '127.0.0.1', body: parsed } + traffic.push(record) + if (request.method === 'GET' && request.url === '/elicitation/example' && record.loopback) { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end('Owned elicitation fixture

The page is open. The native decision is still separate.

') + return + } + if (request.method !== 'POST' || request.url !== '/v1/responses' || !record.loopback) { + response.writeHead(404).end(); return + } + response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }) + const emit = (type, fields = {}) => response.write(`event: ${type}\ndata: ${JSON.stringify({ type, sequence_number: sequence++, ...fields })}\n\n`) + const id = `resp_contract_${record.index}` + const base = { id, object: 'response', created_at: 1, status: 'in_progress', output: [] } + emit('response.created', { response: base }) + const plan = await handler(record, { emit, response }) + if (!plan || response.destroyed || response.writableEnded) return + const items = plan.items ?? [{ id: `msg_contract_${record.index}`, type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: plan.text ?? 'First fragment and final fragment', annotations: [] }] }] + for (let outputIndex = 0; outputIndex < items.length; outputIndex++) { + const item = items[outputIndex] + emit('response.output_item.added', { output_index: outputIndex, item: item.type === 'message' ? { ...item, status: 'in_progress', content: [] } : { ...item, arguments: '' } }) + if (item.type === 'message') { + for (let contentIndex = 0; contentIndex < item.content.length; contentIndex++) { + const content = item.content[contentIndex] + emit('response.content_part.added', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, part: { ...content, text: '' } }) + for (const delta of plan.deltas ?? [content.text.slice(0, 14), content.text.slice(14)]) { + emit('response.output_text.delta', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, delta }) + } + if (plan.pauseAfterDelta) await plan.pauseAfterDelta() + if (response.destroyed) return + emit('response.output_text.done', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, text: content.text }) + emit('response.content_part.done', { item_id: item.id, output_index: outputIndex, content_index: contentIndex, part: content }) + } + } else if (item.type === 'function_call') { + emit('response.function_call_arguments.delta', { item_id: item.id, output_index: outputIndex, delta: item.arguments }) + emit('response.function_call_arguments.done', { item_id: item.id, output_index: outputIndex, arguments: item.arguments }) + } + emit('response.output_item.done', { output_index: outputIndex, item }) + } + emit('response.completed', { response: { ...base, status: 'completed', output: items, usage: { input_tokens: 10, input_tokens_details: { cached_tokens: 0 }, output_tokens: 5, output_tokens_details: { reasoning_tokens: 0 }, total_tokens: 15 } } }) + response.end() + } catch (error) { + if (!response.headersSent) response.writeHead(500) + response.end(JSON.stringify({ error: { message: error.message } })) + } + }) + server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)) }) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve) }) + return { + baseUrl: `http://127.0.0.1:${server.address().port}/v1`, traffic, + async close() { for (const socket of sockets) socket.destroy(); await new Promise(resolve => server.close(resolve)) }, + } +} + +/** Run the model-wire fixture beside the actual managed provider, never on host localhost. */ +export async function startInManagedContext(rig, source) { + const { containerId, handlerSource, port = 51991 } = source + assert.match(containerId, /^[a-f0-9]{64}$/) + assert.equal(typeof handlerSource, 'string') + assert(Number.isSafeInteger(port) && port > 1024 && port < 65536) + const directory = `/home/freshell/provider/.freshell-fixture/codex-wire-${randomUUID()}` + const trafficPath = `${directory}/traffic.jsonl` + const readyPath = `${directory}/ready.json` + // Import the original ESM bytes: Vitest's transformed function.toString() + // references its host-only module loader and cannot run inside the provider. + const moduleUrl = `data:text/javascript;base64,${fs.readFileSync(fileURLToPath(import.meta.url)).toString('base64')}` + const code = `import fs from 'node:fs'; + const {createCodexLocalProvider} = await import(${JSON.stringify(moduleUrl)}); + const handle = (${handlerSource}); + const fixture = await createCodexLocalProvider(async (record, context) => { + fs.appendFileSync(${JSON.stringify(trafficPath)}, JSON.stringify(record) + '\\n'); + return handle(record, context); + }, {port:${port}}); + fs.writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({baseUrl:fixture.baseUrl,uid:process.getuid(),gid:process.getgid(),pid:process.pid,node:process.version})); + process.once('SIGTERM', async () => { await fixture.close(); fs.writeFileSync(${JSON.stringify(`${directory}/closed`)},'closed');process.exit(0); });` + rig.ownedProviderExec(containerId, ['node', '-e', ` + const fs=require('node:fs'),{spawn}=require('node:child_process'); + fs.mkdirSync(${JSON.stringify(directory)}, {recursive:true,mode:0o700}); + const log=fs.openSync(${JSON.stringify(`${directory}/stderr`)},'a',0o600); + const child=spawn(process.execPath,['--input-type=module','-e',${JSON.stringify(code)}],{detached:true,stdio:['ignore','ignore',log]}); + fs.writeFileSync(${JSON.stringify(`${directory}/pid`)},String(child.pid));child.unref();`]) + let ready + const deadline = Date.now() + 30_000 + while (!ready && Date.now() < deadline) { + ready = JSON.parse(rig.ownedProviderExec(containerId, ['node', '-e', `const fs=require('node:fs');process.stdout.write(fs.existsSync(${JSON.stringify(readyPath)})?fs.readFileSync(${JSON.stringify(readyPath)},'utf8'):'null')`])) + if (!ready) await new Promise(resolve => setTimeout(resolve, 50)) + } + assert(ready, `managed Codex loopback fixture did not start: ${rig.ownedProviderExec(containerId, ['cat', `${directory}/stderr`])}`) + assert.equal(ready.uid, 65534) + assert.equal(ready.gid, 0) + return { + baseUrl: ready.baseUrl, + context: ready, + get traffic() { + return JSON.parse(rig.ownedProviderExec(containerId, ['node', '-e', `const fs=require('node:fs');const p=${JSON.stringify(trafficPath)};process.stdout.write(JSON.stringify(fs.existsSync(p)?fs.readFileSync(p,'utf8').trim().split('\\n').filter(Boolean).map(JSON.parse):[]))`])) + }, + async stop() { + if (!rig.runtime.isContainerRunning(containerId)) return + // This PID was created and recorded by this invocation in the owned namespace. + rig.ownedProviderExec(containerId, ['node', '-e', `const fs=require('node:fs');const p=Number(fs.readFileSync(${JSON.stringify(`${directory}/pid`)},'utf8'));if(p!==${ready.pid})throw Error('fixture PID changed');try{process.kill(p,'SIGTERM')}catch(e){if(e.code!=='ESRCH')throw e}`]) + const deadline = Date.now() + 10_000 + while (Date.now() < deadline) { + if (rig.ownedProviderExec(containerId, ['node', '-e', `process.stdout.write(String(require('node:fs').existsSync(${JSON.stringify(`${directory}/closed`)})))`]) === 'true') return + await new Promise(resolve => setTimeout(resolve, 50)) + } + throw new Error(`managed Codex loopback fixture did not close: ${containerId}/${ready.pid}`) + }, + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && ['--mcp-fixture', '--mcp-startup-fixture'].includes(process.argv[2])) serveMcpFixture(process.argv[3], process.argv[4], process.argv[2] === '--mcp-startup-fixture') diff --git a/test/integration/providers/codex-managed-cleanup.mjs b/test/integration/providers/codex-managed-cleanup.mjs new file mode 100644 index 0000000000..0e74584350 --- /dev/null +++ b/test/integration/providers/codex-managed-cleanup.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { runManagedContainer, verifyManagedPrerequisites } from './codex-managed-contract.mjs' +import { prepareContractImage } from '../../../scripts/testing/prepare-codex-contract.mjs' + +const owned = fs.mkdtempSync(path.join(process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR ?? (fs.existsSync('/freshell-test-artifacts') ? '/freshell-test-artifacts' : os.tmpdir()), 'codex-image-cleanup-')) +const originalPath = process.env.PATH +const base = `sha256:${'a'.repeat(64)}` +const state = path.join(owned, 'state.json') +const calls = path.join(owned, 'calls.jsonl') +const fake = path.join(owned, 'docker') +fs.writeFileSync(fake, `#!${process.execPath} +import fs from 'node:fs'; +const state=${JSON.stringify(state)}, calls=${JSON.stringify(calls)}; +const args=process.argv.slice(2);fs.appendFileSync(calls,JSON.stringify(args)+'\\n'); +const data=fs.existsSync(state)?JSON.parse(fs.readFileSync(state,'utf8')):{}; +const save=()=>fs.writeFileSync(state,JSON.stringify(data)); +if(args[0]==='image'&&args[1]==='tag'){data[args[3]]={Id:args[2],Config:{Labels:{}}};save()} +else if(args[0]==='build'){const owner=args[args.indexOf('--label')+1].split('=')[1];const tag=args[args.indexOf('-t')+1];data[tag]={Id:'sha256:'+'b'.repeat(64),Config:{Labels:{'org.freshell.codex-contract-owner':owner}}};save();process.stderr.write('injected lost successful build acknowledgement');process.exitCode=71} +else if(args[0]==='image'&&args[1]==='inspect'){const value=data[args[2]];if(!value){process.stderr.write('Error: No such image: '+args[2]);process.exitCode=1}else process.stdout.write(args.includes('--format')?value.Id:JSON.stringify([value]))} +else if(args[0]==='image'&&args[1]==='rm'){assertOwned: {if(!data[args[2]])throw Error('unexpected deletion');delete data[args[2]];save()}} +else if(args[0]==='create'){const name=args[args.indexOf('--name')+1];const owner=args[args.indexOf('--label')+1].split('=')[1];data[name]={Id:'c'.repeat(64),Image:'sha256:'+'a'.repeat(64),Config:{Labels:{'org.freshell.codex-contract':process.env.CODEX_CLEANUP_FOREIGN==='1'?'foreign':owner}}};save();process.stderr.write('injected lost successful create acknowledgement');process.exitCode=72} +else if(args[0]==='inspect'){const value=data[args[1]];if(value)process.stdout.write(JSON.stringify([value]));else{process.stderr.write('Error: No such object: '+args[1]);process.exitCode=1}} +else if(args[0]==='rm'){const entry=Object.entries(data).find(([,v])=>v.Id===args[2]);if(!entry)throw Error('unowned container deletion');delete data[entry[0]];save()} +else if(args[0]==='volume'&&args[1]==='inspect'){process.stderr.write('Error: No such volume');process.exitCode=1} +else throw Error('unexpected fake Docker operation '+JSON.stringify(args)); +`, { mode: 0o755 }) +// The fake CLI is a regular process with an owned executable directory. It +// records actual subprocess calls; it never contacts a Docker daemon. +process.env.PATH = `${owned}:${originalPath}` +const contexts = [] +try { + for (const failure of ['dockerfile-write', 'lost-build-reply']) { + fs.writeFileSync(state, '{}') + fs.writeFileSync(calls, '') + const owner = `cleanup-${randomUUID()}` + const contextRoot = path.join(owned, 'contexts') + const context = path.join(contextRoot, owner) + contexts.push(context) + if (failure === 'dockerfile-write') fs.mkdirSync(path.join(context, 'Dockerfile'), { recursive: true }) + await assert.rejects(prepareContractImage(base, owner, contextRoot)) + assert.deepEqual(JSON.parse(fs.readFileSync(state, 'utf8')), {}, `${failure}: every acquired owned image tag must be removed`) + } + for (const foreign of [false, true]) { + fs.writeFileSync(state, '{}'); fs.writeFileSync(calls, '') + process.env.CODEX_CLEANUP_FOREIGN = foreign ? '1' : '0' + const owner = `container-${randomUUID()}` + await assert.rejects(runManagedContainer({ owner, image: base, receiptDir: owned, + common: ['--label', `org.freshell.codex-contract=${owner}`], label: 'lost-reply', args: [] }), error => { + const primary = foreign ? error.errors?.[0] : error + assert.match(primary?.message ?? '', /injected lost successful create acknowledgement/) + if (foreign) assert(error instanceof AggregateError, 'execution and ownership refusal must both remain diagnosable') + return true + }) + const retained = JSON.parse(fs.readFileSync(state, 'utf8')) + assert.equal(Object.keys(retained).length, foreign ? 1 : 0, 'only the exact owned lost-ack container may be removed') + const deleted = fs.readFileSync(calls, 'utf8').trim().split('\n').map(line => JSON.parse(line)).filter(args => args[0] === 'rm') + assert.equal(deleted.length, foreign ? 0 : 1) + } + delete process.env.CODEX_CLEANUP_FOREIGN + fs.writeFileSync(calls, '') + assert.throws(() => verifyManagedPrerequisites({ complete: true, policyClientProbe: { path: `/freshell-test-artifacts/freshcodex-contract-${randomUUID()}/policy-client-probe`, sha256: 'a'.repeat(64) } })) + const admissionCalls = fs.readFileSync(calls, 'utf8').trim().split('\n').map(line => JSON.parse(line)) + assert.deepEqual(admissionCalls.map(args => args.slice(0, 2)), [['volume', 'inspect']], 'a missing prepared volume must refuse before create or provisioning') + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_image_cleanup_passed', cases: 5 })) +} finally { + process.env.PATH = originalPath + for (const context of contexts) fs.rmSync(context, { recursive: true, force: true }) + fs.rmSync(owned, { recursive: true, force: true }) +} diff --git a/test/integration/providers/codex-managed-contract.mjs b/test/integration/providers/codex-managed-contract.mjs new file mode 100644 index 0000000000..7719b3ac66 --- /dev/null +++ b/test/integration/providers/codex-managed-contract.mjs @@ -0,0 +1,127 @@ +// The managed proof uses the installed npm entrypoint, not the staged executable. +import assert from 'node:assert/strict' +import fsp from 'node:fs/promises' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { repoRoot, artifactMetadata, verifyArtifact, nativeSandboxPolicy, prepareContractImage, verifySandboxTargetCache } from '../../../scripts/testing/prepare-codex-contract.mjs' + +const hash = bytes => createHash('sha256').update(bytes).digest('hex') +function docker(args, options = {}) { + const result = spawnSync('docker', args, { encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024, ...options }) + assert.equal(result.status, 0, `${result.error?.message ?? ''}\n${result.stderr}\n${result.stdout}`) + return result.stdout +} + +export async function runManagedContainer({ owner, image, receiptDir, common, label, args, extra = [] }) { + const name = `${owner}-${label}` + let id + let failure + try { + id = docker(['create', '--name', name, ...common, ...extra, image, ...args]).trim() + assert.match(id, /^[a-f0-9]{64}$/) + const observed = JSON.parse(docker(['inspect', id]))[0] + assert.equal(observed.Config.Labels['org.freshell.codex-contract'], owner) + assert.equal(observed.Image, image) + assert.equal(observed.Config.User, '65534:0') + assert.equal(observed.HostConfig.ReadonlyRootfs, true) + assert.deepEqual(observed.HostConfig.CapDrop, ['ALL']) + assert.equal(observed.HostConfig.NetworkMode, 'none') + await fsp.writeFile(path.join(receiptDir, `${label}-container.json`), `${JSON.stringify(observed, null, 2)}\n`) + const result = spawnSync('docker', ['start', '--attach', id], { encoding: 'utf8', timeout: 1_200_000, maxBuffer: 64 * 1024 * 1024 }) + await fsp.writeFile(path.join(receiptDir, `${label}-execution.json`), `${JSON.stringify({ status: result.status, error: result.error?.message, stderr: result.stderr }, null, 2)}\n`) + await fsp.writeFile(path.join(receiptDir, `${label}-stdout.jsonl`), result.stdout, { mode: 0o600 }) + return result + } catch (error) { + failure = error + throw error + } finally { + try { + // The exact UUID name remains cleanup authority after a lost create ACK. + const inspection = spawnSync('docker', ['inspect', name], { encoding: 'utf8', timeout: 30_000 }) + if (inspection.status === 0) { + const observed = JSON.parse(inspection.stdout)[0] + assert.equal(observed.Config.Labels['org.freshell.codex-contract'], owner) + assert.equal(observed.Image, image) + assert.match(observed.Id, /^[a-f0-9]{64}$/) + docker(['rm', '--force', observed.Id]) + } else assert.match(inspection.stderr, /No such (?:object|container)/) + } catch (cleanup) { + console.error(JSON.stringify({ severity: 'error', event: 'codex_managed_container_cleanup_failed', owner, name, message: cleanup.message })) + if (failure) throw new AggregateError([failure, cleanup], 'Managed Codex execution and owned cleanup failed') + throw cleanup + } + } +} + +export function verifyManagedPrerequisites(staged) { + assert.equal(staged.complete, true, 'managed contract requires completed preparation') + assert.match(staged.policyClientProbe?.path ?? '', /^\/freshell-test-artifacts\/freshcodex-contract-[a-f0-9-]+\/policy-client-probe$/) + assert.match(staged.policyClientProbe?.sha256 ?? '', /^[a-f0-9]{64}$/) + // Refuse absent/foreign preparation before Docker can create any resources. + return verifySandboxTargetCache() +} + +export async function runManagedContract(harness, receiptDir, options = {}) { + const owner = `managed-codex-${randomUUID()}` + const artifact = await verifyArtifact(path.join(repoRoot, '.runtime-build/codex-contract/artifact')) + const staged = JSON.parse(await fsp.readFile(path.join(repoRoot, '.runtime-build/codex-contract/staged.json'), 'utf8')) + const targetCache = verifyManagedPrerequisites(staged) + const policy = nativeSandboxPolicy() + const policyFile = path.join(receiptDir, 'codex-policy.json') + await fsp.writeFile(policyFile, policy.json, { mode: 0o644 }) + // Proxy-approval requirements belong only to the approval fixture derivative. + // The production network-enabled mode is qualified against the actual base. + const image = options.probeNetworkEnabled + ? { image: harness.imageRef, baseImage: harness.imageRef, subdir: owner } + : await prepareContractImage(harness.imageRef, owner) + try { + const ambient = path.join(receiptDir, `${owner}-ambient-helper`) + await fsp.mkdir(ambient, { mode: 0o755 }) + await fsp.writeFile(path.join(ambient, 'bwrap'), '#!/bin/sh\ncase "$1" in --version) echo ambient-incompatible-bwrap;; --help) echo "--as-pid-1 --perms";; *) printf used > "$TMPDIR/ambient-bwrap-used"; exit 97;; esac\n', { mode: 0o755 }) + const targetMount = `type=volume,source=${targetCache.name},target=/freshell-test-artifacts,readonly,${Object.entries(targetCache.labels).map(([key, value]) => `volume-label=${key}=${value}`).join(',')}` + const common = ['--label', `org.freshell.codex-contract=${owner}`, '--network', 'none', '--read-only', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--security-opt', `seccomp=${policyFile}`, '--user', '65534:0', '--pids-limit', '512', '--memory', '1g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=128m,mode=1777', '--tmpfs', '/probe-state:rw,nosuid,nodev,size=512m,mode=700,uid=65534,gid=0', '--mount', `type=bind,source=${repoRoot},target=/contract,readonly`, '--mount', `type=bind,source=${artifact.path}/artifact-receipt.json,target=/native-reference.json,readonly`, '--mount', `type=bind,source=${ambient},target=/opt/freshell-codex-ambient,readonly`, '--mount', targetMount, '--env', 'FRESHELL_CODEX_MANAGED_CONTRACT=1', '--env', `FRESHELL_CODEX_POLICY_CLIENT_PROBE=${staged.policyClientProbe.path}`, '--env', 'PATH=/opt/freshell-codex-ambient:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', '--entrypoint', '/usr/local/bin/node'] + const run = (label, args, extra = []) => runManagedContainer({ owner, image: image.image, receiptDir, common, label, args, extra }) + const missing = await run('missing-helper', [policy.native.launchWrapper, '--version'], ['--entrypoint', '/usr/local/bin/node', '--tmpfs', `${policy.native.helperDirectory}:ro,nosuid,nodev,size=1m,mode=755`]) + assert.notEqual(missing.status, 0, 'missing owned helper must refuse before native startup') + assert.match(missing.stderr, /owned bwrap helper is missing/) + const command = ['/contract/test/integration/providers/codex-cli-contract.mjs', '--scenario', options.scenario ?? 'all', '--state-root', '/probe-state', '--receipt-dir', '/probe-state/receipts', '--emit-receipt'] + if (options.probeNetworkEnabled) command.push('--probe-network-enabled') + if (options.probeApprovalPolicy) command.push('--probe-approval-policy', options.probeApprovalPolicy) + if (options.sandboxMode) command.push('--sandbox-mode', options.sandboxMode) + const result = await run('native', command) + const captures = result.stdout.split('\n').filter(Boolean).flatMap(line => { try { const frame = JSON.parse(line); return frame.nativeCapture ? [frame.capture] : [] } catch { return [] } }) + const index = [] + for (const capture of captures) { + const file = `${index.length.toString().padStart(2, '0')}-${capture.scenario}.json` + const json = `${JSON.stringify(capture)}\n` + await fsp.writeFile(path.join(receiptDir, file), json, { mode: 0o600 }) + index.push({ file, sha256: hash(json), scenario: capture.scenario, parameters: capture.parameters, passed: capture.passed, uid: capture.environment.uid, gid: capture.environment.gid, binary: capture.binary, requestCount: capture.requests.length, fixtureRequestCount: capture.fixtureTraffic.length }) + } + await fsp.writeFile(path.join(receiptDir, 'receipt-index.json'), `${JSON.stringify({ image, profile: { id: policy.id, sha256: policy.profileSha256 }, captures: index }, null, 2)}\n`) + assert.equal(result.status, 0, result.stderr) + assert.equal(captures.length, (options.scenario ?? 'all') === 'all' ? 24 : 1, 'all selected actual managed native cases must execute') + for (const capture of captures) { + assert.equal(capture.passed, true) + assert.equal(capture.environment.uid, 65534) + assert.equal(capture.environment.gid, 0) + assert.equal(capture.binary.path, policy.native.launchWrapper) + assert.equal(capture.binary.version, artifactMetadata.version) + for (const helper of capture.observations.executedSandboxHelpers ?? []) assert.equal(helper.executable, capture.environment.managedLayout.bwrap) + } + assert(captures.some(capture => capture.observations.executedSandboxHelpers?.length), 'the installed bundled helper must actually execute') + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_native_contract_passed', image: image.image, cases: captures.length, uid: 65534, gid: 0, profile: policy.profileSha256 })) + } finally { + if (image.contractImage) { + const observed = JSON.parse(docker(['image', 'inspect', image.contractImage.tag]))[0] + assert.equal(observed.Id, image.image) + assert.equal(observed.Config.Labels['org.freshell.codex-contract-owner'], owner) + docker(['image', 'rm', image.contractImage.tag]) + } + } + if ((options.scenario ?? 'all') === 'all') { + const networkReceipts = path.join(receiptDir, 'network-enabled') + await fsp.mkdir(networkReceipts, { mode: 0o700 }) + await runManagedContract(harness, networkReceipts, { scenario: 'approval-policy-restricted-exec', probeNetworkEnabled: true, probeApprovalPolicy: 'never', sandboxMode: 'workspace-write' }) + } +} diff --git a/test/integration/providers/codex-managed-launch.mjs b/test/integration/providers/codex-managed-launch.mjs new file mode 100644 index 0000000000..84071ca641 --- /dev/null +++ b/test/integration/providers/codex-managed-launch.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' + +const root = fs.mkdtempSync(path.join(process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR ?? os.tmpdir(), 'codex-launch-unit-')) +const hash = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex') +try { + const packageRoot = path.join(root, 'package') + const platformPackageRoot = path.join(packageRoot, 'node_modules/platform') + const helperDirectory = path.join(root, 'helpers') + for (const directory of [packageRoot, platformPackageRoot, helperDirectory]) fs.mkdirSync(directory, { recursive: true }) + fs.writeFileSync(path.join(packageRoot, 'package.json'), JSON.stringify({ name: '@openai/codex', version: '0.164.0', type: 'module' })) + fs.writeFileSync(path.join(platformPackageRoot, 'package.json'), JSON.stringify({ version: '0.164.0-linux-x64' })) + const marker = path.join(root, 'dispatched.json') + const npmWrapper = path.join(packageRoot, 'entry.mjs') + fs.writeFileSync(npmWrapper, `import fs from 'node:fs'; import assert from 'node:assert/strict'; + assert(process.env.PATH.startsWith(${JSON.stringify(helperDirectory + ':')}), 'verified helper must precede the ambient PATH'); + assert.equal(process.env.CODEX_MANAGED_PACKAGE_ROOT, ${JSON.stringify(packageRoot)}); + assert.equal(process.env.CODEX_MANAGED_BY_NPM, '1'); + assert.equal(process.env.CODEX_MANAGED_BY_BUN, undefined); + assert.equal(process.env.npm_config_user_agent, undefined); + assert.deepEqual(process.argv.slice(2), ['argument with spaces', '$'+'(literal)', 'line1\\nline2']); + fs.writeFileSync(${JSON.stringify(marker)}, JSON.stringify({args:process.argv.slice(2),path:process.env.PATH})); + `, { mode: 0o755 }) + for (const name of ['native', 'bwrap']) fs.writeFileSync(path.join(platformPackageRoot, name), '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + fs.symlinkSync(path.join(platformPackageRoot, 'bwrap'), path.join(helperDirectory, 'bwrap')) + const launchWrapper = path.join(root, 'launch.mjs') + fs.copyFileSync('docker/runtime/codex-launch.mjs', launchWrapper) + const identity = { version: '0.164.0', platform: process.platform, arch: process.arch, launchWrapper, nodeExecutable: process.execPath, npmWrapper, packageRoot, helperDirectory, layoutFile: path.join(root, 'layout.json'), executable: 'native', bwrap: 'bwrap', bwrapSha256: hash(path.join(platformPackageRoot, 'bwrap')), npmEntrypointSha256: hash(npmWrapper) } + const layout = { ...identity, platformPackageRoot, nativeExecutable: path.join(platformPackageRoot, 'native'), bwrap: path.join(platformPackageRoot, 'bwrap') } + fs.writeFileSync(identity.layoutFile, JSON.stringify(layout)) + fs.writeFileSync(path.join(root, 'codex-native-sandbox.json'), JSON.stringify({ native: identity })) + const run = () => spawnSync(process.execPath, [launchWrapper, 'argument with spaces', '$(literal)', 'line1\nline2'], { + env: { PATH: '/usr/bin:/bin', HOME: root, CODEX_MANAGED_BY_BUN: '1', npm_config_user_agent: 'bun/fixture' }, encoding: 'utf8', timeout: 10_000, + }) + const positive = run() + assert.equal(positive.status, 0, positive.stderr) + assert(fs.existsSync(marker), 'actual npm entrypoint fixture must execute') + fs.unlinkSync(marker) + fs.unlinkSync(path.join(helperDirectory, 'bwrap')) + const missing = run() + assert.notEqual(missing.status, 0) + assert.match(missing.stderr, /owned bwrap helper is missing/) + assert.equal(fs.existsSync(marker), false, 'missing helper must refuse before npm dispatch') + console.log(JSON.stringify({ severity: 'info', event: 'codex_managed_launch_unit_passed', cases: 2 })) +} finally { fs.rmSync(root, { recursive: true, force: true }) } diff --git a/test/integration/server/fresh-agent-parity.test.ts b/test/integration/server/fresh-agent-parity.test.ts index 6df1d9599f..f280dcc9a5 100644 --- a/test/integration/server/fresh-agent-parity.test.ts +++ b/test/integration/server/fresh-agent-parity.test.ts @@ -1,20 +1,26 @@ import fs from 'node:fs' import path from 'node:path' import os from 'node:os' -import { randomUUID } from 'node:crypto' -import { execFileSync } from 'node:child_process' +import http from 'node:http' +import { createHash, randomUUID } from 'node:crypto' +import { execFile, execFileSync } from 'node:child_process' +import { promisify } from 'node:util' import { pathToFileURL } from 'node:url' import WebSocket from 'ws' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { WS_PROTOCOL_VERSION } from '../../../shared/ws-protocol.js' import { ManagedRuntimeBrowserRig } from '../../e2e-browser/helpers/managed-runtime.js' +import { ensureMcpServerBuilt, McpStdioClient } from '../../e2e-browser/helpers/mcp-stdio-client.js' import { authorizeFreshAgentParityRequest, captureProviderParityVolumes, cleanupProviderParityVolumes, readProviderParitySource, type ProviderParitySource } from '../../helpers/provider-parity-broker.js' import { makeProviderParityResourcesReadable, prepareProviderParityUserRoots, supervisorRegistrySecretScan } from '../../helpers/provider-parity-environment.js' import { exposeGrantsToHarness, fakeOnecliGrants, withMissingGrant } from '../../helpers/provider-parity-onecli.js' import type { InitialProviderVolumeAuthorization } from '../../../scripts/testing/runtime-test-broker.js' +// @ts-expect-error The native model-wire fixture is also used by the offline Node runner. +import { startInManagedContext } from '../providers/codex-local-provider.mjs' const root = path.resolve(import.meta.dirname, '../../..') +const runFile = promisify(execFile) const providers = [ { provider: 'claude', sessionType: 'freshclaude', config: '.claude/settings.json' }, { provider: 'codex', sessionType: 'freshcodex', config: '.codex/config.toml' }, @@ -26,13 +32,15 @@ async function waitFor(label: string, probe: () => T | undefined | Promise setTimeout(resolve, 100)) + // Pace one-read observation callbacks; other API calls share the budget. + await new Promise(resolve => setTimeout(resolve, 250)) } throw new Error(`timed out waiting for ${label}`) } class FreshWire { - private readonly frames: any[] = [] + readonly frames: any[] = [] + readonly sentControls: any[] = [] private constructor(private readonly socket: WebSocket) { socket.on('message', data => { @@ -52,7 +60,10 @@ class FreshWire { return wire } - send(frame: unknown): void { this.socket.send(JSON.stringify(frame)) } + send(frame: unknown): void { + if (frame && typeof frame === 'object' && 'type' in frame && ['freshAgent.approval.respond', 'freshAgent.question.respond', 'freshAgent.elicitation.respond'].includes(String(frame.type))) this.sentControls.push(frame) + this.socket.send(JSON.stringify(frame)) + } wait(predicate: (frame: any) => boolean): Promise { return waitFor('fresh-agent frame', () => this.frames.find(predicate)).catch(error => { @@ -352,3 +363,658 @@ describe('hosted fresh-agent provider inputs', () => { } }, 240_000) }) + +describe('installed Codex through the managed gateway and durable actor', () => { + let rig: ManagedRuntimeBrowserRig + let home: string + let workspace: string + let grants: ReturnType + let restoreGrants: (() => void) | undefined + let source: ProviderParitySource + let wire: FreshWire + let model: Awaited> | undefined + let naming: http.Server + let retainedModelTraffic: any[] = [] + let finalObservation: (() => Promise) | undefined + const namingRequests: string[] = [] + const owners: InitialProviderVolumeAuthorization[] = [] + + beforeAll(async () => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'freshcodex-managed-native-')) + workspace = path.join(home, 'workspace') + fs.mkdirSync(workspace) + fs.mkdirSync(path.join(workspace, 'allowed')) + // Match the qualified native sandbox fixture: these read-only mount + // targets have the same empty protected directories as native qualification. + fs.mkdirSync(path.join(workspace, '.agents')) + fs.mkdirSync(path.join(workspace, '.codex')) + fs.writeFileSync(path.join(workspace, 'child-input.txt'), 'managed-child-command-ran\n') + execFileSync('git', ['init', '--quiet', workspace]) + const xdg = path.join(home, 'xdg') + prepareProviderParityUserRoots(home, xdg) + grants = fakeOnecliGrants(['codex']) + restoreGrants = exposeGrantsToHarness(grants.serverEnv) + naming = http.createServer(async (request, response) => { + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk)) + namingRequests.push(Buffer.concat(chunks).toString('utf8')) + response.writeHead(200, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ candidates: [{ content: { parts: [{ text: 'Managed native suggested name' }] } }] })) + }) + await new Promise(resolve => naming.listen(0, '127.0.0.1', resolve)) + const namingAddress = naming.address() + if (!namingAddress || typeof namingAddress === 'string') throw new Error('Owned naming fixture has no TCP port') + rig = new ManagedRuntimeBrowserRig(root, 2, + { XDG_CONFIG_HOME: xdg, ...grants.serverEnv, RUST_LOG: 'info', GOOGLE_GENERATIVE_AI_API_KEY: 'managed-naming-fixture', + FRESHELL_GEMINI_BASE_URL: `http://127.0.0.1:${namingAddress.port}` }, { XDG_CONFIG_HOME: xdg }, 'test', + { enabledProviders: [], freshAgentModes: ['freshcodex'], fixtureFreshAgentModes: [] }, home, + { workspaceRoots: [workspace], providerInputRoots: [], bootstrapFiles: Object.values(grants.grants), defaultCwd: workspace }) + await rig.start() + const health = await rig.runtime.adminOk(rig.supervisor, { method: 'health' }) + source = await readProviderParitySource(rig.runtime, health.data.installation_id) + const catalog = path.join(home, '.codex', 'models.json') + fs.writeFileSync(catalog, JSON.stringify({ models: [{ slug: 'contract-model', display_name: 'Managed native model', + description: 'Owned native managed contract', default_reasoning_level: 'low', + supported_reasoning_levels: [{ effort: 'low', description: 'Low' }, { effort: 'xhigh', description: 'Extra high' }], + shell_type: 'unified_exec', visibility: 'list', supported_in_api: true, priority: 0, support_verbosity: false, + truncation_policy: { mode: 'tokens', limit: 10000 }, context_window: 128000, + experimental_supported_tools: ['request_user_input_async'], supports_search_tool: false, + tool_mode: 'direct', use_responses_lite: false, model_messages: { instructions_template: 'Use the available tools to follow the user request.' } }] })) + const mcpModule = path.join(workspace, 'managed-mcp.mjs') + fs.copyFileSync(path.join(root, 'test/integration/providers/codex-local-provider.mjs'), mcpModule) + fs.writeFileSync(path.join(home, '.codex/config.toml'), `model = "contract-model" +model_provider = "contract-loopback" +approval_policy = "untrusted" +model_catalog_json = ${JSON.stringify(catalog)} +web_search = "disabled" +[features] +apps = false +default_mode_request_user_input = true +auth_elicitation = true +non_prefixed_mcp_tool_names = false +unified_exec = true +[tools.experimental_request_user_input] +enabled = true +[model_providers.contract-loopback] +name = "Managed owned loopback" +base_url = "http://127.0.0.1:51991/v1" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +request_max_retries = 0 +stream_max_retries = 0 +[mcp_servers.contract] +command = "node" +args = ${JSON.stringify([mcpModule, "--mcp-fixture", "/home/freshell/provider/.codex/managed-mcp-traffic.jsonl", "http://127.0.0.1:51991"])} +enabled = true +required = true +startup_readiness = "connection" +startup_timeout_sec = 15 +tool_timeout_sec = 120 +`) + makeProviderParityResourcesReadable(home) + // The managed RW bind preserves POSIX permissions. Unlike the generic + // read-only resource fixture, a selected writable project must let its + // intended provider group create native sandbox mountpoints. Grant only + // this owned synthetic workspace group access, never provider HOME. + fs.chmodSync(workspace, 0o770) + fs.chmodSync(path.join(workspace, "allowed"), 0o770) + wire = await FreshWire.connect(rig.info) + }, 900_000) + + afterAll(async () => { + try { + if (rig && finalObservation) { + try { rig.runtime.writeIncident('freshcodex-managed-native-final-state', await finalObservation()) } + catch (error) { rig.runtime.writeIncident('freshcodex-managed-native-final-state-error', { error: String(error) }) } + } + await wire?.close() + await model?.stop() + if (rig) { + const volumes = captureProviderParityVolumes(owners) + const cleanup = await rig.stop() + expect(cleanup.ok, cleanup.errors.join('\n')).toBe(true) + cleanupProviderParityVolumes(volumes) + } + } finally { + if (naming) { + naming.closeAllConnections() + await new Promise(resolve => naming.close(() => resolve())) + } + restoreGrants?.() + if (grants) fs.rmSync(grants.directory, { recursive: true, force: true }) + if (home) fs.rmSync(home, { recursive: true, force: true }) + } + }, 180_000) + + it('uses the installed wrapper for catalog, settings, saved output and exact native controls', async () => { + const requestId = `native-managed-${randomUUID()}` + owners.push(authorizeFreshAgentParityRequest(rig.runtime.broker, source, 'codex', requestId)) + wire.send({ type: 'freshAgent.create', requestId, provider: 'codex', sessionType: 'freshcodex', + cwd: workspace, model: 'contract-model', effort: 'xhigh', sandbox: 'workspace-write', permissionMode: 'untrusted', + namingHandle: `name-${requestId}`, tabId: `tab-${requestId}` }) + const created = await wire.wait(frame => frame.requestId === requestId + && ['freshAgent.created', 'freshAgent.create.failed'].includes(frame.type)) + if (created.type !== 'freshAgent.created') { + const inventory = await rig.inventory() + const providerFiles = inventory.filter(row => row.containerId).map(row => { + try { return { containerId: row.containerId, files: rig.ownedProviderExec(row.containerId!, ['node', '-e', `const fs=require('node:fs');process.stdout.write(JSON.stringify({mcpModulePresent:fs.existsSync(${JSON.stringify(path.join(workspace, 'managed-mcp.mjs'))}),config:fs.readFileSync('/home/freshell/provider/.codex/config.toml','utf8')}))`]) } } + catch { return { containerId: row.containerId, unavailable: true } } + }) + const nativeLogs = [...rig.runtime.broker.receiptIds()].map(containerId => { + try { return { containerId, logs: rig.runtime.containerLogs(containerId) } } + catch { return { containerId, unavailable: true } } + }) + rig.runtime.writeIncident('freshcodex-managed-native-create-failure', { created, inventory, nativeLogs, providerFiles, + supervisor: rig.runtime.containerLogs(rig.supervisor.containerId), web: rig.web.capturedOutput() }) + } + expect(created.type, JSON.stringify(created)).toBe('freshAgent.created') + const view = await waitFor('native managed provider', async () => { + const row = await rig.runningViewForFreshSession(created.sessionId) + return row?.containerId && row.nativeSessionId ? row : undefined + }) + let providerContainerId = view.containerId! + expect(rig.ownedProviderExec(providerContainerId, ['/opt/freshell-codex/launch', '--version'])).toContain('0.164.0') + const inspectNativeContext = () => JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs');const layout=JSON.parse(fs.readFileSync('/opt/freshell-codex/layout.json','utf8')); + const candidates=fs.readdirSync('/proc').filter(p=>Number.isInteger(Number(p))).flatMap(pid=>{try{ + const status=fs.readFileSync('/proc/'+pid+'/status','utf8');const field=k=>status.split(String.fromCharCode(10)).find(line=>line.startsWith(k+':'))?.slice(k.length+1).trim(); + let executable,exeError,appServer=false;try{executable=fs.readlinkSync('/proc/'+pid+'/exe')}catch(error){exeError=error.code} + try{appServer=fs.readFileSync('/proc/'+pid+'/cmdline','utf8').split(String.fromCharCode(0)).includes('app-server')}catch{} + if(executable!==layout.nativeExecutable&&!appServer&&field('Name')!=='codex')return[]; + let cwd,cwdError,workspace;try{cwd=fs.readlinkSync('/proc/'+pid+'/cwd');const s=fs.statSync(cwd);let writable=false;try{fs.accessSync(cwd,fs.constants.W_OK);writable=true}catch{}workspace={uid:s.uid,gid:s.gid,mode:(s.mode&511).toString(8),writable}}catch(error){cwdError=error.code} + return[{pid:Number(pid),parentPid:Number(field('PPid')),state:field('State'),uid:Number(field('Uid')?.split(String.fromCharCode(9))[0]),gid:Number(field('Gid')?.split(String.fromCharCode(9))[0]), + executable,exeError,appServer,cwd,cwdError,workspace,noNewPrivs:field('NoNewPrivs'),capEff:field('CapEff')}]; + }catch{return[]}});const found=candidates.filter(row=>row.executable===layout.nativeExecutable&&row.appServer); + if(found.length!==1)throw Error('Expected one actual installed native Codex app-server: '+JSON.stringify({expectedNative:layout.nativeExecutable,matches:found.length,candidates}));process.stdout.write(JSON.stringify(found[0]));`])) + const nativeContext = inspectNativeContext() + expect(nativeContext).toMatchObject({ uid: 65534, gid: 0, cwd: workspace, noNewPrivs: '1', capEff: '0000000000000000', + workspace: { gid: 0, mode: '770', writable: true } }) + const handlerSource = `async record => { + const input=record.body.input; + const user=input.filter(item=>item.role==='user').at(-1); + const prompt=JSON.stringify(user); + const call=(name,args,id)=>{ + let namespace;let tool=record.body.tools.find(tool=>tool.type==='function'&&tool.name===name); + if(!tool){const group=record.body.tools.find(tool=>tool.type==='namespace'&&tool.tools?.some(member=>member.name===name));tool=group?.tools.find(member=>member.name===name);namespace=group?.name;} + if(!tool)throw Error('Native model did not advertise '+name); + return {items:[{id:'fc_'+id,type:'function_call',call_id:'call_'+id,...(namespace?{namespace}:{}),name,arguments:JSON.stringify(args),status:'completed'}]}; + }; + const output=id=>input.find(item=>item.type==='function_call_output'&&item.call_id==='call_'+id); + const mcpRoute=['form','openai/form','openaiForm','url'].find(route=>prompt.includes('MANAGED_MCP_'+route)); + if(mcpRoute){const id='mcp_'+mcpRoute.replaceAll('/','_');if(!output(id))return call('elicit',{route:mcpRoute,sequence:'matrix'},id);if(!JSON.stringify(output(id)).includes('Fixture sequence completed'))throw Error('Actual MCP roundtrip did not complete');return{text:'Managed MCP '+mcpRoute+' completed.'};} + if(prompt.includes('MANAGED_EFFECT_')){const denied=prompt.includes('MANAGED_EFFECT_DECLINE');const readonly=prompt.includes('MANAGED_EFFECT_READONLY');const restricted=prompt.includes('MANAGED_EFFECT_RESTRICTED');const id=denied?'effect_decline':readonly?'effect_readonly':restricted?'effect_restricted':'effect_accept';if(!output(id))return call('exec_command',{cmd:'id -u; printf managed-'+id+' > allowed/'+id,shell:'/bin/sh',login:false,max_output_tokens:1000},id);return{text:'Managed '+id+' result: '+JSON.stringify(output(id))};} + if(prompt.includes('MANAGED_CHILD_NATIVE')){if(!output('child_command'))return call('exec_command',{cmd:'cat child-input.txt',shell:'/bin/sh',login:false,max_output_tokens:1000},'child_command');if(!JSON.stringify(output('child_command')).includes('managed-child-command-ran'))throw Error('Actual child command output missing');return{text:'Managed child final answer.',pauseAfterDelta:async()=>{const fs=await import('node:fs');const deadline=Date.now()+90000;while(!fs.existsSync('/home/freshell/provider/.freshell-fixture/release-child')){if(Date.now()>deadline)throw Error('Managed child release deadline');await new Promise(resolve=>setTimeout(resolve,50));}}};} + if(prompt.includes('MANAGED_DURABLE_CHILD')){if(!output('managed_child'))return call('spawn_agent',{message:'MANAGED_CHILD_NATIVE: run the controlled child command and return its result.',fork_context:false},'managed_child');return{text:'Managed parent registered child.'};} + if(prompt.includes('AWAITED_PLAN')&&!input.some(item=>item.call_id==='call_awaited'))return call('request_user_input',{questions:[{id:'managed_choice',header:'Choice',question:'Which managed result?',options:[{label:'Alpha',description:'First result'},{label:'Beta',description:'Second result'}]}]},'awaited'); + if(prompt.includes('ASYNC_QUESTION')&&!input.some(item=>item.call_id==='call_async'))return call('request_user_input_async',{questions:[{title:'Which managed environment?',options:['Staging','Production']}]},'async'); + if(prompt.includes('MANAGED_RECOVERY_SECOND'))return{text:'Managed recovery second answer.'}; + return {text:prompt.includes('ASYNC_QUESTION')?'Managed work continued before the answer.':prompt.includes('AWAITED_PLAN')?'Managed awaited answer completed.':'Managed native final answer.'}; + }` + model = await startInManagedContext(rig, { containerId: providerContainerId, handlerSource }) + expect(model.context).toMatchObject({ uid: 65534, gid: 0 }) + const headers = { 'x-auth-token': rig.info.token } + const observationReadTraffic: { route: string; requestedAt: number; completedAt: number; status: number; retryAfter: string | null }[] = [] + const observeRead = async (route: string, init: RequestInit) => { + const requestedAt = Date.now() + const response = await fetch(`${rig.info.baseUrl}${route}`, init) + observationReadTraffic.push({ route, requestedAt, completedAt: Date.now(), status: response.status, retryAfter: response.headers.get('retry-after') }) + return response + } + const snapshot = async () => { + const response = await observeRead(`/api/fresh-agent/threads/freshcodex/codex/${created.sessionId}`, { headers }) + expect(response.ok, await response.clone().text()).toBe(true) + return response.json() + } + const ownedHostEvidence = () => { + const runtimeRoot = rig.supervisor.runtimeRoot + const lifecycle = fs.readdirSync(runtimeRoot, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && entry.name.startsWith('incarnation-')) + .map(entry => { + const file = path.join(runtimeRoot, entry.name, 'host-lifecycle.jsonl') + return { incarnationId: entry.name, events: fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)) : [] } + }) + const soulsRoot = path.join(runtimeRoot, 'souls') + const actorProfiles = fs.existsSync(soulsRoot) ? fs.readdirSync(soulsRoot).flatMap(entry => { + const file = path.join(soulsRoot, entry, 'actor/fresh-agent-state.json') + if (!fs.existsSync(file)) return [] + const { profile } = JSON.parse(fs.readFileSync(file, 'utf8')) + const fields = ['provider', 'runtimeVariant', 'providerStoreId', 'nativeSessionId', 'sessionRef', 'namingHandle', + 'cwd', 'model', 'effort', 'permissionMode', 'sandbox', 'collaborationMode'] + return [{ directory: entry, profile: Object.fromEntries(fields.filter(key => key in profile).map(key => [key, profile[key]])) }] + }) : [] + return { lifecycle, actorProfiles } + } + finalObservation = async () => { + let final: unknown + try { final = await snapshot() } catch (error) { final = { error: String(error) } } + const currentViews = await rig.inventory() + const hostDiagnostics = [...rig.runtime.broker.receiptIds()].map(containerId => { + let state: unknown + try { const inspected = rig.runtime.inspectContainer(containerId); state = inspected.State } + catch (error) { state = { error: String(error) } } + return { containerId, state, logs: rig.runtime.containerLogs(containerId).slice(-64 * 1024) } + }) + let nativeFiles: unknown + try { nativeFiles = JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs'),p='/home/freshell/provider/.codex/managed-mcp-traffic.jsonl'; + process.stdout.write(JSON.stringify({mcp:fs.existsSync(p)?fs.readFileSync(p,'utf8').trim().split(String.fromCharCode(10)).filter(Boolean).map(JSON.parse):[],effects:fs.readdirSync(${JSON.stringify(path.join(workspace, 'allowed'))}).map(name=>{const p=${JSON.stringify(path.join(workspace, 'allowed'))}+'/'+name;return{name,uid:fs.statSync(p).uid,content:fs.readFileSync(p,'utf8')}})}));`])) + } catch (error) { nativeFiles = { error: String(error) } } + let traffic: unknown = retainedModelTraffic + try { if (model) traffic = model.traffic } catch (error) { traffic = { retained: retainedModelTraffic, error: String(error) } } + const logDirectory = path.join(home, '.freshell', 'logs') + const requestLogFiles = fs.readdirSync(logDirectory).filter(name => name.startsWith('rust-server.jsonl')) + const publicHttpRequests = requestLogFiles.flatMap(name => fs.readFileSync(path.join(logDirectory, name), 'utf8').split('\n')).flatMap(line => { + let row: any + try { row = JSON.parse(line) } catch { return [] } + if (row.msg !== 'http_request') return [] + return [{ timestamp: row.ts, requestId: row.request_id, serverPid: row.server_pid, method: row.method, + route: String(row.route).split('?')[0], status: row.status, durationMs: row.duration_ms }] + }) + const apiRequests = publicHttpRequests.filter(row => row.route.startsWith('/api/') && row.route !== '/api/health') + const publicApiCounts = { total: apiRequests.length, + byRoute: apiRequests.reduce>((counts, row) => { counts[row.route] = (counts[row.route] ?? 0) + 1; return counts }, {}), + byStatus: apiRequests.reduce>((counts, row) => { counts[row.status] = (counts[row.status] ?? 0) + 1; return counts }, {}) } + return { created, initialView: view, currentViews, nativeContext, final, + fixtureContext: model?.context, traffic, sentControls: wire.sentControls, frames: wire.frames, + nativeFiles, hostDiagnostics, observationReadTraffic, publicHttpRequests, publicApiCounts, ownedHostEvidence: ownedHostEvidence() } + } + const catalogResponse = await fetch(`${rig.info.baseUrl}/api/fresh-agent/model-capabilities/freshcodex?${new URLSearchParams({ sessionId: created.sessionId, sessionRef: JSON.stringify(created.sessionRef), cwd: workspace })}`, { headers }) + expect(catalogResponse.ok, await catalogResponse.clone().text()).toBe(true) + expect((await catalogResponse.json()).models).toEqual(expect.arrayContaining([expect.objectContaining({ id: 'contract-model', supportedEffortLevels: ['low', 'xhigh'] })])) + const send = async (text: string, settings?: unknown) => { + const id = `send-${randomUUID()}` + wire.send({ type: 'freshAgent.send', sessionType: 'freshcodex', provider: 'codex', sessionId: created.sessionId, requestId: id, text, ...(settings ? { settings } : {}) }) + const accepted = await wire.wait(frame => frame.requestId === id && ['freshAgent.send.accepted', 'error'].includes(frame.type)) + expect(accepted.type, JSON.stringify(accepted)).toBe('freshAgent.send.accepted') + return accepted + } + await send('MANAGED_NATIVE_FIRST') + const saved = await waitFor('managed native saved first answer', async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('Managed native final answer.') ? value : undefined + }) + expect(saved.settings).toMatchObject({ model: 'contract-model', effort: 'xhigh', sandbox: 'workspace-write', permissionMode: 'untrusted', cwd: workspace }) + expect(model.traffic[0].body.reasoning.effort).toBe('xhigh') + await send('AWAITED_PLAN', { collaborationMode: 'plan' }) + const pending = await waitFor('managed native awaited Plan control', async () => { + const value = await snapshot() + return value.pendingQuestions?.length ? value.pendingQuestions[0] : undefined + }) + expect(pending.questions[0].id).toBe('managed_choice') + wire.send({ type: 'freshAgent.question.respond', sessionType: 'freshcodex', provider: 'codex', sessionId: created.sessionId, + requestId: pending.requestId, answers: { managed_choice: 'Alpha' } }) + await waitFor('managed native awaited decision drained', async () => { + const value = await snapshot() + return value.status === 'idle' && !value.pendingQuestions.length && JSON.stringify(value.turns).includes('Managed awaited answer completed.') ? true : undefined + }) + await send('ASYNC_QUESTION', { collaborationMode: 'default' }) + const asyncSnapshot = await waitFor('managed native async continuation without answer', async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('Managed work continued before the answer.') ? value : undefined + }) + expect(asyncSnapshot.pendingQuestions).toHaveLength(0) + expect(JSON.stringify(asyncSnapshot.turns)).toContain('Which managed environment?') + await send('Question: Which managed environment? Answer: Staging.', { effort: null }) + await waitFor('managed contextual answer at loopback model', () => model!.traffic.some((row: any) => JSON.stringify(row.body.input).includes('Answer: Staging.')) ? true : undefined) + await waitFor('managed contextual answer completion', async () => (await snapshot()).status === 'idle' ? true : undefined) + const resumedResponse = await fetch(`${rig.info.baseUrl}/api/tabs`, { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ agent: 'codex', cwd: workspace, sessionRef: { provider: 'codex', sessionId: view.nativeSessionId } }) }) + expect(resumedResponse.ok, await resumedResponse.clone().text()).toBe(true) + const resumed = await resumedResponse.json() + const paneId = resumed.data?.paneId ?? resumed.paneId + expect(paneId).toBeTruthy() + const captureResponse = await fetch(`${rig.info.baseUrl}/api/panes/${paneId}/capture`, { headers }) + expect(captureResponse.ok, await captureResponse.clone().text()).toBe(true) + expect(await captureResponse.text()).toContain('Managed native final answer.') + const standalone = ensureMcpServerBuilt(root) + const standaloneEnvironment = { ...process.env, FRESHELL_URL: rig.info.baseUrl, FRESHELL_TOKEN: rig.info.token } + const cliEntry = path.join(root, 'dist/tools/freshell-cli/index.js') + const cliCapture = await runFile(process.execPath, [cliEntry, 'capture-pane', '--target', paneId], { + cwd: root, env: standaloneEnvironment, encoding: 'utf8', timeout: 60_000, maxBuffer: 1024 * 1024, + }) + expect(cliCapture.stdout).toContain('MANAGED_NATIVE_FIRST') + expect(cliCapture.stdout).toContain('Managed native final answer.') + const standaloneMcp = new McpStdioClient({ command: process.execPath, args: [standalone.path], env: standaloneEnvironment, requestTimeoutMs: 60_000 }) + try { + await standaloneMcp.initialize() + const mcpCapture = await standaloneMcp.callFreshellAction('capture-pane', { target: paneId }) + expect(mcpCapture).toContain('MANAGED_NATIVE_FIRST') + expect(mcpCapture).toContain('Managed native final answer.') + const mcpWait = await standaloneMcp.callFreshellAction('wait-for', { target: paneId, pattern: 'Managed native final answer.', timeout: 0 }) + expect(mcpWait).toMatchObject({ status: 'ok', data: { matched: true } }) + rig.runtime.writeIncident('freshcodex-managed-native-standalone-integration', { paneId, userRef: created.sessionId, + nativeSessionId: view.nativeSessionId, cli: { entry: cliEntry, capture: cliCapture.stdout }, + mcp: { entry: standalone.path, capture: mcpCapture, wait: mcpWait } }) + } finally { + await standaloneMcp.close() + } + expect(inspectNativeContext().pid).toBe(nativeContext.pid) + const durableNameRef = { kind: 'session', provider: 'codex', sessionId: view.nativeSessionId } + const readNameFor = async (ref: unknown) => { + const response = await observeRead('/api/session-names/read', { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ refs: [ref] }) }) + expect(response.ok, `name read ${response.status}: ${await response.clone().text()}`).toBe(true) + return (await response.json()).names[0]?.record + } + const readName = () => readNameFor(durableNameRef) + const nativeMetadata = JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs'),path=require('node:path'); + const env=Object.fromEntries(fs.readFileSync('/proc/${nativeContext.pid}/environ','utf8').split(String.fromCharCode(0)).flatMap(entry=>{ + const at=entry.indexOf('=');const key=entry.slice(0,at);return ['HOME','CODEX_HOME'].includes(key)?[[key,entry.slice(at+1)]]:[]; + })); + const errors=[],headers=[],directories=[];let visited=0; + const stat=p=>{const s=fs.statSync(p);return{uid:s.uid,gid:s.gid,mode:(s.mode&511).toString(8)}}; + const roots=[...new Set([env.CODEX_HOME,env.HOME&&path.join(env.HOME,'.codex')].filter(Boolean))]; + const walk=(dir,depth)=>{if(depth>8||visited++>1024)return;try{ + directories.push({path:dir,...stat(dir)}); + for(const entry of fs.readdirSync(dir,{withFileTypes:true})){ + const file=path.join(dir,entry.name);if(entry.isDirectory())walk(file,depth+1); + else if(entry.isFile()&&entry.name.endsWith('.jsonl')){ + const fd=fs.openSync(file,'r');let first;try{const b=Buffer.alloc(65536);first=b.toString('utf8',0,fs.readSync(fd,b,0,b.length,0)).split(String.fromCharCode(10))[0]}finally{fs.closeSync(fd)} + let meta;try{meta=JSON.parse(first)}catch{} + headers.push({path:file,...stat(file),type:meta?.type,id:meta?.payload?.id??meta?.payload?.session_id}); + } + } + }catch(error){errors.push({path:dir,code:error.code})}}; + for(const root of roots)walk(path.join(root,'sessions'),0); + const hostStatus=fs.readFileSync('/proc/1/status','utf8').split(String.fromCharCode(10)).filter(line=>/^(Uid|Gid|CapEff|NoNewPrivs):/.test(line)); + process.stdout.write(JSON.stringify({env,roots,directories,headers,errors,hostStatus}));`])) + const hostMetadataAccess = JSON.parse(rig.ownedContainerExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs');const root='/home/freshell/provider';const s=fs.statSync(root); + let readError;try{fs.readdirSync(root+'/.codex/sessions')}catch(error){readError=error.code} + process.stdout.write(JSON.stringify({uid:process.getuid(),root:{uid:s.uid,gid:s.gid,mode:(s.mode&511).toString(8)},readError}));`])) + expect(hostMetadataAccess).toMatchObject({ uid: 0, root: { uid: 65534, mode: '700' }, readError: 'EACCES' }) + expect(nativeMetadata.headers).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'session_meta', id: view.nativeSessionId })])) + rig.runtime.writeIncident('freshcodex-managed-native-before-naming', { created, view, nativeContext, nativeMetadata, fixtureContext: model.context, saved, asyncSnapshot, + hostMetadataAccess, traffic: model.traffic, final: await snapshot() }) + const durableName = await waitFor('managed native durable shared name', async () => { + const record = await readName() + return record?.ref?.sessionId === view.nativeSessionId ? record : undefined + }, 15_000) + expect(durableName.ref).toEqual(durableNameRef) + await waitFor('managed first-message naming request', () => namingRequests.some(body => body.includes('MANAGED_NATIVE_FIRST')) ? true : undefined) + const renamed = await fetch(`${rig.info.baseUrl}/api/session-names`, { method: 'PATCH', headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ target: durableNameRef, name: 'Managed native saved name', nameIntent: 'user' }) }) + expect(renamed.ok, await renamed.clone().text()).toBe(true) + const canonicalName = await readName() + expect(canonicalName).toMatchObject({ ref: durableNameRef, name: 'Managed native saved name', source: 'manual' }) + const readNativeName = (nativeId = view.nativeSessionId, context = nativeContext) => JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs');const argv=fs.readFileSync('/proc/${context.pid}/cmdline','utf8').split(String.fromCharCode(0)); + const endpoint=argv[argv.indexOf('--listen')+1];const target=new URL(endpoint); + if(target.protocol!=='ws:'||target.hostname!=='127.0.0.1'||!target.port)throw Error('Expected the existing native loopback endpoint'); + const socket=new WebSocket(endpoint);let settled=false; + const finish=(error,value)=>{if(settled)return;settled=true;clearTimeout(timer);socket.close(); + if(error){process.stderr.write(String(error));process.exit(1)}process.stdout.write(JSON.stringify(value),()=>process.exit(0));}; + const timer=setTimeout(()=>finish('Existing native metadata observer timed out'),8000); + socket.addEventListener('error',()=>finish('Existing native metadata connection failed')); + socket.addEventListener('open',()=>socket.send(JSON.stringify({id:1,method:'initialize',params:{clientInfo:{name:'freshell_name_read_probe',version:'1'}}}))); + socket.addEventListener('message',event=>{const frame=JSON.parse(String(event.data));if(frame.error)return finish(frame.error.message); + if(frame.id===1){socket.send(JSON.stringify({method:'initialized'}));socket.send(JSON.stringify({id:2,method:'thread/read',params:{threadId:${JSON.stringify(nativeId)},includeTurns:false}}));} + if(frame.id===2)finish(null,{id:frame.result.thread.id,title:frame.result.thread.name??null});});`])) + const nativeNameBeforeWait = readNativeName() + rig.runtime.writeIncident('freshcodex-managed-native-name-writeback', { canonicalName, nativeNameBeforeWait, view, nativeContext }) + await waitFor('managed native name writeback', () => { + const observation = readNativeName() + return observation.id === view.nativeSessionId && observation.title === canonicalName.name ? observation : undefined + }, 15_000) + rig.runtime.writeIncident('freshcodex-managed-native-journey', { created, view, nativeContext, context: model.context, + traffic: model.traffic, saved, asyncSnapshot, canonicalName, nativeName: readNativeName(), final: await snapshot() }) + + const approval = async (decision: 'accept' | 'cancel', threadId = view.nativeSessionId) => { + const pending = await waitFor('actual managed native approval', async () => { + const value = await snapshot() + return value.pendingApprovals?.find((item: any) => item.providerRequest?.threadId === threadId) + }) + expect(pending.providerRequest.threadId).toBe(threadId) + expect(pending.native.availableDecisions).toContain(decision) + wire.send({ type: 'freshAgent.approval.respond', provider: 'codex', sessionType: 'freshcodex', sessionId: created.sessionId, + requestId: pending.requestId, decision: { nativeDecision: decision } }) + return pending + } + const effects: any[] = [] + for (const choice of ['ACCEPT', 'DECLINE', 'RESTRICTED', 'READONLY'] as const) { + const file = `effect_${choice.toLowerCase() === 'readonly' ? 'readonly' : choice.toLowerCase()}` + const physical = () => JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs'),p=${JSON.stringify(path.join(workspace, 'allowed'))}+'/'+${JSON.stringify(file)}; + process.stdout.write(JSON.stringify(fs.existsSync(p)?{exists:true,uid:fs.statSync(p).uid,content:fs.readFileSync(p,'utf8')}:{exists:false}));`])) + expect(physical()).toEqual({ exists: false }) + const frameCursor = wire.frames.length + const accepted = await send(`MANAGED_EFFECT_${choice}`, { sandbox: choice === 'READONLY' ? 'read-only' : 'workspace-write', permissionMode: choice === 'READONLY' || choice === 'RESTRICTED' ? 'never' : 'untrusted' }) + const decision = choice === 'ACCEPT' ? 'accept' : 'cancel' + const control = choice === 'READONLY' || choice === 'RESTRICTED' ? undefined : await approval(decision) + const completed = await waitFor(`managed ${choice} command result`, async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('MANAGED_EFFECT_'+choice) + && !value.pendingApprovals.some((pending: any) => pending.requestId === control?.requestId) + && wire.frames.slice(frameCursor).some(frame => { + const event = frame.type === 'freshAgent.event' ? frame.event : frame + const nativeTurn = value.turns.find((turn: any) => turn.role === 'user' && turn.items?.some((item: any) => item.text === 'MANAGED_EFFECT_'+choice))?.nativeTurnId + return event.type === 'freshAgent.transcript.patch' && event.sessionId === created.sessionId + && event.ops?.some((op: any) => op.op === 'settleTurn' && op.nativeTurnId === nativeTurn && ['completed', 'interrupted', 'failed'].includes(op.status)) + }) + && (choice === 'DECLINE' || JSON.stringify(value.turns).includes('Managed '+file+' result:')) ? value : undefined + }) + const effect = physical() + if (choice === 'ACCEPT' || choice === 'RESTRICTED') expect(effect).toMatchObject({ exists: true, uid: 65534, content: 'managed-'+file }) + else expect(effect).toEqual({ exists: false }) + if (choice === 'READONLY') { + // Native0.164 can return a failed unifiedExec result to the model + // without persisting a commandExecution history item. Read the real + // native tool result, never the fixture's repeated assistant phrase. + const command = model.traffic.flatMap((row: any) => row.body?.input ?? []) + .filter((item: any) => item.type === 'function_call_output' && item.call_id === 'call_effect_readonly').at(-1) + expect(command?.output).toMatch(/Process exited with code [1-9][0-9]*/) + expect(command?.output).toContain('Output:\n65534\n') + expect(command?.output).toContain('Read-only file system') + expect(command?.output).not.toContain('bwrap:') + } + if (control) expect(wire.sentControls.filter(frame => frame.type === 'freshAgent.approval.respond' && frame.requestId === control.requestId)).toEqual([{ + type: 'freshAgent.approval.respond', provider: 'codex', sessionType: 'freshcodex', sessionId: created.sessionId, + requestId: control.requestId, decision: { nativeDecision: decision }, + }]) + effects.push({ choice, nativeDecision: control ? decision : null, accepted, control, effect, terminal: completed, settings: completed.settings }) + rig.runtime.writeIncident('freshcodex-managed-native-command-effects', effects) + } + const mcpResults: any[] = [] + for (const route of ['form', 'openai/form', 'openaiForm', 'url']) { + await send(`MANAGED_MCP_${route}`, { sandbox: 'workspace-write', permissionMode: 'untrusted' }) + const ids = new Set() + for (const action of ['accept', 'decline', 'cancel']) { + const pending = await waitFor(`managed ${route} ${action} elicitation`, async () => { + const value = await snapshot() + return value.pendingElicitations?.find((item: any) => !ids.has(item.requestId)) + }) + ids.add(pending.requestId) + // Fetching the URL is a read; the explicit native decision is separate. + if (route === 'url') { + expect(rig.ownedProviderExec(providerContainerId, ['node', '-e', `fetch('http://127.0.0.1:51991/elicitation/example').then(r=>{if(!r.ok)throw Error('URL fixture failed');process.stdout.write('opened')})`])).toBe('opened') + expect((await snapshot()).pendingElicitations.some((item: any) => item.requestId === pending.requestId)).toBe(true) + } + wire.send({ type: 'freshAgent.elicitation.respond', provider: 'codex', sessionType: 'freshcodex', sessionId: created.sessionId, + requestId: pending.requestId, action, content: action === 'accept' && route !== 'url' ? { name: 'fixture', count: 2.5, enabled: true, color: 'blue' } : null }) + } + await waitFor(`managed ${route} MCP completion`, async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('Managed MCP '+route+' completed.') ? true : undefined + }) + const traffic = JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', `const fs=require('node:fs');process.stdout.write(JSON.stringify(fs.readFileSync('/home/freshell/provider/.codex/managed-mcp-traffic.jsonl','utf8').trim().split(String.fromCharCode(10)).map(JSON.parse)))`])) + const results = traffic.filter((row: any) => row.event === 'elicitation_result' && row.route === route) + expect(results.map((row: any) => row.result.action)).toEqual(['accept', 'decline', 'cancel']) + expect(traffic.some((row: any) => row.event === 'fixture_error')).toBe(false) + mcpResults.push({ route, results }) + rig.runtime.writeIncident('freshcodex-managed-native-mcp-results', mcpResults) + } + await send('MANAGED_DURABLE_CHILD') + const registered = await waitFor('public managed registered child', async () => { + const value = await snapshot() + return value.childThreads?.find((item: any) => item.threadId) ? value : undefined + }) + const child = registered.childThreads[0] + const childId = child.threadId + const childControl = await approval('accept', childId) + const childResponse = await waitFor('public managed active child read', async () => { + const response = await fetch(`${rig.info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${created.sessionId}/children/${childId}`, { headers }) + expect(response.ok, await response.clone().text()).toBe(true) + const value = await response.json() + return value.status === 'live' && JSON.stringify(value.snapshot?.turns).includes('MANAGED_CHILD_NATIVE') ? value : undefined + }) + expect(childResponse).toMatchObject({ parentThreadId: view.nativeSessionId, childThreadId: childId, status: 'live', + snapshot: { threadId: childId, capabilities: { send: false, interrupt: false, fork: false, undo: false } } }) + expect(inspectNativeContext().pid).toBe(nativeContext.pid) + rig.ownedProviderExec(providerContainerId, ['node', '-e', "require('node:fs').writeFileSync('/home/freshell/provider/.freshell-fixture/release-child','release')"]) + const childSaved = await waitFor('public managed saved child after completion', async () => { + const response = await fetch(`${rig.info.baseUrl}/api/fresh-agent/threads/freshcodex/codex/${created.sessionId}/children/${childId}`, { headers }) + expect(response.ok).toBe(true) + const value = await response.json() + return value.status === 'saved' && JSON.stringify(value).includes('Managed child final answer.') ? value : undefined + }) + expect(childSaved).toMatchObject({ parentThreadId: view.nativeSessionId, childThreadId: childId, status: 'saved' }) + await waitFor('managed child control drained', async () => !(await snapshot()).pendingApprovals.length ? true : undefined) + rig.runtime.writeIncident('freshcodex-managed-native-durable-child', { registered, childControl, childResponse, childSaved, parent: await snapshot() }) + const beforeRestart = { view: await rig.runningViewForFreshSession(created.sessionId), snapshot: await snapshot(), name: await readName() } + expect(beforeRestart.name).toEqual(canonicalName) + const previousTraffic = model.traffic + retainedModelTraffic = previousTraffic + rig.runtime.writeIncident('freshcodex-managed-native-before-host-restart', { beforeRestart, canonicalName, effects, mcpResults, registered, childControl, childResponse, childSaved, traffic: previousTraffic }) + await model.stop() + model = undefined + rig.runtime.restartOwnedRuntimeExact(providerContainerId) + const restarted = await waitFor('ordinary same-conversation host recovery readiness', async () => { + const current = await rig.runningViewForFreshSession(created.sessionId) + const diagnostics = [...rig.runtime.broker.receiptIds()].map(containerId => ({ containerId, + logs: rig.runtime.containerLogs(containerId).slice(-32 * 1024) })) + rig.runtime.writeIncident('freshcodex-managed-native-host-restart-status', { current, diagnostics, ownedHostEvidence: ownedHostEvidence() }) + if (!current?.containerId || !current.hostBootId + || current.hostBootId === beforeRestart.view!.hostBootId + || !rig.runtime.isContainerRunning(current.containerId)) return undefined + providerContainerId = current.containerId + try { + const context = inspectNativeContext() + expect(context).toMatchObject({ uid: 65534, gid: 0, noNewPrivs: '1', capEff: '0000000000000000' }) + const value = await snapshot() + return value.threadId === view.nativeSessionId && value.status !== 'unavailable' ? current : undefined + } catch { return undefined } + }) + expect(restarted.nativeSessionId).toBe(view.nativeSessionId) + model = await startInManagedContext(rig, { containerId: providerContainerId, handlerSource }) + await send('MANAGED_AFTER_HOST_RESTART') + const afterRestart = await waitFor('same-conversation restarted send saved', async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('MANAGED_AFTER_HOST_RESTART') ? value : undefined + }) + expect(afterRestart.settings).toEqual(beforeRestart.snapshot.settings) + expect(afterRestart.threadId).toBe(view.nativeSessionId) + expect(JSON.stringify(afterRestart.turns)).toContain('MANAGED_NATIVE_FIRST') + expect(await readName()).toEqual(canonicalName) + expect(model.traffic.filter((row: any) => row.path === '/v1/responses')).toHaveLength(1) + rig.runtime.writeIncident('freshcodex-managed-native-tools-restart', { created, nativeContext, canonicalName, effects, mcpResults, + registered, childControl, childResponse, childSaved, beforeRestart, restarted, afterRestart, traffic: previousTraffic, restartedTraffic: model.traffic, finalName: await readName() }) + + const recoveryCwd = path.join(workspace, 'recovery-cwd') + fs.mkdirSync(recoveryCwd) + fs.chmodSync(recoveryCwd, 0o755) + await send('MANAGED_RECOVERY_READY', { cwd: recoveryCwd, model: 'contract-model', effort: 'xhigh', + collaborationMode: 'plan', permissionMode: 'never', sandbox: 'read-only' }) + const recoverySettings = await waitFor('saved exact settings before native crash', async () => { + const value = await snapshot() + return value.status === 'idle' && value.settings?.cwd === recoveryCwd ? value.settings : undefined + }) + expect(recoverySettings).toMatchObject({ cwd: recoveryCwd, model: 'contract-model', effort: 'xhigh', + collaborationMode: 'plan', permissionMode: 'never', sandbox: 'read-only' }) + const beforeCrash = { view: await rig.runningViewForFreshSession(created.sessionId), snapshot: await snapshot(), + name: await readName(), nativeContext: inspectNativeContext(), trafficCursor: model.traffic.length } + expect(beforeCrash.name).toEqual(canonicalName) + rig.runtime.writeIncident('freshcodex-managed-native-before-crash', beforeCrash) + // Kill only this receipt-owned, inspected release native PID. Archive its + // REAL native-generated history after exit, keeping every original byte. + // The host stays live; no internal exited flag or Started event is forged. + rig.runtime.killOwnedRuntimePidExact(providerContainerId, beforeCrash.nativeContext.pid) + const nativeExit = await waitFor('exact native process terminal state before history archive', () => { + const state = JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs'),p='/proc/${beforeCrash.nativeContext.pid}/status'; + process.stdout.write(JSON.stringify(fs.existsSync(p)?{exists:true,state:fs.readFileSync(p,'utf8').split(String.fromCharCode(10)).find(line=>line.startsWith('State:')).slice(6).trim()}:{exists:false}));`])) + return !state.exists || state.state.startsWith('Z') ? state : undefined + }) + const originalRollout = nativeMetadata.headers.find((header: any) => header.id === view.nativeSessionId).path + const archived = JSON.parse(rig.ownedProviderExec(providerContainerId, ['node', '-e', ` + const fs=require('node:fs'),path=require('node:path'),crypto=require('node:crypto'); + const root='/home/freshell/provider/.codex';const original=${JSON.stringify(originalRollout)}; + const directory='/home/freshell/provider/.freshell-fixture/native-crash-history';fs.mkdirSync(directory,{mode:448}); + const selected=[original,...fs.readdirSync(root).filter(name=>/^state_[0-9]+[.]sqlite(?:-wal|-shm)?$/.test(name)||name==='session_index.jsonl').map(name=>path.join(root,name))]; + const files=selected.filter(file=>fs.existsSync(file)).map((file,index)=>{const bytes=fs.readFileSync(file);const destination=path.join(directory,String(index)+'-'+path.basename(file));fs.renameSync(file,destination);return{source:file,archive:destination,sha256:crypto.createHash('sha256').update(bytes).digest('hex'),bytes:bytes.length,base64:bytes.toString('base64')}}); + if(!files.some(file=>file.source===original))throw Error('Exact real native rollout was not archived');process.stdout.write(JSON.stringify({directory,files}));`])) + const archiveDirectory = path.join(rig.runtime.evidenceDir, 'native-crash-history') + fs.mkdirSync(archiveDirectory, { mode: 0o700 }) + const archiveManifest = archived.files.map((file: any, index: number) => { + const bytes = Buffer.from(file.base64, 'base64') + expect(createHash('sha256').update(bytes).digest('hex')).toBe(file.sha256) + const copy = path.join(archiveDirectory, `${index}-${path.basename(file.source)}`) + fs.writeFileSync(copy, bytes, { mode: 0o600 }) + const { base64, ...metadata } = file + return { ...metadata, copy } + }) + fs.writeFileSync(path.join(archiveDirectory, 'manifest.json'), JSON.stringify(archiveManifest, null, 2), { mode: 0o600 }) + const recoveryAccepted = await send('AWAITED_PLAN_RECOVERY') + const recovered = await rig.runningViewForFreshSession(created.sessionId) + rig.runtime.writeIncident('freshcodex-managed-native-crash-first-send', { userRef: created.sessionId, beforeCrash, + archiveManifest, nativeExit, recoveryAccepted, recovered, newTraffic: model.traffic.slice(beforeCrash.trafficCursor), frames: wire.frames.slice(-30) }) + // Acceptance must durably commit the new native source before returning. + expect(recovered?.nativeSessionId).toBeTruthy() + expect(recovered!.nativeSessionId).not.toBe(view.nativeSessionId) + expect(recovered!.freshAgentSessionId).toBe(created.sessionId) + const recoveredId = recovered!.nativeSessionId! + const recoveryControl = await waitFor('new native source control through original durable user reference', async () => { + const value = await snapshot() + expect(value.threadId).toBe(recoveredId) + expect(value.settings).toEqual(recoverySettings) + return value.pendingQuestions?.find((question: any) => question.providerRequest.threadId === recoveredId) + }) + wire.send({ type: 'freshAgent.question.respond', provider: 'codex', sessionType: 'freshcodex', sessionId: created.sessionId, + requestId: recoveryControl.requestId, answers: { managed_choice: 'Alpha' } }) + await waitFor('recovered native exact control resolution', async () => { + const value = await snapshot() + return value.status === 'idle' && !value.pendingQuestions.length ? true : undefined + }) + await send('MANAGED_RECOVERY_SECOND') + const second = await waitFor('second send on same durable user reference', async () => { + const value = await snapshot() + return value.status === 'idle' && JSON.stringify(value.turns).includes('Managed recovery second answer.') ? value : undefined + }) + expect(second.threadId).toBe(recoveredId) + expect(second.settings).toEqual(recoverySettings) + expect(JSON.stringify(second.turns)).not.toContain('MANAGED_NATIVE_FIRST') + expect(await readName()).toEqual(canonicalName) + const recoveredNameRef = { kind: 'session', provider: 'codex', sessionId: recoveredId } + const recoveredName = await waitFor('policy-correct new conversation name record', async () => { + const name = await readNameFor(recoveredNameRef) + return name?.ref?.sessionId === recoveredId ? name : undefined + }) + expect(recoveredName.ref).toEqual(recoveredNameRef) + expect(recoveredName.name).not.toBe(canonicalName.name) + const renamedRecovery = await fetch(`${rig.info.baseUrl}/api/session-names`, { method: 'PATCH', + headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify({ target: recoveredNameRef, + name: 'Managed recovered saved name', nameIntent: 'user' }) }) + expect(renamedRecovery.ok, await renamedRecovery.clone().text()).toBe(true) + rig.runtime.writeIncident('freshcodex-managed-native-recovery-name-progress', { userRef: created.sessionId, + oldNativeRef: durableNameRef, newNativeRef: recoveredNameRef, oldName: await readName(), firstNewName: recoveredName, + manualNewName: await readNameFor(recoveredNameRef), recovered, recoveryControl, second, archiveManifest }) + const recoveredNativeContext = inspectNativeContext() + expect(recoveredNativeContext).toMatchObject({ uid: 65534, gid: 0, noNewPrivs: '1', capEff: '0000000000000000' }) + await waitFor('recovered source native name writeback', () => readNativeName(recoveredId, recoveredNativeContext).title === 'Managed recovered saved name' ? true : undefined, 15_000) + const recoveredCapture = await fetch(`${rig.info.baseUrl}/api/panes/${paneId}/capture`, { headers }) + expect(recoveredCapture.ok, await recoveredCapture.clone().text()).toBe(true) + const capture = await recoveredCapture.text() + expect(capture).toContain('Managed recovery second answer.') + expect(capture).not.toContain('MANAGED_NATIVE_FIRST') + const newTraffic = model.traffic.slice(beforeCrash.trafficCursor).filter((row: any) => row.path === '/v1/responses') + expect(newTraffic.filter((row: any) => row.body.input.filter((item: any) => item.role === 'user').at(-1)?.content?.some((part: any) => part.text?.includes('MANAGED_RECOVERY_SECOND')))).toHaveLength(1) + expect(newTraffic.every((row: any) => !JSON.stringify(row.body.input).includes('MANAGED_NATIVE_FIRST'))).toBe(true) + expect((await rig.inventory()).filter(row => row.provider === 'codex' && row.nativeSessionId === recoveredId && row.launchState === 'running')).toHaveLength(1) + rig.runtime.writeIncident('freshcodex-managed-native-recovery', { userRef: created.sessionId, oldNativeRef: created.sessionRef, + newNativeRef: recoveredNameRef, beforeCrash, archiveManifest, nativeExit, recoveryAccepted, recovered, recoveryControl, second, capture, + oldName: await readName(), firstNewName: recoveredName, finalNewName: await readNameFor(recoveredNameRef), + nativeName: readNativeName(recoveredId, recoveredNativeContext), recoveredNativeContext, newTraffic }) + + }, 360_000) +}) diff --git a/test/integration/server/managed-provider-parity.test.ts b/test/integration/server/managed-provider-parity.test.ts index 0311d1ac96..08549be6f3 100644 --- a/test/integration/server/managed-provider-parity.test.ts +++ b/test/integration/server/managed-provider-parity.test.ts @@ -542,6 +542,36 @@ process.stdout.write(JSON.stringify({rootExists:fs.existsSync(root),fileExists:f } }, 180_000) + it('managed Claude prepares its complete installed environment before dispatch', async () => { + const wire = await TerminalWire.connect(rig.info, true) + sockets.push(wire) + await wire.create('claude', workspace, authorizeManagedRequest) + const launch = await waitFor('managed Claude installed environment consumer', () => ( + wire.recordRows().find(row => row.provider === 'claude' && row.kind === 'launch') + )) + // The real child executes the selected command, imports the last installed + // dependency and reads its resource after candidate preparation completes. + expect(launch.selectedEnvironment).toEqual({ + dependency: 'installed-dependency-nonce', resource: 'selected-resource-nonce', entries: 4201, + }) + expect(launch.providerConfig['settings.json']).toMatchObject({ paritySetting: true }) + expect(launch.providerPlugin).toBe('claude parity plugin\n') + expect(launch.childIdentity).toEqual({ uid: 65534, gid: 0 }) + expect(launch.privateTopology).toEqual({ uid: 0, gid: 0, mode: 0o710 }) + expect(launch.onecliChildDigest).toBe(onecli.childDigests.claude) + expect(launch.onecliControlPresent).toBe(false) + const mcp = await waitFor('managed Claude prepared MCP probe', () => ( + wire.recordRows().find(row => row.provider === 'claude' && row.kind === 'mcp') + )) + expect(mcp.error).toBeUndefined() + expect(mcp.tools).toContain('freshell') + expect(mcp.call?.ok).toBe(true) + expect(mcp.authenticatedCall).toMatchObject({ isError: false, count: 0, truncated: false }) + for (const marker of [nestedSecretMarker, 'fixture-secret-byte']) { + expect(JSON.stringify({ launch, mcp })).not.toContain(marker) + } + }, 180_000) + it.each(providers)('%s preserves provider-visible launch and MCP behavior', async provider => { const observed: RecordRow[] = [] const mcpResults: RecordRow[] = [] diff --git a/test/unit/claude-sidecar/fixtures/shutdown-child-query-module.mjs b/test/unit/claude-sidecar/fixtures/shutdown-child-query-module.mjs new file mode 100644 index 0000000000..976d18011f --- /dev/null +++ b/test/unit/claude-sidecar/fixtures/shutdown-child-query-module.mjs @@ -0,0 +1,39 @@ +import { spawn } from 'node:child_process' +import { query as streamingQuery } from './streaming-query-module.mjs' + +// close():void initiates SDK cleanup without joining the child. Hold a real +// child after SIGTERM so the sidecar must keep owning it until it exits. +export function query({ prompt, options }) { + const command = { + command: process.execPath, + args: ['-e', ` + const fs = require('node:fs'); const path = require('node:path'); + process.on('SIGTERM', () => process.stdout.write('stopping\\n', error => { + if (error) process.exit(1); + fs.writeFileSync(path.join(process.cwd(), 'native-terminated'), 'yes'); + })); + process.stdin.resume(); + const timer = setInterval(() => { + if (!fs.existsSync(path.join(process.cwd(), 'native-release'))) return; + clearInterval(timer); fs.writeFileSync(path.join(process.cwd(), 'native-exited'), 'yes'); process.exit(0); + }, 10); + process.stdout.write('ready\\n'); + `], + cwd: options.cwd, + env: options.env, + signal: new AbortController().signal, + } + const child = options.spawnClaudeCodeProcess + ? options.spawnClaudeCodeProcess(command) + : spawn(command.command, command.args, { ...command, stdio: ['pipe', 'pipe', 'pipe'] }) + const ready = new Promise((resolve, reject) => { + child.stdout.once('data', resolve) + child.once('error', reject) + }) + const sdk = streamingQuery({ prompt, options }) + const close = sdk.close.bind(sdk) + return Object.assign(sdk, { + async initializationResult() { await ready; return { pid: child.pid, commands: [] } }, + close() { close(); child.stdin.end() }, + }) +} diff --git a/test/unit/claude-sidecar/lifecycle-facts.test.ts b/test/unit/claude-sidecar/lifecycle-facts.test.ts index f4ccf3dc75..5515026873 100644 --- a/test/unit/claude-sidecar/lifecycle-facts.test.ts +++ b/test/unit/claude-sidecar/lifecycle-facts.test.ts @@ -27,6 +27,16 @@ function commandStates(bridge: RealSidecar, uuid: string): string[] { const isResult = (frame: SidecarFrame) => frame.type === 'sdk.result' +/** A complete rejection log for this send; stderr can arrive after stdout. */ +function rejectedSendLog(bridge: RealSidecar, uuid: string): Record | undefined { + const stderr = bridge.stderr() + const complete = stderr.slice(0, stderr.lastIndexOf('\n') + 1) + return complete.split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record) + .find((record) => record.event === 'send.rejected' && record.uuid === uuid) +} + describe('uuid-stamped sends', () => { it('the CLI runs each send under the uuid the server gave it', async () => { const { bridge, sessionId } = await session() @@ -153,14 +163,28 @@ describe('session facts', () => { }) describe('rejected sends', () => { - it('a bad image is rejected and the sidecar keeps serving the session', async () => { + it.each(['flowing', 'delayed'] as const)('a bad image is rejected and the sidecar keeps serving the session (%s stderr)', async (stderrDelivery) => { const { bridge, sessionId } = await session() const rejected = randomUUID() - bridge.send({ type: 'send', sessionId, uuid: rejected, text: 'look', images: [{ mediaType: 'image/svg+xml', data: 'PHN2Zy8+' }] }) - const rejection = await bridge.waitFor('sdk.send.rejected') - expect(rejection).toMatchObject({ sessionId, uuid: rejected, code: 'INVALID_IMAGE' }) - expect(rejection.message).toMatch(/image\/svg\+xml/) - expect(bridge.stderr()).toContain('"event":"send.rejected"') + const stderrStream = bridge.child.stderr! + if (stderrDelivery === 'delayed') stderrStream.pause() + try { + bridge.send({ type: 'send', sessionId, uuid: rejected, text: 'look', images: [{ mediaType: 'image/svg+xml', data: 'PHN2Zy8+' }] }) + const rejection = await bridge.waitFor('sdk.send.rejected') + expect(rejection).toMatchObject({ sessionId, uuid: rejected, code: 'INVALID_IMAGE' }) + expect(rejection.message).toMatch(/image\/svg\+xml/) + if (stderrDelivery === 'delayed') { + expect(stderrStream.isPaused()).toBe(true) + expect(rejectedSendLog(bridge, rejected)).toBeUndefined() + } + stderrStream.resume() + await expect.poll(() => rejectedSendLog(bridge, rejected), { timeout: 10_000 }).toMatchObject({ + severity: 'warn', component: 'claude-sidecar', event: 'send.rejected', + sessionId, uuid: rejected, code: 'INVALID_IMAGE', error: rejection.message, + }) + } finally { + stderrStream.resume() + } const next = randomUUID() bridge.send({ type: 'send', sessionId, uuid: next, text: 'hello' }) @@ -169,6 +193,8 @@ describe('rejected sends', () => { expect(bridge.ledger().filter((row) => row.kind === 'prompt').map((row) => row.uuid)).toEqual([next]) expect(commandStates(bridge, rejected)).toEqual([]) expect(bridge.child.exitCode).toBeNull() + bridge.send({ type: 'shutdown' }) + expect(await bridge.waitExit()).toEqual({ code: 0, signal: null }) }) it('a send without a valid uuid is rejected', async () => { diff --git a/test/unit/claude-sidecar/shutdown-ownership.test.ts b/test/unit/claude-sidecar/shutdown-ownership.test.ts new file mode 100644 index 0000000000..8052deef87 --- /dev/null +++ b/test/unit/claude-sidecar/shutdown-ownership.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' +import { startRealSidecar } from './harness.js' + +it.each(['shutdown', 'stdout-closed'])('on %s, retains the native child and its pipes until that child closes', async (mode) => { + const sidecar = await startRealSidecar({ env: { + FRESHELL_CLAUDE_SDK_QUERY_MODULE: fileURLToPath(new URL('./fixtures/shutdown-child-query-module.mjs', import.meta.url)), + } }) + const created = await sidecar.requestCreate() + expect(created.type, sidecar.stderr()).toBe('created') + const pid = created.cliPid as number + const marker = (name: string) => path.join(sidecar.cwd, `native-${name}`) + try { + sidecar.send({ type: 'send', sessionId: created.sessionId, text: 'keep streaming' }) + await sidecar.waitFor('sdk.stream') + if (mode === 'stdout-closed') sidecar.child.stdout!.destroy() + else sidecar.send({ type: 'shutdown' }) + + // The held child has received termination, or the sidecar incorrectly + // exited first. The latter must fail even if cleanup later succeeds. + await expect.poll(() => fs.existsSync(marker('terminated')) || sidecar.child.exitCode !== null).toBe(true) + expect(sidecar.child.exitCode, sidecar.stderr()).toBeNull() + expect(fs.existsSync(marker('terminated')), sidecar.stderr()).toBe(true) + fs.writeFileSync(marker('release'), 'yes') + expect(await sidecar.waitExit(), sidecar.stderr()).toEqual({ code: 0, signal: null }) + expect(fs.existsSync(marker('exited'))).toBe(true) + } finally { + fs.writeFileSync(marker('release'), 'yes') + await expect.poll(() => { + try { process.kill(pid, 0); return false } catch { return true } + }, { timeout: 5_000 }).toBe(true) + } +}) diff --git a/test/unit/cli/action-capabilities.test.ts b/test/unit/cli/action-capabilities.test.ts index b5d00757dc..a75b45aee6 100644 --- a/test/unit/cli/action-capabilities.test.ts +++ b/test/unit/cli/action-capabilities.test.ts @@ -75,9 +75,11 @@ describe('standalone CLI capability contract', () => { it.each([ ['new-tab', 'claude'], ['new-tab', 'kilroy'], + ['new-tab', 'codex'], ['split-pane', 'claude'], ['split-pane', 'kilroy'], - ])('%s sends %s fresh-agent options and canonical Claude resume identity', async (action, agent) => { + ['split-pane', 'codex'], + ])('%s sends %s fresh-agent options and canonical resume identity', async (action, agent) => { const bodies: Array<{ url: string; body: Record }> = [] const server = createServer(async (request, response) => { response.setHeader('content-type', 'application/json') @@ -106,7 +108,7 @@ describe('standalone CLI capability contract', () => { url: action === 'new-tab' ? '/api/tabs' : '/api/panes/p1/split', body: { agent, model: 'catalog-model', effort: 'max', - sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, }, }) expect(bodies[0].body).not.toHaveProperty('resumeSessionId') @@ -120,7 +122,6 @@ describe('standalone CLI capability contract', () => { ['run', 'echo', 'blocked'], ['fresh-send', 'blocked'], ['attach', '--terminal', 'term-1'], - ['new-tab', '--agent', 'codex'], ['split-pane', '--agent', 'opencode'], ['split-pane', '--model', 'model'], ['split-pane', '--effort', 'high'], diff --git a/test/unit/cli/commands.test.ts b/test/unit/cli/commands.test.ts index 4577b06a4d..b453876a51 100644 --- a/test/unit/cli/commands.test.ts +++ b/test/unit/cli/commands.test.ts @@ -16,10 +16,39 @@ const require = createRequire(import.meta.url) const cliPath = resolve(process.cwd(), 'tools/freshell-cli/index.ts') const tsxLoader = pathToFileURL(require.resolve('tsx')).href +describe('Freshcodex creation through the real CLI', () => { + it('keeps an uncertain send receipt without replaying or exposing unrelated error payloads', async () => { + const receipt = { status: 'error', code: 'CODEX_DELIVERY_AMBIGUOUS', message: 'delivery is uncertain', retryable: false, requestId: 'uncertain-request', sessionId: 'native-session', incarnationId: 'owned-incarnation', answer: 'do-not-log-this-answer' } + const result = await invokeCli(['new-tab', '--agent', 'codex', '--prompt', 'recoverable prompt'], { sendError: receipt }) + expect(result.code).toBe(1) + expect(result.stdout).toBe('') + expect(result.requests.map((request) => request.url)).toEqual(['/api/tabs', '/api/panes/p1/send-keys']) + const diagnostics = result.stderr.trim().split('\n').map((line) => JSON.parse(line)) + expect(diagnostics).toContainEqual(expect.objectContaining({ code: 'CODEX_DELIVERY_AMBIGUOUS', retryable: false, requestId: 'uncertain-request', sessionId: 'native-session', incarnationId: 'owned-incarnation' })) + expect(result.stderr).not.toContain('do-not-log-this-answer') + }) + it('promotes canonical resume identity and sends the initial prompt separately', async () => { + const result = await invokeCli(['new-tab', '--agent', 'codex', '--resume', '550e8400-e29b-41d4-a716-446655440000', '--model', 'provider-current', '--effort', 'xhigh', '--prompt', 'initial Codex prompt']) + expect(result.code, result.stderr).toBe(0) + expect(result.requests).toEqual([ + { url: '/api/tabs', body: expect.objectContaining({ agent: 'codex', model: 'provider-current', effort: 'xhigh', sessionRef: { provider: 'codex', sessionId: '550e8400-e29b-41d4-a716-446655440000' } }) }, + { url: '/api/panes/p1/send-keys', body: { data: 'initial Codex prompt\r' } }, + ]) + expect(result.requests[0].body).not.toHaveProperty('resumeSessionId') + }) + + it.each(['new-tab', 'split-pane'])('%s still refuses terminal-mode raw Codex resume before HTTP', async (action) => { + const result = await invokeCli([action, '--mode', 'codex', '--resume', 'raw-codex-id']) + expect(result.code).toBe(1) + expect(result.requests).toHaveLength(0) + expect(result.stderr).toContain('Restore requires sessionRef') + }) +}) + /** Spawns the real CLI (retained-flags.test.ts pattern) against a stub HTTP * server and records every resulting HTTP call. `outcome` (name-reliability * Task 4) is the rename outcome the stub's accepted update carries. */ -async function invokeCli(args: string[], opts: { outcome?: unknown } = {}) { +async function invokeCli(args: string[], opts: { outcome?: unknown; sendError?: Record } = {}) { const requests: Array<{ url: string; body: any }> = [] const server = createServer(async (request, response) => { const chunks: Buffer[] = [] @@ -27,7 +56,10 @@ async function invokeCli(args: string[], opts: { outcome?: unknown } = {}) { const text = Buffer.concat(chunks).toString() requests.push({ url: request.url ?? '', body: text ? JSON.parse(text) : undefined }) response.setHeader('content-type', 'application/json') - if (request.method === 'GET' && request.url === '/api/tabs') { + if (request.method === 'POST' && request.url?.endsWith('/send-keys') && opts.sendError) { + response.statusCode = 409 + response.end(JSON.stringify(opts.sendError)) + } else if (request.method === 'GET' && request.url === '/api/tabs') { response.end(JSON.stringify({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' })) } else if (request.method === 'GET' && request.url === '/api/panes?tabId=t1') { response.end(JSON.stringify({ panes: [{ id: 'p1', index: 0, kind: 'terminal' }] })) diff --git a/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx index 46f626788d..8a449cd82b 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentApprovalCard.test.tsx @@ -97,4 +97,72 @@ describe('FreshAgentApprovalCard', () => { for (const button of screen.getAllByRole('button')) expect(button).toBeDisabled() }) + it('identifies native stdin writes and sends only the offered native decisions', () => { + const onAlwaysAllow = vi.fn() + const { onRespond } = renderCard({ + requestId: 'stdin-approval', + toolName: 'Bash', + native: { kind: 'writeStdin', command: 'python interactive.py', availableDecisions: ['accept', 'cancel'] }, + }, { onAlwaysAllow }) + const card = within(screen.getByRole('alert', { name: 'Permission request for Bash' })) + + expect(card.getByText('Write to a running command')).toBeVisible() + expect(card.getAllByRole('button').map((button) => button.textContent)).toEqual(['Allow', 'Stop']) + expect(card.queryByRole('button', { name: /always allow/i })).not.toBeInTheDocument() + fireEvent.click(card.getByRole('button', { name: 'Allow', exact: true })) + fireEvent.click(card.getByRole('button', { name: 'Stop', exact: true })) + + expect(onRespond.mock.calls).toEqual([[{ nativeDecision: 'accept' }], [{ nativeDecision: 'cancel' }]]) + expect(onAlwaysAllow).not.toHaveBeenCalled() + }) + + it.each([ + { + name: 'permissions', + toolName: 'Additional permissions', + details: { permissions: { fileSystem: { entries: [ + { path: { type: 'path', path: '/srv/reports' }, access: 'read' }, + { path: { type: 'path', path: '/srv/output' }, access: 'write' }, + ] }, network: { enabled: true } } }, + offered: ['accept', 'decline'], + actions: [{ label: 'Allow', decision: 'accept' }, { label: 'Decline', decision: 'decline' }], + }, + { + name: 'additionalPermissions', + toolName: 'Bash', + details: { additionalPermissions: { fileSystem: { entries: [ + { path: { type: 'path', path: '/var/cache/build' }, access: 'write' }, + { path: { type: 'glob_pattern', pattern: '/srv/private/**' }, access: 'deny' }, + ] }, network: { enabled: false } } }, + offered: ['accept', 'cancel', { acceptWithExecpolicyAmendment: { execpolicyAmendment: ['build'] } }], + actions: [{ label: 'Allow', decision: 'accept' }, { label: 'Stop', decision: 'cancel' }], + }, + { + name: 'grantRoot', + toolName: 'Edit', + details: { path: '/srv/generated' }, + offered: ['acceptForSession', 'decline'], + actions: [{ label: 'Allow for this session', decision: 'acceptForSession' }, { label: 'Decline', decision: 'decline' }], + }, + ])('renders requested $name details and preserves native action callbacks', ({ name, toolName, details, offered, actions }) => { + const onAlwaysAllow = vi.fn() + const nativeDetails = name === 'grantRoot' ? { grantRoot: details.path } : details + const { onRespond } = renderCard({ + requestId: `detail-${name}`, + toolName, + native: { ...nativeDetails, availableDecisions: offered }, + }, { onAlwaysAllow }) + const card = within(screen.getByRole('alert', { name: `Permission request for ${toolName}` })) + const preview = card.getByText((_content, element) => element?.tagName === 'PRE') + + expect(preview).toBeVisible() + expect(JSON.parse(preview.textContent ?? '')).toEqual(details) + expect(card.getAllByRole('button').map((button) => button.textContent)).toEqual(actions.map((action) => action.label)) + expect(card.queryByRole('button', { name: /always allow/i })).not.toBeInTheDocument() + for (const action of actions) fireEvent.click(card.getByRole('button', { name: action.label, exact: true })) + + expect(onRespond.mock.calls).toEqual(actions.map((action) => [{ nativeDecision: action.decision }])) + expect(onAlwaysAllow).not.toHaveBeenCalled() + }) + }) diff --git a/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx new file mode 100644 index 0000000000..e85bc747f2 --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentChildObserver.test.tsx @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FreshAgentAsyncQuestionContext } from '@/components/fresh-agent/FreshAgentItemCard' +import { FreshAgentChildObserver } from '@/components/fresh-agent/FreshAgentChildObserver' +import { api } from '@/lib/api' +import { codexContractSnapshot } from '../../../../fixtures/fresh-agent/codex/contract-fixtures.js' + +vi.mock('@/lib/api', () => ({ api: { get: vi.fn(), post: vi.fn() } })) +vi.mock('@/components/markdown/LazyMarkdown', () => ({ LazyMarkdown: ({ content }: { content: string }) =>

{content}

})) +const parent = { parentSessionId: 'parent', provider: 'codex', sessionType: 'freshcodex', observedEpoch: 4, observedGeneration: 7 } as const +const readable = (status: 'live' | 'saved' = 'saved') => ({ parentThreadId: 'parent', childThreadId: 'child', status, snapshot: { ...codexContractSnapshot, threadId: 'child', turns: [{ id: 'answer', turnId: 'answer', role: 'assistant', summary: '', items: [{ id: 'body', kind: 'text', text: 'Exact observed child' }] }] } }) + +describe('FreshAgentChildObserver', () => { + afterEach(() => { expect(api.post).not.toHaveBeenCalled(); cleanup(); vi.useRealTimers(); vi.clearAllMocks() }) + + it('reads the parent-scoped route with its owner fence and never offers writer controls', async () => { + vi.mocked(api.get).mockResolvedValue(readable()) + render() + expect(await screen.findByText('Exact observed child')).toBeVisible() + expect(api.get).toHaveBeenCalledWith('/api/fresh-agent/threads/freshcodex/codex/parent/children/child?observedEpoch=4&observedGeneration=7', expect.objectContaining({ signal: expect.any(AbortSignal) })) + expect(api.post).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /send|stop|configure|resume/i })).not.toBeInTheDocument() + }) + + it('keeps child async questions read-only even under a parent answer context', async () => { + const onParentAnswer = vi.fn() + const response = readable() + response.snapshot.turns[0].items = [{ id: 'child-question', kind: 'text', text: 'A child asks', extensions: { codex: { itemId: 'native-child-question', delivery: 'async', questions: [{ title: 'Child question?', options: ['Child choice'] }] } } }] as never + vi.mocked(api.get).mockResolvedValue(response) + render() + expect(await screen.findByText('Child question?')).toBeVisible() + const choice = screen.queryByRole('button', { name: 'Child choice', exact: true }) + if (choice) fireEvent.click(choice) + expect(onParentAnswer).not.toHaveBeenCalled() + expect(choice).not.toBeInTheDocument() + expect(screen.getByText('Child choice')).toBeVisible() + expect(api.post).not.toHaveBeenCalled() + }) + + it('retains already available content during a source rebind, then refreshes it without changing identity', async () => { + vi.mocked(api.get).mockResolvedValueOnce(readable()).mockRejectedValue(new Error('source unavailable')) + const { rerender } = render() + expect(await screen.findByText('Exact observed child')).toBeVisible() + rerender() + expect(await screen.findByText(/previously available content/i)).toBeVisible() + expect(screen.getByText('Exact observed child')).toBeVisible() + expect(api.post).not.toHaveBeenCalled() + }) + + it('preserves explicit running native MCP outcomes when read-only history becomes unavailable', async () => { + const response = readable('live') + const live = { ...response, snapshot: { ...response.snapshot, status: 'running', turns: [{ id: 'tools', turnId: 'tools', role: 'assistant', summary: '', items: [ + { id: 'first', kind: 'mcp_tool', server: 'native', tool: 'first', status: 'running', arguments: {} }, + { id: 'second', kind: 'mcp_tool', server: 'native', tool: 'second', status: 'running', arguments: {} }, + ] }] } } + vi.mocked(api.get).mockResolvedValueOnce(live).mockRejectedValue(new Error('source unavailable')) + const { rerender } = render() + fireEvent.click(await screen.findByRole('button', { name: 'Toggle activity details' })) + expect(screen.getAllByLabelText('running')).toHaveLength(2) + expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + rerender() + expect(await screen.findByText(/previously available content/i)).toBeVisible() + expect(screen.getAllByLabelText('running')).toHaveLength(2) + expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + }) + + it('makes pending materialization finite and can later read saved content', async () => { + vi.useFakeTimers() + vi.mocked(api.get).mockResolvedValue({ parentThreadId: 'parent', childThreadId: 'child', status: 'pending', reason: 'history_unmaterialized', retryable: true }) + render() + await act(async () => { await vi.advanceTimersByTimeAsync(10_000) }) + expect(api.get).toHaveBeenCalledTimes(5) + expect(screen.getByText(/history is unavailable/i)).toBeVisible() + vi.mocked(api.get).mockResolvedValue(readable()) + fireEvent.click(screen.getByRole('button', { name: 'Refresh child history' })) + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(screen.getByText('Exact observed child')).toBeVisible() + expect(api.post).not.toHaveBeenCalled() + }) + + it('moves from genuinely pending history to the exact materialized child without a writer', async () => { + vi.useFakeTimers() + vi.mocked(api.get).mockResolvedValueOnce({ parentThreadId: 'parent', childThreadId: 'child', status: 'pending', reason: 'history_unmaterialized', retryable: true }).mockResolvedValue(readable()) + render() + await act(async () => { await vi.advanceTimersByTimeAsync(0) }) + expect(screen.getByText('Waiting for child history to be saved.')).toBeVisible() + expect(screen.queryByText('Exact observed child')).not.toBeInTheDocument() + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(screen.getByText('Exact observed child')).toBeVisible() + expect(api.get).toHaveBeenCalledTimes(2) + }) + + it('does not poll permanently unavailable ephemeral history or synthesize an empty transcript', async () => { + vi.mocked(api.get).mockResolvedValue({ parentThreadId: 'parent', childThreadId: 'child', status: 'unavailable', reason: 'ephemeral_history_unavailable', retryable: false }) + render() + expect(await screen.findByText(/ephemeral child/i)).toBeVisible() + expect(screen.queryByRole('button', { name: 'Refresh child history' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Assistant transcript turn')).not.toBeInTheDocument() + expect(api.get).toHaveBeenCalledTimes(1) + }) + + it('rejects a response for a different child and returns focus after deliberate close', async () => { + vi.mocked(api.get).mockResolvedValue({ ...readable(), childThreadId: 'unrelated' }) + const onClose = vi.fn() + const opener = document.createElement('button') + document.body.append(opener) + opener.focus() + const { unmount } = render() + await waitFor(() => expect(api.get).toHaveBeenCalled()) + expect(screen.queryByText('Exact observed child')).not.toBeInTheDocument() + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + unmount() + expect(opener).toHaveFocus() + opener.remove() + }) + + it('contains Tab and Shift-Tab, uses the latest Escape callback, and returns focus to its opener', async () => { + vi.mocked(api.get).mockResolvedValue(readable()) + const opener = document.createElement('button') + document.body.append(opener) + opener.focus() + const initialClose = vi.fn() + const latestClose = vi.fn() + const { rerender, unmount } = render() + await screen.findByText('Exact observed child') + const close = screen.getByRole('button', { name: 'Close child conversation' }) + const lastControl = screen.getByRole('button', { name: 'Copy turn', exact: true }) + expect(close).toHaveFocus() + fireEvent.keyDown(close, { key: 'Tab', shiftKey: true }) + expect(lastControl).toHaveFocus() + fireEvent.keyDown(lastControl, { key: 'Tab' }) + expect(close).toHaveFocus() + rerender() + expect(close).toHaveFocus() + fireEvent.keyDown(lastControl, { key: 'Escape' }) + expect(initialClose).not.toHaveBeenCalled() + expect(latestClose).toHaveBeenCalledTimes(1) + unmount() + expect(opener).toHaveFocus() + opener.remove() + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx index f103e0541d..34e5da4d6b 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx @@ -34,6 +34,29 @@ describe('FreshAgentComposer', () => { }) afterEach(() => cleanup()) + it('loads a restored ordinary draft when the native storage key binds after mount without sending it', () => { + const onSend = vi.fn() + const draft = 'Answer to Codex question: {"itemId":"parent-answer","questionId":"0","question":"Where?","answer":"Staging"}' + window.sessionStorage.setItem('native-session-draft', draft) + const { rerender } = render() + expect(getInput()).toHaveValue('') + rerender() + expect(getInput()).toHaveValue(draft) + expect(window.sessionStorage.getItem('native-session-draft')).toBe(draft) + expect(onSend).not.toHaveBeenCalled() + }) + + it('keeps newer unsent text when the native storage key binds', () => { + window.sessionStorage.setItem('native-session-draft', 'An older stored draft') + const onSend = vi.fn() + const { rerender } = render() + fireEvent.change(getInput(), { target: { value: 'A newer unsent draft' } }) + rerender() + expect(getInput()).toHaveValue('A newer unsent draft') + expect(window.sessionStorage.getItem('native-session-draft')).toBe('A newer unsent draft') + expect(onSend).not.toHaveBeenCalled() + }) + it('blocks shell escapes without a Rust shell route', () => { const onSend = vi.fn() render() diff --git a/test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx index ccf18031d8..7d4f6d850e 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx @@ -35,6 +35,20 @@ describe('FreshAgentDiffPanel', () => { expect(screen.getByRole('button', { name: 'Diff: src/app.tsx' })).toBeInTheDocument() }) + it('shows supplied native diff and later replacement without a cwd/path fetch', async () => { + const { rerender } = render() + await userEvent.click(screen.getByRole('button', { name: 'Diff: Turn changes' })) + expect(await screen.findByText('+two')).toBeVisible() + expect(screen.getByText('diff --git a/one.ts b/one.ts')).toBeVisible() + expect(screen.getByText('diff --git a/two.ts b/two.ts')).toBeVisible() + expect(screen.queryByText(/Diff unavailable/)).toBeNull() + rerender() + expect(screen.getByText('+three')).toBeVisible() + expect(screen.queryByText('+two')).toBeNull() + expect(screen.queryByText('diff --git a/one.ts b/one.ts')).toBeNull() + expect(apiGet).not.toHaveBeenCalled() + }) + it('renders the shared diff view with data-file-path copy target metadata', () => { const { container } = render( , diff --git a/test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx new file mode 100644 index 0000000000..8aab6011dd --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentElicitationBanner.test.tsx @@ -0,0 +1,80 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import FreshAgentElicitationBanner from '@/components/fresh-agent/FreshAgentElicitationBanner' +import type { FreshAgentPendingElicitation } from '@shared/fresh-agent-contract' + +afterEach(cleanup) +const base: FreshAgentPendingElicitation = { requestId: '41', mode: 'form', serverName: 'contract', message: 'A native form' } + +it('sends primitive and enum-array values with their exact native field IDs', () => { + const onRespond = vi.fn() + render() + expect(screen.getByLabelText('Token')).toHaveAttribute('type', 'password') + expect(screen.getByLabelText('Token')).toHaveValue('') + fireEvent.change(screen.getByLabelText('Token'), { target: { value: ' exact, token! ' } }) + fireEvent.change(screen.getByLabelText('Count'), { target: { value: '3' } }) + fireEvent.change(screen.getByLabelText('Enabled'), { target: { value: 'false' } }) + fireEvent.change(screen.getByLabelText('Color'), { target: { value: 'blue,green' } }) + const colors = screen.getByLabelText('Colors') as HTMLSelectElement + colors.options[0].selected = true + colors.options[1].selected = true + fireEvent.change(colors) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).toHaveBeenCalledWith('accept', { 'token-id': ' exact, token! ', 'count-id': 3, 'enabled-id': false, 'color-id': 'blue,green', 'colors-id': ['blue', 'green'] }) +}) + +it('opening a URL does not accept it; each explicit action sends null content', () => { + const onRespond = vi.fn() + render() + fireEvent.click(screen.getByRole('link', { name: 'Open contract page' })) + expect(onRespond).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.click(screen.getByRole('button', { name: 'Decline' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onRespond.mock.calls).toEqual([['accept', null], ['decline', null], ['cancel', null]]) +}) + +it('does not submit required or out-of-range values', () => { + const onRespond = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).not.toHaveBeenCalled() + fireEvent.change(screen.getByLabelText('Count'), { target: { value: '8' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).not.toHaveBeenCalled() +}) + +it('clears optional boolean and enum choices without silently retaining an old answer', () => { + const onRespond = vi.fn() + render() + fireEvent.change(screen.getByLabelText('Enabled'), { target: { value: 'true' } }) + fireEvent.change(screen.getByLabelText('Enabled'), { target: { value: '' } }) + fireEvent.change(screen.getByLabelText('Color'), { target: { value: 'blue' } }) + fireEvent.change(screen.getByLabelText('Color'), { target: { value: '' } }) + const colors = screen.getByLabelText('Colors') as HTMLSelectElement + colors.options[0].selected = true + fireEvent.change(colors) + colors.options[0].selected = false + fireEvent.change(colors) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).toHaveBeenCalledWith('accept', {}) +}) + + +it('keeps a date-time answer with its explicit UTC zone as the native string', () => { + const onRespond = vi.fn() + render() + fireEvent.change(screen.getByLabelText('When'), { target: { value: '2026-10-26T10:30:00Z' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onRespond).toHaveBeenCalledWith('accept', { time: '2026-10-26T10:30:00Z' }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx index 5019fdbe37..e7d32d4f46 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import { FreshAgentItemCard, FreshAgentDelegationBlock, FreshAgentOpenSessionContext, FreshAgentToolBlock, stripSystemReminders } from '@/components/fresh-agent/FreshAgentItemCard' +import { FreshAgentItemCard, FreshAgentDelegationBlock, FreshAgentOpenSessionContext, FreshAgentObserveChildContext, FreshAgentToolBlock, stripSystemReminders } from '@/components/fresh-agent/FreshAgentItemCard' import { formatThoughtDuration } from '@/components/fresh-agent/shared/format-duration' vi.mock('@/components/markdown/LazyMarkdown', async () => { @@ -13,6 +13,44 @@ vi.mock('@/components/markdown/LazyMarkdown', async () => { describe('FreshAgentItemCard', () => { afterEach(() => cleanup()) + it('shows the actual native sleep duration in the activity preview', () => { + render() + expect(screen.getByText('1250 ms')).toBeVisible() + }) + + it('keeps each collaboration receiver outcome and selects its exact child without a resume action', () => { + const onObserve = vi.fn(), openSession = vi.fn() + const parent = { parentSessionId: 'parent', provider: 'codex', sessionType: 'freshcodex', observedEpoch: 8, observedGeneration: 9 } as const + render() + expect(screen.getByText(/First result/)).toHaveTextContent('first-model') + expect(screen.getByText(/Second error/)).toHaveTextContent('second-model') + fireEvent.click(screen.getByRole('button', { name: 'Observe child child-two' })) + expect(onObserve).toHaveBeenCalledWith({ type: 'observe-child', parent, childThreadId: 'child-two' }) + expect(openSession).not.toHaveBeenCalled() + }) + + it('renders saved native choices and keeps image output accessible through authenticated file links', () => { + render(<>) + expect(screen.getByText('Next step?')).toBeVisible() + expect(screen.getByText('Inspect')).toBeVisible() + expect(screen.getByText('Verify')).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: 'ImageGeneration tool call' })) + expect(screen.getByRole('link', { name: '/tmp/generated output.png' })).toHaveAttribute('href', '/api/files/raw?path=%2Ftmp%2Fgenerated%20output.png') + }) + + it('renders a declined command as declined rather than completed', () => { + render() + expect(screen.getByLabelText('declined')).toBeInTheDocument() + expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + expect(screen.getByText(/declined/i)).toBeInTheDocument() + }) + + it('shows native file-change output progress in its tool disclosure', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Edit tool call' })) + expect(screen.getByText('Applying patch')).toBeVisible() + }) + it('renders markdown text while preserving XSS defenses through the markdown renderer', () => { const { container } = render( { expect(formatThoughtDuration(3_720_000)).toBe('1h 2m') }) }) + + +describe('true asynchronous Codex questions', () => { + afterEach(cleanup) + const item = { id: 'async-item', kind: 'text' as const, text: 'I can keep working.', extensions: { codex: { itemId: 'native-async-item', delivery: 'async', questions: [{ title: 'Where?', options: ['Staging', 'Production'] }, { title: 'Deadline?', options: null }] } } } + it('offers contextual choices and free entry without fabricating native control IDs', () => { + const onAnswerQuestion = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + expect(onAnswerQuestion).toHaveBeenCalledWith({ itemId: 'native-async-item', questionId: '0', question: 'Where?', text: 'Staging' }) + fireEvent.change(screen.getByLabelText('Deadline?'), { target: { value: ' Friday, 5 pm! ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Submit', exact: true })) + expect(onAnswerQuestion).toHaveBeenLastCalledWith({ itemId: 'native-async-item', questionId: '1', question: 'Deadline?', text: ' Friday, 5 pm! ' }) + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx index 9997be7915..57e0aa817e 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentModelDialog.test.tsx @@ -1,5 +1,5 @@ import { configureStore } from '@reduxjs/toolkit' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -7,6 +7,7 @@ import { FreshAgentModelDialog } from '@/components/fresh-agent/FreshAgentModelD import { useAppSelector } from '@/store/hooks' import panesReducer, { initLayout, mergePaneContent } from '@/store/panesSlice' import settingsReducer from '@/store/settingsSlice' +import freshAgentReducer, { applyRuntimeOwner } from '@/store/freshAgentSlice' import type { FreshAgentPaneContent } from '@/store/paneTypes' import type { FreshAgentSettingScopes } from '@shared/fresh-agent-contract' @@ -115,6 +116,7 @@ function createStore() { reducer: { panes: panesReducer, settings: settingsReducer, + freshAgent: freshAgentReducer, }, }) } @@ -582,6 +584,146 @@ describe('FreshAgentModelDialog (freshopencode)', () => { }) describe('FreshAgentModelDialog (freshcodex)', () => { + beforeEach(() => getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ + ok:true,sessionType:'freshcodex',runtimeProvider:'codex',status:'fresh',fetchedAt:1234, + models:[['gpt-6-astra','GPT-6 Astra'],['gpt-5.6-luna','GPT-5.6 Luna'],['gpt-5.6-sol','GPT-5.6 Sol']].map(([id,displayName]) => ({ + id,displayName,provider:'codex',supportsEffort:true,supportedEffortLevels:['low','medium','high','xhigh','max'],defaultEffort:'max',supportsAdaptiveThinking:false, + })), + })) + + const nativeCatalog = { + ok: true as const, sessionType: 'freshcodex' as const, runtimeProvider: 'codex' as const, + status: 'fresh' as const, fetchedAt: 1234, + models: [{ id: 'provider-current', displayName: 'Provider Current', provider: 'codex' as const, + supportsEffort: true, supportedEffortLevels: ['none', 'ultra'], defaultEffort: 'ultra', + inputModalities: ['text', 'image'], supportsAdaptiveThinking: false }], + } + + it('offers Codex Default as an explicit effort clear through the real pane, configure and saved defaults', async () => { + const store = createStore() + seedFreshcodexPane(store, { model: 'provider-current', effort: 'ultra' }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue(nativeCatalog) + renderDialog(store, { open: true }) + await screen.findByRole('listbox', { name: 'Thinking levels for Provider Current' }) + await screen.findByRole('option', { name: /^ultra\b/ }) + fireEvent.click(screen.getByRole('option', { name: 'Default', exact: true })) + fireEvent.click(screen.getByRole('button', { name: 'Use Provider Current · Default' })) + expect(paneContent(store).effort).toBeUndefined() + expect(paneContent(store).effortIsNativeDefault).toBe(true) + expect(wsSendSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.configure', settings: { model: 'provider-current', effort: null } })) + // The established settings thunk encodes an own undefined as JSON null. + expect(saveServerSettingsPatchSpy).toHaveBeenCalledWith({ freshAgent: { providers: { freshcodex: { modelSelection: { kind: 'exact', modelId: 'provider-current' }, effort: undefined } } } }) + }) + + it('keeps a custom model and effort staged before create acknowledgement when the later catalog omits them', async () => { + const store = createStore() + seedFreshcodexPane(store, { sessionId: undefined, status: 'creating' }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ ...nativeCatalog, models: [] }) + renderDialog(store, { open: true }) + fireEvent.change(screen.getByRole('textbox', { name: 'Exact model ID' }), { target: { value: 'unlisted-custom' } }) + fireEvent.change(screen.getByRole('textbox', { name: 'Reasoning effort' }), { target: { value: 'ultra' } }) + expect(paneContent(store).model).toBe('gpt-6-astra') + store.dispatch(mergePaneContent({ tabId:'tab-1',paneId:'pane-1',updates:{sessionId:'ack-custom',status:'idle'} })) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name:'Use unlisted-custom · ultra' })) + expect(paneContent(store)).toMatchObject({model:'unlisted-custom',effort:'ultra'}) + }) + + it('automatically probes the acknowledged runtime without erasing pre-create search or the saved exact choice', async () => { + const store = createStore() + seedFreshcodexPane(store, { sessionId: undefined, status: 'creating', model: 'saved-custom', effort: 'xhigh' }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue(nativeCatalog) + renderDialog(store, { open: true }) + expect(getFreshAgentModelCapabilitiesSpy).not.toHaveBeenCalled() + expect(screen.getByRole('status')).toHaveTextContent(/waiting.*runtime/i) + const search = screen.getByRole('searchbox') + fireEvent.change(search, { target: { value: 'provider' } }) + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { sessionId: 'bound-native', status: 'idle' } })) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledWith('freshcodex', expect.objectContaining({ + codexContext: expect.objectContaining({ sessionId: 'bound-native', cwd: '/repo/project-a' }), + }))) + fireEvent.click(await screen.findByRole('option', { name: 'Provider Current' })) + expect(search).toHaveValue('provider') + fireEvent.click(screen.getByRole('button', { name: 'Use Provider Current · ultra' })) + expect(paneContent(store)).toMatchObject({ model: 'provider-current', effort: 'ultra' }) + expect(wsSendSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.configure', settings: { model: 'provider-current', effort: 'ultra' } })) + }) + + it('keeps the bound catalog stable when the same session reference is rematerialized', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + try { + const store = createStore() + const owner = { type: 'session.runtimeOwner' as const, provider: 'codex', sessionId: 'thread-dialog', + epoch: 7, generation: 3, ownerKind: 'fresh-agent' as const, operationId: 'catalog-owner', + transition: 'handoff-committed' as const } + store.dispatch(applyRuntimeOwner(owner)) + seedFreshcodexPane(store, { sessionRef: { provider: 'codex', sessionId: 'thread-dialog' } }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue(nativeCatalog) + renderDialog(store, { open: true }) + fireEvent.click(await screen.findByRole('option', { name: 'Provider Current' })) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'provider' } }) + fireEvent.click(screen.getByRole('option', { name: 'none', exact: true })) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(1) + await act(async () => { + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { + sessionRef: { provider: 'codex', sessionId: 'thread-dialog' }, + } })) + }) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(1) + now.mockReturnValue(1_001) + await act(async () => { store.dispatch(applyRuntimeOwner(owner)) }) + expect(getFreshAgentModelCapabilitiesSpy, JSON.stringify(getFreshAgentModelCapabilitiesSpy.mock.calls)).toHaveBeenCalledTimes(1) + // A real owner transition at the same fence remains a new source. + await act(async () => { store.dispatch(applyRuntimeOwner({ ...owner, transition: 'handoff-started' })) }) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(2) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenLastCalledWith('freshcodex', expect.objectContaining({ + codexContext: expect.objectContaining({ observedEpoch: 7, observedGeneration: 3 }), + })) + expect(screen.getByRole('searchbox')).toHaveValue('provider') + expect(screen.getByRole('button', { name: 'Use Provider Current · none' })).toBeEnabled() + } finally { + now.mockRestore() + } + }) + + it('keeps the exact current model and xhigh visible when catalog fails or omits it', async () => { + const store = createStore() + seedFreshcodexPane(store, { model: 'saved-custom', effort: 'xhigh' }) + getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ ...nativeCatalog, ok: false, status: 'unavailable', models: [], error: { code: 'CAPABILITY_PROBE_FAILED', message: 'offline' } }) + renderDialog(store, { open: true }) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalled()) + expect(screen.getByRole('button', { name: /Use saved-custom.*xhigh/ })).toBeEnabled() + expect(screen.queryByRole('option', { name: /GPT-6 Astra/ })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /Use saved-custom.*xhigh/ })) + expect(paneContent(store)).toMatchObject({ model: 'saved-custom', effort: 'xhigh' }) + }) + + it('rejects a delayed old-context catalog after rebind while preserving search and staged selection', async () => { + const store = createStore() + seedFreshcodexPane(store, { model: 'saved-custom', effort: 'xhigh' }) + let release!: (v: typeof nativeCatalog) => void + getFreshAgentModelCapabilitiesSpy.mockReturnValueOnce(new Promise((r) => { release = r })).mockResolvedValue(nativeCatalog) + renderDialog(store, { open: true }) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(1)) + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'provider' } }) + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { sessionId: 'replacement-native' } })) + fireEvent.click(await screen.findByRole('option', { name: 'Provider Current' })) + release({ ...nativeCatalog, models: [{ ...nativeCatalog.models[0], id: 'stale-model', displayName: 'Stale Provider' }] }) + await waitFor(() => expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledTimes(2)) + expect(screen.getByRole('searchbox')).toHaveValue('provider') + expect(screen.queryByRole('option', { name: 'Stale Provider' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Use Provider Current · ultra' })).toBeEnabled() + }) + + it('shows unavailable after failed ordinary creation without creating a catalog runtime', () => { + const store = createStore() + seedFreshcodexPane(store, { sessionId: undefined, status: 'create-failed', model: 'saved-custom', effort: 'ultra' }) + renderDialog(store, { open: true }) + expect(screen.getByRole('status')).toHaveTextContent(/catalog unavailable/i) + expect(getFreshAgentModelCapabilitiesSpy).not.toHaveBeenCalled() + expect(wsSendSpy).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /Use saved-custom.*ultra/ })).toBeEnabled() + }) it('preserves the current thinking level when confirming an unchanged model', () => { const store = createStore() seedFreshcodexPane(store, { effort: 'low' }) @@ -590,35 +732,16 @@ describe('FreshAgentModelDialog (freshcodex)', () => { expect(paneContent(store).effort).toBe('low') }) - it('discards a cancelled model selection when the dialog reopens', () => { + it('discards a cancelled model selection when the dialog reopens', async () => { const store = createStore() seedFreshcodexPane(store) const view = renderDialog(store, { open: true }) - fireEvent.click(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })) + fireEvent.click(await screen.findByRole('option', { name: /GPT-5\.6 Luna/ })) view.rerender() view.rerender() fireEvent.click(screen.getByRole('button', { name: 'Use GPT-6 Astra · max' })) expect(paneContent(store).model).toBe('gpt-6-astra') }) - it('uses the static freshcodex table without probing the catalog endpoint', async () => { - const store = createStore() - seedFreshcodexPane(store) - - renderDialog(store, { open: true }) - - await screen.findByRole('dialog', { name: 'Model and thinking level' }) - expect(getFreshAgentModelCapabilitiesSpy).not.toHaveBeenCalled() - // the current model is marked everywhere it appears (Recent + its group) - expect(screen.getAllByRole('option', { name: /GPT-6 Astra.*current/ }).length).toBeGreaterThanOrEqual(1) - expect(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })).toBeInTheDocument() - - // GPT-6 Astra levels canonically ordered - const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-6 Astra' }) - const levelNames = Array.from(levelsList.querySelectorAll('[role="option"]')).map((el) => el.textContent) - expect(levelNames.map((name) => name?.replace(/last used|highest|current|●/g, '').trim())).toEqual(['low', 'medium', 'high', 'xhigh', 'max']) - expect(screen.getByRole('button', { name: 'Use GPT-6 Astra · max' })).toBeInTheDocument() - }) - it('commits a freshcodex model + level under the freshcodex provider defaults and MRU scope', async () => { const onClose = vi.fn() const store = createStore() @@ -649,6 +772,25 @@ describe('FreshAgentModelDialog (freshcodex)', () => { const levelMru = JSON.parse(window.localStorage.getItem('freshcodex.modelLevelMru.v1') ?? '[]') expect(levelMru).toEqual([expect.objectContaining({ modelId: 'gpt-5.6-luna', level: 'low' })]) }) + + it('uses the bound native catalog while preserving the current row, advertised models and thinking levels', async () => { + const store = createStore() + seedFreshcodexPane(store) + renderDialog(store, {open:true}) + await screen.findByRole('option', {name:/GPT-5\.6 Luna/}) + expect(getFreshAgentModelCapabilitiesSpy).toHaveBeenCalledWith('freshcodex',expect.objectContaining({ + codexContext:expect.objectContaining({sessionId:'thread-dialog',cwd:'/repo/project-a'}), + })) + expect(screen.getAllByRole('option',{name:/GPT-6 Astra.*current/}).length).toBeGreaterThanOrEqual(1) + await waitFor(() => { + const levels = screen.getByRole('listbox', { name: 'Thinking levels for GPT-6 Astra' }) + const names = Array.from(levels.querySelectorAll('[role="option"]')) + .map(el => el.textContent?.replace(/last used|highest|current|●/g, '').trim()) + expect(names).toEqual(['Default', 'low', 'medium', 'high', 'xhigh', 'max']) + }) + fireEvent.click(screen.getByRole('button',{name:'Use GPT-6 Astra · max'})) + expect(paneContent(store)).toMatchObject({model:'gpt-6-astra',effort:'max'}) + }) }) describe('FreshAgentModelDialog (freshclaude)', () => { diff --git a/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx index c3ca1bf887..d774b919f0 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentQuestionBanner.test.tsx @@ -18,6 +18,18 @@ const singleQuestion = { describe('FreshAgentQuestionBanner', () => { afterEach(() => cleanup()) + it('preserves the legacy no-flag Other entry and trimming behavior when choices are absent', () => { + const onAnswer = vi.fn() + render() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Other', exact: true })) + const input = screen.getByRole('textbox', { name: 'Legacy question' }) + expect(input).toHaveFocus() + fireEvent.change(input, { target: { value: ' legacy answer ' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onAnswer).toHaveBeenCalledWith({ 'Legacy question': 'legacy answer' }) + }) + it('keeps distinct provider question IDs when question text repeats', () => { const onAnswer = vi.fn() render( { expect(screen.getByRole('button', { name: 'Other' })).toBeDisabled() }) }) + + +describe('native question metadata', () => { + afterEach(cleanup) + it('masks free entry without requiring Other and preserves the exact native answer', () => { + const onAnswer = vi.fn() + render() + const input = screen.getByLabelText('Enter token') + expect(input).toHaveAttribute('type', 'password') + expect(screen.queryByRole('button', { name: 'Other' })).not.toBeInTheDocument() + fireEvent.change(input, { target: { value: ' token, punctuation! ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Submit' })) + expect(onAnswer).toHaveBeenCalledWith({ token: ' token, punctuation! ' }) + }) + it('omits Other for a native choice that forbids it', () => { + render() + expect(screen.queryByRole('button', { name: 'Other' })).not.toBeInTheDocument() + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx index d1f68c2535..d72ef1e506 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentSettingsButton.test.tsx @@ -220,6 +220,41 @@ afterEach(() => { }) describe('FreshAgentSettingsButton', () => { + it.each(['read-only', 'workspace-write', 'danger-full-access'])('stages Codex file access %s through the real store, configure and defaults', (sandbox) => { + const store = createStore() + seedPane(store, { sessionType: 'freshcodex', provider: 'codex', status: 'running', model: 'provider-current', effort: 'ultra', permissionMode: 'never' }) + renderButton(store) + fireEvent.click(screen.getByRole('button', { name: 'Agent settings' })) + const input = screen.getByRole('combobox', { name: 'File access', exact: true }) + expect(input).toBeEnabled() + fireEvent.change(input, { target: { value: sandbox } }) + expect(readPaneContent(store)).toMatchObject({ sandbox, model: 'provider-current', effort: 'ultra', permissionMode: 'never' }) + expect(wsSendSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.configure', settings: { sandbox } })) + expect(saveServerSettingsPatchSpy).toHaveBeenCalledWith({ freshAgent: { providers: { freshcodex: { sandbox } } } }) + }) + + it.each(['plan', 'default'])('stages Codex work mode %s independently of approval and file access', (collaborationMode) => { + const store = createStore() + seedPane(store, { sessionType: 'freshcodex', provider: 'codex', status: 'running', sandbox: 'read-only', permissionMode: 'never', model: 'provider-current', effort: 'ultra' }) + renderButton(store) + fireEvent.click(screen.getByRole('button', { name: 'Agent settings' })) + fireEvent.change(screen.getByRole('combobox', { name: 'Work mode', exact: true }), { target: { value: collaborationMode } }) + expect(readPaneContent(store)).toMatchObject({ collaborationMode, sandbox: 'read-only', permissionMode: 'never', model: 'provider-current', effort: 'ultra' }) + expect(wsSendSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.configure', settings: { collaborationMode } })) + expect(saveServerSettingsPatchSpy).toHaveBeenCalledWith({ freshAgent: { providers: { freshcodex: { collaborationMode } } } }) + }) + + it.each(['create-only', 'unsupported'])('disables unavailable Codex work and file changes for scope %s', async (scope) => { + getFreshAgentThreadSnapshotSpy.mockResolvedValue({ ...THREAD_SNAPSHOT_WITHOUT_SCOPES, capabilities: { ...THREAD_SNAPSHOT_WITHOUT_SCOPES.capabilities, settingScopes: { sandbox: scope, collaborationMode: scope } } }) + const store = createStore() + seedPane(store, { sessionType: 'freshcodex', provider: 'codex' }) + renderButton(store) + fireEvent.click(screen.getByRole('button', { name: 'Agent settings' })) + await waitFor(() => expect(screen.getByRole('combobox', { name: 'File access', exact: true })).toBeDisabled()) + expect(screen.getByRole('combobox', { name: 'Work mode', exact: true })).toBeDisabled() + expect(wsSendSpy).not.toHaveBeenCalled() + }) + it('offers valid Codex approval policies without implying sandbox access', () => { const store = createStore() seedPane(store, { sessionType: 'freshcodex', provider: 'codex', permissionMode: 'on-request', sandbox: 'read-only' }) @@ -328,6 +363,11 @@ describe('FreshAgentSettingsButton', () => { }) it('opens the shared dialog from the freshcodex Change… button and persists the committed choice', async () => { + getFreshAgentModelCapabilitiesSpy.mockResolvedValue({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1_234, + models: [{ id: 'gpt-5.6-luna', displayName: 'GPT-5.6 Luna', provider: 'codex', supportsEffort: true, + supportedEffortLevels: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], supportsAdaptiveThinking: false }], + }) const store = createStore() seedPane(store, { sessionType: 'freshcodex', @@ -341,7 +381,7 @@ describe('FreshAgentSettingsButton', () => { fireEvent.click(screen.getByRole('button', { name: /Change/ })) await screen.findByRole('dialog', { name: 'Model and thinking level' }) - fireEvent.click(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })) + fireEvent.click(await screen.findByRole('option', { name: /GPT-5\.6 Luna/ })) const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) const lowOption = Array.from(levelsList.querySelectorAll('[role="option"]')).find((el) => el.textContent?.includes('low')) expect(lowOption).toBeDefined() diff --git a/test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx new file mode 100644 index 0000000000..51078f5c67 --- /dev/null +++ b/test/unit/client/components/fresh-agent/FreshAgentSidebar.test.tsx @@ -0,0 +1,27 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { FreshAgentSidebar } from '@/components/fresh-agent/FreshAgentSidebar' +import { FreshAgentObserveChildContext } from '@/components/fresh-agent/FreshAgentItemCard' + +afterEach(cleanup) + +it('observes the selected Codex identity with its parent fence from the child panel', () => { + const parent = { parentSessionId: 'parent', provider: 'codex', sessionType: 'freshcodex', observedEpoch: 1, observedGeneration: 2 } as const + const onObserve = vi.fn() + render() + expect(screen.getByText(/Child failure/)).toHaveTextContent('child-model') + fireEvent.click(screen.getByRole('button', { name: 'Observe child child' })) + expect(onObserve).toHaveBeenCalledWith({ type: 'observe-child', parent, childThreadId: 'child' }) +}) + +it('renders the latest plan and step statuses, replacing earlier progress', () => { + const props: any = { worktrees: [], childThreads: [], codexPlan: { explanation: 'Work in order', plan: [{ step: 'Inspect', status: 'inProgress' }, { step: 'Verify', status: 'pending' }] } } + const { rerender } = render() + expect(screen.getByText('Plan')).toBeVisible() + expect(screen.getByText('Inspect').closest('li')).toHaveTextContent('In progress') + expect(screen.getByText('Verify').closest('li')).toHaveTextContent('Pending') + rerender() + expect(screen.getByText('Inspect').closest('li')).toHaveTextContent('Completed') + expect(screen.queryByText('Verify')).toBeNull() + expect(screen.queryByText('Work in order')).toBeNull() +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx index ef690173bb..1a582c7bf3 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx @@ -23,6 +23,47 @@ vi.mock('@/components/markdown/LazyMarkdown', async () => { describe('FreshAgentTranscript', () => { afterEach(() => cleanup()) + it('shows native asynchronous choices when the agent message has no text body', () => { + render() + expect(screen.getByText('Choose next step')).toBeVisible() + expect(screen.getByText('Inspect')).toBeVisible() + expect(screen.getByText('Verify')).toBeVisible() + }) + + it.each(['running', 'completed', 'failed', 'declined'] as const)('preserves %s command and file outcomes in the transcript', (status) => { + render() + const label = { running: 'running', completed: 'complete', failed: 'error', declined: 'declined' }[status] + expect(screen.getAllByLabelText(label)).toHaveLength(2) + if (status !== 'completed') expect(screen.queryByLabelText('complete')).not.toBeInTheDocument() + const command = screen.getByRole('button', { name: 'Bash tool call' }) + if (command.getAttribute('aria-expanded') !== 'true') fireEvent.click(command) + expect(screen.getByText('Command output')).toBeVisible() + if (status === 'failed') expect(screen.getByText('Exit code: 17')).toBeVisible() + }) + + it.each([ + [{ provenance: 'saved', diagnostic: 'interrupted', refreshPending: false }, 'Only saved output remains.'], + [{ provenance: 'retained', diagnostic: 'unsaved', refreshPending: false }, 'Saved output could not be confirmed.'], + [{ provenance: 'saved-and-retained', diagnostic: 'provider-truncated', refreshPending: false }, 'Codex shortened the saved tool output.'], + [{ provenance: 'preview', refreshPending: false }, 'Native preview.'], + [{ provenance: 'retained', refreshPending: true }, 'Waiting for saved history.'], + ] as const)('renders finite output provenance %j', (outputState, text) => { + render() + expect(screen.getByRole('note', { name: 'Output persistence' })).toHaveTextContent(text) + expect(screen.getAllByText('Available output')).toHaveLength(1) + }) + + it('keeps richer retained output in a disclosure within the saved turn group', async () => { + render() + expect(screen.getAllByLabelText('Assistant transcript turn')).toHaveLength(1) + fireEvent.click(screen.getByText('Retained output', { selector: 'summary' })) + expect(await screen.findByText('Unfinished retained body')).toBeVisible() + expect(screen.getByText('Saved body')).toBeVisible() + }) + it('renders normalized text turns', () => { render( ({ getFreshAgentThreadSnapshot: vi.fn(), })) +vi.mock('@/components/markdown/LazyMarkdown', () => ({ LazyMarkdown: ({ content }: { content: string }) => {content} })) + vi.mock('@/lib/ws-client', () => ({ getWsClient: () => ({ send: wsMock.send, @@ -233,3 +235,122 @@ describe('FreshAgentView approvals (client-alerts G20)', () => { ]) }) }) + + +it('retains an MCP form from a snapshot and sends a separate typed answer', async () => { + const current = { ...snapshot(CODEX, [], 1), pendingElicitations: [{ requestId: '41', mode: 'form', serverName: 'contract', message: 'Enter credentials', fields: [{ id: 'token', type: 'string', title: 'Token', required: true, secret: true }], providerRequest: { id: 41, method: 'mcpServer/elicitation/request' } }] } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(current) + await mount(createStore(CODEX)) + const token = screen.getByLabelText('Token') + expect(token).toHaveAttribute('type', 'password') + fireEvent.change(token, { target: { value: ' exact, token! ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Continue', exact: true })) + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.elicitation.respond', requestId: '41', action: 'accept', content: { token: ' exact, token! ' } })) +}) + +it.each(['freshAgent.elicitation.request', 'freshAgent.elicitation.cancelled'])('refreshes an idle native form from %s without waiting for a turn or poll', async (eventType) => { + const request = { requestId: 'live-elicitation', mode: 'form', serverName: 'contract', message: 'Live form', fields: [{ id: 'name', title: 'Live name', type: 'string', required: true }] } + const before = eventType === 'freshAgent.elicitation.request' ? [] : [request] + const after = eventType === 'freshAgent.elicitation.request' ? [request] : [] + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', pendingElicitations: before }) + const store = createStore(CODEX) + await mount(store) + expect(screen.queryAllByRole('region', { name: 'Request from contract' })).toHaveLength(before.length) + const reads = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 2), status: 'idle', pendingElicitations: after }) + const message = { type: 'freshAgent.event', ...CODEX, event: { ...request, type: eventType } } + act(() => { + handleFreshAgentMessage(store.dispatch, message) + for (const handler of wsMock.handlers) handler(message) + }) + await advance(SNAPSHOT_DEBOUNCE_MS) + await advance(0) + expect(screen.queryAllByRole('region', { name: 'Request from contract' })).toHaveLength(after.length) + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(reads + 1) + expect(wsMock.send.mock.calls.filter(([frame]) => frame.type === 'freshAgent.elicitation.respond' || frame.type === 'freshAgent.send')).toHaveLength(0) +}) + +it('native approvals expose only offered actions and no pane auto-grant', async () => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), pendingApprovals: [{ requestId: '41', toolName: 'Bash', native: { networkApprovalContext: { host: 'example.com', protocol: 'https' }, availableDecisions: ['accept', 'cancel'] } }] }) + await mount(createStore(CODEX)) + expect(screen.getByRole('alert', { name: 'Permission request for Bash' })).toHaveTextContent('example.com') + expect(screen.queryByRole('button', { name: /always allow/i })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Deny tool use' })).not.toBeInTheDocument() + fireEvent.click(within(screen.getByRole('alert', { name: 'Permission request for Bash' })).getByRole('button', { name: 'Stop', exact: true })) + expect(responds().at(-1)).toMatchObject({ requestId: '41', decision: { nativeDecision: 'cancel' } }) +}) + + +it.each([true, false])('answers async questions through the ordinary composer and waits for ordinary acceptance (native turn ID: %s)', async (hasTurnId) => { + const current = { ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn:row-1', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'I can keep working.', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging', 'Production'] }] } } }] }] } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(current) + const store = createStore(CODEX) + await mount(store) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) + expect((composer as HTMLTextAreaElement).value).toContain('native-async-item') + expect((composer as HTMLTextAreaElement).value).toContain('Staging') + expect(wsMock.send.mock.calls.some(([m]) => m.type === 'freshAgent.question.respond')).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await advance(0) + const sent = wsMock.send.mock.calls.map(([m]) => m).find((m) => m.type === 'freshAgent.send') + expect(sent).toMatchObject({ text: expect.stringContaining('Staging') }) + expect(screen.queryByText('Answer accepted as a message')).not.toBeInTheDocument() + act(() => { for (const handler of wsMock.handlers) handler({ type: 'freshAgent.send.accepted', ...CODEX, requestId: sent.requestId, ...(hasTurnId ? { submittedTurnId: 'ordinary-next-turn' } : {}) }) }) + expect(screen.getByText('Answer accepted as a message')).toBeVisible() +}) + +it('changing an unsent async choice updates that answer and preserves the ordinary draft', async () => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'Work continues', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging', 'Production'] }] } } }] }] }) + await mount(createStore(CODEX)) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement + fireEvent.change(composer, { target: { value: 'Keep my ordinary note' } }) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + fireEvent.click(screen.getByRole('button', { name: 'Production', exact: true })) + expect(composer.value).toContain('Keep my ordinary note') + expect(composer.value).toContain('Production') + expect(composer.value).not.toContain('Staging') + expect(wsMock.send.mock.calls.some(([message]) => message.type === 'freshAgent.send' || message.type === 'freshAgent.question.respond')).toBe(false) +}) + +it('restores an ordinary async-answer draft silently and associates acceptance only after explicit Send', async () => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'Work continues', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging'] }] } } }] }] }) + const store = createStore(CODEX) + await mount(store) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + cleanup() + resetSnapshotSchedulerForTests() + await mount(store) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement + expect(composer.value).toContain('native-async-item') + expect(wsMock.send.mock.calls.filter(([message]) => message.type === 'freshAgent.send')).toHaveLength(0) + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await advance(0) + const sent = wsMock.send.mock.calls.map(([message]) => message).find((message) => message.type === 'freshAgent.send') + expect(sent.text).toContain('Staging') + act(() => { for (const handler of wsMock.handlers) handler({ type: 'freshAgent.send.accepted', ...CODEX, requestId: sent.requestId, submittedTurnId: 'ordinary-next-turn' }) }) + expect(screen.getByText('Answer accepted as a message')).toBeVisible() + expect(wsMock.send.mock.calls.filter(([message]) => message.type === 'freshAgent.question.respond')).toHaveLength(0) +}) + +it.each([ + ['SESSION_RESERVED', 'Another resume is in flight', 'Answer not sent; your draft is in the composer.'], + ['INTERNAL_ERROR', 'CODEX_DELIVERY_AMBIGUOUS: response was lost', 'Answer delivery uncertain; review the composer before sending again.'], +])('retains an ordinary async answer and its delivery indication after %s', async (code, message, indication) => { + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...snapshot(CODEX, [], 1), status: 'idle', turns: [{ id: 'native-turn', turnId: 'native-turn', role: 'assistant', summary: '', items: [{ id: 'async-item', kind: 'text', text: 'Work continues', extensions: { codex: { delivery: 'async', itemId: 'native-async-item', questions: [{ title: 'Where?', options: ['Staging'] }] } } }] }] }) + await mount(createStore(CODEX)) + fireEvent.click(screen.getByRole('button', { name: 'Staging', exact: true })) + const composer = screen.getByRole('textbox', { name: 'Chat message input' }) as HTMLTextAreaElement + const original = composer.value + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await advance(0) + const sent = wsMock.send.mock.calls.map(([frame]) => frame).find((frame) => frame.type === 'freshAgent.send') + fireEvent.change(composer, { target: { value: 'A newer ordinary draft' } }) + act(() => { for (const handler of wsMock.handlers) handler({ type: 'error', requestId: sent.requestId, code, message }) }) + expect(composer).toHaveValue(`${original}\nA newer ordinary draft`) + expect(screen.getByText(indication)).toBeVisible() + expect(screen.queryByText('Answer accepted as a message')).not.toBeInTheDocument() + await advance(1000) + expect(wsMock.send.mock.calls.filter(([frame]) => frame.type === 'freshAgent.send')).toHaveLength(1) + expect(wsMock.send.mock.calls.filter(([frame]) => frame.type === 'freshAgent.question.respond')).toHaveLength(0) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx index d93dcf991c..b6740f42cd 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.reconcile.test.tsx @@ -525,6 +525,35 @@ describe('FreshAgentView SESSION_RESERVED re-drive (Task 14)', () => { expect(document.querySelector('.fresh-agent-error-card')).toBeNull() }) + it('keeps the Freshcodex prompt and settings when preparation refuses the selected configuration', async () => { + const key = 'fresh-agent-draft:freshcodex:req-codex-config' + const draft = 'Keep this prompt until the selected configuration is repaired.' + const selected = { + sessionType: 'freshcodex' as const, provider: 'codex' as const, + status: 'creating' as const, createRequestId: 'req-codex-config', initialCwd: '/owned/project', + model: 'contract-model', modelSelection: { kind: 'exact' as const, modelId: 'contract-model' }, + effort: 'low', permissionMode: 'untrusted', sandbox: 'workspace-write' as const, + } + window.sessionStorage.setItem(key, draft) + try { + renderFreshAgentPane(selected) + await flush() + const creates = sentOfType('freshAgent.create').length + const message = 'Codex approval_policy in /owned/codex/config.toml cannot preserve the selected source.' + receiveWsBoth({ type: 'freshAgent.create.failed', requestId: selected.createRequestId, + code: 'INVALID_REQUEST', message, retryable: false }) + await flush() + expect(screen.getByText(message)).toBeVisible() + expect(leafContent(store.getState())).toEqual(expect.objectContaining({ ...selected, status: 'create-failed' })) + expect(store.getState().freshAgent.pendingCreateFailures[selected.createRequestId]).toEqual({ code: 'INVALID_REQUEST', message, retryable: false }) + expect(screen.getByRole('textbox')).toHaveValue(draft) + expect(window.sessionStorage.getItem(key)).toBe(draft) + await act(async () => { await vi.advanceTimersByTimeAsync(FRESH_AGENT_RESERVE_RETRY_FLOOR_MS + 20) }) + expect(sentOfType('freshAgent.create')).toHaveLength(creates) + expect(sentOfType('freshAgent.send')).toHaveLength(0) + } finally { window.sessionStorage.removeItem(key) } + }) + it('non-reserved create.failed still lands create-failed status (regression)', async () => { renderFreshAgentPane({ status: 'creating', createRequestId: 'req-1' }) await flush() diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 1982c81009..8a96c8a1f4 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -20,6 +20,7 @@ import { FreshAgentView, IDLE_INCOMPLETE_MAX_RETRIES, locatorMatchesPane } from import { FreshAgentSettingsButton } from '@/components/fresh-agent/FreshAgentSettingsButton' import { initLayout, + mergePaneContent, splitPane, applyFreshAgentReconcileAttach, requestPaneRefresh, @@ -1281,6 +1282,37 @@ describe('new conversation close acceptance', () => { }) describe('FreshAgentView', () => { + it('converges Codex native settings into the next send while preserving newer staged choices and absent metadata', async () => { + const store = createStore() + const locator = { sessionId: 'settings-convergence', sessionType: 'freshcodex' as const, provider: 'codex' as const } + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ status: 'idle', capabilities: { send: true, interrupt: true }, turns: [] }) + store.dispatch(sessionInit(locator)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { + kind: 'fresh-agent', ...locator, createRequestId: 'settings-convergence-create', status: 'idle', + model: 'stale-model', effort: 'low', initialCwd: '/stale', sandbox: 'danger-full-access', permissionMode: 'never', collaborationMode: 'default', + } })) + render() + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeEnabled()) + const metadata = (settings: Record) => act(() => { + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, event: { type: 'freshAgent.session.metadata', sessionId: locator.sessionId, ...settings } }) + }) + const native = { model: 'native-model', effort: 'ultra', cwd: '/native', sandbox: 'read-only', permissionMode: 'on-request', collaborationMode: 'plan' } + metadata(native) + const { cwd: nativeCwd, ...nativePaneSettings } = native + await waitFor(() => expect(getFreshAgentPaneContent(store)).toMatchObject({ ...nativePaneSettings, initialCwd: nativeCwd })) + const staged = { model: 'staged-model', effort: 'future-effort', initialCwd: '/staged', sandbox: 'workspace-write' as const, permissionMode: 'never', collaborationMode: 'default' as const } + act(() => store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: staged }))) + metadata({ model: native.model, effort: native.effort }) + expect(getFreshAgentPaneContent(store)).toMatchObject(staged) + metadata({ ...staged, cwd: staged.initialCwd }) + metadata({ model: 'other-device-model', effort: null, cwd: '/other', sandbox: 'read-only', permissionMode: 'untrusted', collaborationMode: 'plan' }) + await waitFor(() => expect(getFreshAgentPaneContent(store)).toMatchObject({ model: 'other-device-model', initialCwd: '/other', sandbox: 'read-only', permissionMode: 'untrusted', collaborationMode: 'plan' })) + expect(getFreshAgentPaneContent(store).effort).toBeUndefined() + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { target: { value: 'use the native settings' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.send')[0].settings).toEqual({ model: 'other-device-model', effort: null, cwd: '/other', sandbox: 'read-only', permissionMode: 'untrusted', collaborationMode: 'plan' }) + }) it('renders and dismisses a failed close while keeping the stopped managed conversation', async () => { const store = createStore() const handlers = new Set<(message: unknown) => void>() @@ -1396,6 +1428,27 @@ describe('FreshAgentView', () => { expect(screen.getByRole('status', { name: 'Queued messages' })).toHaveTextContent('1 queued') }) + it('holds queued Codex input after ambiguous delivery without replaying the original', async () => { + const queue = await setup('idle') + queue.send('Original uncertain input') + queue.send('Later queued input') + const first = sentFreshAgentMessages('freshAgent.send')[0] + const listener = wsMock.onMessage.mock.calls.at(-1)?.[0] as unknown as (message: unknown) => void + act(() => listener({ type: 'error', code: 'INTERNAL_ERROR', requestId: first.requestId, + message: 'CODEX_DELIVERY_AMBIGUOUS: response was lost' })) + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue('Original uncertain input')) + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1) + expect(screen.getByRole('status', { name: 'Queued messages' })).toHaveTextContent('1 queued') + expect(screen.getByText(`Delivery uncertain (${first.requestId}): response was lost. Check the conversation before sending again.`)).toBeInTheDocument() + act(() => listener({ type: 'error', code: 'INTERNAL_ERROR', requestId: first.requestId, + message: 'CODEX_DELIVERY_AMBIGUOUS: response was lost' })) + expect(screen.getByRole('textbox', { name: 'Chat message input' })).toHaveValue('Original uncertain input') + queue.status('running') + queue.status('idle') + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(2)) + expect(sentFreshAgentMessages('freshAgent.send')[1]).toMatchObject({ text: 'Later queued input' }) + }) + it('advances after a fast interrupted turn whose status updates share one render', async () => { const queue = await setup('idle') queue.send('First message') @@ -2504,7 +2557,7 @@ describe('FreshAgentView', () => { sessionType: 'freshcodex', provider: 'codex', model: 'gpt-6-astra', - effort: 'max', + effort: undefined, })) const onMessage = wsMock.onMessage.mock.calls[0]?.[0] @@ -3480,7 +3533,7 @@ describe('FreshAgentView', () => { settings: { cwd: '/repo', model: 'gpt-5.3-codex-spark', - effort: 'max', + collaborationMode: 'default', }, })) }) @@ -6903,6 +6956,11 @@ describe('FreshAgentView', () => { }) it('lets Freshcodex settings choose model and thinking level from the gear popover’s Change… dialog', async () => { + apiMock.getFreshAgentModelCapabilities.mockResolvedValue({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1_234, + models: [{ id: 'gpt-5.6-luna', displayName: 'GPT-5.6 Luna', provider: 'codex', supportsEffort: true, + supportedEffortLevels: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], supportsAdaptiveThinking: false }], + }) const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -6934,14 +6992,14 @@ describe('FreshAgentView', () => { fireEvent.click(screen.getByRole('button', { name: /Change/ })) await screen.findByRole('dialog', { name: 'Model and thinking level' }) - fireEvent.click(screen.getByRole('option', { name: /GPT-5\.6 Luna/ })) + fireEvent.click(await screen.findByRole('option', { name: /GPT-5\.6 Luna/ })) // GPT-5.6 Luna declares the current GPT-5.6 reasoning levels in // canonical order. - const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) + const levelsList = await screen.findByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) const levelTexts = Array.from(levelsList.querySelectorAll('[role="option"]')).map((el) => el.textContent) expect(levelTexts.map((text) => text?.replace(/last used|highest|current|●/g, '').trim())).toEqual( - ['none', 'low', 'medium', 'high', 'xhigh', 'max'], + ['Default', 'none', 'low', 'medium', 'high', 'xhigh', 'max'], ) fireEvent.click(screen.getByRole('button', { name: 'Use GPT-5.6 Luna · max' })) @@ -6965,6 +7023,11 @@ describe('FreshAgentView', () => { }) it('persists Freshcodex thinking and permission settings as fresh-agent provider defaults', async () => { + apiMock.getFreshAgentModelCapabilities.mockResolvedValue({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1_234, + models: [{ id: 'gpt-5.6-luna', displayName: 'GPT-5.6 Luna', provider: 'codex', supportsEffort: true, + supportedEffortLevels: ['none', 'low', 'medium', 'high', 'xhigh', 'max'], supportsAdaptiveThinking: false }], + }) const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -6990,9 +7053,9 @@ describe('FreshAgentView', () => { fireEvent.click(screen.getByRole('button', { name: 'Agent settings' })) // Thinking now persists through the Change… dialog, not a retired dropdown. - fireEvent.click(screen.getByRole('button', { name: /GPT-5\.6 Luna · medium.*Change/ })) + fireEvent.click(screen.getByRole('button', { name: /gpt-5\.6-luna · medium.*Change/ })) await screen.findByRole('dialog', { name: 'Model and thinking level' }) - const levelsList = screen.getByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) + const levelsList = await screen.findByRole('listbox', { name: 'Thinking levels for GPT-5.6 Luna' }) const highOption = Array.from(levelsList.querySelectorAll('[role="option"]')).find((el) => el.textContent?.includes('high')) expect(highOption).toBeDefined() fireEvent.click(highOption!) @@ -8182,7 +8245,36 @@ describe('FreshAgentView', () => { expect(screen.queryByRole('radio', { name: 'custom-codex-model' })).not.toBeInTheDocument() }) - it('normalizes stale Freshcodex thinking effort before create and send', async () => { + it.each([ + { name: 'opaque effort', effort: 'future-effort', effortIsNativeDefault: false, wire: 'future-effort' }, + { name: 'native Default', effort: undefined, effortIsNativeDefault: true, wire: null }, + ])('preserves a newer $name choice across create acknowledgement and stale initial settings', async ({ effort, effortIsNativeDefault, wire }) => { + const store = createStore() + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ + status: 'idle', capabilities: { send: true, interrupt: true }, turns: [], + settings: { model: 'provider-current', effort: 'ultra' }, + }) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { + kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', + createRequestId: 'pre-ack-settings', status: 'creating', model: 'provider-current', effort: 'ultra', + } })) + render() + expect(sentFreshAgentMessages('freshAgent.create')[0]).toMatchObject({ requestId: 'pre-ack-settings', effort: 'ultra' }) + act(() => store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { effort, effortIsNativeDefault } }))) + const onMessage = wsMock.onMessage.mock.calls[0]?.[0] + expect(onMessage).toBeTypeOf('function') + act(() => onMessage({ type: 'freshAgent.created', requestId: 'pre-ack-settings', + sessionId: 'pre-ack-native', sessionType: 'freshcodex', provider: 'codex', runtimeProvider: 'codex' })) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Chat message input' })).toBeEnabled()) + expect(getFreshAgentPaneContent(store)).toMatchObject({ effort, effortIsNativeDefault }) + fireEvent.change(screen.getByRole('textbox', { name: 'Chat message input' }), { target: { value: 'use newer settings' } }) + fireEvent.click(screen.getByRole('button', { name: 'Send', exact: true })) + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.send')[0].settings).toMatchObject({ model: 'provider-current', effort: wire }) + }) + + it('preserves explicit Freshcodex xhigh effort before create and send', async () => { const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -8206,7 +8298,7 @@ describe('FreshAgentView', () => { expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.create', requestId: 'req-stale-effort', - effort: 'max', + effort: 'xhigh', })) const onMessage = wsMock.onMessage.mock.calls[0]?.[0] @@ -8232,7 +8324,7 @@ describe('FreshAgentView', () => { expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.send', - settings: expect.objectContaining({ effort: 'max' }), + settings: expect.objectContaining({ effort: 'xhigh' }), })) }) @@ -10252,6 +10344,8 @@ describe('FreshAgentView', () => { ['freshclaude', 'INVALID_SESSION_ID', 'The Claude agent process for this session exited. Resume the session to continue.', 'The Claude agent process for this session exited. Resume the session to continue.'], ['kilroy', 'INTERNAL_ERROR', 'SESSION_NOT_FOUND: claude session not found', 'claude session not found'], + ['freshcodex', 'SESSION_RESERVED', 'Another resume for this session is in flight', 'Another resume for this session is in flight'], + ['freshcodex', 'INTERNAL_ERROR', 'CODEX_DELIVERY_AMBIGUOUS: native response was lost', 'native response was lost'], ] as const)('puts a refused %s send back into the composer and says why (%s)', async (sessionType, code, message, reason) => { const store = createStore() let onMessage: ((message: Record) => void) | undefined @@ -10266,16 +10360,17 @@ describe('FreshAgentView', () => { turns: [], }) const durable = '0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d' + const provider = sessionType === 'freshcodex' ? 'codex' : 'claude' store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', sessionType, - provider: 'claude', + provider, createRequestId: `req-${sessionType}-refused`, sessionId: `${sessionType}-pane-refused`, - sessionRef: { provider: 'claude', sessionId: durable }, + sessionRef: { provider, sessionId: durable }, status: 'idle', }, })) @@ -10293,13 +10388,16 @@ describe('FreshAgentView', () => { }) const sendFrame = sentFreshAgentMessages('freshAgent.send').at(-1) expect(composer).toHaveValue('') + if (provider === 'codex') fireEvent.change(composer, { target: { value: 'newer draft' } }) act(() => { onMessage?.({ type: 'error', code, message, requestId: sendFrame?.requestId, timestamp: new Date().toISOString() }) }) - await waitFor(() => expect(composer).toHaveValue('please keep this')) - expect(screen.getByText(`Message not sent: ${reason}`)).toBeInTheDocument() + await waitFor(() => expect(composer).toHaveValue(provider === 'codex' ? 'please keep this\nnewer draft' : 'please keep this')) + expect(screen.getByText(message.startsWith('CODEX_DELIVERY_AMBIGUOUS:') + ? `Delivery uncertain (${sendFrame?.requestId}): ${reason}. Check the conversation before sending again.` + : `Message not sent: ${reason}`)).toBeInTheDocument() expect(getFreshAgentPaneContent(store).pendingLocalEcho).toBeUndefined() }) @@ -11646,7 +11744,7 @@ describe('FreshAgentView /model slash command', () => { expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(0) }) - it('opens the shared model dialog for freshcodex without any catalog probe', async () => { + it('opens the shared model dialog for freshcodex using the bound native catalog context', async () => { const store = createStore() store.dispatch(initLayout({ tabId: 'tab-1', @@ -11672,7 +11770,9 @@ describe('FreshAgentView /model slash command', () => { fireEvent.click(screen.getByRole('button', { name: 'Send' })) expect(await screen.findByRole('dialog', { name: 'Model and thinking level' })).toBeInTheDocument() - expect(apiMock.getFreshAgentModelCapabilities).not.toHaveBeenCalled() + expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenCalledWith('freshcodex', expect.objectContaining({ + codexContext: expect.objectContaining({ sessionId: 'ses_model_cmd', sessionRef: { provider: 'codex', sessionId: 'ses_model_cmd' } }), + })) }) it('shows the shared catalog-unavailable notice instead of an empty dialog when the freshopencode probe fails', async () => { @@ -12403,6 +12503,94 @@ describe('/undo + /redo dispatch (kata 1wxv)', () => { }) describe('FreshAgentView session status strip', () => { + describe('bound Codex catalog strip', () => { + const model = 'catalog-only-model' + const catalog = (label: string) => ({ ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', + status: 'fresh', fetchedAt: 1_000, models: [{ id: model, displayName: label, provider: 'codex', + supportsEffort: true, supportedEffortLevels: ['ultra'], supportsAdaptiveThinking: false }] }) + const nativeId = savedCodexNativeHistory.threadId + const content = { kind: 'fresh-agent' as const, sessionType: 'freshcodex' as const, provider: 'codex' as const, + createRequestId: 'bound-strip-create', initialCwd: '/repo/bound-catalog', model, + modelSelection: { kind: 'exact' as const, modelId: model }, effort: 'ultra' } + const owner = { type: 'session.runtimeOwner' as const, provider: 'codex' as const, sessionId: nativeId, + epoch: 7, generation: 3, ownerKind: 'fresh-agent' as const, operationId: 'bound-strip-owner', + transition: 'handoff-committed' as const } + const bound = { ...content, sessionId: nativeId, sessionRef: { provider: 'codex' as const, sessionId: nativeId }, + status: 'connected' as const } + + it('waits for ordinary binding then resolves the model name through its exact owned Codex context', async () => { + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + apiMock.getFreshAgentModelCapabilities.mockResolvedValue(catalog('Owning runtime model')) + const store = createStore() + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { ...content, status: 'creating' } })) + render() + expect(apiMock.getFreshAgentModelCapabilities).not.toHaveBeenCalled() + expect(screen.queryByRole('button', { name: /^Model: / })).toBeNull() + const createsBefore = sentFreshAgentMessages('freshAgent.create') + act(() => { + store.dispatch(applyRuntimeOwner(owner)) + store.dispatch(mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: bound })) + store.dispatch(sessionInit({ sessionType: 'freshcodex', provider: 'codex', sessionId: nativeId, model, effort: 'ultra' })) + }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Model: Owning runtime model — change model' })).toBeInTheDocument()) + expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenCalledExactlyOnceWith('freshcodex', { + cwd: content.initialCwd, codexContext: { sessionId: nativeId, sessionRef: bound.sessionRef, + cwd: content.initialCwd, observedEpoch: 7, observedGeneration: 3 }, + }) + expect(screen.getByRole('button', { name: 'Model: Owning runtime model — change model' })) + .toHaveAttribute('title', 'catalog-only-model · effort ultra') + expect(screen.queryByRole('button', { name: `Model: ${model} — change model` })).toBeNull() + expect(sentFreshAgentMessages('freshAgent.create')).toEqual(createsBefore) + }) + + it.each(['raw-id', 'unavailable', 'wrong-provider'] as const)('keeps an unstamped custom Codex model intact and unlabeled after a %s reply', async (reply) => { + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + const pending = createDeferred() + apiMock.getFreshAgentModelCapabilities.mockReturnValue(pending.promise) + const store = createStore() + store.dispatch(applyRuntimeOwner(owner)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: bound })) + store.dispatch(sessionInit({ sessionType: 'freshcodex', provider: 'codex', sessionId: nativeId, model, effort: 'ultra' })) + render() + await waitFor(() => expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenCalledTimes(1)) + const response = reply === 'raw-id' ? catalog(model) + : reply === 'unavailable' ? { ...catalog('Unused label'), ok: false, status: 'unavailable', models: [] } + : { ...catalog('Other provider label'), sessionType: 'freshopencode', runtimeProvider: 'opencode' } + await act(async () => { pending.resolve(response) }) + expect(screen.queryByRole('button', { name: /^Model: / })).toBeNull() + expect(getFreshAgentPaneContent(store)).toMatchObject({ model, effort: 'ultra', modelSelection: { kind: 'exact', modelId: model } }) + expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(0) + }) + + it('ignores a stale same-model Codex source reply without reprobing identical owner observations', async () => { + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(new Promise(() => {})) + const old = createDeferred(), current = createDeferred() + apiMock.getFreshAgentModelCapabilities.mockReturnValueOnce(old.promise).mockReturnValueOnce(current.promise) + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000) + try { + const store = createStore() + store.dispatch(applyRuntimeOwner(owner)) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: bound })) + store.dispatch(sessionInit({ sessionType: 'freshcodex', provider: 'codex', sessionId: nativeId, model, effort: 'ultra' })) + render() + await waitFor(() => expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenCalledTimes(1)) + now.mockReturnValue(1_001) + act(() => store.dispatch(applyRuntimeOwner(owner))) + expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenCalledTimes(1) + act(() => store.dispatch(applyRuntimeOwner({ ...owner, generation: 4, operationId: 'next-strip-owner' }))) + await waitFor(() => expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenCalledTimes(2)) + expect(apiMock.getFreshAgentModelCapabilities).toHaveBeenLastCalledWith('freshcodex', expect.objectContaining({ + codexContext: expect.objectContaining({ sessionId: nativeId, observedEpoch: 7, observedGeneration: 4 }), + })) + await act(async () => { current.resolve(catalog('Current source model')) }) + expect(screen.getByRole('button', { name: 'Model: Current source model — change model' })).toBeInTheDocument() + await act(async () => { old.resolve(catalog('Stale source model')) }) + expect(screen.queryByRole('button', { name: 'Model: Stale source model — change model' })).toBeNull() + expect(screen.getByRole('button', { name: 'Model: Current source model — change model' })).toBeInTheDocument() + } finally { now.mockRestore() } + }) + }) + it('renders the chip with the effective model display name when the pane has no explicit model', () => { const store = createStore() // Provider defaults live in server settings (mirrors the "saved provider @@ -15005,3 +15193,318 @@ describe('task delegation open-session link (freshopencode tui parity)', () => { }) }) }) + +describe('task6 hosted recovery in the mounted view', () => { + it.each(['accepted', 'refused', 'ambiguous'] as const)('keeps per-request async answer %s visible after retained recovery', async (outcome) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const snapshot = {...native,status:'idle',capabilities:{...native.capabilities,send:true,interrupt:true},extensions:{codex:{ownerKind:'fresh-agent',statusFromLiveState:true}},turns:[{id:'async-turn',turnId:'async-turn',role:'assistant',summary:'',items:[{id:'async-answer',kind:'text',text:'Work can continue.',extensions:{codex:{itemId:'async-answer',delivery:'async',questions:[{id:'where',title:'Where?',options:['Staging']}]}}}]}]} + const store = createStore() + const locator = {provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:native.threadId} + const handlers = new Set<(message:any)=>void>() + wsMock.onMessage.mockImplementation((handler)=>{handlers.add(handler);return()=>{handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(snapshot) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'receipt-view',status:'idle'}})) + render() + fireEvent.click(await screen.findByRole('button',{name:'Staging',exact:true})) + const composer = screen.getByRole('textbox',{name:'Chat message input'}) + const draft = (composer as HTMLTextAreaElement).value + expect(draft).toContain('Answer to Codex question:') + fireEvent.click(screen.getByRole('button',{name:'Send',exact:true})) + await waitFor(()=>expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1)) + const sent = sentFreshAgentMessages('freshAgent.send')[0] + expect(screen.getByText('Sending answer as a message')).toBeInTheDocument() + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + const retained = {...snapshot,extensions:{codex:{projection:{id:'receipt-source',historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['async-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'async-turn':['async-turn']},turnOutputStates:{},settledTurnIds:[]}}}} + const deliver = (message:any)=>act(()=>{handleFreshAgentMessage(store.dispatch,message);for(const handler of handlers)handler(message)}) + try { + deliver({type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'receipt-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot:retained,pendingControls:[],attentionMarks:[]}}}) + await expectRenderedText('Work can continue.') + // A fresh transcript cannot decide this request's outcome. + expect(screen.getByText('Sending answer as a message')).toBeInTheDocument() + deliver(outcome==='accepted'?{type:'freshAgent.send.accepted',...locator,requestId:sent.requestId,submittedTurnId:'native-receipt-turn'}:{type:'error',code:'INTERNAL_ERROR',requestId:sent.requestId,message:outcome==='ambiguous'?'CODEX_DELIVERY_AMBIGUOUS: native response was lost':'CODEX_TURN_START_FAILED: native input refused'}) + await waitFor(()=>expect(screen.getByText(outcome==='accepted'?'Answer accepted as a message':outcome==='ambiguous'?'Answer delivery uncertain; review the composer before sending again.':'Answer not sent; your draft is in the composer.')).toBeInTheDocument()) + if(outcome!=='accepted') expect(composer).toHaveValue(draft) + expect(sentFreshAgentMessages('freshAgent.send')).toHaveLength(1) + expect(sentFreshAgentMessages('freshAgent.question.respond')).toHaveLength(0) + } finally {cleanup();await act(async()=>held.resolve(retained))} + }) + + it.each(['freshAgent.host.backpressure', 'freshAgent.host.journal.reset'])('shows retained rows on %s while the native history refresh is pending', async (kind) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider:'codex' as const, sessionType:'freshcodex' as const, sessionId:native.threadId } + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'retained-view',status:'idle'}})) + render() + await expectRenderedText('Saved native Codex answer') + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + apiMock.getFreshAgentThreadSnapshot.mockClear() + const snapshot = {...native,turns:[{id:'retained-turn',turnId:'retained-turn',role:'assistant',summary:'',items:[{id:'retained-answer',kind:'text',text:'Recovered final hosted answer'}]}],extensions:{codex:{projection:{ + id:'host-source',historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['retained-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'retained-turn':['retained-turn']},turnOutputStates:{'retained-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['retained-turn'], + }}}} + const message = {type:'freshAgent.event',...locator,event:{type:kind,retainedState:{source:{projectionId:'host-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot,pendingControls:[],attentionMarks:[]}}} + try { + act(() => { handleFreshAgentMessage(store.dispatch, message as never); for (const handler of handlers) handler(message) }) + await expectRenderedText('Recovered final hosted answer') + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + expect(screen.getByText('Recovered final hosted answer')).toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally { cleanup(); await act(async () => held.resolve(snapshot)) } + }) +}) + +describe('task6 retained source against in-flight native history', () => { + it('refreshes saved native history at an unchanged event prefix', async () => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider: 'codex' as const, sessionType: 'freshcodex' as const, sessionId: native.threadId } + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => { handlers.add(handler); return () => { handlers.delete(handler) } }) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', ...locator, createRequestId: 'same-event-prefix', status: 'idle' } })) + render() + await expectRenderedText('Saved native Codex answer') + const retained = FreshAgentSnapshotSchema.parse({ ...native, turns: [{ id: 'clock-row', turnId: 'clock-row', role: 'assistant', summary: '', items: [{ id: 'clock-answer', kind: 'text', text: 'Retained before native persistence' }] }], extensions: { codex: { projection: { + id: 'clock-source', historyGeneration: 0, eventRevision: 10, eventOwnedTurnIds: ['clock-turn'], nativePreviewTurnIds: [], nativeFinalTurnIds: [], turnRows: { 'clock-turn': ['clock-row'] }, turnOutputStates: { 'clock-turn': { provenance: 'retained', refreshPending: true } }, + } } } }) + const saved = FreshAgentSnapshotSchema.parse({ ...retained, turns: [{ ...retained.turns[0], items: [{ id: 'clock-answer', kind: 'text', text: 'Later exact saved native history' }] }], extensions: { codex: { projection: { + ...retained.extensions.codex.projection, eventOwnedTurnIds: [], nativeFinalTurnIds: ['clock-turn'], turnOutputStates: { 'clock-turn': { provenance: 'saved', refreshPending: false } }, + } } } }) + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + apiMock.getFreshAgentThreadSnapshot.mockClear() + const message = { type: 'freshAgent.event', ...locator, event: { type: 'freshAgent.host.backpressure', retainedState: { source: { projectionId: 'clock-source', ownerEpoch: 0, ownerGeneration: 0, soulId: 'soul', incarnationId: 'incarnation', hostBootId: 'host' }, snapshot: retained, pendingControls: [], attentionMarks: [] } } } + try { + act(() => { handleFreshAgentMessage(store.dispatch, message as never); for (const handler of handlers) handler(message) }) + await expectRenderedText('Retained before native persistence') + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => held.resolve(saved)) + await expectRenderedText('Later exact saved native history') + expect(screen.queryByText('Retained before native persistence')).not.toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally { cleanup(); await act(async () => held.resolve(saved)) } + }) + + it('keeps the accepted source when an older native history request finishes afterwards', async () => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider:'codex' as const, sessionType:'freshcodex' as const, sessionId:native.threadId } + const metadata = (id: string, revision: number) => ({id,historyGeneration:0,eventRevision:revision,eventOwnedTurnIds:['retained-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'retained-turn':['retained-turn']},turnOutputStates:{'retained-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['retained-turn']}) + const snapshot = {...native,turns:[{id:'retained-turn',turnId:'retained-turn',role:'assistant',summary:'',items:[{id:'retained-answer',kind:'text',text:'Newest retained source output'}]}],extensions:{codex:{projection:metadata('current-source',10)}}} + const stale = {...snapshot,turns:[{...snapshot.turns[0],items:[{id:'retained-answer',kind:'text',text:'Obsolete source history'}]}],extensions:{codex:{projection:metadata('old-source',1)}}} + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => {handlers.add(handler);return () => {handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(native) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'retained-race',status:'idle'}})) + render() + await expectRenderedText('Saved native Codex answer') + const held = createDeferred() + const later = createDeferred() + const heldStarted = vi.fn(() => held.promise) + apiMock.getFreshAgentThreadSnapshot.mockImplementationOnce(heldStarted).mockReturnValue(later.promise) + const reset = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}} + const message = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'current-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot,pendingControls:[],attentionMarks:[]}}} + try { + // Establish the older read at the scheduler's actual entry boundary. + // Advancing its debounce avoids making this race depend on wall time. + vi.useFakeTimers() + act(() => {for (const handler of [...handlers]) handler(reset)}) + await act(async () => {await vi.advanceTimersByTimeAsync(SNAPSHOT_DEBOUNCE_MS)}) + expect(heldStarted).toHaveBeenCalledTimes(1) + vi.useRealTimers() + act(() => {handleFreshAgentMessage(store.dispatch,message as never);for (const handler of handlers) handler(message)}) + await expectRenderedText('Newest retained source output') + await act(async () => held.resolve(stale)) + await waitFor(() => expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe('current-source')) + expect(screen.getByText('Newest retained source output')).toBeVisible() + expect(screen.queryByText('Obsolete source history')).not.toBeInTheDocument() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {vi.useRealTimers();cleanup();await act(async () => {held.resolve(stale);later.resolve(snapshot)})} + }) +}) + +describe('task6 late retained source after native replacement', () => { + it.each([{earlierRetained:false,late:'retained'},{earlierRetained:true,late:'retained'},{earlierRetained:false,late:'http'},{earlierRetained:true,late:'http'}])('rejects a known retired source after native replacement (earlier retained=$earlierRetained; late=$late)', async ({earlierRetained,late}) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = { provider:'codex' as const, sessionType:'freshcodex' as const, sessionId:native.threadId } + const sourceSnapshot = (id: string, text: string) => FreshAgentSnapshotSchema.parse({...native,turns:[{id:'source-turn',turnId:'source-turn',role:'assistant',summary:'',items:[{id:'answer',kind:'text',text}]}],extensions:{codex:{projection:{id,historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['source-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'source-turn':['source-turn']},turnOutputStates:{'source-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['source-turn']}}}}) + const original = sourceSnapshot('old-source', 'Output from the original source') + const replacement = sourceSnapshot('replacement-source', 'Output from the current native source') + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => {handlers.add(handler);return () => {handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(original) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'native-replaced-source',status:'idle'}})) + render() + await expectRenderedText('Output from the original source') + const refresh = createDeferred() + const lateNative = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockClear() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(refresh.promise) + const retained = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'old-source',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot:original,pendingControls:[],attentionMarks:[]}}} + const receive = (message: any) => {handleFreshAgentMessage(store.dispatch,message);for (const handler of handlers) handler(message)} + try { + act(() => receive(earlierRetained ? retained : {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}})) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => refresh.resolve(replacement)) + await expectRenderedText('Output from the current native source') + if (late === 'retained') act(() => receive(retained)) + else { + apiMock.getFreshAgentThreadSnapshot.mockClear() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(lateNative.promise) + act(() => receive({type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}})) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalled()) + await act(async () => lateNative.resolve(original)) + } + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe('replacement-source') + expect(screen.getByText('Output from the current native source')).toBeVisible() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {cleanup();await act(async () => {refresh.resolve(replacement);lateNative.resolve(replacement)})} + }) +}) + +describe('task6 retired source lifetime', () => { + it.each(['retained', 'http'])('rejects the first source through %s after 33 legitimate source replacements', async (late) => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const store = createStore() + const locator = {provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:native.threadId} + const sourceSnapshot = (index: number) => FreshAgentSnapshotSchema.parse({...native,turns:[{id:'source-turn',turnId:'source-turn',role:'assistant',summary:'',items:[{id:'answer',kind:'text',text:`Observed native source ${index}`}]}],extensions:{codex:{projection:{id:`scope-source-${index}`,historyGeneration:0,eventRevision:1,eventOwnedTurnIds:['source-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'source-turn':['source-turn']},turnOutputStates:{'source-turn':{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['source-turn']}}}}) + const first = sourceSnapshot(0) + let apiSnapshot = first + const handlers = new Set<(message: any) => void>() + wsMock.onMessage.mockImplementation((handler) => {handlers.add(handler);return () => {handlers.delete(handler)}}) + apiMock.getFreshAgentThreadSnapshot.mockImplementation(async () => apiSnapshot) + const receive = (message: any) => {handleFreshAgentMessage(store.dispatch,message);for (const handler of handlers) handler(message)} + const retained = (snapshot: typeof first) => ({type:'freshAgent.event',...locator,event:{type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:snapshot.extensions.codex.projection.id,ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot,pendingControls:[],attentionMarks:[]}}}) + const reset = {type:'freshAgent.event',...locator,event:{type:'freshAgent.host.journal.reset'}} + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'retired-lifetime',status:'idle'}})) + render() + try { + await expectRenderedText('Observed native source 0') + act(() => receive(retained(first))) + for (let index = 1; index <= 33; index++) { + apiSnapshot = sourceSnapshot(index) + const readsBefore = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + act(() => receive(reset)) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBeGreaterThan(readsBefore)) + await expectRenderedText(`Observed native source ${index}`) + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe(`scope-source-${index}`) + act(() => receive(retained(apiSnapshot))) + } + if (late === 'retained') act(() => receive(retained(first))) + else { + const held = createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockReturnValue(held.promise) + const readsBefore = apiMock.getFreshAgentThreadSnapshot.mock.calls.length + act(() => receive(reset)) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot.mock.calls.length).toBeGreaterThan(readsBefore)) + await act(async () => held.resolve(first)) + } + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.id).toBe('scope-source-33') + expect(screen.getByText('Observed native source 33')).toBeVisible() + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {cleanup()} + }, 45_000) +}) + +describe('task6 gap recovery after the final frame', () => { + it('keeps current rows and requests the latest prefix when an in-flight recovery response predates the final patch', async () => { + const native=FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const locator={provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:native.threadId} + const snapshot=(revision:number,text:string)=>FreshAgentSnapshotSchema.parse({...native,status:'running',turns:[{id:'gap-row',turnId:'gap-row',role:'assistant',summary:'',items:[{id:'gap-answer',kind:'text',text}]}],extensions:{codex:{projection:{id:'gap-source',historyGeneration:0,eventRevision:revision,eventOwnedTurnIds:['gap-turn'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{'gap-turn':['gap-row']},turnOutputStates:{'gap-turn':{provenance:'retained',refreshPending:false}},settledTurnIds:[]}}}}) + const initial=snapshot(1,'Known visible prefix') + const stale=snapshot(7,'Covered through revision seven') + const latest=snapshot(8,'Authoritative output through the final revision eight') + const store=createStore() + const handlers=new Set<(message:any)=>void>() + wsMock.onMessage.mockImplementation((handler)=>{handlers.add(handler);return()=>{handlers.delete(handler)}}) + const receive=(event:Record)=>{const message={type:'freshAgent.event',...locator,event};handleFreshAgentMessage(store.dispatch,message as never);for(const handler of handlers)handler(message)} + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue(initial) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',...locator,createRequestId:'gap-final-prefix',status:'running'}})) + render() + await expectRenderedText('Known visible prefix') + const held=createDeferred() + apiMock.getFreshAgentThreadSnapshot.mockClear() + apiMock.getFreshAgentThreadSnapshot.mockReturnValueOnce(held.promise).mockResolvedValue(latest) + const patch=(afterRevision:number,revision:number)=>({type:'freshAgent.transcript.patch',sessionId:native.threadId,projectionId:'gap-source',historyGeneration:0,afterRevision,ops:[{op:'appendItem',nativeTurnId:'gap-turn',rowId:'gap-row',itemId:'gap-answer',field:'text',text:' unbased suffix',revision}]}) + try { + act(()=>receive(patch(4,7))) + await waitFor(()=>expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(1)) + act(()=>receive(patch(7,8))) + // No more transport/provider frames arrive after this final patch. + await act(async()=>held.resolve(stale)) + await expectRenderedText('Authoritative output through the final revision eight') + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledTimes(2) + expect(store.getState().freshAgentLive.bySession[`freshcodex:codex:${native.threadId}`].projection?.needsResnapshot).toBe(false) + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + } finally {cleanup();await act(async()=>held.resolve(stale))} + }) +}) + +describe('task6 owner context assessment', () => { + it('uses the current owner fence for child reads and the current parent when retrying after rebind', async () => { + const native=FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const childId='22222222-2222-4222-8222-222222222222' + const replacement='33333333-3333-4333-8333-333333333333' + const store=createStore() + const owner={type:'session.runtimeOwner' as const,provider:'codex' as const,sessionId:native.threadId,epoch:1,generation:1,ownerKind:'fresh-agent' as const,operationId:'first-owner',transition:'handoff-committed' as const} + apiMock.getFreshAgentThreadSnapshot.mockImplementation((_type,_provider,id) => Promise.resolve({...native,threadId:id,childThreads:[{id:childId,threadId:childId,origin:'spawnAgent'}],extensions:{codex:{projection:{availability:{status:'unavailable'}}}}})) + apiMock.get.mockImplementation((url:string) => { + const parent=url.includes(replacement)?replacement:native.threadId + return Promise.resolve(url.includes('/children/')?{parentThreadId:parent,childThreadId:childId,status:'saved',snapshot:{...native,threadId:childId}}:{checkpoints:[]}) + }) + store.dispatch(applyRuntimeOwner(owner)) + store.dispatch(initLayout({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',provider:'codex',sessionType:'freshcodex',sessionId:native.threadId,createRequestId:'owner-context',status:'idle'}})) + render() + fireEvent.click(await screen.findByRole('button',{name:`Observe child ${childId}`})) + await waitFor(() => expect(apiMock.get).toHaveBeenCalledWith(expect.stringContaining('observedEpoch=1&observedGeneration=1'),expect.anything())) + act(() => store.dispatch(applyRuntimeOwner({...owner,epoch:2,generation:3,operationId:'next-owner'}))) + await waitFor(() => expect(apiMock.get).toHaveBeenCalledWith(expect.stringContaining('observedEpoch=2&observedGeneration=3'),expect.anything())) + fireEvent.click(screen.getByRole('button',{name:'Close child conversation'})) + act(() => { + store.dispatch(applyRuntimeOwner({...owner,sessionId:replacement,epoch:3,generation:4,operationId:'replacement-owner'})) + store.dispatch(updatePaneContent({tabId:'tab-1',paneId:'pane-1',content:{kind:'fresh-agent',provider:'codex',sessionType:'freshcodex',sessionId:replacement,createRequestId:'replacement-context',status:'idle'}})) + }) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenLastCalledWith('freshcodex','codex',replacement,expect.anything())) + apiMock.getFreshAgentThreadSnapshot.mockClear() + fireEvent.click(await screen.findByRole('button',{name:'Retry history'})) + await waitFor(() => expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith('freshcodex','codex',replacement,expect.anything())) + fireEvent.click(await screen.findByRole('button',{name:`Observe child ${childId}`})) + await waitFor(() => expect(apiMock.get).toHaveBeenCalledWith(expect.stringContaining(`${replacement}/children/${childId}?observedEpoch=3&observedGeneration=4`),expect.anything())) + expect(sentFreshAgentMessages('freshAgent.send')).toEqual([]) + }) +}) + +describe('Codex child observation in the parent view', () => { + it('persists only the selected child, preserves the parent draft and actions, and reopens without a child lifecycle', async () => { + const native = FreshAgentSnapshotSchema.parse(savedCodexNativeHistory) + const childId = '22222222-2222-4222-8222-222222222222' + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ ...native, childThreads: [{ id: childId, threadId: childId, origin: 'spawnAgent' }] }) + apiMock.get.mockImplementation((url: string) => Promise.resolve(url.includes('/children/') ? { + parentThreadId: native.threadId, childThreadId: childId, status: 'saved', snapshot: { ...native, threadId: childId, turns: [{ id: 'child-answer', turnId: 'child-answer', role: 'assistant', summary: '', items: [{ id: 'child-text', kind: 'text', text: 'Read-only child answer' }] }] }, + } : { checkpoints: [] })) + const store = createStore() + store.dispatch(initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: { kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', sessionId: native.threadId, sessionRef: { provider: 'codex', sessionId: native.threadId }, createRequestId: 'parent-observe', status: 'idle' } })) + const mounted = render() + const input = await screen.findByRole('textbox', { name: 'Chat message input' }) + fireEvent.change(input, { target: { value: 'Parent draft survives observation' } }) + fireEvent.click(await screen.findByRole('button', { name: `Observe child ${childId}` })) + expect(await screen.findByText('Read-only child answer')).toBeVisible() + expect(input).toHaveValue('Parent draft survives observation') + expect(getFreshAgentPaneContent(store)).toMatchObject({ sessionId: native.threadId, observedChildThreadId: childId }) + fireEvent.click(screen.getByRole('button', { name: 'Close child conversation' })) + expect(screen.queryByRole('dialog', { name: 'Child conversation' })).not.toBeInTheDocument() + expect(getFreshAgentPaneContent(store).observedChildThreadId).toBeUndefined() + fireEvent.click(screen.getByRole('button', { name: `Observe child ${childId}` })) + expect(await screen.findByText('Read-only child answer')).toBeVisible() + mounted.unmount() + render() + expect(await screen.findByText('Read-only child answer')).toBeVisible() + expect(getFreshAgentPaneContent(store).sessionRef).toEqual({ provider: 'codex', sessionId: native.threadId }) + expect(wsMock.send.mock.calls.map(([message]) => message).filter((message) => message.sessionId === childId || message.sessionRef?.sessionId === childId)).toEqual([]) + }) +}) diff --git a/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts b/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts index e7551d4662..4401e87dc7 100644 --- a/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts +++ b/test/unit/client/components/fresh-agent/fresh-agent-event-classes.test.ts @@ -29,6 +29,8 @@ describe('classifyHiddenPaneEvent', () => { ['permission.cancelled', { type: 'freshAgent.permission.cancelled' }, 'surface'], ['question.request', { type: 'freshAgent.question.request' }, 'surface'], ['question.cancelled', { type: 'freshAgent.question.cancelled' }, 'surface'], + ['elicitation.request', { type: 'freshAgent.elicitation.request' }, 'surface'], + ['elicitation.cancelled', { type: 'freshAgent.elicitation.cancelled' }, 'surface'], // None: status-only frames never stale a hidden pane. ['idle snapshot', { type: 'freshAgent.session.snapshot', status: 'idle' }, 'none'], ['status', { type: 'freshAgent.status', status: 'running' }, 'none'], @@ -66,6 +68,8 @@ describe('isSnapshotInvalidatingFreshAgentEvent', () => { 'freshAgent.permission.cancelled', 'freshAgent.question.request', 'freshAgent.question.cancelled', + 'freshAgent.elicitation.request', + 'freshAgent.elicitation.cancelled', 'freshAgent.session.rolledBack', 'freshAgent.session.redone', 'freshAgent.rolledBack', diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index 99acf2f045..bba318cb9a 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -2543,7 +2543,7 @@ describe('PaneContainer', () => { expect(paneContent.provider).toBe('codex') expect(paneContent.initialCwd).toBe('/home/user/freshcodex-project') expect(paneContent.model).toBe('gpt-6-astra') - expect(paneContent.effort).toBe('max') + expect(paneContent.effort).toBeUndefined() } }) @@ -2555,7 +2555,7 @@ describe('PaneContainer', () => { })) }) - it('normalizes saved Freshcodex effort from provider settings against the active model', async () => { + it.each(['xhigh', 'max'])('preserves saved Freshcodex effort %s from provider settings for native validation', async (effort) => { const node = createPickerNode('pane-1') const store = createStoreWithClaude( node, @@ -2563,7 +2563,7 @@ describe('PaneContainer', () => { { codex: { model: 'gpt-5.6-luna' } }, ['claude', 'codex'], { claude: true, codex: true }, - { freshcodex: { effort: 'xhigh' } }, + { freshcodex: { effort } }, ) mockApiPost.mockResolvedValueOnce({ valid: true, resolvedPath: '/home/user/freshcodex-project' }) @@ -2584,10 +2584,19 @@ describe('PaneContainer', () => { const paneContent = (state.layouts['tab-1'] as Extract).content expect(paneContent.kind).toBe('fresh-agent') if (paneContent.kind === 'fresh-agent') { + expect(paneContent.sessionType).toBe('freshcodex') + expect(paneContent.provider).toBe('codex') + expect(paneContent.initialCwd).toBe('/home/user/freshcodex-project') + expect(paneContent.status).toBe('creating') + expect(paneContent.createRequestId).toBeDefined() expect(paneContent.model).toBe('gpt-5.6-luna') - expect(paneContent.effort).toBe('max') + expect(paneContent.effort).toBe(effort) } }) + + expect(saveServerSettingsPatchSpy).toHaveBeenCalledWith({ + codingCli: { providers: { codex: { model: 'gpt-5.6-luna', cwd: '/home/user/freshcodex-project' } } }, + }) }) it('enables Freshopencode from the picker without persisting the built-in model fallback', async () => { diff --git a/test/unit/client/lib/fresh-agent-context-usage.test.ts b/test/unit/client/lib/fresh-agent-context-usage.test.ts index 7b74f921f6..98e9004592 100644 --- a/test/unit/client/lib/fresh-agent-context-usage.test.ts +++ b/test/unit/client/lib/fresh-agent-context-usage.test.ts @@ -6,9 +6,16 @@ import { resolveFreshAgentContextUsage, guardContextUsageTokenSummary, collectFreshAgentContextUsageKeys, + latestCodexUsage, } from '@/lib/fresh-agent-context-usage' import { makeFreshAgentSessionKey } from '@shared/fresh-agent' +it('preserves the native latest and cumulative samples without counting token subsets twice', () => { + expect(latestCodexUsage({ last: { totalTokens: 20, inputTokens: 15, cachedInputTokens: 8, outputTokens: 5, reasoningOutputTokens: 3 }, total: { totalTokens: 200, inputTokens: 150, outputTokens: 50 }, modelContextWindow: 258400 })).toEqual({ observed: true, latestTokens: 20, totalTokens: 200, modelContextWindow: 258400 }) + expect(latestCodexUsage({ last: { totalTokens: 0 }, total: { totalTokens: 0 } })).toEqual({ observed: true, latestTokens: 0, totalTokens: 0 }) + expect(latestCodexUsage(undefined)).toEqual({ observed: false }) +}) + // Fixture style mirrors PaneContainer.test.tsx's makeSession/makeContent // builders: minimal objects carrying only the fields under test, cast to the // imported types. diff --git a/test/unit/client/lib/fresh-agent-create-content.test.ts b/test/unit/client/lib/fresh-agent-create-content.test.ts index 4f9d3eef34..5c477226ab 100644 --- a/test/unit/client/lib/fresh-agent-create-content.test.ts +++ b/test/unit/client/lib/fresh-agent-create-content.test.ts @@ -1,10 +1,48 @@ import { describe, expect, it } from 'vitest' import fixtures from '../../../fixtures/fresh-agent/claude-create-defaults.json' -import { createDefaultServerSettings, type ServerSettings } from '@shared/settings' +import { createDefaultServerSettings, mergeServerSettings, type ServerSettings } from '@shared/settings' import { buildFreshAgentPaneContent } from '@/lib/fresh-agent-create-content' -import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' +import { getEffectiveFreshAgentEffort, resolveFreshAgentType } from '@/lib/fresh-agent-registry' describe('fresh-agent create defaults shared with REST', () => { + it('keeps a stated native-default effort distinct from fallback provider defaults and explicit next-turn choices', () => { + const content = { sessionType: 'freshcodex' as const, provider: 'codex' as const, model: 'provider-current', effortIsNativeDefault: true } + const inherited = { ...content, effortIsNativeDefault: false } + expect(getEffectiveFreshAgentEffort(inherited, { effort: 'ultra' })).toBe('ultra') + expect(getEffectiveFreshAgentEffort({ ...content, effort: 'max' }, { effort: 'ultra' })).toBe('max') + expect(getEffectiveFreshAgentEffort(content, { effort: 'ultra' })).toBeUndefined() + }) + it('retains Codex work mode, file access and cwd through the saved-default settings path', () => { + const settings = mergeServerSettings(createDefaultServerSettings(), JSON.parse(JSON.stringify({ freshAgent: { providers: { freshcodex: { + modelSelection: { kind: 'exact', modelId: 'provider-current' }, effort: 'ultra', + defaultPermissionMode: 'never', sandbox: 'read-only', cwd: '/work', collaborationMode: 'plan', + } } } }))) + const content = buildFreshAgentPaneContent({ entry: resolveFreshAgentType('freshcodex')!, createRequestId: 'complete-defaults', freshAgentSettings: settings.freshAgent, codingCliSettings: settings.codingCli }) + expect(content).toMatchObject({ model: 'provider-current', effort: 'ultra', permissionMode: 'never', sandbox: 'read-only', initialCwd: '/work', collaborationMode: 'plan' }) + }) + it('leaves an omitted Codex effort to the installed model instead of fabricating max for a custom model', () => { + const defaults = createDefaultServerSettings() + const content = buildFreshAgentPaneContent({ + entry: resolveFreshAgentType('freshcodex')!, createRequestId: 'native-default', + codingCliSettings: defaults.codingCli, + freshAgentSettings: {...defaults.freshAgent,providers:{...defaults.freshAgent.providers, + freshcodex:{modelSelection:{kind:'exact',modelId:'provider-current'}}, + }}, + }) + expect(content.model).toBe('provider-current') + expect(content.effort).toBeUndefined() + }) + it('creates Codex with the exact saved custom model and native effort', () => { + const defaults = createDefaultServerSettings() + const content = buildFreshAgentPaneContent({ + entry: resolveFreshAgentType('freshcodex')!, createRequestId: 'native-choice', + codingCliSettings: defaults.codingCli, + freshAgentSettings: { ...defaults.freshAgent, providers: { ...defaults.freshAgent.providers, + freshcodex: { modelSelection: { kind: 'exact', modelId: 'provider-current' }, effort: 'ultra' }, + } }, + }) + expect(content).toMatchObject({ model: 'provider-current', effort: 'ultra', status: 'creating' }) + }) it.each(fixtures)('$name', ({ sessionType, settings, expected }) => { const defaultServerSettings = createDefaultServerSettings() const entry = resolveFreshAgentType(sessionType)! diff --git a/test/unit/client/lib/fresh-agent-model-capabilities.test.ts b/test/unit/client/lib/fresh-agent-model-capabilities.test.ts index 4ceee7264e..519de1a1e3 100644 --- a/test/unit/client/lib/fresh-agent-model-capabilities.test.ts +++ b/test/unit/client/lib/fresh-agent-model-capabilities.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' +import { api, getFreshAgentModelCapabilities } from '@/lib/api' import type { FreshAgentModelCapabilities } from '@shared/fresh-agent-model-capabilities' import { @@ -16,11 +17,36 @@ import { mergeClaudeModelCapabilities, mergeClaudeSelectorOptions, parseFreshAgentSettingsModelValue, + parseFreshAgentModelCapabilitiesResponse, requiresFreshAgentModelCapabilityValidation, resolveFreshAgentModelSelection, resolveFreshOpencodeCapabilityById, } from '@/lib/fresh-agent-model-capabilities' +it('encodes the bound Codex conversation reference and owner fence on the existing HTTP request', async () => { + const get = vi.spyOn(api, 'get').mockResolvedValueOnce({ ok:false, sessionType:'freshcodex',runtimeProvider:'codex',status:'unavailable',models:[],error:{code:'RUNTIME_PENDING',message:'pending'} }) + try { + await getFreshAgentModelCapabilities('freshcodex', { codexContext: { + sessionId: 'pane alias', sessionRef: { provider: 'codex', sessionId: 'native/current' }, cwd: '/project A', observedEpoch: 7, observedGeneration: 3, + } } as Parameters[1]) + const url = new URL(get.mock.calls[0][0], 'http://fixture') + expect(url.searchParams.get('sessionId')).toBe('pane alias') + expect(JSON.parse(url.searchParams.get('sessionRef')!)).toEqual({ provider:'codex',sessionId:'native/current' }) + expect(url.searchParams.get('cwd')).toBe('/project A') + expect(url.searchParams.get('observedEpoch')).toBe('7') + expect(url.searchParams.get('observedGeneration')).toBe('3') + } finally { get.mockRestore() } +}) + +it('accepts native Codex defaults and input modalities without changing opaque effort values', () => { + const response = parseFreshAgentModelCapabilitiesResponse({ + ok: true, sessionType: 'freshcodex', runtimeProvider: 'codex', status: 'fresh', fetchedAt: 1, + models: [{ id: 'provider-current', displayName: 'Provider Current', provider: 'codex', + supportsEffort: true, supportedEffortLevels: ['ultra'], defaultEffort: 'ultra', inputModalities: ['text', 'image'], supportsAdaptiveThinking: false }], + }) + expect(response.models[0]).toMatchObject({ defaultEffort: 'ultra', inputModalities: ['text', 'image'] }) +}) + const capabilities = { sessionType: 'freshclaude', runtimeProvider: 'claude', @@ -634,34 +660,6 @@ describe('mergeClaudeModelCapabilities', () => { }) describe('fresh-agent-model-capabilities static catalog mapping', () => { - it('maps the freshcodex static menu into the shared capability shape', () => { - const capabilities = getFreshAgentStaticModelCapabilities('freshcodex') - - expect(capabilities).toMatchObject({ - sessionType: 'freshcodex', - runtimeProvider: 'codex', - status: 'fresh', - }) - expect(capabilities?.models.map((model) => model.id)).toEqual([ - 'gpt-6-astra', - 'gpt-5.6-sol', - 'gpt-5.6-terra', - 'gpt-5.6-luna', - 'gpt-5.3-codex-spark', - ]) - expect(capabilities?.models[0]).toMatchObject({ - displayName: 'GPT-6 Astra', - provider: 'codex', - supportsEffort: true, - supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], - supportsAdaptiveThinking: false, - }) - expect(capabilities?.models[1]?.supportedEffortLevels).toEqual(['none', 'low', 'medium', 'high', 'xhigh', 'max']) - for (const model of capabilities?.models ?? []) { - expect(model.source).toEqual({ id: 'openai', displayName: 'openai' }) - } - }) - it('maps the claude static menu into the shared capability shape', () => { for (const sessionType of ['freshclaude', 'kilroy'] as const) { const capabilities = getFreshAgentStaticModelCapabilities(sessionType) diff --git a/test/unit/client/lib/fresh-agent-settings-convergence.test.ts b/test/unit/client/lib/fresh-agent-settings-convergence.test.ts new file mode 100644 index 0000000000..52a9c829db --- /dev/null +++ b/test/unit/client/lib/fresh-agent-settings-convergence.test.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { convergeCodexSettings } from '@/lib/fresh-agent-settings-convergence' + +it('keeps a newly explicit native Default when an older effort statement arrives after an inherited selection', () => { + const original = { effort: undefined, effortIsNativeDefault: false } + const first = convergeCodexSettings(undefined, 'codex:owned', original, {}) + const cleared = { ...original, effortIsNativeDefault: true } + const late = convergeCodexSettings(first.cursor, 'codex:owned', cleared, { effort: 'ultra' }) + expect(late.updates).toEqual({}) + const acknowledged = convergeCodexSettings(late.cursor, 'codex:owned', cleared, { effort: null }) + expect(acknowledged.updates).toEqual({}) + const changed = convergeCodexSettings(acknowledged.cursor, 'codex:owned', cleared, { effort: 'future-effort' }) + expect(changed.updates).toEqual({ effort: 'future-effort', effortIsNativeDefault: false }) +}) diff --git a/test/unit/client/lib/fresh-agent-transcript-projection.test.ts b/test/unit/client/lib/fresh-agent-transcript-projection.test.ts new file mode 100644 index 0000000000..707a54c734 --- /dev/null +++ b/test/unit/client/lib/fresh-agent-transcript-projection.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from 'vitest' +import { applyFreshAgentTranscriptPatch, bindTranscriptProjection, hydrateTranscriptProjection, mergeFreshAgentTranscriptProjection, type TranscriptProjection } from '@/lib/fresh-agent-transcript-projection' +import { FreshAgentSnapshotSchema } from '@shared/fresh-agent-contract' + +const row = { id: 't', turnId: 't', role: 'assistant' as const, summary: '', items: [{ id: 'a', kind: 'text' as const, text: '' }] } +const patch = (ops: unknown[], projectionId = 'p') => ({ sessionId: 'thread', projectionId, historyGeneration: 0, ops }) +const opened = (): TranscriptProjection => applyFreshAgentTranscriptPatch({ id: 'p', eventRevision: 0, historyGeneration: 0, turns: [] }, patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [row], revision: 1 }])) +const read = (turns: unknown[], projection: Record = {}) => FreshAgentSnapshotSchema.parse({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread', revision: 0, status: 'running', capabilities: { send: false, interrupt: true, approvals: false, questions: false, fork: false }, tokenUsage: {inputTokens: 0, outputTokens: 0, totalTokens: 0}, turns, extensions: { codex: { projection: { id: 'p', historyGeneration: 0, eventRevision: 0, eventOwnedTurnIds: [], nativeFinalTurnIds: [], nativePreviewTurnIds: [], turnOutputStates: {}, turnRows: { t: turns.map((t: any) => t.turnId) }, ...projection } } } }) +const append = (text: string, revision: number) => ({ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text, revision }) + +describe('independent native reads and event bodies', () => { + it('shows deltas before completion and replaces the complete item exactly once', () => { + let live = opened() + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'Hel', revision: 2 }])) + expect(live.turns[0]?.items).toEqual([{ id: 'a', kind: 'text', text: 'Hel' }]) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'a', kind: 'text', text: 'Hello' }, complete: true, revision: 3 }])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'lo', revision: 4 }])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) + + it('does not adopt a projection incarnation from an arbitrary patch', () => { + const live = opened() + expect(applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: 'other', turns: [row], revision: 100 }], 'foreign'))).toEqual(live) + }) + + it('renders an event-owned turn missing from a behind snapshot', () => { + const snapshot = FreshAgentSnapshotSchema.parse({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread', revision: 0, status: 'running', capabilities: { send: false, interrupt: true, approvals: false, questions: false, fork: false }, tokenUsage: {inputTokens: 0, outputTokens: 0, totalTokens: 0}, turns: [], extensions: { codex: { projection: { id: 'p', historyGeneration: 0, eventRevision: 0, eventOwnedTurnIds: [], nativeFinalTurnIds: [], nativePreviewTurnIds: [], turnOutputStates: {} } } } }) + expect(mergeFreshAgentTranscriptProjection(snapshot, opened())).toHaveLength(1) + }) + + it('keeps a read-ahead preview separate from the event append baseline', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('Hel', 2)])) + const snapshot = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'Hello' }] }], { nativePreviewTurnIds: ['t'] }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + live = applyFreshAgentTranscriptPatch(live, patch([append('lo', 3)])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)).toHaveLength(1) + }) + + it('does not lose a newer delta to an event-owned behind read', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([append('Hel', 2)])) + const snapshot = read([row], { eventOwnedTurnIds: ['t'], eventRevision: 1 }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hel' }]) + }) + + it('replaces an entire split native group without comparing coincidental item ids or deduplicating repeated bodies', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([append('stale', 2)])) + const rows = [{ ...row, id: 't:row-0', turnId: 't:row-0', role: 'user', nativeTurnId: 't', items: [{ id: 'item-1', kind: 'text', text: 'same' }] }, { ...row, id: 't:row-1', turnId: 't:row-1', items: [{ id: 'item-2', kind: 'text', text: 'same' }, { id: 'item-3', kind: 'text', text: 'same' }] }] + const snapshot = read(rows, { nativeFinalTurnIds: ['t'] }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)).toEqual(snapshot.turns) + }) + + it('rejects late item appends after completion even after eight subsequent turns', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'settleTurn', nativeTurnId: 't', revision: 2 }])) + for (let i = 0; i < 9; i++) live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: `new-${i}`, turns: [{ ...row, id: `new-${i}`, turnId: `new-${i}` }], revision: i + 3 }])) + live = applyFreshAgentTranscriptPatch(live, patch([append('late', 12)])) + expect(live.turns[0].items).toEqual(row.items) + }) + + it('an accepted history mutation removes a group permanently for that generation', () => { + let live = opened() + live = applyFreshAgentTranscriptPatch(live, { sessionId: 'thread', projectionId: 'p', historyGeneration: 1, ops: [{ op: 'resetTurn', nativeTurnId: 't', turns: [], revision: 2 }] }) + live = applyFreshAgentTranscriptPatch(live, patch([append('old', 3)])) + expect(live.turns).toEqual([]) + expect(mergeFreshAgentTranscriptProjection(read([], { historyGeneration: 1 }), live)).toEqual([]) + }) + + it('ignores reordered or duplicate revision deliveries and old-incarnation frozen extras', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([append('Hello', 3), append('duplicate', 3), append('older', 2)])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + expect(mergeFreshAgentTranscriptProjection(read([], { id: 'replacement' }), live)).toEqual([]) + }) + + it('appends indexed reasoning independently, preserving Unicode', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'r', kind: 'reasoning', summary: [], content: [] }, revision: 2 }])) + live = applyFreshAgentTranscriptPatch(live, patch([ + { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'r', field: 'summary', index: 1, text: '思', revision: 3 }, + { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'r', field: 'summary', index: 0, text: '🙂', revision: 4 }, + { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'r', field: 'summary', index: 1, text: '考', revision: 5 }, + ])) + expect(live.turns[0].items[1]).toMatchObject({ kind: 'reasoning', summary: ['🙂', '思考'] }) + }) + + it('keeps frozen output only as a finite disclosure for accepted interrupted history after rebind', () => { + const old = applyFreshAgentTranscriptPatch(opened(), patch([append('unfinished', 2)])) + const live = bindTranscriptProjection(old, 'replacement', 0) + const saved = read([{ ...row, items: [] }], { id: 'replacement', nativeFinalTurnIds: ['t'], turnOutputStates: { t: { provenance: 'saved', diagnostic: 'interrupted', refreshPending: false } } }) + const turns = mergeFreshAgentTranscriptProjection(saved, live) + expect(turns[0].outputState).toMatchObject({ provenance: 'saved-and-retained', diagnostic: 'interrupted', refreshPending: false, retainedItems: [{ id: 'a', kind: 'text', text: 'unfinished' }] }) + expect(mergeFreshAgentTranscriptProjection(read([], { id: 'replacement' }), live)).toEqual([]) + const complete = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'new native final' }] }], { id: 'replacement', nativeFinalTurnIds: ['t'], turnOutputStates: { t: { provenance: 'saved', refreshPending: false } } }) + expect(mergeFreshAgentTranscriptProjection(complete, live)[0].outputState?.retainedItems).toBeUndefined() + }) + + it('freeze after host loss does not keep waiting for an item native cannot save', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('unfinished', 2)])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'settleTurn', nativeTurnId: 't', status: 'interrupted', revision: 3 }])) + const snapshot = read([row], { eventOwnedTurnIds: ['t'], eventRevision: 1 }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0]).toMatchObject({ items: [{ id: 'a', kind: 'text', text: 'unfinished' }], outputState: { diagnostic: 'interrupted', refreshPending: false } }) + }) + + it('hydrates a captured event-owned prefix on reconnect, while a native preview stays nonappendable', () => { + const prefix = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'Hel' }] }], { eventOwnedTurnIds: ['t'], eventRevision: 2 }) + let live = hydrateTranscriptProjection({ id: 'p', eventRevision: 0, historyGeneration: 0, turns: [] }, prefix) + live = applyFreshAgentTranscriptPatch(live, patch([append('lo', 3)])) + expect(mergeFreshAgentTranscriptProjection(prefix, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + const preview = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'Hello' }] }], { nativePreviewTurnIds: ['t'], eventRevision: 2 }) + let cold = hydrateTranscriptProjection({ id: 'p', eventRevision: 0, historyGeneration: 0, turns: [] }, preview) + cold = applyFreshAgentTranscriptPatch(cold, patch([append('lo', 3)])) + expect(cold.turns).toEqual([]) + expect(mergeFreshAgentTranscriptProjection(preview, cold)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) + + it('a higher revision from another turn cannot replace a newer captured body in this turn', () => { + let live = opened() + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: 'later', turns: [{ ...row, id: 'later', turnId: 'later' }], revision: 10 }])) + const snapshot = read([{ ...row, items: [{ id: 'a', kind: 'text', text: 'server complete' }] }], { eventOwnedTurnIds: ['t'], eventRevision: 5 }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live)[0].items).toEqual([{ id: 'a', kind: 'text', text: 'server complete' }]) + }) + + it('retains bounded ended payloads while permanently fencing the discarded operations', () => { + let live = opened() + let revision = 2 + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'settleTurn', nativeTurnId: 't', revision: revision++ }])) + for (let i = 0; i < 40; i++) { + const id = `n-${i}` + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: id, turns: [{ ...row, id, turnId: id }], revision: revision++ }, { op: 'settleTurn', nativeTurnId: id, revision: revision++ }])) + } + expect(live.turns).toHaveLength(32) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [row], revision: revision++ }, append('late', revision++)])) + expect(live.turns.some((row) => row.turnId === 't')).toBe(false) + }) + + it('preserves a warm interrupted disclosure across a read-only saved-history source without reviving removed membership', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('unfinished', 2)])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'settleTurn', nativeTurnId: 't', status: 'interrupted', revision: 3 }])) + const saved = read([{ ...row, items: [], outputState: { provenance: 'saved', diagnostic: 'interrupted', refreshPending: false } }]) + saved.extensions.codex = { nativeHistoryAvailable: true, ownerKind: 'vacant' } + expect(mergeFreshAgentTranscriptProjection(saved, live)[0].outputState?.retainedItems).toEqual([{ id: 'a', kind: 'text', text: 'unfinished' }]) + expect(mergeFreshAgentTranscriptProjection({ ...saved, turns: [] }, live)).toEqual([]) + expect(mergeFreshAgentTranscriptProjection({ ...saved, threadId: 'different' }, live)[0].outputState?.retainedItems).toBeUndefined() + }) + + it('groups canonical projection metadata without depending on the additive row lookup hint', () => { + const snapshot = read([row], { eventOwnedTurnIds: ['t'], eventRevision: 1 }) + delete (snapshot.extensions.codex!.projection as any).turnRows + expect(mergeFreshAgentTranscriptProjection(snapshot, opened())).toHaveLength(1) + }) + + it('keeps an older unsaved group before a later saved turn even after its live payload lands', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([append('older unsaved', 2), { op: 'settleTurn', nativeTurnId: 't', revision: 3 }, { op: 'resetTurn', nativeTurnId: 'later', turns: [{ ...row, id: 'later', turnId: 'later' }], revision: 4 }])) + const snapshot = read([{ ...row, id: 'later', turnId: 'later', items: [{ id: 'answer-later', kind: 'text', text: 'later saved' }] }], { nativeFinalTurnIds: ['later'], turnRows: { later: ['later'] } }) + expect(mergeFreshAgentTranscriptProjection(snapshot, live).map((turn) => turn.turnId)).toEqual(['t', 'later']) + }) + + it('shows file-change output progress on its existing item', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'files', kind: 'file_change', status: 'running', changes: [] }, revision: 2 }])) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'files', field: 'output', text: 'Applying patch', revision: 3 }])) + expect(live.turns[0].items[1]).toMatchObject({ output: 'Applying patch' }) + }) + + it('never appends to read-only recovery preview items, but admits an actual complete replacement', () => { + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [{ ...row, items: [{ id: 'a', kind: 'text', text: 'read preview' }] }], nonAppendableItemIds: ['a'], revision: 2 }])) + live = applyFreshAgentTranscriptPatch(live, patch([append('leak', 3)])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'read preview' }]) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'a', kind: 'text', text: 'actual complete' }, complete: true, revision: 4 }])) + expect(live.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'actual complete' }]) + }) + + it('refreshes only read-only prefix fields across an event-owned snapshot fence', () => { + const prior = { id: 'prior', kind: 'text' as const, text: 'older preview' } + let live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'resetTurn', nativeTurnId: 't', turns: [{ ...row, items: [prior, ...row.items] }], nonAppendableItemIds: ['prior'], revision: 2 }, append('Hel', 3)])) + const snapshot = read([{ ...row, items: [{ ...prior, text: 'newer preview' }, ...row.items] }], { eventOwnedTurnIds: ['t'], eventRevision: 2, nonAppendableItemIds: { t: ['prior'] } }) + live = hydrateTranscriptProjection(live, snapshot) + expect(live.turns[0].items).toEqual([{ ...prior, text: 'newer preview' }, { id: 'a', kind: 'text', text: 'Hel' }]) + live = applyFreshAgentTranscriptPatch(live, patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { ...prior, text: 'actual complete' }, complete: true, revision: 4 }])) + live = hydrateTranscriptProjection(live, snapshot) + expect(live.turns[0].items).toEqual([{ ...prior, text: 'actual complete' }, { id: 'a', kind: 'text', text: 'Hel' }]) + }) + + it('keeps unmatched recovery context available as a saved alternative after an actual item replacement', () => { + const live = applyFreshAgentTranscriptPatch(opened(), patch([{ op: 'upsertItem', nativeTurnId: 't', row, item: { id: 'a', kind: 'text', text: 'actual complete' }, complete: true, revision: 2 }])) + const saved = read([{ ...row, items: [{ id: 'item-1', kind: 'text', text: 'earlier saved commentary' }, { id: 'item-2', kind: 'text', text: 'native read preview' }] }], { nativePreviewTurnIds: ['t'] }) + const rows = mergeFreshAgentTranscriptProjection(saved, live) + expect(rows).toHaveLength(1) + expect(rows[0].items).toEqual([{ id: 'a', kind: 'text', text: 'actual complete' }]) + expect(rows[0].outputState?.savedItems).toEqual(saved.turns[0].items) + }) +}) diff --git a/test/unit/client/lib/fresh-agent-ws.test.ts b/test/unit/client/lib/fresh-agent-ws.test.ts index 6985e40019..4884ea28fc 100644 --- a/test/unit/client/lib/fresh-agent-ws.test.ts +++ b/test/unit/client/lib/fresh-agent-ws.test.ts @@ -1,3 +1,4 @@ +import savedCodexNativeHistory from '../../../fixtures/managed-native-history/codex.json' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { configureStore } from '@reduxjs/toolkit' import freshAgentReducer, { materializeSession, sessionCreated } from '@/store/freshAgentSlice' @@ -59,6 +60,59 @@ function createFreshAgentPaneStore(seenActionTypes: string[] = []) { } describe('fresh-agent-ws', () => { + it('routes native patches outside the session slice and fences complete items and stale init incarnations', () => { + const store = createFreshAgentStore() + const outer = { type: 'freshAgent.event' as const, sessionId: 'native-live', sessionType: 'freshcodex' as const, provider: 'codex' as const } + const send = (event: Record) => handleFreshAgentMessage(store.dispatch, { ...outer, event }) + send({ type: 'freshAgent.session.init', sessionId: outer.sessionId, projectionId: 'p', historyGeneration: 0 }) + const sessions = store.getState().freshAgent.sessions + const row = { id: 't', turnId: 't', role: 'assistant', summary: '', items: [{ id: 'a', kind: 'text', text: '' }] } + send({ type: 'freshAgent.transcript.patch', sessionId: outer.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'resetTurn', nativeTurnId: 't', turns: [row], revision: 1 }, { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'Hel', revision: 2 }] }) + const key = 'freshcodex:codex:native-live' + expect(store.getState().freshAgentLive.bySession[key].projection?.turns[0].items[0]).toMatchObject({ text: 'Hel' }) + expect(store.getState().freshAgent.sessions).toBe(sessions) + send({ type: 'freshAgent.transcript.patch', sessionId: outer.sessionId, projectionId: 'foreign', historyGeneration: 0, ops: [{ op: 'resetTurn', nativeTurnId: 'other', turns: [row], revision: 100 }] }) + send({ type: 'freshAgent.session.init', sessionId: outer.sessionId, projectionId: 'late-old-incarnation', historyGeneration: 0 }) + expect(store.getState().freshAgentLive.bySession[key].projection?.id).toBe('p') + send({ type: 'freshAgent.transcript.patch', sessionId: outer.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'upsertItem', nativeTurnId: 't', row: { ...row, items: [] }, item: { id: 'a', kind: 'text', text: 'Hello' }, complete: true, revision: 3 }, { op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'lo', revision: 4 }] }) + expect(store.getState().freshAgentLive.bySession[key].projection?.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) + it('task6 applies retained output immediately and rejects a retired source after replacement', () => { + const store = createFreshAgentStore() + const outer = { type: 'freshAgent.event' as const, provider: 'codex' as const, sessionType: 'freshcodex' as const, sessionId: savedCodexNativeHistory.threadId } + const send = (id: string, text: string, incarnationId: string) => handleFreshAgentMessage(store.dispatch, { ...outer, + event: { type: 'freshAgent.host.backpressure', retainedState: { + source: { projectionId: id, ownerEpoch: 0, ownerGeneration: 0, soulId: 'soul', incarnationId, hostBootId: incarnationId }, + snapshot: { ...savedCodexNativeHistory, turns: [{ id:'t',turnId:'t',role:'assistant',summary:'',items:[{id:'answer',kind:'text',text}] }], extensions: { codex: { projection: { + id,historyGeneration:0,eventRevision:10,eventOwnedTurnIds:['t'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{t:['t']},turnOutputStates:{t:{provenance:'retained',diagnostic:'unsaved',refreshPending:false}},settledTurnIds:['t'],completedItems:{t:['answer']}, + } } } }, pendingControls: [], attentionMarks: [], + } }, + }) + send('p1','Recovered final output','incarnation-one') + const key = `freshcodex:codex:${outer.sessionId}` + expect(store.getState().freshAgentLive.bySession[key]?.projection?.turns[0].items[0]).toMatchObject({text:'Recovered final output'}) + send('p2','Replacement output','incarnation-two') + send('p1','Late retired output','incarnation-one') + expect(store.getState().freshAgentLive.bySession[key]?.projection?.id).toBe('p2') + expect(store.getState().freshAgentLive.bySession[key]?.projection?.turns[0].items[0]).toMatchObject({text:'Replacement output'}) + }) + it('task6 detects a missing consumed patch prefix without applying an unbased append', () => { + const store = createFreshAgentStore() + const outer = {type:'freshAgent.event' as const,provider:'codex' as const,sessionType:'freshcodex' as const,sessionId:'patch-gap'} + const send=(event:Record) => handleFreshAgentMessage(store.dispatch,{...outer,event}) + send({type:'freshAgent.session.init',projectionId:'p',historyGeneration:0}) + send({type:'freshAgent.transcript.patch',sessionId:'patch-gap',projectionId:'p',historyGeneration:0,ops:[{op:'resetTurn',nativeTurnId:'t',revision:1,turns:[{id:'t',turnId:'t',role:'assistant',summary:'',items:[{id:'a',kind:'text',text:'Known prefix'}]}]}]}) + send({type:'freshAgent.transcript.patch',sessionId:'patch-gap',projectionId:'p',historyGeneration:0,afterRevision:4,ops:[{op:'appendItem',nativeTurnId:'t',rowId:'t',itemId:'a',field:'text',text:' unbased suffix',revision:7}]}) + const projection=store.getState().freshAgentLive.bySession['freshcodex:codex:patch-gap'].projection + expect(projection?.needsResnapshot).toBe(true) + expect(projection?.turns[0].items[0]).toMatchObject({text:'Known prefix'}) + send({type:'freshAgent.transcript.patch',sessionId:'patch-gap',projectionId:'p',historyGeneration:0,afterRevision:7,ops:[{op:'appendItem',nativeTurnId:'t',rowId:'t',itemId:'a',field:'text',text:' another unbased suffix',revision:8}]}) + expect(store.getState().freshAgentLive.bySession['freshcodex:codex:patch-gap'].projection?.turns[0].items[0]).toMatchObject({text:'Known prefix'}) + send({type:'freshAgent.host.backpressure',retainedState:{source:{projectionId:'p',ownerEpoch:0,ownerGeneration:0,soulId:'soul',incarnationId:'incarnation',hostBootId:'host'},snapshot:{...savedCodexNativeHistory,threadId:'patch-gap',turns:[{id:'t',turnId:'t',role:'assistant',summary:'',items:[{id:'a',kind:'text',text:'The authoritative complete prefix'}]}],extensions:{codex:{projection:{id:'p',historyGeneration:0,eventRevision:8,eventOwnedTurnIds:['t'],nativePreviewTurnIds:[],nativeFinalTurnIds:[],turnRows:{t:['t']},turnOutputStates:{t:{provenance:'retained',refreshPending:false}},settledTurnIds:[]}}}},pendingControls:[],attentionMarks:[]}}) + const recovered=store.getState().freshAgentLive.bySession['freshcodex:codex:patch-gap'].projection + expect(recovered?.needsResnapshot).toBe(false) + expect(recovered?.turns[0].items[0]).toMatchObject({text:'The authoritative complete prefix'}) + }) beforeEach(() => { _resetCancelledCreates() }) @@ -92,6 +146,54 @@ describe('fresh-agent-ws', () => { expect(layout.content).not.toHaveProperty('namingHandle', 'spent-original-handle') }) + it('adopts the Codex name without changing settings, child selection, retained source or writer', () => { + const store = configureStore({ reducer: { + freshAgent: freshAgentReducer, freshAgentLive: freshAgentLiveReducer, panes: panesReducer, + } }) + const sessionId = savedCodexNativeHistory.threadId + const locator = { provider: 'codex', sessionType: 'freshcodex', sessionId } as const + store.dispatch(initLayout({ tabId: 'tab', paneId: 'pane', content: { + kind: 'fresh-agent', ...locator, createRequestId: 'stable-create', status: 'running', + sessionRef: { provider: 'codex', sessionId }, + namingHandle: 'spent-handle', nameRef: { kind: 'pending', id: 'spent-handle' }, + model: 'native-custom', effort: 'xhigh', sandbox: 'workspace-write', + initialCwd: '/workspace', observedChildThreadId: 'observed-child', + } })) + const ws = { send: vi.fn() } + const source = { projectionId: 'combined-source', ownerEpoch: 2, ownerGeneration: 3, + soulId: 'soul', incarnationId: 'incarnation', hostBootId: 'host' } + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, event: { + type: 'freshAgent.host.backpressure', retainedState: { + source, snapshot: { ...savedCodexNativeHistory, turns: [{ + id: 'turn', turnId: 'turn', role: 'assistant', summary: '', + items: [{ id: 'answer', kind: 'text', text: 'Retained native answer' }], + }], extensions: { codex: { projection: { + id: source.projectionId, historyGeneration: 0, eventRevision: 1, + eventOwnedTurnIds: ['turn'], nativePreviewTurnIds: [], nativeFinalTurnIds: [], + turnRows: { turn: ['turn'] }, turnOutputStates: { turn: { provenance: 'retained', refreshPending: false } }, + settledTurnIds: ['turn'], + } } } }, pendingControls: [], attentionMarks: [], + }, + } }, ws) + const key = `freshcodex:codex:${sessionId}` + const retained = store.getState().freshAgentLive.bySession[key] + expect(retained.projection?.turns[0].items[0]).toMatchObject({ text: 'Retained native answer' }) + const nameRef = { kind: 'session', provider: 'codex', sessionId } as const + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.session.materialized', ...locator, + previousSessionId: sessionId, sessionRef: { provider: 'codex', sessionId }, nameRef, + }, ws) + const layout = store.getState().panes.layouts.tab + if (layout.type !== 'leaf') throw new Error('expected a pane') + expect(resolvePaneNameRef(layout.content)).toEqual(nameRef) + expect(layout.content).toMatchObject({ ...locator, createRequestId: 'stable-create', + model: 'native-custom', effort: 'xhigh', sandbox: 'workspace-write', + initialCwd: '/workspace', observedChildThreadId: 'observed-child', + }) + expect(layout.content).not.toHaveProperty('namingHandle') + expect(store.getState().freshAgentLive.bySession[key]).toEqual(retained) + expect(ws.send).not.toHaveBeenCalled() + }) + it('registers resumed creates with history hydration and handles freshAgent.created', () => { const store = createFreshAgentStore() @@ -1476,3 +1578,20 @@ describe('runtime-owner folds (kata b8ke)', () => { expect(sent.observedGeneration).toBeUndefined() }) }) + + +it('rings server-authored async question attention once without pending controls or changing work status', () => { + const actions: Array<{ type: string }> = [] + const store = configureStore({ reducer: { freshAgent: freshAgentReducer, panes: panesReducer, turnCompletion: turnCompletionReducer }, middleware: (getDefault) => getDefault().concat(() => (next) => (action) => { actions.push(action as { type: string }); return next(action) }) }) + const locator = { sessionId: 'async-thread', sessionType: 'freshcodex', provider: 'codex' } as const + store.dispatch(initLayout({ tabId: 'async-tab', paneId: 'async-pane', content: { kind: 'fresh-agent', ...locator, createRequestId: 'async-create', status: 'running' } })) + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...locator, event: { type: 'freshAgent.status', status: 'running' } }) + const available = { type: 'freshAgent.event' as const, ...locator, event: { type: 'freshAgent.question.available', itemId: 'async-item', awaited: false, at: 1234 } } + expect(handleFreshAgentMessage(store.dispatch, available)).toBe(true) + handleFreshAgentMessage(store.dispatch, available) + const session = store.getState().freshAgent.sessions['freshcodex:codex:async-thread'] + expect(session.status).toBe('running') + expect(session.pendingQuestions).toEqual({}) + expect(actions.filter((action) => action.type === 'turnCompletion/recordTurnComplete')).toHaveLength(1) + expect(store.getState().panes.activePane['async-tab']).toBe('async-pane') +}) diff --git a/test/unit/client/store/freshAgentLiveSlice.test.ts b/test/unit/client/store/freshAgentLiveSlice.test.ts index 444f5f22c9..0f3ce6963f 100644 --- a/test/unit/client/store/freshAgentLiveSlice.test.ts +++ b/test/unit/client/store/freshAgentLiveSlice.test.ts @@ -19,7 +19,9 @@ import freshAgentLiveReducer, { transcriptAnnounced, transcriptExpectationsDropped, transcriptLanded, + transcriptProjectionSnapshotReceived, } from '@/store/freshAgentLiveSlice' +import { FreshAgentSnapshotSchema } from '@shared/fresh-agent-contract' import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws' import { LIVE_STREAM_FLUSH_MS, streamBatcherFor } from '@/lib/fresh-agent-stream-batcher' import type { LiveStreamOp } from '@/store/freshAgentLiveTypes' @@ -58,6 +60,38 @@ function streamTurn(store: Store) { } describe('freshAgentLive slice (transcript-live D3)', () => { + const codexLocator = { sessionId: 'codex-reconnect', sessionType: 'freshcodex' as const, provider: 'codex' as const } + const codexKey = 'freshcodex:codex:codex-reconnect' + const codexSnapshot = (revision = 2) => FreshAgentSnapshotSchema.parse({ sessionType: 'freshcodex', provider: 'codex', threadId: codexLocator.sessionId, revision: 0, status: 'running', capabilities: { send: false, interrupt: true, approvals: false, questions: false, fork: false }, tokenUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, turns: [{ id: 't', turnId: 't', role: 'assistant', summary: '', items: [{ id: 'a', kind: 'text', text: 'Hel' }] }], extensions: { codex: { projection: { id: 'p', historyGeneration: 0, eventRevision: revision, eventOwnedTurnIds: ['t'], nativePreviewTurnIds: [], nativeFinalTurnIds: [], turnRows: { t: ['t'] }, turnOutputStates: {} } } } }) + + it('buffers a reconnect delta without adopting its source, then applies it to the captured event-owned prefix', () => { + const store = createStore() + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...codexLocator, event: { type: 'freshAgent.transcript.patch', sessionId: codexLocator.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'lo', revision: 3 }] } }) + expect(tail(store, codexKey).projection).toBeUndefined() + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot: codexSnapshot() })) + expect(tail(store, codexKey).projection?.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hello' }]) + }) + + it('flags a bounded reconnect-buffer gap until a current authoritative event prefix arrives', () => { + const store = createStore() + for (let revision = 3; revision < 100; revision++) handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...codexLocator, event: { type: 'freshAgent.transcript.patch', sessionId: codexLocator.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'appendItem', nativeTurnId: 't', rowId: 't', itemId: 'a', field: 'text', text: 'x'.repeat(2048), revision }] } }) + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot: codexSnapshot() })) + expect(tail(store, codexKey).projection?.needsResnapshot).toBe(true) + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot: codexSnapshot(99) })) + expect(tail(store, codexKey).projection?.needsResnapshot).not.toBe(true) + expect(tail(store, codexKey).projection?.turns[0].items).toEqual([{ id: 'a', kind: 'text', text: 'Hel' }]) + }) + + it('host loss preserves a prior failure and freezes completed output as unconfirmed rather than changing it to an interruption', () => { + const store = createStore() + const snapshot = codexSnapshot() + snapshot.turns[0].error = { name: 'ProviderError', message: 'The native turn failed' } + store.dispatch(transcriptProjectionSnapshotReceived({ ...codexLocator, snapshot })) + handleFreshAgentMessage(store.dispatch, { type: 'freshAgent.event', ...codexLocator, event: { type: 'freshAgent.transcript.patch', sessionId: codexLocator.sessionId, projectionId: 'p', historyGeneration: 0, ops: [{ op: 'settleTurn', nativeTurnId: 't', status: 'failed', revision: 3 }] } }) + expect(tail(store, codexKey).projection?.turns[0].outputState?.diagnostic).not.toBe('interrupted') + store.dispatch(markSessionLost(codexLocator)) + expect(tail(store, codexKey).projection?.turns[0]).toMatchObject({ error: { message: 'The native turn failed' }, outputState: { diagnostic: 'unsaved', refreshPending: false } }) + }) it('binds each assistant message to the first unbound streamed block of its message id and kind', () => { const store = createStore() streamTurn(store) diff --git a/test/unit/client/store/panesPersistence.test.ts b/test/unit/client/store/panesPersistence.test.ts index a547be59d0..2cb785fd7e 100644 --- a/test/unit/client/store/panesPersistence.test.ts +++ b/test/unit/client/store/panesPersistence.test.ts @@ -497,6 +497,28 @@ describe('Panes Persistence Integration', () => { expect(restored.resumeSessionId).toBeUndefined() // always stripped; re-derived from sessionRef at create time }) + it.each([undefined, false, true])('round-trips Codex settings and native Default intent %s through persist and hydrate', (effortIsNativeDefault) => { + const makeStore = () => configureStore({ reducer: { tabs: tabsReducer, panes: panesReducer }, middleware: (getDefault) => getDefault().concat(persistMiddleware as any) }) + const before = makeStore() + before.dispatch(addTab({ mode: 'shell' })) + const tabId = before.getState().tabs.tabs[0].id + before.dispatch(initLayout({ tabId, content: { + kind: 'fresh-agent', provider: 'codex', sessionType: 'freshcodex', createRequestId: 'persist-codex-settings', status: 'idle', + model: 'provider-current', effort: effortIsNativeDefault ? undefined : 'future-effort', + effortIsNativeDefault, initialCwd: '/workspace/nested', sandbox: 'read-only', permissionMode: 'never', collaborationMode: 'plan', + } })) + vi.runAllTimers() + const tabs = loadPersistedTabs() + const panes = loadPersistedPanes() + const after = makeStore() + after.dispatch(hydrateTabs(tabs!.tabs)) + after.dispatch(hydratePanes(panes!)) + expect((after.getState().panes.layouts[tabId] as any).content).toMatchObject({ + model: 'provider-current', effort: effortIsNativeDefault ? undefined : 'future-effort', effortIsNativeDefault, + initialCwd: '/workspace/nested', sandbox: 'read-only', permissionMode: 'never', collaborationMode: 'plan', + }) + }) + it('round-trips a freshclaude pane’s stamped modelEffortLevels (probed-model effort survives reload)', () => { // A pane that selected a probed-only model via the settings popover // carries the row's effort levels as a stamp; the persist denylist keeps diff --git a/test/unit/client/store/persistedState.test.ts b/test/unit/client/store/persistedState.test.ts index 6339f3239e..ab4240aa8b 100644 --- a/test/unit/client/store/persistedState.test.ts +++ b/test/unit/client/store/persistedState.test.ts @@ -25,6 +25,17 @@ const codexDurability = { } describe('persistedState parsers', () => { + it('restores a Codex observer selection under the parent identity and drops malformed selections', () => { + for (const selection of ['native-child', [], '', null]) { + const content = { kind: 'fresh-agent', provider: 'codex', sessionType: 'freshcodex', sessionRef: { provider: 'codex', sessionId: '11111111-1111-4111-8111-111111111111' }, createRequestId: 'parent-create', status: 'idle', observedChildThreadId: selection } + const parsed = parsePersistedPanesRaw(JSON.stringify({ version: PANES_SCHEMA_VERSION, layouts: { parent: { type: 'leaf', id: 'parent-pane', content } }, activePane: { parent: 'parent-pane' } })) + const restored = (parsed!.layouts.parent as any).content + expect(restored.sessionRef).toEqual(content.sessionRef) + expect(restored.createRequestId).toBe('parent-create') + expect(restored.observedChildThreadId).toBe(selection === 'native-child' ? 'native-child' : undefined) + expect(Object.keys(parsed!.layouts)).toEqual(['parent']) + } + }) it('uses v2 namespaced storage and broadcast keys', () => { expect(TABS_STORAGE_KEY).toBe('freshell.tabs.v2') expect(PANES_STORAGE_KEY).toBe('freshell.panes.v2') diff --git a/test/unit/client/store/turnCompletion.replay.test.ts b/test/unit/client/store/turnCompletion.replay.test.ts index 1c4cff1f21..c915482c7d 100644 --- a/test/unit/client/store/turnCompletion.replay.test.ts +++ b/test/unit/client/store/turnCompletion.replay.test.ts @@ -124,7 +124,7 @@ describe('fresh-agent reconnect attention', () => { dispatch(foldReadyFreshAgentAttention(mark(100, 200))) dispatch(observeFreshAgentSession({ provider: 'claude', sessionId: 'session-1' })) expect(store.getState().turnCompletion.lastAtByTerminalId).toEqual({ - 'claude:session-1': 100, 'claude:session-1#waiting': 200, + 'claude:session-1': 100, 'claude:session-1#waiting': 200, 'claude:session-1#question': 0, }) }) @@ -239,7 +239,7 @@ describe('fresh-agent reconnect attention', () => { dispatch(foldReadyFreshAgentAttention([{ ...mark(100, 110)[0], sessionId: 'remote-only' }])) expect(store.getState().turnCompletion.seq).toBe(0) expect(store.getState().turnCompletion.lastAtByTerminalId).toEqual({ - 'claude:remote-only': 100, 'claude:remote-only#waiting': 110, + 'claude:remote-only': 100, 'claude:remote-only#waiting': 110, 'claude:remote-only#question': 0, }) }) @@ -254,3 +254,18 @@ describe('fresh-agent reconnect attention', () => { expect(ReadyMessageSchema.parse({ ...valid, freshAgentAttention: mark(100) }).freshAgentAttention).toEqual(mark(100)) }) }) + + +describe('nonblocking question attention replay', () => { + it('passes the ready parser, seeds reload silently, and replays a newer question once in its own bucket', () => { + const { store, dispatch } = createStore() + const ready = (at: number) => ReadyMessageSchema.parse({ type: 'ready', timestamp: 'now', serverInstanceId: 'server', freshAgentAttention: [{ provider: 'claude', sessionId: 'session-1', lastQuestionAvailableAt: at }] }) + dispatch(foldReadyFreshAgentAttention(ready(100).freshAgentAttention)) + expect(store.getState().turnCompletion.seq).toBe(0) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:session-1#question']).toBe(100) + dispatch(foldReadyFreshAgentAttention(ready(101).freshAgentAttention)) + dispatch(foldReadyFreshAgentAttention(ready(101).freshAgentAttention)) + expect(store.getState().turnCompletion.pendingEvents).toEqual([expect.objectContaining({ terminalId: 'claude:session-1#question', at: 101 })]) + expect(store.getState().turnCompletion.lastAtByTerminalId['claude:session-1#waiting']).toBe(0) + }) +}) diff --git a/test/unit/mcp/freshell-tool.test.ts b/test/unit/mcp/freshell-tool.test.ts index 804acdfed6..bafb6a23c1 100644 --- a/test/unit/mcp/freshell-tool.test.ts +++ b/test/unit/mcp/freshell-tool.test.ts @@ -79,7 +79,6 @@ describe('Rust capability rejection wall', () => { ['run', { command: 'echo blocked' }], ['fresh-send', { sessionId: 's', sessionType: 't', provider: 'codex', text: 'blocked' }], ['attach', { target: 'p', terminalId: 't' }], - ['new-tab', { agent: 'codex' }], ['split-pane', { agent: 'opencode' }], ['split-pane', { model: 'model' }], ['split-pane', { effort: 'high' }], @@ -127,17 +126,41 @@ describe('focus neutrality (MCP wrapper)', () => { }) describe('executeAction -- tab actions', () => { - it.each(['claude', 'kilroy'])('new-tab supports agent %s and maps resume sugar to Claude identity', async (agent) => { + it('preserves uncertain native delivery identity and never replays the rejected HTTP operation', async () => { + mockClient.get.mockImplementation((url: string) => Promise.resolve(url === '/api/tabs' + ? { data: { tabs: [{ id: 'tab-a', activePaneId: 'pmain' }], activeTabId: 'tab-a' } } + : { data: { panes: [{ id: 'pmain', tabId: 'tab-a', index: 0 }] } })) + const receipt = { code: 'CODEX_DELIVERY_AMBIGUOUS', requestId: 'uncertain-request', sessionId: 'native-session', incarnationId: 'owned-incarnation', retryable: false, answer: 'do-not-return-this-answer' } + mockClient.post.mockRejectedValue(Object.assign(new Error('delivery is uncertain'), { status: 409, details: receipt })) + const result = await executeAction('send-keys', { target: 'pmain', keys: 'recoverable prompt', literal: true }) + expect(result).toMatchObject({ code: receipt.code, requestId: receipt.requestId, sessionId: receipt.sessionId, incarnationId: receipt.incarnationId, retryable: false }) + expect(mockClient.post).toHaveBeenCalledTimes(1) + expect(JSON.stringify(result)).not.toContain('do-not-return-this-answer') + }) + it.each(['new-tab', 'split-pane'])('%s creates fresh Codex with canonical identity and exact settings', async (action) => { + mockClient.get.mockImplementation((url: string) => Promise.resolve(url === '/api/tabs' + ? { data: { tabs: [{ id: 'tab-a', activePaneId: 'pmain' }], activeTabId: 'tab-a' } } + : { data: { panes: [{ id: 'pmain', tabId: 'tab-a', index: 0 }] } })) + mockClient.post.mockResolvedValue({ status: 'ok', data: { tabId: 't1', paneId: 'p1' } }) + const sessionRef = { provider: 'codex', sessionId: '550e8400-e29b-41d4-a716-446655440000' } + const result = await executeAction(action, { ...(action === 'split-pane' ? { target: 'pmain' } : {}), agent: 'codex', model: 'provider-current', effort: 'xhigh', cwd: '/work', sessionRef }) + expect(result).not.toHaveProperty('error') + expect(mockClient.post).toHaveBeenCalledWith(action === 'new-tab' ? '/api/tabs' : '/api/panes/pmain/split', expect.objectContaining({ + agent: 'codex', model: 'provider-current', effort: 'xhigh', cwd: '/work', sessionRef, + })) + }) + + it.each(['claude', 'kilroy', 'codex'])('new-tab supports agent %s and maps resume sugar to canonical identity', async (agent) => { mockClient.post.mockResolvedValue({ data: { tabId: 'tab-new', paneId: 'pane-new' } }) const result = await executeAction('new-tab', { agent, model: 'catalog-model', effort: 'max', resume: '550e8400-e29b-41d4-a716-446655440000' }) expect(result).not.toHaveProperty('error') expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ agent, model: 'catalog-model', effort: 'max', - sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, })) }) - it.each(['claude', 'kilroy'])('split-pane supports agent %s and maps resume sugar to Claude identity', async (agent) => { + it.each(['claude', 'kilroy', 'codex'])('split-pane supports agent %s and maps resume sugar to canonical identity', async (agent) => { mockClient.get.mockImplementation((url: string) => Promise.resolve(url === '/api/tabs' ? { data: { tabs: [{ id: 'tab-a', activePaneId: 'pmain' }], activeTabId: 'tab-a' } } : { data: { panes: [{ id: 'pmain', tabId: 'tab-a', index: 0 }] } })) @@ -146,7 +169,7 @@ describe('executeAction -- tab actions', () => { expect(result).not.toHaveProperty('error') expect(mockClient.post).toHaveBeenCalledWith('/api/panes/pmain/split', expect.objectContaining({ agent, model: 'catalog-model', effort: 'max', - sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, })) }) @@ -381,19 +404,7 @@ describe('executeAction -- tab actions', () => { })) }) - it('new-tab with agent: codex is locally rejected by the Rust capability contract', async () => { - mockClient.post.mockResolvedValue({ id: 't1' }) - - const result = await executeAction('new-tab', { - agent: 'codex', - resume: 'thread-pre-durable', - }) - - expect(result).toEqual(expect.objectContaining({ error: 'This fresh-agent provider is unavailable with the Rust Freshell server.' })) - expect(mockClient.post).not.toHaveBeenCalled() - }) - - it.each(['claude', 'kilroy'])('new-tab with agent %s accepts the resumeSessionId alias', async (agent) => { + it.each(['claude', 'kilroy', 'codex'])('new-tab with agent %s accepts the resumeSessionId alias', async (agent) => { mockClient.post.mockResolvedValue({ id: 't1' }) await executeAction('new-tab', { @@ -403,7 +414,7 @@ describe('executeAction -- tab actions', () => { expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ agent, - sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + sessionRef: { provider: agent === 'codex' ? 'codex' : 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, })) expect(mockClient.post.mock.calls[0][1]).not.toHaveProperty('resumeSessionId') }) diff --git a/test/unit/provider-fixtures/claude-boot-persistence.test.ts b/test/unit/provider-fixtures/claude-boot-persistence.test.ts new file mode 100644 index 0000000000..bb88aec404 --- /dev/null +++ b/test/unit/provider-fixtures/claude-boot-persistence.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment node +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { pollTranscript, SCENARIOS } from '../../integration/providers/claude-cli-scenarios.mjs' + +afterEach(() => { vi.useRealTimers() }) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +it('keeps first-appearance timing but waits through partial and wrong-assistant disk rows before capturing the first turn', async () => { + vi.useFakeTimers() + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-boot-persistence-')) + const file = path.join(root, 'session.jsonl') + const partial = [{ type: 'user', uuid: 'u1' }, { type: 'attachment', uuid: 'attachment-1' }] + const unrelated = { type: 'assistant', uuid: 'another-turn' } + const expected = { type: 'assistant', uuid: 'first-turn-assistant' } + const write = (rows: unknown[]) => fs.writeFile(file, rows.map(row => JSON.stringify(row)).join('\n') + '\n') + await write(partial) + const boundary = deferred() + const thirdRead = deferred() + const observations: Record = {} + const trace = { add: vi.fn() } + let reads = 0 + let startedSecondTurn = false + const fixture = { + async readTranscript() { + const rows = (await fs.readFile(file, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + reads++ + if (reads === 2) boundary.resolve('waiting-for-assistant-persistence') + if (reads === 3) thirdRead.resolve() + return rows + }, + } + const session = { + frames: [expected], + init: async () => {}, call: async () => {}, + push(_text: string, label: string) { + if (label === 'u2') { startedSecondTurn = true; boundary.resolve('started-second-turn-with-partial-history') } + }, + waitForResults: async () => ({}), waitForCount: async () => ({}), close: async () => {}, + messages(predicate: (message: unknown) => boolean) { return this.frames.filter(predicate) }, + } + const capturing = SCENARIOS.boot({ + uuid: () => 'session-X', open: () => session, files: async () => [], + observe: (key: string, value: unknown) => { observations[key] = value }, + pollTranscript: (...args: any[]) => pollTranscript(fixture, trace, ...args), + need(value: unknown, what: string) { + if (!value) throw new Error(`scenario prerequisite missing: ${what}`) + return value + }, + }) + try { + await vi.advanceTimersByTimeAsync(2000) + expect(await boundary.promise).toBe('waiting-for-assistant-persistence') + expect(startedSecondTurn).toBe(false) + await write([...partial, unrelated]) + await vi.advanceTimersByTimeAsync(50) + await thirdRead.promise + expect(startedSecondTurn).toBe(false) + await write([...partial, unrelated, expected]) + await vi.advanceTimersByTimeAsync(50) + await capturing + expect(observations.transcriptAfterFirstResult).toEqual({ + // This still measures first file appearance, before the delayed assistant. + msAfterResult: 0, + shape: [...partial, unrelated, expected].map(row => ({ type: row.type, uuid: row.uuid, parentUuid: null })), + }) + expect(startedSecondTurn).toBe(true) + } finally { + await write([...partial, expected]) + await vi.advanceTimersByTimeAsync(50) + await capturing.finally(() => fs.rm(root, { recursive: true, force: true })) + } +}) + +it('bounds a missing assistant persistence wait with a transcript-specific trace', async () => { + vi.useFakeTimers() + const trace = { add: vi.fn() } + const fixture = { readTranscript: async () => [{ type: 'user', uuid: 'u1' }] } + const waiting = pollTranscript(fixture, trace, 'session-X', 100, 1, + (rows: any[]) => rows.some(row => row.type === 'assistant' && row.uuid === 'first-turn-assistant')) + await vi.advanceTimersByTimeAsync(150) + await expect(waiting).resolves.toBeNull() + expect(trace.add).toHaveBeenLastCalledWith({ k: 'transcript-missing', id: 'session-X' }) + expect(vi.getTimerCount()).toBe(0) +}) + +it('refuses a boot capture whose observed assistant never saves and identifies both native IDs', async () => { + vi.useFakeTimers() + const trace = { add: vi.fn() } + const fixture = { readTranscript: async () => [{ type: 'user', uuid: 'u1' }] } + const assistant = { type: 'assistant', uuid: 'first-turn-assistant' } + const session = { + frames: [assistant], init: async () => {}, call: async () => {}, push: vi.fn(), + waitForResults: async () => ({}), + messages: (predicate: (message: unknown) => boolean) => [assistant].filter(predicate), + } + const capturing = SCENARIOS.boot({ + uuid: () => 'session-X', open: () => session, files: async () => [], observe: () => {}, + pollTranscript: (...args: any[]) => pollTranscript(fixture, trace, ...args), + need(value: unknown, what: string) { + if (!value) throw new Error(`scenario prerequisite missing: ${what}`) + return value + }, + }) + const rejected = expect(capturing).rejects.toThrow('first-turn assistant first-turn-assistant saved for session session-X within 4000ms') + rejected.catch(() => {}) + await vi.advanceTimersByTimeAsync(6050) + await rejected + expect(session.push).toHaveBeenCalledExactlyOnceWith('first prompt', 'u1') + expect(trace.add).toHaveBeenLastCalledWith({ k: 'transcript-missing', id: 'session-X' }) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/test/unit/provider-fixtures/claude-fixture-cleanup.test.ts b/test/unit/provider-fixtures/claude-fixture-cleanup.test.ts new file mode 100644 index 0000000000..cfb129e472 --- /dev/null +++ b/test/unit/provider-fixtures/claude-fixture-cleanup.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment node +import { spawn, type ChildProcess } from 'node:child_process' +import { EventEmitter, once } from 'node:events' +import fs from 'node:fs/promises' +import path from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { createClaudeFixture } from '../../integration/providers/claude-local-provider.mjs' + +afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks() }) + +// An ordinary owned writer, stopped by IPC rather than signals. This exercises +// the same HOME ownership as a native CLI running under a separate sidecar. +const writer = ` + const fs = require('node:fs/promises') + const path = require('node:path') + process.on('message', async message => { + if (message === 'finish') return process.disconnect() + if (message !== 'write') return + try { + await fs.writeFile(path.join(process.env.HOME, 'last-native-write'), 'saved') + process.send({ written: true }) + } catch (error) { process.send({ written: false, code: error.code }) } + }) + process.send({ ready: true }) +` + +async function finish(child: ChildProcess, closed: Promise) { + if (child.connected) child.send('finish') + await closed +} + +it.each(['child', 'native-pid'] as const)('retains HOME until an explicitly owned %s finishes its final write and exits', async ownership => { + const fixture = await createClaudeFixture({ prefix: 'claude-fixture-cleanup-' }) + const child = spawn(process.execPath, ['-e', writer], { + env: fixture.env('http://127.0.0.1:9'), + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }) + const closed = once(child, 'close') + await once(child, 'message') + if (ownership === 'child') fixture.ownProcess(child) + else await fixture.ownProcessId(child.pid!) + let writerReleased = false + let removedBeforeWriterRelease = false + const remove = fs.rm + const removal = vi.spyOn(fs, 'rm').mockImplementation(async (target, options) => { + if (target === fixture.root && !writerReleased) removedBeforeWriterRelease = true + return remove(target, options) + }) + const cleaning = fixture.cleanup() + try { + // A final write after teardown begins must still have its owned HOME. + // The IPC acknowledgement establishes the schedule without a sleep. + const written = once(child, 'message') + child.send('write') + const report = (await written)[0] + const saved = report.written ? await fs.readFile(path.join(fixture.home, 'last-native-write'), 'utf8') : null + expect(report).toEqual({ written: true }) + expect(saved).toBe('saved') + writerReleased = true + await finish(child, closed) + await cleaning + expect(removedBeforeWriterRelease, 'cleanup must not remove HOME before its owned writer is released').toBe(false) + await expect(fs.stat(fixture.root)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + writerReleased = true + await finish(child, closed) + try { + await cleaning + await fixture.cleanup() + } finally { removal.mockRestore() } + } +}) + +it('retains HOME while a native PID is live and releases it when the same identity becomes a zombie', async () => { + const fixture = await createClaudeFixture({ prefix: 'claude-fixture-native-boundary-' }) + const pid = 456789 + const procStat = `/proc/${pid}/stat` + let state: 'S' | 'Z' = 'S' + let observingCleanup = false + let observeBoundary!: (kind: 'poll' | 'removed' | 'settled') => void + const boundary = new Promise<'poll' | 'removed' | 'settled'>(resolve => { observeBoundary = resolve }) + const read = fs.readFile + vi.spyOn(fs, 'readFile').mockImplementation(async (target, options) => { + if (target !== procStat) return read(target, options) + if (observingCleanup) observeBoundary('poll') + // Linux stat field 3 is state; field 22 is the captured start time. + return `${pid} (held native writer) ${[state, ...Array(18).fill('0'), '424242', '0'].join(' ')}\n` + }) + await fixture.ownProcessId(pid) + let removedWhileIdentityLive = false + const remove = fs.rm + vi.spyOn(fs, 'rm').mockImplementation(async (target, options) => { + if (target === fixture.root) { + removedWhileIdentityLive ||= state === 'S' + observeBoundary('removed') + } + return remove(target, options) + }) + vi.useFakeTimers() + observingCleanup = true + let cleanupSettled = false + const cleaning = fixture.cleanup().finally(() => { + cleanupSettled = true + observeBoundary('settled') + }) + try { + // Account for early removal/settlement too: an omitted native wait must + // fail on actual HOME deletion rather than hang waiting for a missing poll. + const observed = await boundary + await vi.advanceTimersByTimeAsync(0) + if (observed !== 'poll') await cleaning + expect((await fs.stat(fixture.home)).isDirectory()).toBe(true) + expect(cleanupSettled, 'cleanup must remain pending while the captured native identity is live').toBe(false) + expect(removedWhileIdentityLive).toBe(false) + const finalWrite = path.join(fixture.home, 'last-native-write') + await fs.writeFile(finalWrite, 'saved while live') + expect(await fs.readFile(finalWrite, 'utf8')).toBe('saved while live') + + // No Node exit/close event is available for this separately owned PID. + // Only its kernel state changes; the captured start time stays identical. + state = 'Z' + await vi.advanceTimersByTimeAsync(10) + await cleaning + expect(removedWhileIdentityLive).toBe(false) + await expect(fs.stat(fixture.root)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(vi.getTimerCount()).toBe(0) + } finally { + state = 'Z' + await vi.advanceTimersByTimeAsync(10) + try { await cleaning } finally { await fixture.cleanup() } + } +}) + +it('joins inherited stdio close after sidecar exit before removing its fixture', async () => { + const fixture = await createClaudeFixture({ prefix: 'claude-fixture-pipe-close-' }) + const sidecar = Object.assign(new EventEmitter(), { pid: 12345 }) + fixture.ownProcess(sidecar) + const cleaning = fixture.cleanup() + try { + sidecar.emit('exit', 0, null) + // A descendant still holding the pipes may write after the sidecar exits. + await fs.writeFile(path.join(fixture.home, 'inherited-writer'), 'final') + expect(await fs.readFile(path.join(fixture.home, 'inherited-writer'), 'utf8')).toBe('final') + } finally { + sidecar.emit('close', 0, null) + await cleaning + } + await expect(fs.stat(fixture.root)).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it('bounds a missing owner close and retains HOME for diagnosis instead of deleting active state', async () => { + const fixture = await createClaudeFixture({ prefix: 'claude-fixture-missing-close-' }) + const sidecar = Object.assign(new EventEmitter(), { pid: 12345 }) + fixture.ownProcess(sidecar) + vi.useFakeTimers() + const cleaning = fixture.cleanup() + const failure = cleaning.catch(error => error) + await vi.advanceTimersByTimeAsync(5_000) + const error = await failure + expect(error).toBeInstanceOf(AggregateError) + expect(error.message).toContain('Claude fixture process cleanup incomplete; retained') + expect(error.errors[0].message).toContain('fixture-owned process 12345 did not settle within 5000ms') + expect((await fs.stat(fixture.home)).isDirectory()).toBe(true) + expect(vi.getTimerCount()).toBe(0) + sidecar.emit('close', 0, null) + await fixture.cleanup() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/test/unit/provider-fixtures/claude-scenario-drain.test.ts b/test/unit/provider-fixtures/claude-scenario-drain.test.ts new file mode 100644 index 0000000000..0ea2dd21a2 --- /dev/null +++ b/test/unit/provider-fixtures/claude-scenario-drain.test.ts @@ -0,0 +1,80 @@ +// @vitest-environment node +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { expect, it } from 'vitest' +import { SCENARIOS } from '../../integration/providers/claude-cli-scenarios.mjs' +import { createClaudeCapture } from '../../helpers/claude-session-capture.js' + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +it('captures each MCP turn through its terminal idle before closing either SDK query', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-scenario-drain-')) + await fs.writeFile(path.join(root, 'mcp-probe-report.json'), '{}') + const sessions = [makeSession(), makeSession()] + for (const session of sessions) await session.deliver({ + type: 'result', subtype: 'success', is_error: false, terminal_reason: 'completed', + }) + let opened = 0 + const capturing = SCENARIOS.mcp({ + fixture: { root }, + open: () => sessions[opened++].session, + observe: () => {}, + need(value: unknown, what: string) { + if (!value) throw new Error(`scenario prerequisite missing: ${what}`) + return value + }, + }) + try { + for (const session of sessions) { + // The result is already available; only the native drain is withheld. + await session.boundary.promise + expect(session.query.close).not.toHaveBeenCalled() + await session.releaseIdle() + await session.didClose.promise + expect(session.capturedAtClose).toEqual(['result', 'completed', 'idle']) + } + await capturing + } finally { + for (const session of sessions) await session.releaseIdle() + await capturing.finally(() => fs.rm(root, { recursive: true, force: true })) + } +}) + +function makeSession() { + const capture = createClaudeCapture() + const boundary = deferred() + const didClose = deferred() + const originalClose = capture.session.close.bind(capture.session) + const originalWaitForResultsAndIdle = capture.session.waitForResultsAndIdle + capture.session.waitForResultsAndIdle = function (...args: Parameters) { + const waiting = originalWaitForResultsAndIdle.apply(this, args) + boundary.resolve() + return waiting + } + const observed = { + ...capture, + boundary, + didClose, + capturedAtClose: [] as string[], + async releaseIdle() { + if (capture.session.frames.length !== 1) return + await capture.deliver( + { type: 'command_lifecycle', state: 'completed' }, + { type: 'system', subtype: 'session_state_changed', state: 'idle' }, + ) + }, + } + capture.session.close = async () => { + const closing = originalClose() + boundary.resolve() + await closing + observed.capturedAtClose = capture.events.filter(event => event.k === 'msg').map(event => event.m.state ?? event.m.type) + didClose.resolve() + } + return observed +} diff --git a/test/unit/provider-fixtures/claude-session-settlement.test.ts b/test/unit/provider-fixtures/claude-session-settlement.test.ts new file mode 100644 index 0000000000..4bcc081350 --- /dev/null +++ b/test/unit/provider-fixtures/claude-session-settlement.test.ts @@ -0,0 +1,70 @@ +// @vitest-environment node +import { afterEach, expect, it, vi } from 'vitest' +import { createClaudeCapture as capture } from '../../helpers/claude-session-capture.js' + +afterEach(() => { vi.useRealTimers() }) + +const running = { type: 'system', subtype: 'session_state_changed', state: 'running' } +const idle = { type: 'system', subtype: 'session_state_changed', state: 'idle' } +const completed = { type: 'command_lifecycle', command_uuid: 'u1', state: 'completed' } +const success = { type: 'result', subtype: 'success', is_error: false, terminal_reason: 'completed', user_message_uuid: 'u1', session_id: 'session-X' } + +it.each([false, true])('keeps a successful result capture open through completed and a later idle (prior idle: %s)', async (priorIdle) => { + const { session, query, events, deliver } = capture() + if (priorIdle) await deliver(idle) + await deliver(running, success) + const closing = session.close() + try { + await Promise.resolve() + expect(query.close).not.toHaveBeenCalled() + expect(events.some(event => event.k === 'close')).toBe(false) + await deliver(completed) + expect(query.close).not.toHaveBeenCalled() + await deliver({ ...idle }) + await closing + const closeAt = events.findIndex(event => event.k === 'close') + expect(events.slice(0, closeAt).filter(event => event.k === 'msg').slice(-3).map(event => event.m)) + .toEqual([success, completed, idle]) + expect(query.close).toHaveBeenCalledOnce() + } finally { + query.close() + await closing + } +}) + +it.each([ + { type: 'result', subtype: 'error_during_execution', is_error: true, terminal_reason: 'aborted_streaming' }, + { type: 'result', subtype: 'error_during_execution', is_error: true, terminal_reason: 'aborted_tools' }, + { ...success, is_error: true, terminal_reason: 'api_error' }, + { type: 'result', subtype: 'error_during_execution', is_error: true, num_turns: 0 }, +])('preserves deliberate close after $subtype/$terminal_reason without requiring successful idle', async (result) => { + const { session, query, events, deliver } = capture() + await deliver(result) + await session.close() + expect(query.close).toHaveBeenCalledOnce() + expect(events.filter(event => event.k === 'msg').map(event => event.m)).toEqual([result]) + expect(events.some(event => event.k === 'wait-missed')).toBe(false) +}) + +it('closes an unfinished new turn without waiting on a previous successful result', async () => { + const { session, query, events, deliver } = capture() + // Queued turns start after the previous result with no idle between them. + await deliver(running, success, completed, + { type: 'command_lifecycle', command_uuid: 'u2', state: 'started' }) + await session.close() + expect(query.close).toHaveBeenCalledOnce() + expect(events.some(event => event.k === 'wait-missed')).toBe(false) +}) + +it('fails a missing successful idle within a finite deadline and still closes the owned query', async () => { + vi.useFakeTimers() + const { session, query, events, deliver } = capture() + await deliver(running, success, completed) + const closing = session.close() + const rejected = expect(closing).rejects.toThrow(/successful result.*idle/) + rejected.catch(() => {}) + await vi.advanceTimersByTimeAsync(10_000) + await rejected + expect(query.close).toHaveBeenCalledOnce() + expect(events.some(event => event.k === 'wait-missed')).toBe(true) +}) diff --git a/test/unit/provider-fixtures/codex-native-close.test.ts b/test/unit/provider-fixtures/codex-native-close.test.ts new file mode 100644 index 0000000000..80114ce643 --- /dev/null +++ b/test/unit/provider-fixtures/codex-native-close.test.ts @@ -0,0 +1,61 @@ +// @vitest-environment node +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, expect, it, vi } from 'vitest' + +const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() })) +vi.mock('node:child_process', async importOriginal => ({ ...await importOriginal(), spawn })) +import { NativeClient } from '../../integration/providers/codex-cli-contract.mjs' + +afterEach(() => { vi.useRealTimers(); vi.clearAllMocks() }) + +function fixture() { + const child = Object.assign(new EventEmitter(), { + pid: 12345, + exitCode: null as number | null, + signalCode: null as string | null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + kill: vi.fn((signal: string) => { + setTimeout(() => { child.signalCode = signal; child.emit('exit', null, signal) }, 0) + return true + }), + }) + spawn.mockReturnValue(child) + const capture: any = { probeOnly: true, environment: {}, observations: {}, frames: [], responses: [], notifications: [] } + const native = new NativeClient('/owned/launcher', { workspace: '/owned/workspace', environment: {} }, capture) + return { child, native, capture } +} + +it('waits beyond launcher exit for inherited native stdio to close, preserving final frames', async () => { + vi.useFakeTimers() + const { child, native, capture } = fixture() + let settled = false + const closing = native.close().then(() => { settled = true }) + await vi.advanceTimersByTimeAsync(0) + expect(child.signalCode).toBe('SIGTERM') + expect(settled).toBe(false) + + child.stdout.write(`${JSON.stringify({ method: 'native/final-frame', params: {} })}\n`) + child.stderr.write('final native shutdown diagnostic') + child.stdout.end(); child.stderr.end() + child.emit('close', null, 'SIGTERM') + await closing + expect(capture.notifications).toEqual([{ method: 'native/final-frame', params: {} }]) + expect(capture.stderr).toBe('final native shutdown diagnostic') + expect(capture.processCloses).toEqual([{ connectionId: 0, pid: child.pid, code: null, signal: 'SIGTERM' }]) + expect(child.kill.mock.calls).toEqual([['SIGTERM']]) + expect(vi.getTimerCount()).toBe(0) +}) + +it('fails within a bounded deadline when an exited launcher leaves native stdio open', async () => { + vi.useFakeTimers() + const { child, native } = fixture() + const closing = native.close() + const rejected = expect(closing).rejects.toThrow(/owned process stdio close.*12345/) + rejected.catch(() => {}) + await vi.advanceTimersByTimeAsync(5000) + await rejected + expect(child.kill.mock.calls).toEqual([['SIGTERM']]) + child.stdout.end(); child.stderr.end(); child.emit('close', null, 'SIGTERM') + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/test/unit/shared/fresh-agent-contract.test.ts b/test/unit/shared/fresh-agent-contract.test.ts index f7a6ace7bb..bf00c3bb0f 100644 --- a/test/unit/shared/fresh-agent-contract.test.ts +++ b/test/unit/shared/fresh-agent-contract.test.ts @@ -1,14 +1,17 @@ +import { FreshAgentClientMessageSchema } from '../../../shared/ws-protocol.js' import { describe, expect, it } from 'vitest' import { FRESH_AGENT_CONTRACT_SCHEMA_NAMES, FreshAgentActionResultSchema, FreshAgentCapabilitiesSchema, + FreshAgentChildObservationSchema, FreshAgentContractErrorSchema, FreshAgentRequestIdSchema, FreshAgentSessionCommandSchema, FreshAgentSnapshotSchema, FreshAgentTranscriptItemSchema, + FreshAgentTranscriptPatchSchema, FreshAgentTurnBodySchema, FreshAgentTurnPageSchema, FreshAgentTurnSchema, @@ -25,6 +28,33 @@ import { } from '../../fixtures/fresh-agent/codex/contract-fixtures.js' describe('fresh-agent shared contract schemas', () => { + it('requires the exact child body only for readable observations and preserves finite unavailable states', () => { + const identity = { parentThreadId: 'parent', childThreadId: codexContractSnapshot.threadId } + const saved = FreshAgentChildObservationSchema.parse({ ...identity, status: 'saved', snapshot: codexContractSnapshot }) + expect(saved.status).toBe('saved') + for (const reason of ['ephemeral_history_unavailable', 'history_unavailable', 'source_unavailable']) { + const unavailable = { ...identity, status: 'unavailable', reason, retryable: reason === 'source_unavailable' } + expect(FreshAgentChildObservationSchema.safeParse(unavailable).success).toBe(true) + expect(FreshAgentChildObservationSchema.safeParse({ ...unavailable, snapshot: codexContractSnapshot }).success).toBe(false) + } + expect(FreshAgentChildObservationSchema.safeParse({ ...identity, status: 'pending', reason: 'history_unmaterialized', retryable: true }).success).toBe(true) + expect(FreshAgentChildObservationSchema.safeParse({ ...identity, childThreadId: 'wrong', status: 'live', snapshot: codexContractSnapshot }).success).toBe(false) + }) + it('validates native delta patches and rejects unsafe revisions or malformed operations', () => { + const patch = { type: 'freshAgent.transcript.patch', sessionId: 'thread', projectionId: 'runtime', historyGeneration: 0, ops: [{ op: 'appendItem', nativeTurnId: 'turn', rowId: 'turn:row-1', itemId: 'answer', field: 'text', text: '🙂', revision: 1 }] } + expect(FreshAgentTranscriptPatchSchema.parse(patch).ops[0]).toMatchObject({ text: '🙂', revision: 1 }) + expect(FreshAgentTranscriptPatchSchema.safeParse({ ...patch, ops: [{ ...patch.ops[0], revision: Number.MAX_SAFE_INTEGER + 1 }] }).success).toBe(false) + expect(FreshAgentTranscriptPatchSchema.safeParse({ ...patch, ops: [{ ...patch.ops[0], field: 'credentials' }] }).success).toBe(false) + expect(FreshAgentTranscriptPatchSchema.safeParse({ ...patch, ops: [{ ...patch.ops[0], op: 'replaceEverything' }] }).success).toBe(false) + }) + + it('preserves observed zero usage, native model limit, supplied diff and finite interrupted provenance', () => { + const parsed = FreshAgentSnapshotSchema.parse({ ...codexContractSnapshot, tokenUsage: { observed: true, inputTokens: 0, outputTokens: 0, totalTokens: 0, modelContextWindow: 258400 }, diffs: [{ id: 'turn:t', title: 'Turn changes', diff: '+native' }], turns: [{ id: 't', turnId: 't', summary: '', items: [], outputState: { provenance: 'saved', diagnostic: 'interrupted', refreshPending: false } }] }) + expect(parsed.tokenUsage.observed).toBe(true) + expect(parsed.tokenUsage.modelContextWindow).toBe(258400) + expect(parsed.diffs[0].diff).toBe('+native') + expect(parsed.turns[0].outputState?.diagnostic).toBe('interrupted') + }) it('parses Claude and Codex snapshots through one shared durable contract', () => { const claudeSnapshot = FreshAgentSnapshotSchema.parse(claudeContractSnapshot) expect(claudeSnapshot.sessionType).toBe('freshclaude') @@ -433,3 +463,22 @@ describe('durable opencode error projection (turn-level and tool-level)', () => }).success).toBe(false) }) }) + + +it('keeps native question flags and retained MCP forms in the durable contract', () => { + const parsed = FreshAgentSnapshotSchema.parse({ ...codexContractSnapshot, pendingQuestions: [{ requestId: '51', isBlocking: false, questions: [{ id: 'token', question: 'Token?', isOther: false, isSecret: true }] }], pendingElicitations: [{ requestId: '41', mode: 'openaiForm', serverName: 'contract', message: 'Form', fields: [{ id: 'enabled', title: 'Enabled', type: 'boolean', required: true }] }] }) + expect(parsed.pendingQuestions[0]).toMatchObject({ isBlocking: false, questions: [{ isOther: false, isSecret: true }] }) + expect(parsed.pendingElicitations[0]).toMatchObject({ mode: 'openaiForm', fields: [{ type: 'boolean' }] }) +}) + + +it('keeps elicitation replies separate from string-valued question answers and rejects nested content', () => { + const locator = { provider: 'codex', sessionType: 'freshcodex', sessionId: 'native-thread', requestId: '41' } + const frame = { ...locator, type: 'freshAgent.elicitation.respond', action: 'accept', content: { token: ' exact, answer! ', count: 3, enabled: false, colors: ['blue', 'green'] } } + expect(FreshAgentClientMessageSchema.parse(frame)).toEqual(frame) + for (const action of ['decline', 'cancel']) expect(FreshAgentClientMessageSchema.safeParse({ ...frame, action, content: null }).success).toBe(true) + expect(FreshAgentClientMessageSchema.safeParse({ ...frame, content: { nested: { value: 'unsupported' } } }).success).toBe(false) + expect(FreshAgentClientMessageSchema.safeParse({ ...frame, action: 'other' }).success).toBe(false) + const question = { ...locator, type: 'freshAgent.question.respond', answers: { token: ' exact, answer! ' } } + expect(FreshAgentClientMessageSchema.parse(question)).toEqual(question) +}) diff --git a/test/unit/shared/fresh-agent-models.test.ts b/test/unit/shared/fresh-agent-models.test.ts index e075e9a79a..a66a4aea2a 100644 --- a/test/unit/shared/fresh-agent-models.test.ts +++ b/test/unit/shared/fresh-agent-models.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { FreshAgentSettingsSchema } from '@shared/fresh-agent-contract' import { FRESHCODEX_DEFAULT_MODEL, @@ -10,6 +11,16 @@ import { resolveFreshAgentModelOption, } from '@shared/fresh-agent-models' +describe('Codex exact native model choices', () => { + it('keeps custom and saved models and every explicit native effort for native validation', () => { + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'provider-current')).toBe('provider-current') + for (const effort of ['ultra', 'none', 'xhigh', 'max']) { + expect(normalizeFreshAgentEffort('freshcodex', 'codex', 'provider-current', effort)).toBe(effort) + expect(FreshAgentSettingsSchema.parse({ model: 'provider-current', effort }).effort).toBe(effort) + } + }) +}) + describe('fresh-agent-models freshopencode static menu', () => { it('has no static fallback entries for freshopencode (models come from the live catalog)', () => { expect(FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshopencode).toEqual([]) @@ -31,21 +42,10 @@ describe('fresh-agent-models freshopencode static menu', () => { }) describe('fresh-agent-models freshcodex static menu', () => { - it('tracks the current Codex model availability list', () => { - expect(FRESHCODEX_DEFAULT_MODEL).toBe('gpt-6-astra') - expect(FRESH_AGENT_MODEL_OPTIONS_BY_SESSION_TYPE.freshcodex.map((option) => option.value)).toEqual([ - 'gpt-6-astra', - 'gpt-5.6-sol', - 'gpt-5.6-terra', - 'gpt-5.6-luna', - 'gpt-5.3-codex-spark', - ]) - }) - - it('falls back retired or unknown freshcodex models to the current default', () => { - expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4')).toBe('gpt-6-astra') - expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4-mini')).toBe('gpt-6-astra') - expect(normalizeFreshAgentModel('freshcodex', 'codex', 'unknown-model')).toBe('gpt-6-astra') + it('preserves retired or custom model IDs for native validation', () => { + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4')).toBe('gpt-5.4') + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'gpt-5.4-mini')).toBe('gpt-5.4-mini') + expect(normalizeFreshAgentModel('freshcodex', 'codex', 'unknown-model')).toBe('unknown-model') }) }) diff --git a/test/unit/shared/fresh-agent-registry.test.ts b/test/unit/shared/fresh-agent-registry.test.ts index 59586659ed..856bae720d 100644 --- a/test/unit/shared/fresh-agent-registry.test.ts +++ b/test/unit/shared/fresh-agent-registry.test.ts @@ -31,7 +31,7 @@ describe('fresh-agent registry', () => { }) describe('resolveFreshAgentPaneCreateEffort', () => { - it('keeps claude/codex falling back to the registry default effort', () => { + it('keeps the Claude fallback and leaves omitted Codex effort to its native default', () => { expect(resolveFreshAgentPaneCreateEffort({ sessionType: 'freshclaude', provider: 'claude', @@ -45,7 +45,7 @@ describe('resolveFreshAgentPaneCreateEffort', () => { model: 'gpt-5.5', providerEffort: undefined, fallbackEffort: 'max', - })).toBe('max') + })).toBeUndefined() }) it('passes an explicit freshopencode provider default through for live-catalog models', () => { @@ -165,7 +165,7 @@ describe('resolveFreshAgentPaneModelDefaults', () => { ['kilroy: registry default when nothing is saved', 'kilroy', undefined, undefined, 'opus[1m]', true], ['codex: saved default wins', 'freshcodex', saved('gpt-5.6-luna'), 'gpt-5.6-terra', 'gpt-5.6-luna', true], ['codex: runtime CLI model when nothing is saved', 'freshcodex', undefined, 'gpt-5.6-terra', 'gpt-5.6-terra', true], - ['codex: unknown saved model normalizes to the codex default', 'freshcodex', saved('gpt-unknown'), undefined, 'gpt-6-astra', true], + ['codex: unknown saved model preserves exact intent for native validation', 'freshcodex', saved('gpt-unknown'), undefined, 'gpt-unknown', true], ['codex: registry default when nothing is configured', 'freshcodex', undefined, undefined, 'gpt-6-astra', true], ['opencode: saved default persists', 'freshopencode', saved('umans/kimi'), undefined, 'umans/kimi', true], ['opencode: runtime CLI model persists', 'freshopencode', undefined, 'umans/kimi', 'umans/kimi', true], diff --git a/test/unit/tooling/sandbox-test.sandbox.ts b/test/unit/tooling/sandbox-test.sandbox.ts index 8fec019ee3..a0b0321982 100644 --- a/test/unit/tooling/sandbox-test.sandbox.ts +++ b/test/unit/tooling/sandbox-test.sandbox.ts @@ -239,6 +239,37 @@ with (root / 'receipt.jsonl').open('a') as log: } describe('sandbox wrapper owned launch settlement', () => { + it('joins a completed guard when procfs reports ESRCH during its exit', () => { + const f = fixture('published-success') + try { + const result = spawnSync('python3', ['-c', String.raw` +import errno, json, runpy, sys +from pathlib import Path +from unittest.mock import patch +guard = runpy.run_path(sys.argv[1]) +control = Path(sys.argv[2]) +(control / 'owner-guard.json').write_text(json.dumps({'pid': 194708, 'identity': 'owned-birth'})) +original_read = Path.read_text +def read_during_exit(self, *args, **kwargs): + if str(self) == '/proc/194708/stat': + raise ProcessLookupError(errno.ESRCH, 'Process exited during procfs read') + return original_read(self, *args, **kwargs) +results = [] +with patch.object(Path, 'read_text', read_during_exit): + for code in [0, 1]: + (control / 'owner-guard.done').write_text(json.dumps({'code': code})) + results.append(guard['join'](control)) + (control / 'owner-guard.done').unlink() + results.append(guard['join'](control)) +print(json.dumps(results)) +`, path.resolve('scripts/testing/sandbox-owner-guard.py'), path.join(f.root, 'control')], { + encoding: 'utf8', timeout: 10_000, + }) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([0, 1, 1]) + } finally { f.cleanup() } + }) + it.each(['published', 'published-success'])('fails when the independent guard reports an error before %s completion', mode => { const f = fixture(mode) try { diff --git a/test/unit/tooling/testing/browser-selection.test.ts b/test/unit/tooling/testing/browser-selection.test.ts index ce0979f880..ff5990aee4 100644 --- a/test/unit/tooling/testing/browser-selection.test.ts +++ b/test/unit/tooling/testing/browser-selection.test.ts @@ -1,7 +1,8 @@ -import { execFileSync, spawnSync } from 'node:child_process' +import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { performance } from 'node:perf_hooks' +import { describe, expect, it, vi } from 'vitest' import { LOCAL_ONLY_SPECS } from '../../../e2e-browser/playwright.config.js' import { CLOUD_SKIP_SPECS, CLOUD_SKIP_TITLES } from '../../../e2e-browser/playwright.cloud.config.js' import { createE2eServerHandle } from '../../../e2e-browser/helpers/external-target.js' @@ -31,6 +32,23 @@ function cleanEnvironment(overrides: Record = {}): NodeJS.Proces return { ...env, ...overrides } } +function runDiscovery(args: string[], env: NodeJS.ProcessEnv, timeoutMs = 40_000) { + const started = performance.now() + const result = spawnSync(process.execPath, args, { + cwd: projectRoot, env, encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', + }) + const durationMs = Math.round(performance.now() - started) + if (result.error || result.signal) { + const timedOut = (result.error as NodeJS.ErrnoException | undefined)?.code === 'ETIMEDOUT' + const failure = timedOut ? `timed out after ${timeoutMs}ms` : 'failed' + const context = `CI=${env.CI ?? 'unset'}, elapsed=${durationMs}ms, signal=${result.signal}` + console.error(JSON.stringify({ severity: 'error', event: 'playwright.discovery.failed', + args, ci: env.CI ?? null, timeoutMs, durationMs, signal: result.signal, error: result.error?.message })) + throw new Error(`Playwright discovery ${failure}: ${args.join(' ')} (${context})\n${result.stderr}`, { cause: result.error }) + } + return { ...result, durationMs } +} + function resolvedConfig(configPath: string, env: NodeJS.ProcessEnv): ResolvedProject[] { const script = String.raw` const { loadConfigFromFile } = require(process.argv[1]) @@ -50,12 +68,9 @@ loadConfigFromFile(process.argv[2]).then((config) => { process.exitCode = 1 }) ` - const output = execFileSync(process.execPath, ['-e', script, configLoader, configPath], { - cwd: projectRoot, - env, - encoding: 'utf8', - }) - return JSON.parse(output) as ResolvedProject[] + const result = runDiscovery(['-e', script, configLoader, configPath], env) + if (result.status !== 0) throw new Error(`Playwright config resolution failed:\n${result.stdout}\n${result.stderr}`) + return JSON.parse(result.stdout) as ResolvedProject[] } function listedProjects( @@ -63,25 +78,27 @@ function listedProjects( configPath = playwrightConfig, selectors: string[] = [], ): { output: string; labels: string[]; tests: number; files: number } { - const output = execFileSync(process.execPath, [ + const result = runDiscovery([ playwrightCli, 'test', '--config', configPath, '--list', ...selectors, - ], { - cwd: projectRoot, - env, - encoding: 'utf8', - }) + ], env) + if (result.status !== 0) throw new Error(`Playwright list failed:\n${result.stdout}\n${result.stderr}`) + const output = result.stdout const total = output.match(/Total: (\d+) tests? in (\d+) files?/) if (!total) throw new Error(`Playwright list output did not include a total:\n${output}`) - return { + const listed = { output, labels: [...new Set([...output.matchAll(/^\s*\[([^\]]+)] ›/gm)].map((match) => match[1]))], tests: Number(total[1]), files: Number(total[2]), } + console.info(JSON.stringify({ severity: 'info', event: 'playwright.discovery.listed', + config: path.basename(configPath), ci: env.CI ?? null, selectors, durationMs: result.durationMs, + projects: listed.labels, tests: listed.tests, files: listed.files })) + return listed } // Playwright --list roster lines have the exact shape @@ -96,6 +113,21 @@ function listedSpecFiles(output: string): Set { } describe('browser selection non-vacuity', () => { + it('bounds a stalled discovery subprocess and identifies its command', () => { + const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => runDiscovery(['-e', 'setTimeout(() => {}, 1000)'], cleanEnvironment(), 50)) + .toThrow(/Playwright discovery timed out after 50ms.*-e/) + expect(errorLog).toHaveBeenCalledTimes(1) + expect(JSON.parse(errorLog.mock.calls[0][0] as string)).toMatchObject({ + severity: 'error', event: 'playwright.discovery.failed', timeoutMs: 50, + error: expect.stringContaining('ETIMEDOUT'), + }) + } finally { + errorLog.mockRestore() + } + }) + it('discovers the managed external cases only through their paired dedicated Cloud project', () => { const spec = 'managed-provider-environment-external.spec.ts' const ordinary = listedProjects(cleanEnvironment(), cloudConfig) @@ -112,8 +144,8 @@ describe('browser selection non-vacuity', () => { expect(selected.files).toBe(1) expect([...listedSpecFiles(selected.output)]).toEqual([spec]) // The source exists now: ordinary projects still cannot execute it. - const ordinaryExplicit = spawnSync(process.execPath, [playwrightCli, 'test', '--config', cloudConfig, - '--list', '--project=chromium', spec], { cwd: projectRoot, env: paired, encoding: 'utf8' }) + const ordinaryExplicit = runDiscovery([playwrightCli, 'test', '--config', cloudConfig, + '--list', '--project=chromium', spec], paired) expect(ordinaryExplicit.status).toBe(1) }, 90_000) @@ -135,8 +167,8 @@ describe('browser selection non-vacuity', () => { expect(selected.tests).toBe(5) expect(selected.files).toBe(1) expect([...listedSpecFiles(selected.output)]).toEqual([spec]) - const ordinaryExplicit = spawnSync(process.execPath, [playwrightCli, 'test', '--config', cloudConfig, - '--list', '--project=chromium', spec], { cwd: projectRoot, env: paired, encoding: 'utf8' }) + const ordinaryExplicit = runDiscovery([playwrightCli, 'test', '--config', cloudConfig, + '--list', '--project=chromium', spec], paired) expect(ordinaryExplicit.status).toBe(1) }, 90_000) @@ -144,13 +176,15 @@ describe('browser selection non-vacuity', () => { const env = cleanEnvironment({ FRESHELL_E2E_EXTERNAL_FIXTURE_KIND: 'managed-environment', FRESHELL_E2E_TARGET_URL: 'https://fixture.invalid', FRESHELL_E2E_TARGET_TOKEN: 'synthetic-selection-token', ...(pairing === 'mismatched' ? { FRESHELL_CODE_CHECKPOINT_FIXTURE: '{}' } : {}) }) - const listed = spawnSync(process.execPath, [playwrightCli, 'test', '--config', cloudConfig, '--list', - '--project=chromium-external-target', 'managed-provider-environment-external.spec.ts'], - { cwd: projectRoot, env, encoding: 'utf8' }) + const listed = runDiscovery([playwrightCli, 'test', '--config', cloudConfig, '--list', + '--project=chromium-external-target', 'managed-provider-environment-external.spec.ts'], env) expect(listed.status).toBe(1) expect(`${listed.stdout}\n${listed.stderr}`).toContain('matching fixture pair') }) + // Two full discovery scans import every browser spec. Their measured cloud + // runtime exceeds the generic 30s unit-test budget; each child remains bounded + // at 40s; the outer budget also allows discovery and assertion overhead. it('selects a non-vacuous Chromium lane and all CI application projects', () => { const chromium = listedProjects(cleanEnvironment()) expect(chromium.labels).toEqual(['chromium']) @@ -252,13 +286,13 @@ describe('browser selection non-vacuity', () => { // An ALL-skip focused invocation fails LOUDLY (exit 1, "No tests // found") instead of silently reading as a green zero-test run — a // vacuous focused cloud proof can never masquerade as coverage. - const allSkip = spawnSync(process.execPath, [ + const allSkip = runDiscovery([ playwrightCli, 'test', '--config', cloudConfig, '--list', 'test/e2e-browser/specs/truly-idle-alerting.spec.ts', - ], { cwd: projectRoot, env: cleanEnvironment(), encoding: 'utf8' }) + ], cleanEnvironment()) expect(allSkip.status).toBe(1) expect(`${allSkip.stdout}\n${allSkip.stderr}`).toContain('No tests found') diff --git a/test/unit/tooling/testing/codex-managed-cache.test.ts b/test/unit/tooling/testing/codex-managed-cache.test.ts new file mode 100644 index 0000000000..3c127b9348 --- /dev/null +++ b/test/unit/tooling/testing/codex-managed-cache.test.ts @@ -0,0 +1,153 @@ +// @vitest-environment node +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, it } from 'vitest' + +const ownershipKeys = ['owner', 'purpose', 'schema', 'worktree-sha256'] as const +type Mode = 'available' | 'missing' | 'wrong-name' | typeof ownershipKeys[number] +const sha256 = (bytes: string) => createHash('sha256').update(bytes).digest('hex') + +function fixture(mode: Mode) { + const root = mkdtempSync(path.join(process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR ?? os.tmpdir(), 'codex-managed-cache-')) + for (const file of [ + 'scripts/testing/prepare-codex-contract.mjs', 'test/integration/providers/codex-managed-contract.mjs', + 'test/integration/providers/codex-cli-contract.mjs', + 'test/integration/providers/codex-local-provider.mjs', 'docker/runtime/codex-native-sandbox.json', + ]) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }) + copyFileSync(path.resolve(file), path.join(root, file)) + } + const stage = path.join(root, '.runtime-build/codex-contract') + const artifact = path.join(stage, 'artifact') + for (const directory of ['bin', 'receipts', 'test/fixtures/coding-cli', '.runtime-build/codex-contract/artifact']) mkdirSync(path.join(root, directory), { recursive: true }) + // Tiny verified fixture bytes exercise preparation admission without staging + // or claiming execution of the installed native Codex package. + const members = [ + { path: 'codex', bytes: '#!/bin/sh\nexit 0\n', executable: true }, + { path: 'package.json', bytes: JSON.stringify({ name: '@fixture/codex', version: 'fixture-linux-x64', os: ['linux'], cpu: ['x64'] }), executable: false }, + ] + const metadata = { version: '0.164.0', packageName: '@fixture/codex', packageVersion: 'fixture-linux-x64', + platform: 'linux', arch: 'x64', integrity: 'fixture-only', executable: 'codex', fileCount: members.length, + unpackedBytes: members.reduce((sum, file) => sum + Buffer.byteLength(file.bytes), 0) } + writeFileSync(path.join(root, 'test/fixtures/coding-cli/codex-native-artifact.json'), JSON.stringify(metadata)) + for (const member of members) writeFileSync(path.join(artifact, member.path), member.bytes, { mode: member.executable ? 0o755 : 0o644 }) + writeFileSync(path.join(artifact, 'artifact-receipt.json'), JSON.stringify({ complete: true, ...metadata, + files: members.map(file => ({ path: file.path, bytes: Buffer.byteLength(file.bytes), sha256: sha256(file.bytes), executable: file.executable })) })) + const probe = '/freshell-test-artifacts/freshcodex-contract-00000000-0000-4000-8000-000000000000/policy-client-probe' + writeFileSync(path.join(stage, 'staged.json'), JSON.stringify({ complete: true, policyClientProbe: { path: probe, sha256: 'a'.repeat(64) } })) + const canonical = spawnSync('bash', ['-c', 'pwd -P'], { cwd: root, encoding: 'utf8', timeout: 10_000 }) + expect(canonical.status, canonical.stderr).toBe(0) + const digest = sha256(canonical.stdout.trim()) + const volume = `freshell-sandbox-rust-target-v1-${digest.slice(0, 24)}` + const labels = { 'com.freshell.cache.owner': 'freshell', 'com.freshell.cache.purpose': 'rust-target', + 'com.freshell.cache.schema': '1', 'com.freshell.cache.worktree-sha256': digest } + const image = `sha256:${'b'.repeat(64)}` + writeFileSync(path.join(root, 'bin/docker.mjs'), ` +import fs from 'node:fs'; +const args = process.argv.slice(2), root = ${JSON.stringify(root)}, volume = ${JSON.stringify(volume)}, labels = ${JSON.stringify(labels)}, mode = ${JSON.stringify(mode)}; +fs.appendFileSync(root+'/docker.jsonl', JSON.stringify(args)+'\\n'); +const stateFile = root+'/containers.json'; +const containers = fs.existsSync(stateFile) ? JSON.parse(fs.readFileSync(stateFile,'utf8')) : {}; +const save = () => fs.writeFileSync(stateFile, JSON.stringify(containers)); +if (args[0] === 'volume' && args[1] === 'inspect') { + if (args[2] !== volume || mode === 'missing') { console.error('No such volume: '+args[2]); process.exit(1); } + console.log(JSON.stringify([{ Name: mode === 'wrong-name' ? 'foreign-volume' : volume, + Labels: { ...labels, ...(Object.hasOwn(labels, 'com.freshell.cache.'+mode) ? { ['com.freshell.cache.'+mode]: 'foreign' } : {}) } }])); +} else if (args[0] === 'create') { + const name = args[args.indexOf('--name')+1], id = (name.endsWith('missing-helper') ? 'c' : 'd').repeat(64); + containers[name] = { Id:id, Image:${JSON.stringify(image)}, Config: { User:'65534:0', Labels:{'org.freshell.codex-contract':args[args.indexOf('--label')+1].split('=')[1]} }, + HostConfig:{ReadonlyRootfs:true,CapDrop:['ALL'],NetworkMode:'none'}, args }; + save(); console.log(id); +} else if (args[0] === 'inspect') { + const observed = containers[args[1]] ?? Object.values(containers).find(value => value.Id === args[1]); + if (!observed) { console.error('No such object: '+args[1]); process.exit(1); } + console.log(JSON.stringify([observed])); +} else if (args[0] === 'start') { + const observed = Object.values(containers).find(value => value.Id === args.at(-1)); + if (observed.Id === 'c'.repeat(64)) { console.error('owned bwrap helper is missing'); process.exit(96); } + console.log(JSON.stringify({nativeCapture:true,capture:{scenario:'text-history',parameters:{},passed:true, + environment:{uid:65534,gid:0,managedLayout:{bwrap:'/fixture/bwrap'}},binary:{path:JSON.parse(fs.readFileSync(root+'/docker/runtime/codex-native-sandbox.json','utf8')).native.launchWrapper,version:'0.164.0'}, + requests:[],fixtureTraffic:[],observations:{executedSandboxHelpers:[{executable:'/fixture/bwrap'}]}}})); +} else if (args[0] === 'rm') { + const name = Object.keys(containers).find(name => containers[name].Id === args.at(-1)); + if (!name) throw Error('unowned fixture removal'); + delete containers[name]; save(); +} else throw Error('unexpected Docker mutation: '+JSON.stringify(args)); +`) + const quote = (word: string) => `'${word.replaceAll("'", "'\\''")}'` + writeFileSync(path.join(root, 'bin/docker'), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(path.join(root, 'bin/docker.mjs'))} "$@"\n`) + chmodSync(path.join(root, 'bin/docker'), 0o755) + return { + root, volume, labels, probe, + run() { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', + `import { runManagedContract } from './test/integration/providers/codex-managed-contract.mjs'; + await runManagedContract({imageRef:${JSON.stringify(image)}}, './receipts', ${mode === 'available' ? '{scenario:"text-history",probeNetworkEnabled:true}' : '{scenario:"text-history"}'});`, + ], { cwd: root, encoding: 'utf8', timeout: 30_000, env: { ...process.env, PATH: `${root}/bin:${process.env.PATH}` } }) + const receipt = path.join(root, 'docker.jsonl') + if (!existsSync(receipt)) throw new Error(`Docker fixture was not reached: ${result.error?.message ?? ''}\n${result.stderr}`) + return { ...result, calls: readFileSync(receipt, 'utf8').trim().split('\n').map(line => JSON.parse(line) as string[]) } + }, + cleanup() { rmSync(root, { recursive: true, force: true }) }, + } +} + +it('runs the managed consumer using the canonical labeled target with the legacy cache unavailable', () => { + const f = fixture('available') + try { + const result = f.run() + expect(result.status, result.stderr).toBe(0) + expect(result.calls[0]).toEqual(['volume', 'inspect', f.volume]) + expect(result.calls.flat()).not.toContain('freshell-sandbox-cargo-target') + const creates = result.calls.filter(args => args[0] === 'create') + expect(creates).toHaveLength(2) + for (const args of creates) { + const mounts = args.flatMap((word, index) => word === '--mount' ? [args[index + 1]] : []) + const cacheMount = mounts.find(mount => mount.includes('target=/freshell-test-artifacts'))! + expect(cacheMount.split(',')).toEqual(expect.arrayContaining([ + 'type=volume', `source=${f.volume}`, 'target=/freshell-test-artifacts', 'readonly', + ...Object.entries(f.labels).map(([key, value]) => `volume-label=${key}=${value}`), + ])) + for (const [flag, value] of [['--user', '65534:0'], ['--network', 'none'], ['--cap-drop', 'ALL'], ['--pids-limit', '512'], ['--memory', '1g'], ['--cpus', '2']]) expect(args[args.indexOf(flag) + 1]).toBe(value) + expect(args).toContain('--read-only') + expect(args).toContain('no-new-privileges') + expect(args).toContain('seccomp=receipts/codex-policy.json') + expect(args).toContain(`FRESHELL_CODEX_POLICY_CLIENT_PROBE=${f.probe}`) + } + expect(JSON.parse(readFileSync(path.join(f.root, 'containers.json'), 'utf8'))).toEqual({}) + const receipts = JSON.parse(readFileSync(path.join(f.root, 'receipts/receipt-index.json'), 'utf8')) + expect(receipts.captures).toHaveLength(1) + expect(receipts.captures[0]).toMatchObject({ scenario: 'text-history', passed: true, uid: 65534, gid: 0 }) + } finally { f.cleanup() } +}) + +it.each(['missing', 'wrong-name', ...ownershipKeys] as const)('refuses a %s cache before any managed container or image mutation', mode => { + const f = fixture(mode) + try { + const result = f.run() + expect(result.status).not.toBe(0) + expect(result.calls).toEqual([['volume', 'inspect', f.volume]]) + if (mode !== 'missing') expect(result.stderr).toContain('sandbox target cache ownership labels do not match') + } finally { f.cleanup() } +}) + +it('refuses a missing real-client probe before the native harness starts work', () => { + const f = fixture('available') + try { + const result = spawnSync(process.execPath, ['--input-type=module', '-e', ` + import assert from 'node:assert/strict'; + import { captureNativeScenario } from './test/integration/providers/codex-cli-contract.mjs'; + await assert.rejects(captureNativeScenario('text-history', { + artifactRoot: './.runtime-build/codex-contract/artifact', policyClientProbe: '/freshell-missing-policy-client-probe', + }), error => error.code === 'ENOENT' && error.path === '/freshell-missing-policy-client-probe'); + console.log('codex_missing_policy_probe_refused'); + `], { cwd: f.root, encoding: 'utf8', timeout: 30_000 }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('codex_missing_policy_probe_refused') + expect(readdirSync(path.join(f.root, 'receipts'))).toEqual([]) + expect(existsSync(path.join(f.root, 'docker.jsonl'))).toBe(false) + } finally { f.cleanup() } +}) diff --git a/test/unit/tooling/testing/codex-managed-launch.test.ts b/test/unit/tooling/testing/codex-managed-launch.test.ts new file mode 100644 index 0000000000..10c7428332 --- /dev/null +++ b/test/unit/tooling/testing/codex-managed-launch.test.ts @@ -0,0 +1,14 @@ +import { spawnSync } from 'node:child_process' +import { expect, it } from 'vitest' + +it('executes the managed Codex wrapper with exact arguments and refuses a missing helper before npm dispatch', () => { + const result = spawnSync(process.execPath, ['test/integration/providers/codex-managed-launch.mjs'], { encoding: 'utf8', timeout: 30_000 }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('codex_managed_launch_unit_passed') +}) + +it('cleans owned image tags after setup and lost build acknowledgement failures', () => { + const result = spawnSync(process.execPath, ['test/integration/providers/codex-managed-cleanup.mjs'], { encoding: 'utf8', timeout: 30_000 }) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('codex_managed_image_cleanup_passed') +}) diff --git a/test/unit/tooling/testing/codex-sandbox-cache.test.ts b/test/unit/tooling/testing/codex-sandbox-cache.test.ts new file mode 100644 index 0000000000..4d4617ca7f --- /dev/null +++ b/test/unit/tooling/testing/codex-sandbox-cache.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +import { spawnSync } from 'node:child_process' +import { createHash } from 'node:crypto' +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { expect, it } from 'vitest' + +function fixture(mode: 'available' | 'missing' | 'foreign') { + const root = mkdtempSync(path.join(process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR ?? os.tmpdir(), 'codex-sandbox-cache-')) + for (const file of [ + 'scripts/sandbox-test.sh', 'scripts/testing/prepare-codex-contract.mjs', + 'test/integration/providers/codex-local-provider.mjs', + 'test/fixtures/coding-cli/codex-native-artifact.json', 'docker/runtime/codex-native-sandbox.json', + ]) { + mkdirSync(path.dirname(path.join(root, file)), { recursive: true }) + copyFileSync(path.resolve(file), path.join(root, file)) + } + for (const directory of ['bin', 'control', '.runtime-build/codex-contract']) mkdirSync(path.join(root, directory), { recursive: true }) + const policy = spawnSync(process.execPath, ['--input-type=module', '-e', + 'import { nativeSandboxPolicy } from "./scripts/testing/prepare-codex-contract.mjs"; process.stdout.write(nativeSandboxPolicy().json)', + ], { cwd: root, encoding: 'utf8', timeout: 10_000 }) + expect(policy.status, policy.stderr).toBe(0) + writeFileSync(path.join(root, '.runtime-build/codex-contract/codex-policy.json'), policy.stdout) + const image = `sha256:${'b'.repeat(64)}` + writeFileSync(path.join(root, '.runtime-build/codex-contract/staged.json'), JSON.stringify({ + image, uid: 1000, gid: 1000, nativeSandboxPolicy: { profileSha256: `sha256:${createHash('sha256').update(policy.stdout).digest('hex')}` }, + })) + const canonical = spawnSync('bash', ['-c', 'pwd -P'], { cwd: root, encoding: 'utf8', timeout: 10_000 }) + expect(canonical.status, canonical.stderr).toBe(0) + const digest = createHash('sha256').update(canonical.stdout.trim()).digest('hex') + const volume = `freshell-sandbox-rust-target-v1-${digest.slice(0, 24)}` + const labels = { 'com.freshell.cache.owner': 'freshell', 'com.freshell.cache.purpose': 'rust-target', + 'com.freshell.cache.schema': '1', 'com.freshell.cache.worktree-sha256': digest } + writeFileSync(path.join(root, 'bin/docker.mjs'), ` +import fs from 'node:fs'; +const args = process.argv.slice(2); +fs.appendFileSync(process.env.FIXTURE_RECEIPT, JSON.stringify(args)+'\\n'); +const volume = ${JSON.stringify(volume)}, labels = ${JSON.stringify(labels)}, mode = ${JSON.stringify(mode)}; +if (args[0] === 'image' && args[1] === 'inspect') console.log(${JSON.stringify(image)}); +else if (args[0] === 'volume' && args[1] === 'inspect') { + const name = args.at(-1); + if (name === 'freshell-sandbox-cargo-target' || (name === volume && mode === 'missing')) { + console.error('missing target cache: '+name); process.exit(1); + } + const observed = name === volume ? { ...labels, ...(mode === 'foreign' ? {'com.freshell.cache.owner':'someone-else'} : {}) } : {}; + console.log(JSON.stringify(args.includes('--format') ? observed : [{ Name: name, Labels: observed }])); +} else if (args[0] === 'volume' && args[1] === 'ls') console.log(volume); +else if (args[0] === 'run') console.log(JSON.stringify({verified:true})); +else if (args[0] !== 'ps') process.exit(2); +`) + const quote = (word: string) => `'${word.replaceAll("'", "'\\''")}'` + writeFileSync(path.join(root, 'bin/docker'), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(path.join(root, 'bin/docker.mjs'))} "$@"\n`) + chmodSync(path.join(root, 'bin/docker'), 0o755) + const receipt = path.join(root, 'docker.jsonl') + return { + root, volume, labels, + run(wrapper = false) { + const result = spawnSync(wrapper ? 'bash' : process.execPath, wrapper + ? ['scripts/sandbox-test.sh', '--runtime-suite', 'node test/integration/providers/codex-cli-contract.mjs --scenario text-history --missing-policy-client-probe'] + : ['scripts/testing/prepare-codex-contract.mjs', '--verify-sandbox'], { + cwd: root, encoding: 'utf8', timeout: 30_000, + env: { ...process.env, PATH: `${root}/bin:${process.env.PATH}`, FIXTURE_RECEIPT: receipt, + TMPDIR: path.join(root, 'control'), FRESHELL_SANDBOX_OWNER_GUARD: '0' }, + }) + if (!existsSync(receipt)) throw new Error(`Docker fixture was not reached: ${result.error?.message ?? ''}\n${result.stderr}`) + return { ...result, calls: readFileSync(receipt, 'utf8').trim().split('\n').map(line => JSON.parse(line) as string[]) } + }, + cleanup() { rmSync(root, { recursive: true, force: true }) }, + } +} + +it('verifies native prerequisites in the canonical per-worktree target mounted by the runtime', () => { + const f = fixture('available') + try { + const result = f.run() + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ verified: true }) + expect(result.calls).toContainEqual(['volume', 'inspect', f.volume]) + const verifier = result.calls.find(args => args[0] === 'run')! + expect(verifier).toContain(`${f.volume}:/freshell-test-artifacts:ro`) + expect(verifier).toContain('--read-only') + expect(verifier.slice(verifier.indexOf('--network'), verifier.indexOf('--network') + 2)).toEqual(['--network', 'none']) + expect(result.calls.flat()).not.toContain('freshell-sandbox-cargo-target') + } finally { f.cleanup() } +}) + +it.each(['missing', 'foreign'] as const)('refuses %s native target prerequisites before launching any container', mode => { + const f = fixture(mode) + try { + const result = f.run(true) + expect(result.status).not.toBe(0) + expect(result.calls).toContainEqual(['volume', 'inspect', f.volume]) + expect(result.calls.some(args => args[0] === 'run' || args.slice(0, 2).join(' ') === 'volume ls')).toBe(false) + if (mode === 'foreign') expect(result.stderr).toContain('sandbox target cache ownership labels do not match') + } finally { f.cleanup() } +}) + +it('dispatches a verified native contract with both its seccomp policy and labeled per-worktree cache', () => { + const f = fixture('available') + try { + const result = f.run(true) + expect(result.status, result.stderr).toBe(0) + const runs = result.calls.filter(args => args[0] === 'run') + expect(runs).toHaveLength(2) + const runtime = runs[1] + expect(runtime).toContain(`seccomp=${f.root}/.runtime-build/codex-contract/codex-policy.json`) + expect(runtime).toContain('FRESHELL_CODEX_CONTRACT_POLICY=prepared') + for (const destination of ['/workspace/target', '/freshell-test-artifacts']) { + expect(runtime).toContain(`type=volume,src=${f.volume},dst=${destination},${Object.entries(f.labels).map(([key, value]) => `volume-label=${key}=${value}`).join(',')}`) + } + } finally { f.cleanup() } +}) diff --git a/test/unit/tooling/testing/runtime-build-preflight.test.ts b/test/unit/tooling/testing/runtime-build-preflight.test.ts index ebca142d62..516b10eb6c 100644 --- a/test/unit/tooling/testing/runtime-build-preflight.test.ts +++ b/test/unit/tooling/testing/runtime-build-preflight.test.ts @@ -5,13 +5,23 @@ import path from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' import { setupRuntimeBuildPreflight } from '../../../setup/runtime-build-preflight.js' -import { RUNTIME_BUILD_MANIFEST_ENV, preparedRuntimeBinary } from '../../../../scripts/testing/runtime-build-preflight.js' +import { RUNTIME_BUILD_MANIFEST_ENV, prepareRuntimeBuilds, preparedRuntimeBinary } from '../../../../scripts/testing/runtime-build-preflight.js' import { RuntimeHarness } from '../../../../scripts/testing/runtime-sandbox.js' import { ManagedRuntimeBrowserRig } from '../../../e2e-browser/helpers/managed-runtime.js' +const childFixture = vi.hoisted(() => ({ execute: undefined as undefined | ((program: string, args: string[], options?: any) => any) })) +vi.mock('node:child_process', async importOriginal => { + const actual = await importOriginal() + const execute = ((program: string, args: string[], options?: any) => childFixture.execute + ? childFixture.execute(program, args, options) : actual.execFileSync(program, args, options)) as typeof actual.execFileSync + return { ...actual, execFileSync: execute, default: { ...actual, execFileSync: execute } } +}) + const owned: string[] = [] afterEach(() => { vi.unstubAllEnvs() + vi.restoreAllMocks() + childFixture.execute = undefined for (const directory of owned.splice(0)) fs.rmSync(directory, { recursive: true, force: true }) }) @@ -48,6 +58,13 @@ for (let i=0; i { + expect(fs.statSync(file).mode & 0o111).not.toBe(0) + return { file, bytes: fs.readFileSync(file) } + }) +} + describe('managed runtime build preparation before suite startup', () => { it('does no builds or artifact work for an unrelated selected unit file', async () => { const f = fixture() @@ -67,16 +84,26 @@ describe('managed runtime build preparation before suite startup', () => { path.join(f.root, 'test/integration/server/provider-parity-contract.test.ts'), ] const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) - expect(f.rows()).toHaveLength(5) + expect(f.rows()).toHaveLength(6) const terminal = preparedRuntimeBinary(f.root, 'server-managed-provider-qualification', f.project.config.env)! const fresh = preparedRuntimeBinary(f.root, 'server-managed-fresh-agent-fixtures', f.project.config.env)! + const managed = preparedRuntimeBinary(f.root, 'server-managed-runtime-v1', f.project.config.env)! + const supervisor = preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)! expect(fs.readFileSync(terminal, 'utf8')).toContain('release:managed-provider-qualification') expect(fs.readFileSync(fresh, 'utf8')).toContain('release:managed-fresh-agent-fixtures') - expect(fs.readFileSync(preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)!, 'utf8')).toContain('debug:freshell-supervisor/runtime-test-faults') - expect(fs.statSync(terminal).mode & 0o111).not.toBe(0) + expect(fs.readFileSync(managed, 'utf8')).toContain('release:managed-runtime-v1') + expect(fs.readFileSync(supervisor, 'utf8')).toContain('debug:freshell-supervisor/runtime-test-faults') + const artifacts = snapshotExecutables([terminal, fresh, managed, supervisor]) + // A later server build overwrites Cargo's shared output, including the newest managed variant. + execFileSync(f.command.program, [ + ...f.command.args, 'build', '--locked', '--release', '-p', 'freshell-server', '--features', 'managed-provider-qualification', + ], { cwd: f.root, env: f.project.config.env }) + expect(f.rows()).toHaveLength(7) + const cargoServer = path.join(f.project.config.env.CARGO_TARGET_DIR, 'release', `freshell-server${process.platform === 'win32' ? '.exe' : ''}`) + expect(fs.readFileSync(cargoServer, 'utf8')).toContain('release:managed-provider-qualification') + for (const { file, bytes } of artifacts) expect(fs.readFileSync(file)).toEqual(bytes) await teardown?.() - expect(fs.existsSync(terminal)).toBe(false) - expect(fs.existsSync(fresh)).toBe(false) + for (const { file } of artifacts) expect(fs.existsSync(file)).toBe(false) }) it('reuses the parent invocation artifacts for nested parity runners without compiling or owning their cleanup', async () => { @@ -84,13 +111,23 @@ describe('managed runtime build preparation before suite startup', () => { f.project.vitest.state.getPaths = () => [path.join(f.root, 'test/integration/server/provider-parity-contract.test.ts')] const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) const binary = preparedRuntimeBinary(f.root, 'server-managed-provider-qualification', f.project.config.env)! + const managed = preparedRuntimeBinary(f.root, 'server-managed-runtime-v1', f.project.config.env)! + expect(fs.readFileSync(binary, 'utf8')).toContain('release:managed-provider-qualification') + expect(fs.readFileSync(managed, 'utf8')).toContain('release:managed-runtime-v1') + const artifacts = snapshotExecutables([binary, managed]) + const parentBuilds = f.rows() + expect(parentBuilds).toHaveLength(6) const nested = { ...f.project, config: { ...f.project.config, env: { ...f.project.config.env } } } const childTeardown = await setupRuntimeBuildPreflight(nested, { command: f.command }) - expect(f.rows()).toHaveLength(5) + expect(f.rows()).toEqual(parentBuilds) + expect(preparedRuntimeBinary(f.root, 'server-managed-provider-qualification', nested.config.env)).toBe(binary) + expect(preparedRuntimeBinary(f.root, 'server-managed-runtime-v1', nested.config.env)).toBe(managed) + expect(childTeardown).toBeUndefined() await childTeardown?.() - expect(fs.existsSync(binary)).toBe(true) + for (const { file, bytes } of artifacts) expect(fs.readFileSync(file)).toEqual(bytes) + expect(f.rows()).toEqual(parentBuilds) await teardown?.() - expect(fs.existsSync(binary)).toBe(false) + for (const { file } of artifacts) expect(fs.existsSync(file)).toBe(false) }) it('fails preparation before publishing a partial bundle when a prerequisite build fails', async () => { @@ -132,6 +169,121 @@ describe('managed runtime build preparation before suite startup', () => { expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-provider-qualification') }) + it('freezes and reuses the ordinary managed browser server without a second compiler selection', () => { + const f = fixture() + const prepared = prepareRuntimeBuilds(f.root, ['managed-server'], { env: f.project.config.env, command: f.command }) + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, prepared.manifestPath) + try { + const rig = new ManagedRuntimeBrowserRig(f.root) + fs.mkdirSync(rig.runtime.buildDir, { recursive: true }) + const server = (rig as unknown as { buildManagedServer(): string }).buildManagedServer() + expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-runtime-v1') + expect(f.rows()).toHaveLength(1) + fs.rmSync(rig.runtime.buildDir, { recursive: true, force: true }) + } finally { prepared.cleanup() } + }) + + it('attributes runtime build receipts to the prepared command instead of the host mise compiler', async () => { + const f = fixture() + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, f.project.config.env[RUNTIME_BUILD_MANIFEST_ENV]) + try { + const runtime = new RuntimeHarness(f.root, undefined, 2) + const { execFileSync: execute } = await vi.importActual('node:child_process') + childFixture.execute = (program, args, options) => { + if (program === 'docker') return 'fixture client/server' + if (program === 'git') return execute(program, args, options) + throw new Error(`prepared receipt unexpectedly executes ${program}`) + } + const methods = runtime as unknown as { buildBinaries(): void; collectBuildInfo(): Record } + methods.buildBinaries() + const receipt = methods.collectBuildInfo() + expect(receipt).not.toHaveProperty('rustc') + expect(receipt.build).toEqual({ source: 'prepared', manifestPath: f.project.config.env[RUNTIME_BUILD_MANIFEST_ENV], command: f.command }) + expect(receipt).not.toHaveProperty('hostMiseRustc') + fs.rmSync(runtime.buildDir, { recursive: true, force: true }) + } finally { await teardown?.() } + }) + + it('builds default runtime artifacts with the exact reported mise command and labels the host version', async () => { + const f = fixture() + const runtime = new RuntimeHarness(f.root, undefined, 2) + const builds: string[][] = [] + childFixture.execute = (program, args) => { + if (program === 'docker') return 'fixture client/server' + if (!program.endsWith('/mise')) throw new Error('unexpected builder') + if (args.includes('rustc')) return 'rustc fixture-host-version\n' + // This builder refuses a different compiler selection, before producing artifacts. + if (args[1] !== 'rust@1.96.0' || !args.includes('--locked')) throw new Error('wrong compiler or unlocked build') + builds.push(args) + const target = path.join(f.root, 'target', args.includes('--release') ? 'release' : 'debug') + fs.mkdirSync(target, { recursive: true }) + for (const binary of ['freshell-supervisor', 'freshell-session-host']) { + fs.writeFileSync(path.join(target, binary), `#!/bin/sh\n# ${args.includes('--release') ? 'release' : 'test'}\n`, { mode: 0o755 }) + } + return '' + } + const methods = runtime as unknown as { buildBinaries(): void; collectBuildInfo(): any } + methods.buildBinaries() + const receipt = methods.collectBuildInfo() + expect(receipt.build.source).toBe('mise') + expect(receipt.build.hostMiseRustc).toBe('rustc fixture-host-version') + expect(receipt).not.toHaveProperty('rustc') + expect(builds).toHaveLength(2) + for (const args of builds) expect(args.slice(0, 4)).toEqual(receipt.build.command.args) + expect(fs.readFileSync(runtime.testHostBinary, 'utf8')).toContain('# test') + expect(fs.readFileSync(runtime.releaseHostBinary, 'utf8')).toContain('# release') + fs.rmSync(runtime.buildDir, { recursive: true, force: true }) + }) + + it.each([ + { enabledProviders: ['opencode'], feature: 'managed-runtime-v1' }, + { enabledProviders: ['codex'], feature: 'managed-provider-qualification' }, + { enabledProviders: ['codex'], fixtureFreshAgentModes: ['freshcodex'], feature: 'managed-fresh-agent-fixtures' }, + ])('builds the selected browser server with the exact locked compiler: $feature', selection => { + const f = fixture() + const rig = new ManagedRuntimeBrowserRig(f.root, 2, {}, {}, 'test', selection) + fs.mkdirSync(rig.runtime.buildDir, { recursive: true }) + const builds: string[][] = [] + childFixture.execute = (program, args) => { + if (!program.endsWith('/mise') || args[1] !== 'rust@1.96.0' || !args.includes('--locked')) { + throw new Error('browser selected a different compiler or unlocked build') + } + builds.push(args) + const target = path.join(f.root, 'target/release') + fs.mkdirSync(target, { recursive: true }) + fs.writeFileSync(path.join(target, 'freshell-server'), `#!/bin/sh\n# ${args[args.indexOf('--features') + 1]}\n`, { mode: 0o755 }) + return '' + } + const server = (rig as unknown as { buildManagedServer(): string }).buildManagedServer() + expect(fs.readFileSync(server, 'utf8')).toContain(`# ${selection.feature}`) + expect(builds).toHaveLength(1) + fs.rmSync(rig.runtime.buildDir, { recursive: true, force: true }) + }) + + it('keeps compiler provenance unknown for an older prepared manifest', async () => { + const f = fixture() + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + const manifestPath = f.project.config.env[RUNTIME_BUILD_MANIFEST_ENV]! + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + delete manifest.command + fs.writeFileSync(manifestPath, JSON.stringify(manifest)) + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, manifestPath) + try { + const runtime = new RuntimeHarness(f.root, undefined, 2) + const { execFileSync: execute } = await vi.importActual('node:child_process') + childFixture.execute = (program, args, options) => { + if (program === 'docker') return 'fixture client/server' + if (program === 'git') return execute(program, args, options) + throw new Error('older provenance must not be inferred from an installed compiler') + } + const methods = runtime as unknown as { buildBinaries(): void; collectBuildInfo(): any } + methods.buildBinaries() + expect(methods.collectBuildInfo().build).toEqual({ source: 'prepared', manifestPath, command: null }) + fs.rmSync(runtime.buildDir, { recursive: true, force: true }) + } finally { await teardown?.() } + }) + it('runs preflight before a real Vitest startup hook, selects only requested files, and never starts after a build failure', () => { const f = fixture() const sourceRoot = path.resolve(import.meta.dirname, '../../../..') diff --git a/test/unit/tooling/testing/runtime-sandbox.test.ts b/test/unit/tooling/testing/runtime-sandbox.test.ts index b74b10f846..15ca65c8fc 100644 --- a/test/unit/tooling/testing/runtime-sandbox.test.ts +++ b/test/unit/tooling/testing/runtime-sandbox.test.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { EventEmitter } from 'node:events' import fs from 'node:fs/promises' import http from 'node:http' @@ -983,3 +983,31 @@ describe('runtime harness candidate scratch cleanup', () => { }, ) }) + +it('records the selected Codex pin from each owned runtime inventory', async () => { + for (const version of ['0.164.fixture-a', '0.164.fixture-b']) { + const repository = await fs.mkdtemp(path.join(os.tmpdir(), 'frs-codex-pin-')) + const home = path.join(repository, 'home') + const xdg = path.join(home, '.config') + const workspace = path.join(repository, 'workspace') + let harness: RuntimeHarness | undefined + try { + for (const directory of [workspace, path.join(repository, 'test/runtime'), path.join(repository, 'docker/runtime')]) await fs.mkdir(directory, { recursive: true }) + prepareProviderParityUserRoots(home, xdg) + await fs.writeFile(path.join(repository, 'test/runtime/gate-manifest.json'), '{}') + await fs.writeFile(path.join(repository, 'docker/runtime/provider-versions.json'), JSON.stringify({ providers: { codex: { version } } })) + const host = path.join(repository, 'owned-host') + await fs.writeFile(host, 'owned host fixture') + harness = new RuntimeHarness(repository, randomUUID(), 3, home, xdg, { workspaceRoots: [workspace], bootstrapFiles: [] }) + useOwnedHarnessArtifacts(harness, host) + await harness.prepare() + const result = JSON.parse(await fs.readFile(path.join(harness.evidenceDir, 'provider-results.json'), 'utf8')) + expect(result.providers.codex.version).toBe(version) + } finally { + await harness?.cleanup() + if (harness) await fs.rm(harness.testRoot, { recursive: true, force: true }) + await fs.rm(repository, { recursive: true, force: true }) + vi.restoreAllMocks() + } + } +}) diff --git a/test/unit/tooling/testing/runtime-test-broker.test.ts b/test/unit/tooling/testing/runtime-test-broker.test.ts index 950e77d53a..6daa679ff4 100644 --- a/test/unit/tooling/testing/runtime-test-broker.test.ts +++ b/test/unit/tooling/testing/runtime-test-broker.test.ts @@ -374,6 +374,40 @@ describe('managed runtime broker actor storage', () => { }) }) +describe('Codex native workload policy admission', () => { + it.each(['missing', 'mutated', 'unconfined', 'duplicate', 'extra', 'plain-command', 'other-provider'])('refuses %s policy before forwarding an actual Docker request', async mutation => { + const resource = JSON.parse(await fs.readFile(path.resolve('docker/runtime/codex-native-sandbox.json'), 'utf8')) + await withHttpBroker(async ({ request, forwarded }) => { + const body = JSON.parse(createBody([...ordinaryBinds, actorBind]).toString()) + body.Env.push(`CODEX_CMD=/usr/bin/setpriv --reuid 65534 --regid 0 --clear-groups --no-new-privs -- ${resource.native.launchWrapper}`) + const option = `seccomp=${JSON.stringify(resource.profile)}` + body.HostConfig.SecurityOpt.push(option) + if (mutation === 'missing') body.HostConfig.SecurityOpt.pop() + if (mutation === 'mutated') body.HostConfig.SecurityOpt[1] = `seccomp=${JSON.stringify({ ...resource.profile, defaultErrnoRet: 0 })}` + if (mutation === 'unconfined') body.HostConfig.SecurityOpt[1] = 'seccomp=unconfined' + if (mutation === 'duplicate') body.HostConfig.SecurityOpt.push(option) + if (mutation === 'extra') body.HostConfig.SecurityOpt.push('apparmor=unconfined') + if (mutation === 'plain-command') body.Env[1] = 'CODEX_CMD=codex' + if (mutation === 'other-provider') body.Env = ['FRESHELL_HOSTED_FRESH_AGENT=claude'] + const response = await request('POST', '/v1.47/containers/create?name=native-policy-fixture', body) + expect(response.status).toBe(403) + expect(forwarded).toEqual([]) + }) + }) + + it('forwards the exact declared policy through the owned HTTP broker', async () => { + const resource = JSON.parse(await fs.readFile(path.resolve('docker/runtime/codex-native-sandbox.json'), 'utf8')) + await withHttpBroker(async ({ request, forwarded }) => { + const body = JSON.parse(createBody([...ordinaryBinds, actorBind]).toString()) + body.Env.push(`CODEX_CMD=/usr/bin/setpriv --reuid 65534 --regid 0 --clear-groups --no-new-privs -- ${resource.native.launchWrapper}`) + body.HostConfig.SecurityOpt.push(`seccomp=${JSON.stringify(resource.profile)}`) + const response = await request('POST', '/v1.47/containers/create?name=native-policy-fixture', body) + expect(response.status).toBe(201) + expect(forwarded).toHaveLength(1) + }) + }) +}) + describe('managed MCP mount', () => { it('accepts the exact private grant and approved provider root mounts', () => { const validate = (body: Buffer): { ok: boolean } => ( @@ -660,6 +694,7 @@ function candidateHelperBody(input: ClaudeCandidateAuthorization, role: 'mountpo body.Cmd = [role === 'reader' ? 'project-claude-candidate-private' : 'prepare-provider-mountpoints'] body.HostConfig.CapAdd = ['CHOWN', 'SETGID', 'SETUID'] body.HostConfig.Memory = body.HostConfig.MemorySwap = (role === 'reader' ? 256 : 128) * 1024 * 1024 + body.HostConfig.NanoCpus = role === 'reader' ? 2_000_000_000 : 500_000_000 body.HostConfig.Mounts = [ { Type: 'bind', Source: input.hostBinaryPath, Target: '/runtime/freshell-session-host', ReadOnly: true }, { Type: 'volume', Source: volume, Target: '/home/freshell/provider', ReadOnly: false, VolumeOptions: { NoCopy: true } }, @@ -817,7 +852,7 @@ describe('fixed initial Claude candidate broker', () => { instance.authorizeInitialProviderVolume(sourceAuthorization()); instance.authorizeClaudeCandidate(input) expect((await request('POST', '/v1.47/volumes/create', { Name: volume, Driver: 'local', DriverOpts: {}, Labels: candidateLabels(input) })).status).toBe(201) const body = candidateHelperBody(input, role), before = forwarded.length - for (const mutation of ['uid','command','credential','caps','network','memory','logs','reservation','generation','request','mount','readonly','volume']) { + for (const mutation of ['uid','command','credential','caps','network','memory','cpu','logs','reservation','generation','request','mount','readonly','volume']) { const changed = structuredClone(body) if (mutation === 'uid') changed.User = '65534:0' if (mutation === 'command') changed.Cmd.push('PRIVATE_CANDIDATE_SENTINEL') @@ -825,6 +860,7 @@ describe('fixed initial Claude candidate broker', () => { if (mutation === 'caps') changed.HostConfig.CapAdd.push('DAC_OVERRIDE') if (mutation === 'network') changed.HostConfig.NetworkMode = 'bridge' if (mutation === 'memory') changed.HostConfig.Memory *= 2 + if (mutation === 'cpu') changed.HostConfig.NanoCpus *= 2 if (mutation === 'logs') changed.HostConfig.LogConfig.Type = 'json-file' if (mutation === 'reservation') changed.Labels['com.freshell.source-reservation-id'] = '33333333-3333-4333-8333-333333333333' if (mutation === 'generation') changed.Labels['com.freshell.input-generation'] = 'grant-33333333-3333-4333-8333-333333333333' diff --git a/tools/freshell-cli/index.ts b/tools/freshell-cli/index.ts index 5bc2a21415..7a6822fef9 100644 --- a/tools/freshell-cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -10,6 +10,7 @@ import { partitionSendKeysArgs } from './send-keys-args.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' import { freshAgentResumeProvider, renderCliHelp, resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' import { renameNotAppliedMessage } from '../node-client-runtime/rename-outcome.js' +import { automationErrorReceipt } from '../node-client-runtime/automation-errors.js' type Flags = Record @@ -160,8 +161,8 @@ function resolveSessionRefFlag(mode: unknown, raw: unknown): { rejected: boolean // `--resume ` is promoted to the canonical `sessionRef {provider: mode, // sessionId}` before it hits the wire — exactly like the MCP freshell tool // (the MCP freshell tool's new-tab action) — so the CLI never sends the legacy -// `resumeSessionId` field. An explicit `--session-ref` wins; codex is -// rejected earlier by rejectRawCodexResume. Without a mode there is no +// `resumeSessionId` field. An explicit `--session-ref` wins. Raw Codex terminal +// resume is rejected earlier by rejectRawCodexResume. Without a mode or agent there is no // provider to promote with, so the flag combination is refused loudly rather // than silently dropping the resume identity. function promoteResumeFlag( @@ -463,7 +464,7 @@ async function main() { const resumeProvider = mode ?? freshAgentResumeProvider(agent) const sessionRefResult = resolveSessionRefFlag(resumeProvider, getFlag(flags, 'session-ref')) const prompt = getFlag(flags, 'prompt') as string | undefined - if (rejectRawCodexResume(resumeProvider, resumeSessionId)) return + if (rejectRawCodexResume(mode, resumeSessionId)) return if (sessionRefResult.rejected) return const promoted = promoteResumeFlag(resumeProvider, resumeSessionId, sessionRefResult.sessionRef) if (promoted.rejected) return @@ -585,7 +586,7 @@ async function main() { const resumeSessionId = getFlag(flags, 'resume') as string | undefined const resumeProvider = mode ?? freshAgentResumeProvider(agent) const sessionRefResult = resolveSessionRefFlag(resumeProvider, getFlag(flags, 'session-ref')) - if (rejectRawCodexResume(resumeProvider, resumeSessionId)) return + if (rejectRawCodexResume(mode, resumeSessionId)) return if (sessionRefResult.rejected) return const promoted = promoteResumeFlag(resumeProvider, resumeSessionId, sessionRefResult.sessionRef) if (promoted.rejected) return @@ -991,7 +992,7 @@ async function main() { if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((err) => { - writeError(err) + writeError(err, automationErrorReceipt(err)) process.exitCode = 1 }) } diff --git a/tools/freshell-cli/output.ts b/tools/freshell-cli/output.ts index 81d52d65de..1fa5417e71 100644 --- a/tools/freshell-cli/output.ts +++ b/tools/freshell-cli/output.ts @@ -11,9 +11,9 @@ export function writeJson(data: unknown, pretty = true) { writeText(payload) } -export function writeError(err: unknown) { +export function writeError(err: unknown, receipt?: Record) { const message = err instanceof Error ? err.message : String(err) // stderr is intentionally machine-readable JSONL. CLI command results still // own stdout, so diagnostics never corrupt piping/JSON output there. - process.stderr.write(`${JSON.stringify({ severity: 'error', event: 'cli.error', message })}\n`) + process.stderr.write(`${JSON.stringify({ severity: 'error', event: 'cli.error', message, ...receipt })}\n`) } diff --git a/tools/freshell-mcp/freshell-tool.ts b/tools/freshell-mcp/freshell-tool.ts index a563d75501..bd2cf49a82 100644 --- a/tools/freshell-mcp/freshell-tool.ts +++ b/tools/freshell-mcp/freshell-tool.ts @@ -10,6 +10,7 @@ import { createApiClient, resolveConfig, type ApiClient } from './http-client.js import { translateKeys } from '../node-client-runtime/keys.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' import { renameNotAppliedMessage } from '../node-client-runtime/rename-outcome.js' +import { automationErrorReceipt } from '../node-client-runtime/automation-errors.js' import { ACTION_ALIASES, freshAgentResumeProvider, @@ -59,7 +60,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Fresh agents (in-app) -- Use new-tab with agent="claude", "kilroy", or "opencode", optional model=, effort=, cwd=. Use split-pane with agent="claude" or "kilroy" for a fresh pane beside an existing one. For direct Claude or Codex terminals, use mode="claude" or mode="codex". Drive fresh panes with send-keys and read them with capture-pane. Example: +- Use new-tab with agent="claude", "kilroy", "codex", or "opencode", optional model=, effort=, cwd=. Use split-pane with agent="claude", "kilroy", or "codex" for a fresh pane beside an existing one. Fresh Codex resume uses sessionRef: { provider: "codex", sessionId }; the resume shorthand promotes to that identity. For direct Claude or Codex terminals, use mode="claude" or mode="codex". Drive fresh panes with send-keys and read them with capture-pane. Example: new-tab { agent: "opencode", model: "umans-ai-coding-plan/umans-kimi-k2.7", prompt: "Summarize README.md" } ## Choosing the right action @@ -450,9 +451,13 @@ export async function executeAction( if (err instanceof MissingParamError) { return { error: err.message, hint: `Run action 'help' to see required parameters for '${action}'.` } } + const receipt = automationErrorReceipt(err) return { error: `Action '${action}' failed: ${err.message || err}`, - hint: 'Check that the Freshell server is running and FRESHELL_URL/FRESHELL_TOKEN are set correctly.', + hint: receipt?.code === 'CODEX_DELIVERY_AMBIGUOUS' + ? 'Delivery is uncertain; inspect the conversation before sending again.' + : 'Check that the Freshell server is running and FRESHELL_URL/FRESHELL_TOKEN are set correctly.', + ...receipt, } } } @@ -475,11 +480,11 @@ async function routeAction( const legacyResume = typeof resume === 'string' ? resume : resumeSessionId // Provider the resume sugar keys on: an explicit mode wins; otherwise the // fresh-agent shorthand maps Claude/Kilroy to Claude identity and keeps - // the existing OpenCode promotion and raw-Codex refusal. + // the provider's canonical identity. Raw terminal Codex ids remain refused. const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) - const codexResumeError = rejectRawCodexResume(resumeProvider, legacyResume, explicitSessionRef) + const codexResumeError = rejectRawCodexResume(mode, legacyResume, explicitSessionRef) if (codexResumeError) return codexResumeError - const sessionRef = explicitSessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof legacyResume === 'string' + const sessionRef = explicitSessionRef ?? (typeof resumeProvider === 'string' && typeof legacyResume === 'string' ? { provider: resumeProvider, sessionId: legacyResume } : undefined) const nameIntent = resolveNameIntent(params) @@ -548,9 +553,9 @@ async function routeAction( const paneId = resolved.pane.id const { direction, browser, editor, mode, shell, cwd, target: _t, resume, sessionRef, ...rest } = params || {} const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) - const codexResumeError = rejectRawCodexResume(resumeProvider, resume, sessionRef) + const codexResumeError = rejectRawCodexResume(mode, resume, sessionRef) if (codexResumeError) return codexResumeError - const effectiveSessionRef = sessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof resume === 'string' + const effectiveSessionRef = sessionRef ?? (typeof resumeProvider === 'string' && typeof resume === 'string' ? { provider: resumeProvider, sessionId: resume } : undefined) return c.post(`/api/panes/${encodeURIComponent(paneId)}/split`, { diff --git a/tools/node-client-runtime/action-capabilities.ts b/tools/node-client-runtime/action-capabilities.ts index 9b74b78fc0..ccd9fb8fd1 100644 --- a/tools/node-client-runtime/action-capabilities.ts +++ b/tools/node-client-runtime/action-capabilities.ts @@ -132,12 +132,12 @@ export function unsupportedInvocationResult( const unsupported = unsupportedActionResult(action) if (unsupported) return unsupported const canonical = resolveCanonicalAction(action) - if (canonical === 'new-tab' && invocation?.agent !== undefined && !['claude', 'kilroy', 'opencode'].includes(String(invocation.agent))) { - return { error: 'This fresh-agent provider is unavailable with the Rust Freshell server.', hint: 'Use agent claude, kilroy, or opencode; use mode codex for a Codex terminal.' } + if (canonical === 'new-tab' && invocation?.agent !== undefined && !['claude', 'kilroy', 'codex', 'opencode'].includes(String(invocation.agent))) { + return { error: 'This fresh-agent provider is unavailable with the Rust Freshell server.', hint: 'Use agent claude, kilroy, codex, or opencode.' } } if (canonical === 'split-pane' && ['agent', 'model', 'effort'].some((key) => invocation?.[key] !== undefined) - && !['claude', 'kilroy'].includes(String(invocation?.agent))) { - return { error: 'Fresh-agent splits require agent claude or kilroy.', hint: 'Choose a supported fresh agent or use mode for a terminal pane.' } + && !['claude', 'kilroy', 'codex'].includes(String(invocation?.agent))) { + return { error: 'Fresh-agent splits require agent claude, kilroy, or codex.', hint: 'Choose a supported fresh agent or use mode for a terminal pane.' } } const waitPattern = canonical === 'wait-for' ? invocation?.pattern : undefined if (canonical === 'wait-for' && ( diff --git a/tools/node-client-runtime/automation-errors.ts b/tools/node-client-runtime/automation-errors.ts new file mode 100644 index 0000000000..0fe34812cb --- /dev/null +++ b/tools/node-client-runtime/automation-errors.ts @@ -0,0 +1,18 @@ +/** Preserve the typed automation receipt without forwarding provider payloads. */ +export function automationErrorReceipt(error: unknown): Record | undefined { + if (!error || typeof error !== 'object' || !('details' in error)) return undefined + const details = error.details + if (!details || typeof details !== 'object' || Array.isArray(details)) return undefined + const body = details as Record + if (typeof body.code !== 'string') return undefined + const receipt: Record = { code: body.code } + for (const key of ['sessionId', 'requestId', 'incarnationId', 'submittedTurnId', 'ownerKind']) { + if (typeof body[key] === 'string' && body[key].length > 0) receipt[key] = body[key] + } + if (!receipt.incarnationId && !receipt.ownerKind) return undefined + if (typeof body.retryable === 'boolean') receipt.retryable = body.retryable + for (const key of ['ownerEpoch', 'ownerGeneration', 'retryAfterMs']) { + if (typeof body[key] === 'number' && Number.isFinite(body[key])) receipt[key] = body[key] + } + return receipt +}