diff --git a/Cargo.lock b/Cargo.lock index 122e578dc..f2901cb1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5266,6 +5266,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "uuid", "workdir", ] @@ -6705,6 +6706,7 @@ dependencies = [ name = "yoi-e2e" version = "0.0.0" dependencies = [ + "chrono", "libc", "reqwest", "serde", @@ -6714,6 +6716,7 @@ dependencies = [ "tempfile", "webauthn-authenticator-rs", "webauthn-rs-proto", + "yoi-workspace-server", ] [[package]] diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 5a4f9e50c..9110e0e62 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4108,6 +4108,17 @@ impl RequestActor { #[serde(deny_unknown_fields)] pub struct WhoamiResponse { pub actor: Option, + pub session: BrowserSessionState, +} + +/// Browser navigation evidence, not an authorization grant or a generic 401 classification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserSessionState { + Anonymous {}, + Authenticated { expires_at: Option }, + Expired {}, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -11620,6 +11631,7 @@ pub fn auth_api_typescript() -> String { ActorAuthMethod::decl(&config), AuthenticatedUser::decl(&config), RequestActor::decl(&config), + BrowserSessionState::decl(&config), WhoamiResponse::decl(&config), AuthBootstrapUserRequest::decl(&config), AuthUserResponse::decl(&config), @@ -14050,7 +14062,8 @@ mod tests { "handle": "hare", "display_name": "Hare", "auth_method": "browser_session" - } + }, + "session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" } }); let decoded = serde_json::from_value::(whoami.clone()) .expect("server whoami fixture should match shared DTO"); @@ -14232,11 +14245,36 @@ mod tests { let unexpected_field = serde_json::json!({ "actor": null, + "session": { "kind": "anonymous" }, "access_token": "must-not-be-accepted" }); assert!(serde_json::from_value::(unexpected_field).is_err()); } + #[test] + fn whoami_session_json_distinguishes_anonymous_expired_and_token_auth() { + for session in [ + serde_json::json!({"kind":"anonymous"}), + serde_json::json!({"kind":"expired"}), + serde_json::json!({"kind":"authenticated", "expires_at":null}), + ] { + let value = serde_json::json!({"actor":null, "session":session}); + let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), value); + } + for session in [ + serde_json::json!({"kind":"unknown"}), + serde_json::json!({"kind":"expired", "expires_at":null}), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"actor":null, "session":session}) + ) + .is_err() + ); + } + } + #[test] fn device_login_debug_output_redacts_secret_material() { let start = DeviceLoginStartResponse { diff --git a/crates/standalone/src/host.rs b/crates/standalone/src/host.rs index 60c239b91..6d55edb67 100644 --- a/crates/standalone/src/host.rs +++ b/crates/standalone/src/host.rs @@ -23,9 +23,7 @@ use session_store::{ CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore, }; use thiserror::Error; -use worker::bootstrap::{ - WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id, -}; +use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout}; use worker::controller::WorkerControllerTransport; use worker::ipc::protocol_session::{ WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event, @@ -216,7 +214,6 @@ impl StandaloneHost { WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone()); let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let runtime_base = store.runtime_dir(worker_id); - let bash_output_dir = bash_output_dir_for_worker_id(worker_id); let mut bootstrap = WorkerBootstrap::new( bootstrap_manifest, @@ -224,10 +221,7 @@ impl StandaloneHost { launch.prompt_catalog, workspace_context, filesystem_authority, - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - }, + WorkerBootstrapLayout::Direct { runtime_base }, WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { @@ -428,7 +422,6 @@ impl StandaloneHost { ); let workspace_context = WorkerWorkspaceContext::local_filesystem(None); let runtime_base = store.runtime_dir(worker_id); - let bash_output_dir = bash_output_dir_for_worker_id(worker_id); let mut bootstrap = WorkerBootstrap::new( manifest, @@ -436,10 +429,7 @@ impl StandaloneHost { worker::PromptCatalogSource::builtins_only(), workspace_context, filesystem_authority, - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - }, + WorkerBootstrapLayout::Direct { runtime_base }, WorkerControllerTransport::InProcess, ); if let Some(subject) = &subject { diff --git a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs index 04032a8d7..7dc575f6d 100644 --- a/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs +++ b/crates/standalone/src/subjektiv/journal/tests/connected_compaction.rs @@ -126,7 +126,7 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab worker::PromptCatalogSource::builtins_only(), worker::WorkerWorkspaceContext::no_workspace(), worker::WorkerFilesystemAuthority::None, - worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime"), bash_output_dir: fixture._temp.path().join("bash") }, + worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") }, worker::WorkerControllerTransport::InProcess, ).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap(); assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id); diff --git a/crates/tools/Cargo.toml b/crates/tools/Cargo.toml index f10a68197..22edee14c 100644 --- a/crates/tools/Cargo.toml +++ b/crates/tools/Cargo.toml @@ -23,7 +23,8 @@ thiserror = { workspace = true } tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } tracing = { workspace = true } workdir = { workspace = true } +uuid = { workspace = true, features = ["v7"] } [dev-dependencies] filetime = "0.2.27" -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index 801507c0b..74dcee471 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -1,19 +1,19 @@ //! Bash Tool using WorkdirSession command capability. //! -//! The selected session owns cwd, process launch, bounded output, timeout, and cancellation. +//! Workdir owns launch and byte transport; Bash owns bounded preview and retained evidence. //! Bash is not a sandbox; the Runtime process and OS user define the execution boundary. //! Per-execution handles and nonces prevent cancellation of another command. Dropping a future -//! does not confirm termination; active registrations remain until cleanup requests cancellation -//! and observes terminal output. +//! does not confirm termination. Registrations remain during bounded cancellation/drain cleanup; +//! an unconfirmed provider outcome leaves partial evidence, never a fabricated terminal fence. use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; use std::sync::{Arc, Mutex}; +use crate::bash_output::{BashOutputMetadata, BashOutputView, BashOutputWriter}; use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use async_trait::async_trait; use schemars::JsonSchema; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use workdir::{ CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter, @@ -35,9 +35,9 @@ struct BashParams { timeout: Option, } -pub(crate) struct BashTool { +pub struct BashTool { router: Arc, - output_dir: PathBuf, + output: BashOutputView, state: Arc>, } @@ -63,6 +63,8 @@ struct CommandGuard { execution_id: String, execution_nonce: u64, handle: Option, + cursor: u64, + retained: Option, } impl Drop for CommandGuard { @@ -74,20 +76,66 @@ impl Drop for CommandGuard { let state = Arc::clone(&self.state); let execution_id = self.execution_id.clone(); let execution_nonce = self.execution_nonce; + let mut retained = self.retained.take(); + let cursor = self.cursor; // A dropped provider future is not terminal confirmation. Keep the live // execution registered until cleanup has both requested cancellation and // observed terminal command output, so cancellation/session teardown // cannot race with an apparently empty registry. tokio::spawn(async move { - let _ = workdir.cancel_command(handle.clone()).await; - let _ = workdir - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: INLINE_BYTE_BUDGET, - wait: true, - }) - .await; + let result = tokio::time::timeout(std::time::Duration::from_secs(10), async { + let _ = workdir.cancel_command(handle.clone()).await; + let mut cursor = cursor; + loop { + let page = command_page( + &workdir, + CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + ) + .await?; + if let Some(writer) = &mut retained { + let mut next = cursor; + for chunk in &page.chunks { + if chunk.sequence != next { + return Err(workdir::WorkdirError::Unavailable( + "command cleanup sequence gap".into(), + )); + } + writer + .append_chunk(chunk) + .map_err(workdir::WorkdirError::Unavailable)?; + next = next.checked_add(chunk.bytes.len() as u64).ok_or_else(|| { + workdir::WorkdirError::Unavailable( + "command cleanup cursor overflow".into(), + ) + })?; + } + if page.next_cursor != next { + return Err(workdir::WorkdirError::Unavailable( + "command cleanup cursor gap".into(), + )); + } + } + cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + if let Some(writer) = &mut retained { + writer + .finish_transport(&terminal) + .map_err(workdir::WorkdirError::Unavailable)?; + } + return Ok::<(), workdir::WorkdirError>(()); + } + } + }) + .await; + // Bounded cleanup can end with an unknown provider outcome. The writer then + // terminalizes partial evidence on drop; retaining a dead collector in `active` + // would leak registrations and falsely suggest it could still collect output. + let _ = result; let mut state = state.lock().unwrap(); if state .active @@ -101,15 +149,49 @@ impl Drop for CommandGuard { } } -#[async_trait] -impl Tool for BashTool { - async fn execute( +// Output pages are replayable only with the exact same command/cursor/limit. +// Retry read transport failures, never command start, and cap both attempts and +// backoff. The caller's deadline bounds a disconnected or unresponsive provider. +async fn command_page( + session: &workdir::WorkdirSessionHandle, + request: CommandOutputRequest, +) -> Result { + for attempt in 0..3 { + match session.command_output(request.clone()).await { + Err( + workdir::WorkdirError::Transport(_) + | workdir::WorkdirError::Unavailable(_) + | workdir::WorkdirError::OutcomeUnknown(_), + ) if attempt < 2 => { + tokio::time::sleep(std::time::Duration::from_millis(100 << attempt)).await; + } + result => return result, + } + } + unreachable!("bounded retry loop returns its last result") +} + +impl BashTool { + pub fn input_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(BashParams)).expect("Bash schema serialization") + } + + pub fn new(router: Arc, output: BashOutputView) -> Self { + Self { + router, + output, + state: Arc::new(Mutex::new(BashExecutionState::default())), + } + } + + pub async fn execute_result( &self, input_json: &str, ctx: agen::tool::ToolExecutionContext, - ) -> Result { + ) -> Result { let params: BashParams = serde_json::from_str(input_json) .map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?; + let retained = self.output.begin().map_err(ToolError::ExecutionFailed)?; let selected = crate::routing::resolve_session( &self.router, params.target_workdir.as_deref(), @@ -134,14 +216,15 @@ impl Tool for BashTool { execution_id: execution_id.clone(), execution_nonce, handle: None, + cursor: 0, + retained: Some(retained), }; let handle = session .start_command(CommandRequest { command: params.command, timeout_secs, - output_limit: INLINE_BYTE_BUDGET, + transport_byte_budget: self.output.byte_budget(), cwd: params.cwd, - spill_dir: Some(self.output_dir.clone()), tool_call_id: Some(call_id.clone()), }) .await @@ -167,15 +250,101 @@ impl Tool for BashTool { .await .map_err(crate::ToolsError::from)?; } - let output = session - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: INLINE_BYTE_BUDGET, - wait: true, - }) + let mut cursor = 0; + let mut stdout_preview = Vec::new(); + let mut stderr_preview = Vec::new(); + let mut seen = [0_u64; 2]; + let mut empty_polls = 0; + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs + 30); + let terminal = loop { + let page = tokio::time::timeout_at( + deadline, + command_page( + &session, + CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + ), + ) .await + .map_err(|_| { + ToolError::ExecutionFailed( + "command byte transport deadline exceeded; terminal outcome unknown".into(), + ) + })? .map_err(crate::ToolsError::from)?; + let mut next = cursor; + for chunk in &page.chunks { + if chunk.sequence != next || chunk.bytes.is_empty() { + return Err(ToolError::ExecutionFailed( + "command byte transport sequence gap".into(), + )); + } + next = next + .checked_add(chunk.bytes.len() as u64) + .filter(|n| *n <= self.output.byte_budget()) + .ok_or_else(|| { + ToolError::ExecutionFailed( + "command byte transport exceeds delivery budget".into(), + ) + })?; + let index = if chunk.stream == workdir::CommandStream::Stdout { + 0 + } else { + 1 + }; + if chunk.offset != seen[index] { + return Err(ToolError::ExecutionFailed( + "command byte transport offset gap".into(), + )); + } + seen[index] += chunk.bytes.len() as u64; + guard + .retained + .as_mut() + .unwrap() + .append_chunk(chunk) + .map_err(ToolError::ExecutionFailed)?; + let available = + INLINE_BYTE_BUDGET.saturating_sub(stdout_preview.len() + stderr_preview.len()); + let preview = if index == 0 { + &mut stdout_preview + } else { + &mut stderr_preview + }; + preview.extend_from_slice(&chunk.bytes[..available.min(chunk.bytes.len())]); + } + if page.next_cursor != next { + return Err(ToolError::ExecutionFailed( + "command byte transport cursor does not fence the page".into(), + )); + } + if page.chunks.is_empty() && page.terminal.is_none() { + empty_polls += 1; + if empty_polls > 32 { + return Err(ToolError::ExecutionFailed( + "command byte transport made no progress after bounded polls".into(), + )); + } + } else { + empty_polls = 0; + } + cursor = page.next_cursor; + guard.cursor = cursor; + if let Some(terminal) = page.terminal { + break terminal; + } + }; + let metadata = guard + .retained + .as_mut() + .unwrap() + .finish_transport(&terminal) + .map_err(ToolError::ExecutionFailed)?; let cancellation_requested = { let mut state = self.state.lock().unwrap(); let owns_registration = state @@ -193,41 +362,78 @@ impl Tool for BashTool { }; guard.handle = None; - let timed_out = output.timed_out; - let summary = if cancellation_requested { - format!("$ {cmd_summary} (cancelled)") - } else if output.timed_out { - format!("$ {cmd_summary} (timed out after {timeout_secs}s)") - } else { - match output.exit_code { - Some(0) => format!("$ {cmd_summary}"), - Some(code) => format!("$ {cmd_summary} (exit {code})"), - None => format!("$ {cmd_summary} (terminated)"), - } - }; - let content = if output.content.is_empty() { - None - } else if output.truncated { - let notice = match output.output_path { - Some(path) => format!( - "[showing bounded WorkdirSession command output; full output saved to {}]", - path.display() - ), - None => "[showing bounded WorkdirSession command output; additional output was truncated]" - .to_owned(), + let timed_out = terminal.status == workdir::CommandStatus::TimedOut; + let summary = + if cancellation_requested || terminal.status == workdir::CommandStatus::Cancelled { + format!("$ {cmd_summary} (cancelled)") + } else if timed_out { + format!("$ {cmd_summary} (timed out after {timeout_secs}s)") + } else { + match terminal.exit_code { + Some(0) => format!("$ {cmd_summary}"), + Some(code) => format!("$ {cmd_summary} (exit {code})"), + None => format!("$ {cmd_summary} (terminated)"), + } }; - Some(format!("{notice}\n{}", output.content)) - } else { - Some(output.content) - }; - let output = ToolOutput { + Ok(BashResult { summary, - content, - attachments: Vec::new(), - }; - if cancellation_requested { + stdout_preview: String::from_utf8_lossy(&stdout_preview).into_owned(), + stderr_preview: String::from_utf8_lossy(&stderr_preview).into_owned(), + preview_omitted: terminal.stdout_bytes.saturating_add(terminal.stderr_bytes) + > (stdout_preview.len() + stderr_preview.len()) as u64, + metadata, + cancelled: cancellation_requested + || terminal.status == workdir::CommandStatus::Cancelled, + }) + } +} + +#[derive(Debug, Serialize)] +pub struct BashResult { + pub summary: String, + pub stdout_preview: String, + pub stderr_preview: String, + pub preview_omitted: bool, + pub metadata: BashOutputMetadata, + pub cancelled: bool, +} + +impl BashResult { + pub fn stdout_entry(&self) -> String { + format!("/bash-output/{}/stdout", self.metadata.id) + } + pub fn stderr_entry(&self) -> String { + format!("/bash-output/{}/stderr", self.metadata.id) + } + pub fn order_entry(&self) -> String { + format!("/bash-output/{}/order", self.metadata.id) + } + pub fn tool_output(&self) -> ToolOutput { + let content = serde_json::json!({ + "stdout_preview": self.stdout_preview, "stderr_preview": self.stderr_preview, + "preview_omitted": self.preview_omitted, "output": self.metadata, + "stdout_entry": self.stdout_entry(), "stderr_entry": self.stderr_entry(), "order_entry": self.order_entry(), + }); + ToolOutput { + summary: self.summary.clone(), + content: Some(content.to_string()), + attachments: vec![], + } + } +} + +#[async_trait] +impl Tool for BashTool { + async fn execute( + &self, + input: &str, + ctx: agen::tool::ToolExecutionContext, + ) -> Result { + let result = self.execute_result(input, ctx).await?; + let output = result.tool_output(); + if result.cancelled { Err(ToolError::Cancelled(output)) - } else if timed_out { + } else if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) { Err(ToolError::Interrupted(output)) } else { Ok(output) @@ -288,164 +494,19 @@ fn truncate_for_summary(command: &str) -> String { summary } -pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition { - routed_bash_tool(crate::routing::singleton_router(session), output_dir) +pub fn bash_tool(session: WorkdirSessionHandle, output: BashOutputView) -> ToolDefinition { + routed_bash_tool(crate::routing::singleton_router(session), output) } pub(crate) fn routed_bash_tool( router: Arc, - output_dir: PathBuf, + output: BashOutputView, ) -> ToolDefinition { Arc::new(move || { - let schema = schemars::schema_for!(BashParams); let meta = ToolMeta::new("Bash") - .description("Execute a shell command in one selected Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by that WorkdirSession provider. This is not a sandbox.") - .input_schema(serde_json::to_value(schema).expect("Bash schema serialization")); - let tool: Arc = Arc::new(BashTool { - router: router.clone(), - output_dir: output_dir.clone(), - state: Arc::new(Mutex::new(BashExecutionState::default())), - }); + .description("Execute a shell command in one selected Workdir. Workdir owns launch, lossless bounded byte transport, timeout and cancel; Bash owns preview, quota-limited ephemeral output and read-only virtual references. This is not a sandbox.") + .input_schema(BashTool::input_schema()); + let tool: Arc = Arc::new(BashTool::new(router.clone(), output.clone())); (meta, tool) }) } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; - use tempfile::TempDir; - use workdir::{LocalWorkdirSession, WorkdirSessionHandle}; - - use super::bash_tool; - use crate::{grep::grep_tool, read::read_tool, tracker::Tracker}; - - fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle { - let scope = Scope::from_config(&ScopeConfig { - allow: vec![ - ScopeRule { - target: root.path().to_path_buf(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }, - ScopeRule { - target: output.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }, - ], - deny: Vec::new(), - }) - .unwrap(); - Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf())) - } - - #[tokio::test] - async fn long_output_is_spilled_and_available_to_read_and_grep() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())(); - let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'"; - let result = bash - .execute( - &serde_json::json!({ "command": command, "cwd": "." }).to_string(), - Default::default(), - ) - .await - .unwrap(); - let rendered = result.content.expect("bounded Bash output"); - let artifact = std::fs::read_dir(output.path()) - .unwrap() - .next() - .expect("artifact entry") - .unwrap() - .path(); - - assert!(rendered.contains("full output saved to")); - assert!(rendered.contains(&artifact.display().to_string())); - let retained = std::fs::read_to_string(&artifact).unwrap(); - assert!(retained.starts_with("line-0000\n")); - assert!(retained.ends_with("FINAL-NEEDLE\n")); - assert_eq!(retained.lines().count(), 2001); - - let (_, read) = read_tool(session.clone(), Tracker::new())(); - let read_result = read - .execute( - &serde_json::json!({ - "file_path": artifact, - "offset": 2000, - "limit": 1, - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!( - read_result - .content - .expect("Read content") - .contains("FINAL-NEEDLE") - ); - - let (_, grep) = grep_tool(session)(); - let grep_result = grep - .execute( - &serde_json::json!({ - "pattern": "FINAL-NEEDLE", - "path": artifact, - "output_mode": "content", - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - let grep_content = grep_result.content.expect("Grep content"); - assert!( - grep_content.contains("FINAL-NEEDLE"), - "unexpected Grep content: {grep_content:?}" - ); - } - - #[tokio::test] - async fn missing_cwd_is_rejected_before_command_start() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session, output.path().to_path_buf())(); - - let error = bash - .execute( - &serde_json::json!({ "command": "printf hidden" }).to_string(), - Default::default(), - ) - .await - .unwrap_err(); - - assert!(error.to_string().contains("missing field `cwd`")); - } - - #[tokio::test] - async fn short_output_does_not_leave_a_spill_artifact() { - let root = TempDir::new().unwrap(); - let output = TempDir::new().unwrap(); - let session = session_with_output_scope(&root, &output); - let (_, bash) = bash_tool(session, output.path().to_path_buf())(); - - let result = bash - .execute( - &serde_json::json!({ "command": "printf short", "cwd": "." }).to_string(), - Default::default(), - ) - .await - .unwrap(); - - assert_eq!(result.content.as_deref(), Some("short")); - assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0); - } -} diff --git a/crates/tools/src/bash_output.rs b/crates/tools/src/bash_output.rs new file mode 100644 index 000000000..538b50433 --- /dev/null +++ b/crates/tools/src/bash_output.rs @@ -0,0 +1,605 @@ +//! Bash-owned bounded output, independent of Workdir transport and Console subscribers. +//! +//! Output is ephemeral evidence, not a filesystem grant. Every lookup checks the live view; +//! children can see their own subtree and ancestors can retain evidence after child revocation. +//! Root teardown clears all bytes. A new root (including restore) has a new incarnation and +//! never reconstructs grants from history. Expiry is measured from admission, enforced on +//! lookup and by a weak reaper once commands run in Tokio. Parent and all descendants share +//! one pool: admission reserves a whole command budget, evicts the oldest terminal evidence +//! first, and refuses rather than evicting a live collector. Revoking a child denies access +//! but leaves ancestor evidence until pool eviction/expiry; root teardown clears the pool. + +use std::collections::VecDeque; +use std::sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, Ordering}, +}; +use std::time::{Duration, Instant}; + +use serde::Serialize; +use workdir::{CommandStatus, CommandStream}; + +const COMMAND_BYTES: usize = 4 * 1024 * 1024; +const STORE_BYTES: usize = 32 * 1024 * 1024; +const STORE_COUNT: usize = 64; +const ORDER_COUNT: usize = 4096; +const RETENTION: Duration = Duration::from_secs(3600); + +#[derive(Clone)] +pub struct BashOutputView { + store: Arc>, + lineage: Vec, + active: Arc, + authority: Arc bool + Send + Sync>, +} + +struct Store { + records: VecDeque>>, + reserved: usize, + retention: Duration, + reaper_started: bool, + _shutdown: tokio::sync::watch::Sender<()>, +} + +struct Record { + id: String, + owner: Vec, + created: Instant, + stdout: Vec, + stderr: Vec, + order: Vec, + stdout_seen: u64, + stderr_seen: u64, + truncated: bool, + limit: Option, + transport_counts: Option<(u64, u64)>, + transport_truncation: Option, + terminal: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OutputRange { + pub stream: CommandStream, + pub start: u64, + pub end: u64, +} + +#[derive(Clone, Debug, Serialize)] +pub struct OutputTerminal { + pub status: Option, + pub exit_code: Option, + /// False on a forcibly dropped collector or failed transport: received bytes are retained, + /// but they are not a claim about the unobserved process tail. + pub transport_complete: bool, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RetentionLimit { + ByteQuota, + OrderQuota, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BashOutputMetadata { + pub id: String, + pub stdout_saved: usize, + pub stderr_saved: usize, + pub stdout_seen: u64, + pub stderr_seen: u64, + pub stdout_delivered: u64, + pub stderr_delivered: u64, + pub retention_limit: Option, + pub transport_truncation: Option, + pub storage_truncated: bool, + pub terminal: OutputTerminal, + pub text_encoding: &'static str, + pub ordering: &'static str, +} + +/// A bounded reservation acquired before command start. Drop terminalizes partial evidence. +pub struct BashOutputWriter { + record: Arc>, +} + +impl Default for BashOutputView { + fn default() -> Self { + Self::new() + } +} + +impl BashOutputView { + pub fn new() -> Self { + let (shutdown, _) = tokio::sync::watch::channel(()); + let store = Arc::new(Mutex::new(Store { + records: VecDeque::new(), + reserved: 0, + retention: RETENTION, + reaper_started: false, + _shutdown: shutdown, + })); + let view = Self { + store, + lineage: vec![uuid::Uuid::now_v7().to_string()], + active: Arc::new(AtomicBool::new(true)), + authority: Arc::new(|| true), + }; + view.start_reaper(); + view + } + + fn start_reaper(&self) { + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + return; + }; + let mut store = self.store.lock().unwrap(); + if store.reaper_started { + return; + } + let receiver = store._shutdown.subscribe(); + store.reaper_started = true; + let weak = Arc::downgrade(&self.store); + runtime.spawn(reap(weak, receiver)); + } + + pub fn byte_budget(&self) -> u64 { + COMMAND_BYTES as u64 + } + + pub fn child(&self, authority: Arc bool + Send + Sync>) -> Self { + let parent = self.clone(); + let mut lineage = self.lineage.clone(); + lineage.push(uuid::Uuid::now_v7().to_string()); + Self { + store: self.store.clone(), + lineage, + active: Arc::new(AtomicBool::new(true)), + authority: Arc::new(move || parent.is_active() && authority()), + } + } + + pub fn is_active(&self) -> bool { + self.active.load(Ordering::Acquire) && (self.authority)() + } + + pub fn is_root(&self) -> bool { + self.lineage.len() == 1 + } + + pub fn revoke(&self) { + self.active.store(false, Ordering::Release); + if self.lineage.len() == 1 { + let mut store = self.store.lock().unwrap(); + store.records.clear(); + store.reserved = 0; + } + } + + pub fn begin(&self) -> Result { + if !self.is_active() { + return Err("Bash output authority revoked".into()); + } + // A host may construct the Feature before entering Tokio. Command admission + // happens inside the runtime, so idle expiry must start here as well. + self.start_reaper(); + let mut store = self.store.lock().unwrap(); + if !self.is_active() { + return Err("Bash output authority revoked".into()); + } + store.expire(Instant::now()); + while store.records.len() >= STORE_COUNT || store.reserved + COMMAND_BYTES > STORE_BYTES { + let Some(index) = store + .records + .iter() + .position(|r| r.lock().unwrap().terminal.is_some()) + else { + return Err("Bash output capacity is occupied by live commands".into()); + }; + store.records.remove(index); + store.reserved -= COMMAND_BYTES; + } + let record = Arc::new(Mutex::new(Record { + id: uuid::Uuid::now_v7().to_string(), + owner: self.lineage.clone(), + created: Instant::now(), + stdout: Vec::new(), + stderr: Vec::new(), + order: Vec::new(), + stdout_seen: 0, + stderr_seen: 0, + truncated: false, + limit: None, + transport_counts: None, + transport_truncation: None, + terminal: None, + })); + store.records.push_back(record.clone()); + store.reserved += COMMAND_BYTES; + Ok(BashOutputWriter { record }) + } + + fn record(&self, id: &str) -> Option>> { + if !self.is_active() { + return None; + } + let mut store = self.store.lock().unwrap(); + if !self.is_active() { + return None; + } + store.expire(Instant::now()); + store + .records + .iter() + .find(|r| { + let r = r.lock().unwrap(); + r.id == id && r.owner.starts_with(&self.lineage) && r.terminal.is_some() + }) + .cloned() + } + + pub fn metadata(&self, id: &str) -> Option { + let record = self.record(id)?; + let r = record.lock().unwrap(); + Some(r.metadata()) + } + + /// Copies at most the caller's bounded range; never publishes output bodies in descriptors. + pub fn read( + &self, + id: &str, + stream: CommandStream, + offset: usize, + limit: usize, + ) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + let bytes = match stream { + CommandStream::Stdout => &r.stdout, + CommandStream::Stderr => &r.stderr, + }; + let start = offset.min(bytes.len()); + Some(bytes[start..start.saturating_add(limit.min(64 * 1024)).min(bytes.len())].to_vec()) + } + + /// Scan the bounded raw source without cloning it, decoding only the requested line window. + /// A long single line uses the common byte reader instead of allocating an oversized text page. + pub fn line_window( + &self, + id: &str, + stream: CommandStream, + offset: usize, + limit: usize, + ) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + let bytes = match stream { + CommandStream::Stdout => &r.stdout, + CommandStream::Stderr => &r.stderr, + }; + let mut lines = bytes.split_inclusive(|b| *b == b'\n').skip(offset); + let mut window = Vec::new(); + for line in lines.by_ref().take(limit.min(2000)) { + if window.len() + line.len() > 64 * 1024 { + return Some(Err( + "text source window exceeds 64 KiB; use the common byte reader".into(), + )); + } + window.extend_from_slice(line); + } + let more = lines.next().is_some(); + Some(Ok((String::from_utf8_lossy(&window).into_owned(), more))) + } + + pub fn order(&self, id: &str) -> Option> { + let record = self.record(id)?; + let r = record.lock().unwrap(); + Some(r.order.clone()) + } +} + +impl Record { + fn metadata(&self) -> BashOutputMetadata { + BashOutputMetadata { + id: self.id.clone(), + stdout_saved: self.stdout.len(), + stderr_saved: self.stderr.len(), + stdout_seen: self.transport_counts.map_or(self.stdout_seen, |c| c.0), + stderr_seen: self.transport_counts.map_or(self.stderr_seen, |c| c.1), + stdout_delivered: self.stdout_seen, + stderr_delivered: self.stderr_seen, + retention_limit: self.limit, + transport_truncation: self.transport_truncation.clone(), + storage_truncated: self.truncated || self.transport_truncation.is_some(), + terminal: self.terminal.clone().expect("finalized record"), + text_encoding: "utf8_lossy", + ordering: "provider_read_observation", + } + } +} + +impl Store { + fn expire(&mut self, now: Instant) { + let before = self.records.len(); + self.records + .retain(|r| now.duration_since(r.lock().unwrap().created) < self.retention); + self.reserved -= (before - self.records.len()) * COMMAND_BYTES; + } +} + +async fn reap(weak: Weak>, mut shutdown: tokio::sync::watch::Receiver<()>) { + loop { + tokio::select! { + _ = tokio::time::sleep(Duration::from_secs(30)) => {}, + _ = shutdown.changed() => return, + } + let Some(store) = weak.upgrade() else { + return; + }; + store.lock().unwrap().expire(Instant::now()); + } +} + +impl BashOutputWriter { + pub fn append_chunk(&mut self, chunk: &workdir::CommandByteChunk) -> Result<(), String> { + let expected = { + let r = self.record.lock().unwrap(); + match chunk.stream { + CommandStream::Stdout => r.stdout_seen, + CommandStream::Stderr => r.stderr_seen, + } + }; + if chunk.offset != expected || chunk.bytes.is_empty() { + return Err("command byte transport stream gap or empty chunk".into()); + } + self.append(chunk.stream, &chunk.bytes); + Ok(()) + } + + pub fn append(&mut self, stream: CommandStream, bytes: &[u8]) { + let mut r = self.record.lock().unwrap(); + let seen = match stream { + CommandStream::Stdout => &mut r.stdout_seen, + CommandStream::Stderr => &mut r.stderr_seen, + }; + let start = *seen; + *seen = seen.saturating_add(bytes.len() as u64); + let available = COMMAND_BYTES.saturating_sub(r.stdout.len() + r.stderr.len()); + let n = if r.truncated || r.order.len() >= ORDER_COUNT { + 0 + } else { + available.min(bytes.len()) + }; + if n < bytes.len() { + r.truncated = true; + if r.limit.is_none() { + r.limit = Some(if r.order.len() >= ORDER_COUNT { + RetentionLimit::OrderQuota + } else { + RetentionLimit::ByteQuota + }); + } + } + if n > 0 { + match stream { + CommandStream::Stdout => r.stdout.extend_from_slice(&bytes[..n]), + CommandStream::Stderr => r.stderr.extend_from_slice(&bytes[..n]), + } + if let Some(last) = r + .order + .last_mut() + .filter(|last| last.stream == stream && last.end == start) + { + last.end += n as u64; + } else { + r.order.push(OutputRange { + stream, + start, + end: start + n as u64, + }); + } + } + } + + pub fn finish_transport( + &mut self, + terminal: &workdir::CommandTerminal, + ) -> Result { + let mut r = self.record.lock().unwrap(); + if terminal.stdout_delivered_bytes != r.stdout_seen + || terminal.stderr_delivered_bytes != r.stderr_seen + || terminal.stdout_bytes < r.stdout_seen + || terminal.stderr_bytes < r.stderr_seen + || ((terminal.stdout_bytes != r.stdout_seen || terminal.stderr_bytes != r.stderr_seen) + != terminal.truncation_reason.is_some()) + { + return Err("command terminal counts do not fence delivered bytes".into()); + } + r.transport_counts = Some((terminal.stdout_bytes, terminal.stderr_bytes)); + r.transport_truncation = terminal.truncation_reason.clone(); + r.terminal = Some(OutputTerminal { + status: Some(terminal.status), + exit_code: terminal.exit_code, + transport_complete: true, + }); + Ok(r.metadata()) + } + + pub fn finish(&mut self, terminal: OutputTerminal) -> BashOutputMetadata { + let mut r = self.record.lock().unwrap(); + r.terminal = Some(terminal); + r.metadata() + } +} + +impl Drop for BashOutputWriter { + fn drop(&mut self) { + let mut r = self.record.lock().unwrap(); + if r.terminal.is_none() { + r.terminal = Some(OutputTerminal { + status: None, + exit_code: None, + transport_complete: false, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn finish(view: &BashOutputView, text: &[u8]) -> String { + let mut writer = view.begin().unwrap(); + writer.append(CommandStream::Stdout, text); + writer + .finish(OutputTerminal { + status: Some(CommandStatus::Completed), + exit_code: Some(0), + transport_complete: true, + }) + .id + } + + #[test] + fn revoked_child_is_denied_while_parent_keeps_evidence_and_sibling_is_denied() { + let root = BashOutputView::new(); + let lease = Arc::new(AtomicBool::new(true)); + let child = root.child({ + let lease = lease.clone(); + Arc::new(move || lease.load(Ordering::Acquire)) + }); + let sibling = root.child(Arc::new(|| true)); + let id = finish(&child, b"child"); + assert_eq!( + child.read(&id, CommandStream::Stdout, 0, 20).unwrap(), + b"child" + ); + assert!(sibling.metadata(&id).is_none()); + assert!(BashOutputView::new().metadata(&id).is_none()); + lease.store(false, Ordering::Release); + assert!(child.metadata(&id).is_none()); + assert_eq!( + root.read(&id, CommandStream::Stdout, 0, 20).unwrap(), + b"child" + ); + root.revoke(); + let restored = BashOutputView::new(); + assert!(root.metadata(&id).is_none()); + assert!(restored.metadata(&id).is_none()); + let new_id = finish(&restored, b"new incarnation"); + assert_ne!(new_id, id); + assert!(child.metadata(&new_id).is_none()); + assert!(root.begin().is_err()); + } + + #[test] + fn parent_and_descendants_share_admission_without_evicting_live_evidence() { + let root = BashOutputView::new(); + let child = root.child(Arc::new(|| true)); + let sibling = root.child(Arc::new(|| true)); + let mut writers = (0..STORE_BYTES / COMMAND_BYTES) + .map(|index| { + if index % 2 == 0 { + root.begin() + } else { + child.begin() + } + .unwrap() + }) + .collect::>(); + assert!(sibling.begin().is_err()); + let mut first = writers.remove(0); + first.append(CommandStream::Stdout, b"finished"); + let metadata = first.finish(OutputTerminal { + status: Some(CommandStatus::Completed), + exit_code: Some(0), + transport_complete: true, + }); + assert!(root.metadata(&metadata.id).is_some()); + let _replacement = sibling.begin().unwrap(); + assert!(root.metadata(&metadata.id).is_none()); + assert!(root.begin().is_err()); + assert_eq!(writers.len(), STORE_BYTES / COMMAND_BYTES - 1); + } + + #[test] + fn quota_and_forced_drop_are_distinct_from_preview_omission() { + let root = BashOutputView::new(); + let mut writer = root.begin().unwrap(); + writer.append(CommandStream::Stdout, &vec![b'x'; COMMAND_BYTES + 1]); + writer.append(CommandStream::Stderr, b"tail"); + let id = writer.record.lock().unwrap().id.clone(); + drop(writer); + let m = root.metadata(&id).unwrap(); + assert_eq!(m.stdout_saved, COMMAND_BYTES); + assert_eq!(m.stdout_seen, COMMAND_BYTES as u64 + 1); + assert_eq!(m.stderr_seen, 4); + assert!(m.storage_truncated); + assert!(!m.terminal.transport_complete); + assert_eq!( + root.read(&id, CommandStream::Stdout, 0, usize::MAX) + .unwrap() + .len(), + 64 * 1024 + ); + } + + #[test] + fn expiry_and_count_eviction_never_reuse_old_references() { + let root = BashOutputView::new(); + let id = finish(&root, b"old"); + root.store.lock().unwrap().retention = Duration::ZERO; + assert!(root.metadata(&id).is_none()); + root.store.lock().unwrap().retention = RETENTION; + let first = finish(&root, b"first"); + for _ in 0..STORE_COUNT { + finish(&root, b"later"); + } + assert!(root.metadata(&first).is_none()); + assert!(root.store.lock().unwrap().reserved <= STORE_BYTES); + } + #[tokio::test(start_paused = true)] + async fn expiry_reclaims_bytes_without_another_read_and_reaper_ends_with_owner() { + // Exercise host construction outside a runtime, followed by real async admission. + let root = std::thread::spawn(BashOutputView::new).join().unwrap(); + finish(&root, b"expires without lookup"); + let mut closed = root.store.lock().unwrap()._shutdown.subscribe(); + tokio::task::yield_now().await; + // The store uses monotonic wall time for publication and Tokio time only for scheduling. + // Age the record deterministically rather than delaying this test in real time. + root.store.lock().unwrap().records[0] + .lock() + .unwrap() + .created = Instant::now() - RETENTION; + tokio::time::advance(Duration::from_secs(31)).await; + tokio::task::yield_now().await; + assert_eq!(root.store.lock().unwrap().reserved, 0); + drop(root); + assert!(closed.changed().await.is_err()); + } + #[test] + fn terminal_counts_must_fence_the_received_prefix_before_marking_evidence_complete() { + let root = BashOutputView::new(); + let mut writer = root.begin().unwrap(); + writer.append(CommandStream::Stdout, b"abc"); + let mut terminal = serde_json::json!({"status":"completed","exit_code":0,"stdout_bytes":9,"stderr_bytes":0,"stdout_delivered_bytes":4,"stderr_delivered_bytes":0,"truncation_reason":"byte_budget"}); + assert!( + writer + .finish_transport(&serde_json::from_value(terminal.clone()).unwrap()) + .is_err() + ); + terminal["stdout_delivered_bytes"] = serde_json::json!(3); + let metadata = writer + .finish_transport(&serde_json::from_value(terminal).unwrap()) + .unwrap(); + assert_eq!(metadata.stdout_saved, 3); + assert_eq!(metadata.stdout_delivered, 3); + assert_eq!(metadata.stdout_seen, 9); + assert!(metadata.storage_truncated); + assert!(metadata.retention_limit.is_none()); + assert_eq!( + metadata.transport_truncation, + Some(workdir::CommandOutputTruncation::ByteBudget) + ); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3bdb410d5..6efd98698 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -5,6 +5,7 @@ pub mod error; pub mod tracker; mod bash; +pub mod bash_output; mod edit; mod file_target; mod glob; @@ -16,7 +17,7 @@ pub mod view_image; mod web; mod write; -pub use bash::bash_tool; +pub use bash::{BashResult, BashTool, bash_tool}; pub use checkout::{CheckoutToolOutput, execute_checkout_tool}; pub use edit::edit_tool; pub use error::ToolsError; @@ -35,7 +36,7 @@ pub use write::write_tool; pub fn core_builtin_tools( session: workdir::WorkdirSessionHandle, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { use workdir::WorkdirSessionCapability; @@ -58,7 +59,7 @@ pub fn core_builtin_tools( tools.push(grep::routed_grep_tool(router.clone())); } if capabilities.supports(WorkdirSessionCapability::Command) { - tools.push(bash::routed_bash_tool(router, bash_output_dir)); + tools.push(bash::routed_bash_tool(router, bash_output)); } tools } @@ -71,7 +72,7 @@ pub fn core_builtin_tools( pub fn routed_builtin_tools( router: std::sync::Arc, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { vec![ read::routed_read_tool(router.clone(), tracker.clone()), @@ -79,14 +80,14 @@ pub fn routed_builtin_tools( edit::routed_edit_tool(router.clone(), tracker), glob::routed_glob_tool(router.clone()), grep::routed_grep_tool(router.clone()), - bash::routed_bash_tool(router, bash_output_dir), + bash::routed_bash_tool(router, bash_output), ] } pub fn routed_builtin_tools_for_current_capabilities( router: std::sync::Arc, tracker: Tracker, - bash_output_dir: std::path::PathBuf, + bash_output: bash_output::BashOutputView, ) -> Vec { use workdir::WorkdirSessionCapability; @@ -108,7 +109,7 @@ pub fn routed_builtin_tools_for_current_capabilities( tools.push(grep::routed_grep_tool(router.clone())); } if capabilities.supports(WorkdirSessionCapability::Command) { - tools.push(bash::routed_bash_tool(router, bash_output_dir)); + tools.push(bash::routed_bash_tool(router, bash_output)); } tools } @@ -121,7 +122,7 @@ pub fn read_only_builtin_tools( workdir::WorkdirSessionCapabilities::READ_ONLY, "read-only tool projection requires a read-only Workdir session" ); - core_builtin_tools(session, Tracker::new(), std::path::PathBuf::new()) + core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new()) } pub fn read_only_routed_builtin_tools( @@ -173,7 +174,7 @@ mod workdir_tool_tests { SharedScope::new(Scope::writable(dir.path()).unwrap()), WorkdirSessionCapabilities::READ_ONLY, )); - let names = core_builtin_tools(session, Tracker::new(), dir.path().join("output")) + let names = core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new()) .into_iter() .map(|definition| definition().0.name) .collect::>(); diff --git a/crates/tools/tests/edge_cases.rs b/crates/tools/tests/edge_cases.rs index 252f6001c..8d5abd689 100644 --- a/crates/tools/tests/edge_cases.rs +++ b/crates/tools/tests/edge_cases.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use agen::tool::{Tool, ToolDefinition}; -use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; +use manifest::Scope; use serde_json::json; use tempfile::TempDir; use tools::{Tracker, core_builtin_tools}; @@ -28,31 +28,23 @@ impl Registry { } } -fn setup() -> (TempDir, TempDir, Registry) { +fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let base = Scope::writable(dir.path()).unwrap(); - let mut config = ScopeConfig { - allow: base.allow_rules(), - deny: base.deny_rules(), - }; - config.allow.push(ScopeRule { - target: spill.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }); - let scope = Scope::from_config(&config).unwrap(); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); - (dir, spill, reg) + let reg = Registry::new(core_builtin_tools( + fs, + tracker, + tools::bash_output::BashOutputView::new(), + )); + (dir, reg) } #[tokio::test] async fn unicode_path_and_content() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("日本語ファイル.txt"); let content = "こんにちは 🦀 世界\nabc\n"; @@ -87,7 +79,7 @@ async fn unicode_path_and_content() { async fn symlink_to_outside_scope_is_rejected_for_write() { use std::os::unix::fs::symlink; - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let outside = TempDir::new().unwrap(); let outside_target = outside.path().join("secret.txt"); std::fs::write(&outside_target, "secret").unwrap(); @@ -151,7 +143,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() { async fn broken_symlink_reports_target_and_repair_hint() { use std::os::unix::fs::symlink; - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let link = dir.path().join("external-project"); let target = dir.path().join("missing-target"); symlink(&target, &link).unwrap(); @@ -173,7 +165,7 @@ async fn broken_symlink_reports_target_and_repair_hint() { #[tokio::test] async fn empty_file_read_and_edit() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("empty.txt"); std::fs::write(&file, "").unwrap(); @@ -207,7 +199,7 @@ async fn empty_file_read_and_edit() { #[tokio::test] async fn very_long_single_line() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("long.txt"); let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines std::fs::write(&file, &big).unwrap(); @@ -226,7 +218,7 @@ async fn very_long_single_line() { #[tokio::test] async fn absolute_path_requires_matching_read_scope() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let outside = tempfile::tempdir().unwrap(); let outside_file = outside.path().join("outside.txt"); std::fs::write(&outside_file, "secret").unwrap(); @@ -247,7 +239,7 @@ async fn absolute_path_requires_matching_read_scope() { #[tokio::test] async fn directory_target_is_rejected_for_read() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let read = reg.get("Read"); let err = read .execute(&json!({ "file_path": "." }).to_string(), Default::default()) @@ -258,7 +250,7 @@ async fn directory_target_is_rejected_for_read() { #[tokio::test] async fn deeply_nested_new_file_is_created() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let deep = dir.path().join("a/b/c/d/e/deep.txt"); let write = reg.get("Write"); write @@ -277,7 +269,7 @@ async fn deeply_nested_new_file_is_created() { #[tokio::test] async fn replace_preserves_unicode() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("u.txt"); std::fs::write(&file, "🦀 rust 🦀\n").unwrap(); @@ -306,7 +298,7 @@ async fn replace_preserves_unicode() { #[tokio::test] async fn grep_handles_unicode_pattern() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("u.txt"); std::fs::write(&file, "English\n日本語\nрусский\n").unwrap(); diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index c8c55e8d6..accfb59ef 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -11,7 +11,7 @@ use agen::tool::{ Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle, ToolExecutionTerminal, ToolMeta, }; -use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; +use manifest::Scope; use serde_json::json; use tempfile::TempDir; use tools::{ @@ -23,21 +23,6 @@ use workdir::{ WorkdirToolScopeRule, }; -fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope { - let base = Scope::writable(workspace).unwrap(); - let mut config = ScopeConfig { - allow: base.allow_rules(), - deny: base.deny_rules(), - }; - config.allow.push(ScopeRule { - target: spill.to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }); - Scope::from_config(&config).unwrap() -} - struct Registry { entries: Vec<(ToolMeta, Arc)>, } @@ -61,39 +46,38 @@ impl Registry { } } -fn setup() -> (TempDir, TempDir, Registry) { +fn setup() -> (TempDir, Registry) { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); - let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf())); - (dir, spill, reg) + let reg = Registry::new(core_builtin_tools( + fs, + tracker, + tools::bash_output::BashOutputView::new(), + )); + (dir, reg) } -fn setup_routed() -> ( - TempDir, - TempDir, - TempDir, - Arc, - Registry, -) { +fn setup_routed() -> (TempDir, TempDir, Arc, Registry) { let left = TempDir::new().unwrap(); let right = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); for (alias, dir) in [("left", &left), ("right", &right)] { - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); router .attach(WorkdirAttachmentAlias::new(alias).unwrap(), session) .unwrap(); } - let definitions = - routed_builtin_tools(router.clone(), Tracker::new(), spill.path().to_path_buf()); - (left, right, spill, router, Registry::new(definitions)) + let definitions = routed_builtin_tools( + router.clone(), + Tracker::new(), + tools::bash_output::BashOutputView::new(), + ); + (left, right, router, Registry::new(definitions)) } fn valid_gif_with_len(target_len: usize) -> Vec { @@ -137,7 +121,7 @@ async fn call_err(tool: &Arc, input: serde_json::Value) -> agen::tool: #[test] fn core_builtin_tools_registers_full_set() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let mut names = reg.names(); names.sort(); assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]); @@ -145,7 +129,7 @@ fn core_builtin_tools_registers_full_set() { #[test] fn meta_has_description_and_schema() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); for (meta, _) in ®.entries { assert!( !meta.description.is_empty(), @@ -163,7 +147,7 @@ fn meta_has_description_and_schema() { #[test] fn routed_tool_schemas_share_optional_target_workdir() { - let (_left, _right, _spill, router, mut reg) = setup_routed(); + let (_left, _right, router, mut reg) = setup_routed(); reg.entries.push(routed_view_image_tool(router)()); for (meta, _) in ®.entries { let target = &meta.input_schema["properties"]["target_workdir"]; @@ -174,7 +158,7 @@ fn routed_tool_schemas_share_optional_target_workdir() { #[tokio::test] async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() { - let (_left, _right, _spill, router, reg) = setup_routed(); + let (_left, _right, router, reg) = setup_routed(); let error = call_err(®.get("Read"), json!({ "file_path": "same.txt" })).await; let message = error.to_string(); assert!(message.contains("target_workdir_required"), "{message}"); @@ -199,7 +183,7 @@ async fn empty_routed_surface_returns_no_attachment_error() { let reg = Registry::new(routed_builtin_tools( router, Tracker::new(), - Path::new("unused").to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let message = call_err(®.get("Read"), json!({ "file_path": "file.txt" })) .await @@ -209,7 +193,7 @@ async fn empty_routed_surface_returns_no_attachment_error() { #[tokio::test] async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { - let (left, right, _spill, router, mut reg) = setup_routed(); + let (left, right, router, mut reg) = setup_routed(); std::fs::write(left.path().join("same.txt"), "left value\n").unwrap(); std::fs::write(right.path().join("same.txt"), "right value\n").unwrap(); std::fs::write(left.path().join("only-left.log"), "LEFT-NEEDLE\n").unwrap(); @@ -282,7 +266,7 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { ) .await; assert_eq!( - std::fs::canonicalize(bash.content.unwrap().trim()).unwrap(), + std::fs::canonicalize(serde_json::from_str::(&bash.content.unwrap()).unwrap()["stdout_preview"].as_str().unwrap().trim()).unwrap(), std::fs::canonicalize(right.path()).unwrap() ); @@ -304,13 +288,12 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() { async fn selected_attachment_capability_is_checked_without_fallback() { let left = TempDir::new().unwrap(); let right = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let router = Arc::new(WorkdirSessionRouter::new()); router .attach( WorkdirAttachmentAlias::new("writable").unwrap(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(left.path(), spill.path()), + Scope::writable(left.path()).unwrap(), left.path().to_path_buf(), )), ) @@ -322,7 +305,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { workdir::Workdir::new("readonly"), right.path().to_path_buf(), right.path().to_path_buf(), - manifest::SharedScope::new(scope_with_spill(right.path(), spill.path())), + manifest::SharedScope::new(Scope::writable(right.path()).unwrap()), workdir::WorkdirSessionCapabilities::READ_ONLY, )), ) @@ -330,7 +313,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { let reg = Registry::new(routed_builtin_tools( router.clone(), Tracker::new(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let message = call_err( @@ -352,7 +335,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() { #[tokio::test] async fn routed_tools_observe_detach_after_registration() { - let (_left, _right, _spill, router, reg) = setup_routed(); + let (_left, _right, router, reg) = setup_routed(); let alias = WorkdirAttachmentAlias::new("left").unwrap(); router.detach(&alias).await.unwrap(); @@ -372,7 +355,6 @@ async fn routed_tools_observe_detach_after_registration() { async fn detach_and_reattach_same_alias_requires_a_fresh_read() { let first = TempDir::new().unwrap(); let second = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); std::fs::write(first.path().join("same.txt"), "same bytes\n").unwrap(); std::fs::write(second.path().join("same.txt"), "same bytes\n").unwrap(); @@ -382,7 +364,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { .attach( alias.clone(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(first.path(), spill.path()), + Scope::writable(first.path()).unwrap(), first.path().to_path_buf(), )), ) @@ -390,7 +372,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { let reg = Registry::new(routed_builtin_tools( router.clone(), Tracker::new(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); call( ®.get("Read"), @@ -403,7 +385,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { .attach( alias.clone(), Arc::new(LocalWorkdirSession::new( - scope_with_spill(second.path(), spill.path()), + Scope::writable(second.path()).unwrap(), second.path().to_path_buf(), )), ) @@ -432,6 +414,8 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { struct ExternalValidatingSession { inner: LocalWorkdirSession, truncate_first_binary_response: bool, + command_response_losses: std::sync::atomic::AtomicUsize, + lose_terminal_only: bool, mutate_after_first_binary_response: Option, } @@ -550,7 +534,23 @@ impl workdir::WorkdirSession for ExternalValidatingSession { &self, request: workdir::CommandOutputRequest, ) -> Result { - self.inner.command_output(request).await + let output = self.inner.command_output(request).await?; + if (!self.lose_terminal_only || output.terminal.is_some()) + && self + .command_response_losses + .fetch_update( + std::sync::atomic::Ordering::AcqRel, + std::sync::atomic::Ordering::Acquire, + |remaining| remaining.checked_sub(1), + ) + .is_ok() + { + // The production provider has committed a page, but its response is lost. + return Err(workdir::WorkdirError::Transport( + "injected lost response".into(), + )); + } + Ok(output) } async fn cancel_command( @@ -568,11 +568,12 @@ impl workdir::WorkdirSession for ExternalValidatingSession { #[tokio::test] async fn external_view_image_uses_a_protocol_bounded_request() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), truncate_first_binary_response: false, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: None, }); let png = include_bytes!("fixtures/view-image/valid.png"); @@ -590,13 +591,14 @@ async fn external_view_image_uses_a_protocol_bounded_request() { #[tokio::test] async fn external_view_image_rejects_truncated_and_cross_version_chunks() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); let image_path = dir.path().join("image.png"); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap(); let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()), truncate_first_binary_response: true, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: None, }); let (_meta, tool) = view_image_tool(truncated)(); @@ -613,6 +615,8 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() { let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), truncate_first_binary_response: false, + command_response_losses: std::sync::atomic::AtomicUsize::new(0), + lose_terminal_only: false, mutate_after_first_binary_response: Some(image_path), }); let (_meta, tool) = view_image_tool(changing)(); @@ -626,8 +630,7 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() { async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() { const LIMIT: usize = 10 * 1024 * 1024; let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let (_meta, tool) = view_image_tool(session)(); @@ -650,8 +653,7 @@ async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() #[tokio::test] async fn view_image_attaches_valid_supported_image_fixtures() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let (_meta, tool) = view_image_tool(session)(); @@ -726,8 +728,7 @@ async fn delegated_view_image_preserves_caller_relative_path_identity() { #[tokio::test] async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let png = include_bytes!("fixtures/view-image/valid.png"); @@ -752,7 +753,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { #[tokio::test] async fn read_then_edit_then_read_roundtrip() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "hello world\n").unwrap(); let p = "a.txt"; @@ -784,7 +785,7 @@ async fn read_then_edit_then_read_roundtrip() { #[tokio::test] async fn write_then_grep_finds_content() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let write = reg.get("Write"); let grep = reg.get("Grep"); @@ -813,7 +814,7 @@ async fn write_then_grep_finds_content() { #[tokio::test] async fn glob_finds_written_files() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let write = reg.get("Write"); let glob = reg.get("Glob"); @@ -837,7 +838,7 @@ async fn glob_finds_written_files() { #[tokio::test] async fn absolute_path_is_rejected() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); let outside = TempDir::new().unwrap(); let write = reg.get("Write"); @@ -859,7 +860,7 @@ async fn absolute_path_is_rejected() { #[tokio::test] async fn write_to_existing_without_read_fails() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("exists.txt"); std::fs::write(&file, "preexisting").unwrap(); @@ -880,7 +881,7 @@ async fn write_to_existing_without_read_fails() { async fn shared_workdir_across_tools() { // The key invariant: all builtin tools share the same WorkdirSession instance, // so read-history set by Read is visible to Edit and Write. - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("shared.txt"); std::fs::write(&file, "one\n").unwrap(); @@ -907,7 +908,7 @@ async fn shared_workdir_across_tools() { #[tokio::test] async fn edit_requires_read_across_tools() { - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "foo\n").unwrap(); @@ -928,7 +929,7 @@ async fn edit_requires_read_across_tools() { #[tokio::test] async fn deterministic_tool_order_is_registration_order() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); // Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect(); assert_eq!( @@ -940,7 +941,7 @@ async fn deterministic_tool_order_is_registration_order() { // Regression: tool name capitalization matches Claude Code reference #[test] fn tool_names_match_reference_spec() { - let (_dir, _spill, reg) = setup(); + let (_dir, reg) = setup(); for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] { assert!( reg.entries.iter().any(|(m, _)| m.name == expected), @@ -953,15 +954,14 @@ fn tool_names_match_reference_spec() { async fn tracker_recent_files_tracks_read_write_edit() { // Build a fresh registry that shares a tracker we can query afterwards. let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = scope_with_spill(dir.path(), spill.path()); + let scope = Scope::writable(dir.path()).unwrap(); let fs: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); let tracker = Tracker::new(); let reg = Registry::new(core_builtin_tools( fs, tracker.clone(), - spill.path().to_path_buf(), + tools::bash_output::BashOutputView::new(), )); let a = dir.path().join("a.txt"); @@ -1003,118 +1003,314 @@ async fn tracker_recent_files_tracks_read_write_edit() { async fn bash_inherits_workdir_cwd() { // The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its // `pwd` should canonicalize to the workspace root we set up. - let (dir, _spill, reg) = setup(); + let (dir, reg) = setup(); let bash = reg.get("Bash"); let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await; let body = out.content.unwrap(); - let actual = std::fs::canonicalize(body.trim()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); + let actual = std::fs::canonicalize(parsed["stdout_preview"].as_str().unwrap().trim()).unwrap(); let expected = std::fs::canonicalize(dir.path()).unwrap(); assert_eq!(actual, expected); } +fn output_fixture() -> ( + TempDir, + WorkdirSessionHandle, + tools::bash_output::BashOutputView, + Arc, +) { + let root = TempDir::new().unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + Scope::writable(root.path()).unwrap(), + root.path().into(), + )); + let view = tools::bash_output::BashOutputView::new(); + let (_, bash) = tools::bash_tool(session.clone(), view.clone())(); + (root, session, view, bash) +} + #[tokio::test] -async fn bash_provider_output_exposes_readable_retained_path() { - let (_dir, spill, reg) = setup(); - let bash = reg.get("Bash"); +async fn bash_output_is_retained_without_a_filesystem_grant_or_telemetry_subscriber() { + let (root, _session, view, bash) = output_fixture(); let out = call( &bash, - json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }), + json!({ "command": "printf 'x%.0s' {1..20480}; printf '\\377€' >&2; exit 7", "cwd": "." }), ) .await; - let body = out.content.unwrap(); - assert!(body.contains("bounded WorkdirSession command output")); - assert!(body.contains("full output saved to")); - assert!(body.contains(spill.path().to_str().unwrap())); - let artifact = std::fs::read_dir(spill.path()) - .unwrap() - .next() - .expect("retained output") - .unwrap() - .path(); - assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480); + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["exit_code"], 7); + assert_eq!(body["preview_omitted"], true); + assert_eq!(body["output"]["storage_truncated"], false); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + vec![b'x'; 20480] + ); + assert_eq!( + view.read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(), + [vec![255], "€".as_bytes().to_vec()].concat() + ); + assert!( + body["stdout_entry"] + .as_str() + .unwrap() + .starts_with("/bash-output/") + ); + assert!(!body.to_string().contains(root.path().to_str().unwrap())); + assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0); +} + +async fn await_command_output( + events: &mut tokio::sync::broadcast::Receiver, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if matches!( + events.recv().await.unwrap(), + workdir::CommandEvent::Output { .. } + ) { + break; + } + } + }) + .await + .expect("command output readiness"); +} +async fn await_command_terminal( + events: &mut tokio::sync::broadcast::Receiver, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if let workdir::CommandEvent::Terminal { status, .. } = events.recv().await.unwrap() { + assert_eq!(status, workdir::CommandStatus::Cancelled); + break; + } + } + }) + .await + .expect("command terminal fence"); } #[tokio::test] async fn bash_cancellation_returns_bounded_progress_as_terminal_output() { - let (dir, _spill, reg) = setup(); - let marker = dir.path().join("must-not-run-after-cancel"); - let command = format!( - "printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'", - marker.display() - ); - let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap(); + let (_root, session, _view, bash) = output_fixture(); + let mut events = session.subscribe_command_events().unwrap(); let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0); - let bash = reg.get("Bash"); let executing = bash.clone(); let execution_context = context.clone(); - let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await }); - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - bash.cancel_execution(&context) - .await - .expect("signal exact execution cancellation"); - let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution) - .await - .expect("cancelled Bash should terminate inside the Engine grace budget") - .expect("Bash task join"); - - let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else { - panic!("expected typed cancellation result"); + let execution = tokio::spawn(async move { + executing.execute(r#"{"command":"printf 'before'; printf 'err-before' >&2; while :; do :; done","cwd":"."}"#, execution_context).await + }); + await_command_output(&mut events).await; + bash.cancel_execution(&context).await.unwrap(); + let ToolError::Cancelled(output) = + tokio::time::timeout(std::time::Duration::from_secs(5), execution) + .await + .unwrap() + .unwrap() + .unwrap_err() + else { + panic!("expected cancellation"); }; - let content = output.content.expect("bounded progress output"); - assert!( - content.contains("before"), - "missing pre-cancel stdout: {content}" - ); - assert!( - content.contains("err-before"), - "missing pre-cancel stderr: {content}" - ); - assert!( - !content.contains("after"), - "post-cancel output leaked: {content}" - ); - assert!(content.len() <= 16 * 1024, "output must remain bounded"); - - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - assert!( - !marker.exists(), - "the cancelled command continued executing after terminal confirmation" - ); + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["stdout_preview"], "before"); + assert_eq!(body["stderr_preview"], "err-before"); + assert_eq!(body["output"]["terminal"]["status"], "cancelled"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); } #[tokio::test] async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() { - let (dir, _spill, reg) = setup(); - let marker = dir.path().join("must-not-survive-force-close"); - let command = format!("sleep 1; touch {}", marker.display()); - let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap(); - let bash = reg.get("Bash"); + let (_root, session, _view, bash) = output_fixture(); + let mut events = session.subscribe_command_events().unwrap(); let context = ToolExecutionContext::new("call-force", "attempt-force", 0); - let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context); - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let (handle, terminal) = ToolExecutionHandle::start( + bash.clone(), + r#"{"command":"printf ready; while :; do :; done","cwd":"."}"#.into(), + context, + ); + await_command_output(&mut events).await; handle.force_close(); assert!(matches!( terminal.await, ToolExecutionTerminal::OutcomeUnknown )); - - tokio::time::sleep(std::time::Duration::from_millis(1_100)).await; - assert!( - !marker.exists(), - "CommandGuard cleanup allowed a force-closed command to continue" - ); - - let output = bash - .execute( - r#"{"command":"printf 'reused'","cwd":"."}"#, - Default::default(), - ) - .await - .expect("workdir session remains reusable after cleanup"); - assert_eq!(output.content.as_deref(), Some("reused")); + await_command_terminal(&mut events).await; + let output = call(&bash, json!({"command":"printf reused", "cwd":"."})).await; + let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["stdout_preview"], "reused"); } // Sanity: unused Path import guard const _: fn() -> &'static Path = || Path::new("/"); + +#[tokio::test] +async fn parent_and_sibling_broker_commands_keep_output_ownership_after_revoke() { + let (root, source, parent_view, parent_bash) = output_fixture(); + let broker = WorkdirToolBroker::new(source); + let mut leases = Vec::new(); + let mut views = Vec::new(); + let mut commands = Vec::new(); + for name in ["left", "right"] { + std::fs::create_dir(root.path().join(name)).unwrap(); + let lease = Arc::new( + broker + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::new(name).unwrap(), + permission: WorkdirToolScopePermission::Write, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: WorkdirPath::new(name).unwrap(), + command: true, + }) + .await + .unwrap(), + ); + let authority = lease.clone(); + let view = parent_view.child(Arc::new(move || authority.is_active())); + let (_, bash) = tools::bash_tool(lease.tool_session(), view.clone())(); + leases.push(lease); + views.push(view); + commands.push(bash); + } + let left_input = + json!({"command":"printf 'left-%.0s' {1..10000}; printf left-err >&2", "cwd":"."}); + let right_input = + json!({"command":"printf 'right-%.0s' {1..10000}; printf right-err >&2", "cwd":"."}); + let parent_input = json!({"command":"printf parent; printf parent-err >&2", "cwd":"."}); + let (left, right, parent) = tokio::join!( + call(&commands[0], left_input), + call(&commands[1], right_input), + call(&parent_bash, parent_input) + ); + let outputs = [left, right, parent] + .map(|out| serde_json::from_str::(&out.content.unwrap()).unwrap()); + for (index, label) in ["left", "right", "parent"].iter().enumerate() { + let id = outputs[index]["output"]["id"].as_str().unwrap(); + let bytes = parent_view + .read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(); + assert_eq!(bytes, format!("{label}-err").as_bytes()); + for (child_index, view) in views.iter().enumerate() { + assert_eq!(view.metadata(id).is_some(), child_index == index); + } + } + let left_id = outputs[0]["output"]["id"].as_str().unwrap(); + leases[0].close().await.unwrap(); + assert!(views[0].metadata(left_id).is_none()); + assert!(parent_view.metadata(left_id).is_some()); + assert!( + views[1] + .metadata(outputs[1]["output"]["id"].as_str().unwrap()) + .is_some() + ); + leases[1].close().await.unwrap(); + broker.close().await.unwrap(); + parent_view.revoke(); + assert!(parent_view.metadata(left_id).is_none()); +} + +#[tokio::test] +async fn utf8_quota_boundary_preserves_raw_prefix_and_reports_saved_vs_seen() { + let (_root, _session, view, bash) = output_fixture(); + let out = call( + &bash, + json!({"command":"printf '%4194303s' ''; printf '€'", "cwd":"."}), + ) + .await; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + let saved = body["output"]["stdout_saved"].as_u64().unwrap() as usize; + assert_eq!(saved, 4 * 1024 * 1024); + assert_eq!(body["output"]["stdout_seen"], 4 * 1024 * 1024 + 2); + assert_eq!(body["output"]["stderr_seen"], 0); + assert_eq!(body["output"]["storage_truncated"], true); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, saved - 1, 10) + .unwrap(), + vec![0xe2] + ); + assert!( + view.line_window(id, workdir::CommandStream::Stdout, 0, 1) + .unwrap() + .is_err() + ); + assert!(out.summary.starts_with("$ ")); +} + +#[tokio::test] +async fn timeout_terminal_fences_retained_stdout_and_stderr() { + let (_root, _session, view, bash) = output_fixture(); + let error = tokio::time::timeout(std::time::Duration::from_secs(5), bash.execute(r#"{"command":"printf before; printf err-before >&2; while :; do :; done","cwd":".","timeout":1}"#, Default::default())).await.unwrap().unwrap_err(); + let ToolError::Interrupted(out) = error else { + panic!("expected timeout output"); + }; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!(body["output"]["terminal"]["status"], "timed_out"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + b"before" + ); + assert_eq!( + view.read(id, workdir::CommandStream::Stderr, 0, 65536) + .unwrap(), + b"err-before" + ); +} + +// Integration: real process pipes and provider replay into the production Bash collector. +// Only response delivery is fault-injected; Runtime's HTTP suite covers actual wire routing. +#[tokio::test] +async fn lost_page_and_terminal_responses_replay_without_restarting_or_double_appending() { + for lose_terminal_only in [false, true] { + let dir = TempDir::new().unwrap(); + let session = Arc::new(ExternalValidatingSession { + inner: LocalWorkdirSession::new( + Scope::writable(dir.path()).unwrap(), + dir.path().into(), + ), + truncate_first_binary_response: false, + mutate_after_first_binary_response: None, + command_response_losses: std::sync::atomic::AtomicUsize::new(1), + lose_terminal_only, + }); + let view = tools::bash_output::BashOutputView::new(); + let definitions = core_builtin_tools(session.clone(), Tracker::new(), view.clone()); + let bash = Registry::new(definitions).get("Bash"); + let out = call(&bash, json!({"cwd":".","command":"printf once >> effect.txt; printf 'x%.0s' {1..200000}; printf err >&2; exit 3"})).await; + let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap(); + assert_eq!( + std::fs::read(dir.path().join("effect.txt")).unwrap(), + b"once" + ); + assert_eq!( + session + .command_response_losses + .load(std::sync::atomic::Ordering::Acquire), + 0 + ); + assert_eq!(body["output"]["stdout_saved"], 200000); + assert_eq!(body["output"]["stdout_delivered"], 200000); + assert_eq!(body["output"]["stderr_saved"], 3); + assert_eq!(body["output"]["terminal"]["exit_code"], 3); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!(body["output"]["storage_truncated"], false); + let id = body["output"]["id"].as_str().unwrap(); + for offset in (0..200000).step_by(65536) { + assert_eq!( + view.read(id, workdir::CommandStream::Stdout, offset, 65536) + .unwrap(), + vec![b'x'; (200000 - offset).min(65536)] + ); + } + use workdir::WorkdirSession as _; + session.close().await.unwrap(); + } +} diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index 0afb851e7..b8541897a 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -20,7 +20,7 @@ serde_json.workspace = true sha2.workspace = true tempfile.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } +tokio = { workspace = true, features = ["io-util", "process", "rt", "sync", "time"] } [dev-dependencies] serde_json.workspace = true diff --git a/crates/workdir/src/command_test_support.rs b/crates/workdir/src/command_test_support.rs new file mode 100644 index 000000000..62748a4d4 --- /dev/null +++ b/crates/workdir/src/command_test_support.rs @@ -0,0 +1,43 @@ +//! Bounded collection for tests whose subject is command routing/lifecycle, +//! rather than the byte-page protocol itself. + +use crate::*; + +#[derive(Debug)] +pub(crate) struct CollectedCommand { + pub content: String, + pub status: CommandStatus, + pub exit_code: Option, + pub timed_out: bool, +} + +#[async_trait::async_trait] +pub(crate) trait CollectCommand: WorkdirSession { + async fn collect_command_output( + &self, + mut request: CommandOutputRequest, + ) -> Result { + let mut bytes = Vec::new(); + request.wait = true; + loop { + let page = self.command_output(request.clone()).await?; + for chunk in page.chunks { + bytes.extend(chunk.bytes); + assert!( + bytes.len() <= 16 * 1024 * 1024, + "fixture output exceeded bounded test collection" + ); + } + request.cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + return Ok(CollectedCommand { + content: String::from_utf8_lossy(&bytes).into_owned(), + status: terminal.status, + exit_code: terminal.exit_code, + timed_out: terminal.status == CommandStatus::TimedOut, + }); + } + } + } +} +impl CollectCommand for T {} diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index ce708ddf5..867d24498 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -9,7 +9,8 @@ //! COMMAND remains separate explicit authority. General session capability intersections may form //! subsets independently of this grant hierarchy; the codec never implicitly expands nonhierarchical //! grants. Commands run with the shared host's OS permissions, not a sandbox confining processes -//! to a directory. Output is bounded and inline; provider-local spill paths are not exposed. +//! to a directory. Output uses bounded byte pages with cursor acknowledgements; +//! providers never receive caller-selected output paths. use std::path::{Component, Path}; @@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize}; use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError}; use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities}; -pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2; +pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 3; /// Maximum serialized Backend-to-provider command frame size. pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024; /// Maximum serialized provider-to-Backend result frame size. Read bytes are @@ -33,9 +34,9 @@ const MAX_EXTERNAL_GREP_CONTEXT: usize = 100; const MAX_EXTERNAL_SCOPE_RULES: usize = 64; pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024; pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024; -pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024; +pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = crate::MAX_COMMAND_READ_BYTES; pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600; -const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000; +const MAX_EXTERNAL_COMMAND_CURSOR: u64 = crate::MAX_COMMAND_TRANSPORT_BYTES; fn valid_command_handle(handle: &crate::CommandHandle) -> bool { !handle.0.is_empty() @@ -272,11 +273,10 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() } WorkdirSessionOperation::CommandStart(request) => { !request.command.is_empty() + && request.validate().is_ok() && request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES && (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs) - && (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit) && is_root_relative(&request.cwd) - && request.spill_dir.is_none() && request.tool_call_id.as_ref().is_none_or(|tool_call_id| { !tool_call_id.is_empty() && tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES @@ -462,11 +462,19 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle), WorkdirSessionOperationResult::CommandStatus(_) => true, WorkdirSessionOperationResult::CommandOutput(output) => { - output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + output.transport_bounds_valid() + && output.chunks.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES && output - .next_cursor - .is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR) - && output.output_path.is_none() + .chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + && output.next_cursor <= MAX_EXTERNAL_COMMAND_CURSOR + && output + .terminal + .as_ref() + .is_none_or(|terminal| terminal.status != crate::CommandStatus::Running) } WorkdirSessionOperationResult::CommandCancel => true, }; @@ -813,11 +821,16 @@ mod tests { #[test] fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() { - let future = r#"{ - "version": 3, - "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} - }"#; - assert!(serde_json::from_str::(future).is_err()); + for version in [ + EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION - 1, + EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + 1, + ] { + let unsupported = serde_json::json!({ + "version": version, + "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} + }); + assert!(serde_json::from_value::(unsupported).is_err()); + } let invalid_limits = r#"{ "max_source_bytes": 1024, @@ -837,32 +850,35 @@ mod tests { assert!(ExternalWorkdirOperation::try_from(absolute).is_err()); let command = WorkdirSessionOperation::CommandStart(CommandRequest { - command: "cat /home/operator/private/session.log".to_string(), + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf unsafe".to_string(), timeout_secs: 1, - output_limit: 1024, - cwd: WorkdirPath::root(), - spill_dir: Some("/home/operator/private/output".into()), + + cwd: serde_json::from_value(serde_json::json!("/absolute")).unwrap(), + tool_call_id: None, }); assert!(ExternalWorkdirOperation::try_from(command).is_err()); let command = WorkdirSessionOperation::CommandStart(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf bounded".to_string(), timeout_secs: 30, - output_limit: 12 * 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: Some("tool-call-1".to_string()), }); assert!(ExternalWorkdirOperation::try_from(command).is_ok()); assert!( ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart( CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1), timeout_secs: 30, - output_limit: 12 * 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, } )) @@ -899,13 +915,17 @@ mod tests { assert!( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( crate::CommandOutput { - status: crate::CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "bounded".to_string(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: vec![], + next_cursor: 0, + terminal: Some(crate::CommandTerminal { + status: crate::CommandStatus::Completed, + exit_code: Some(0), + stdout_bytes: 0, + stderr_bytes: 0, + stdout_delivered_bytes: 0, + stderr_delivered_bytes: 0, + truncation_reason: None, + }) } )) .is_ok() @@ -913,13 +933,14 @@ mod tests { assert!( ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput( crate::CommandOutput { - status: crate::CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "bounded".to_string(), - next_cursor: None, - truncated: true, - output_path: Some("/provider/private/output".into()), + chunks: vec![crate::CommandByteChunk { + sequence: 0, + stream: crate::CommandStream::Stdout, + offset: 0, + bytes: vec![0; MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + 1] + }], + next_cursor: 0, + terminal: None } )) .is_err() @@ -970,7 +991,7 @@ mod tests { #[test] fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() { let unknown = r#"{ - "version": 2, + "version": 3, "message": { "kind": "heartbeat", "generation": 1, @@ -1024,4 +1045,81 @@ mod tests { assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err()); assert!(ExternalWorkdirOperationId::new("operation-1").is_ok()); } + + #[test] + fn command_budget_and_page_metadata_obey_external_hard_bounds() { + fn request(budget: u64) -> WorkdirSessionOperation { + WorkdirSessionOperation::CommandStart(CommandRequest { + command: "true".into(), + timeout_secs: 1, + cwd: WorkdirPath::root(), + transport_byte_budget: budget, + tool_call_id: None, + }) + } + for budget in [0, crate::MAX_COMMAND_TRANSPORT_BYTES] { + let operation = ExternalWorkdirOperation::try_from(request(budget)).unwrap(); + let wire = serde_json::to_value(operation).unwrap(); + assert!(serde_json::from_value::(wire).is_ok()); + } + assert!( + ExternalWorkdirOperation::try_from(request(crate::MAX_COMMAND_TRANSPORT_BYTES + 1)) + .is_err() + ); + fn page() -> crate::CommandOutput { + crate::CommandOutput { + chunks: vec![crate::CommandByteChunk { + sequence: 0, + stream: crate::CommandStream::Stdout, + offset: 0, + bytes: b"abc".to_vec(), + }], + next_cursor: 3, + terminal: Some(crate::CommandTerminal { + status: crate::CommandStatus::Completed, + exit_code: Some(0), + stdout_bytes: 100 * 1024 * 1024, + stderr_bytes: 7, + stdout_delivered_bytes: 3, + stderr_delivered_bytes: 0, + truncation_reason: Some(crate::CommandOutputTruncation::ByteBudget), + }), + } + } + let accepted = ExternalWorkdirOperationResult::try_from( + WorkdirSessionOperationResult::CommandOutput(page()), + ) + .unwrap(); + assert!( + serde_json::from_value::( + serde_json::to_value(accepted).unwrap() + ) + .is_ok() + ); + for mutate in [ + |p: &mut crate::CommandOutput| p.next_cursor = crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + |p: &mut crate::CommandOutput| p.chunks[0].offset = crate::MAX_COMMAND_TRANSPORT_BYTES, + |p: &mut crate::CommandOutput| p.chunks[0].sequence = u64::MAX, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_delivered_bytes = 4, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_bytes = 2, + |p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().truncation_reason = None, + ] { + let mut invalid = page(); + mutate(&mut invalid); + let wire = serde_json::to_value(WorkdirSessionOperationResult::CommandOutput(invalid)) + .unwrap(); + assert!(serde_json::from_value::(wire).is_err()); + } + assert!( + ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandOutput( + crate::CommandOutputRequest { + handle: crate::CommandHandle("command-1".into()), + cursor: crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + limit: 1, + wait: false, + } + )) + .is_err() + ); + } } diff --git a/crates/workdir/src/external_local_tests.rs b/crates/workdir/src/external_local_tests.rs index 0a316586f..7332d6a06 100644 --- a/crates/workdir/src/external_local_tests.rs +++ b/crates/workdir/src/external_local_tests.rs @@ -1,6 +1,9 @@ +#[cfg(test)] +use crate::command_test_support::CollectCommand as _; use crate::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation, }; + use crate::{ BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, @@ -59,11 +62,12 @@ async fn external_local_provider_is_strictly_read_only() { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "echo changed > item.txt".to_string(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -145,11 +149,12 @@ async fn external_local_provider_read_write_is_filesystem_only() { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "touch command-ran".to_string(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -193,17 +198,18 @@ async fn external_command_permission_is_explicit_and_independent_from_file_acces let handle = session .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf command-ok".to_string(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await .unwrap(); let output = session - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 8457ff7e0..7464943da 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -847,6 +847,7 @@ mod client { &self, request: CommandRequest, ) -> Result { + request.validate()?; match self .operate(WorkdirSessionOperation::CommandStart(request)) .await? @@ -873,17 +874,39 @@ mod client { &self, request: CommandOutputRequest, ) -> Result { + if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) { + return Err(WorkdirError::InvalidArgument( + "command byte limit is outside transport bounds".into(), + )); + } let wait = request.wait; - loop { - match self - .operate(WorkdirSessionOperation::CommandOutput(request.clone())) - .await? - { - WorkdirSessionOperationResult::CommandOutput(result) - if wait && result.status == CommandStatus::Running => {} - WorkdirSessionOperationResult::CommandOutput(result) => return Ok(result), - _ => return Err(Self::mismatch("command_output")), + match self + .operate(WorkdirSessionOperation::CommandOutput(request.clone())) + .await? + { + WorkdirSessionOperationResult::CommandOutput(result) => { + let size = result + .chunks + .iter() + .try_fold(0usize, |size, chunk| size.checked_add(chunk.bytes.len())); + if !result.transport_bounds_valid() + || size.is_none_or(|size| { + size > request.limit + || request.cursor.checked_add(size as u64) + != Some(result.next_cursor) + }) + { + return Err(Self::mismatch("bounded command byte page")); + } + if wait && result.chunks.is_empty() && result.terminal.is_none() { + // A waiting request must block at the provider. Never spin HTTP + // requests against an immediate empty response. A caller may retry + // this exact cursor with its own bounded transport retry policy. + return Err(Self::mismatch("waiting command byte page")); + } + return Ok(result); } + _ => return Err(Self::mismatch("command_output")), } } @@ -942,20 +965,47 @@ mod client { response: reqwest::Response, ) -> Result { if response.status().is_success() { - response.json().await.map_err(http_unavailable) + let bytes = bounded_response_bytes(response).await?; + serde_json::from_slice(&bytes) + .map_err(|_| WorkdirError::Transport("Runtime Workdir response was invalid".into())) } else { Err(decode_error(response).await) } } + async fn bounded_response_bytes( + mut response: reqwest::Response, + ) -> Result, WorkdirError> { + const MAX_RESPONSE_BYTES: usize = crate::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES; + if response + .content_length() + .is_some_and(|size| size > MAX_RESPONSE_BYTES as u64) + { + return Err(WorkdirError::Transport( + "Runtime Workdir response exceeded transport bound".into(), + )); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(http_unavailable)? { + if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(bytes.len()) { + return Err(WorkdirError::Transport( + "Runtime Workdir response exceeded transport bound".into(), + )); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + async fn decode_error(response: reqwest::Response) -> WorkdirError { - response - .json::() - .await - .map(WorkdirTransportError::into_workdir_error) - .unwrap_or_else(|_| { - WorkdirError::Transport("Runtime Workdir error response was invalid".to_string()) - }) + match bounded_response_bytes(response).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(WorkdirTransportError::into_workdir_error) + .unwrap_or_else(|_| { + WorkdirError::Transport("Runtime Workdir error response was invalid".into()) + }), + Err(error) => error, + } } fn http_unavailable(_error: reqwest::Error) -> WorkdirError { diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index de9e7f8e5..7f052d5be 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -618,3 +618,6 @@ mod capability_tests { assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63); } } + +#[cfg(test)] +mod command_test_support; diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 813056001..c84970b0f 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -13,7 +13,8 @@ use std::collections::{BTreeMap, HashMap}; use std::fmt::Debug; -use std::io::{Read as _, Seek as _, SeekFrom, Write as _}; +#[cfg(test)] +use std::io::Write as _; #[cfg(unix)] use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; @@ -54,15 +55,8 @@ fn command_observed_at_ms() -> u64 { .unwrap_or(u64::MAX) } -#[derive(Debug)] -enum LocalCommand { - Running { - task: JoinHandle>, - completion: watch::Receiver, - cancel: watch::Sender, - }, - Completed(CommandOutput), -} +mod command_transport; +use command_transport::LocalCommand; #[derive(Debug, Clone)] struct CommandTelemetry { @@ -643,7 +637,6 @@ struct LocalWorkdirSessionInner { root: PathBuf, pinned_root: Option>, scope: SharedScope, - command_output_scope: SharedScope, cwd: PathBuf, capabilities: WorkdirSessionCapabilities, read_limits: Option, @@ -662,9 +655,7 @@ impl Drop for LocalWorkdirSessionInner { fn drop(&mut self) { if let Ok(mut commands) = self.commands.try_lock() { for (_, command) in commands.drain() { - if let LocalCommand::Running { task, .. } = command { - task.abort(); - } + command.task.abort(); } } } @@ -864,37 +855,12 @@ impl LocalWorkdirSession { capabilities: WorkdirSessionCapabilities, command_environment: BTreeMap, resources: Vec>, - ) -> Self { - Self::materialized_bound_with_environment_and_command_output_scope( - workdir, - root, - cwd, - scope.clone(), - scope, - capabilities, - command_environment, - resources, - ) - } - - /// Construct a local provider session whose Workdir filesystem authority is - /// independent from the provider-owned command output authority. - pub fn materialized_bound_with_environment_and_command_output_scope( - workdir: Workdir, - root: PathBuf, - cwd: PathBuf, - scope: SharedScope, - command_output_scope: SharedScope, - capabilities: WorkdirSessionCapabilities, - command_environment: BTreeMap, - resources: Vec>, ) -> Self { Self::materialized_bound_with_policy( workdir, root, cwd, scope, - command_output_scope, capabilities, command_environment, resources, @@ -1024,7 +990,6 @@ impl LocalWorkdirSession { workdir, root.clone(), root, - scope.clone(), scope, capabilities, BTreeMap::new(), @@ -1052,7 +1017,6 @@ impl LocalWorkdirSession { root: PathBuf, cwd: PathBuf, scope: SharedScope, - command_output_scope: SharedScope, capabilities: WorkdirSessionCapabilities, command_environment: BTreeMap, resources: Vec>, @@ -1066,7 +1030,6 @@ impl LocalWorkdirSession { root, pinned_root, scope, - command_output_scope, cwd, capabilities, read_limits, @@ -1845,20 +1808,12 @@ impl WorkdirSession for LocalWorkdirSession { } async fn start_command(&self, request: CommandRequest) -> Result { + request.validate()?; self.ensure_capability(WorkdirSessionCapability::Command)?; // Command admission, resource capture and registration must be atomic // with terminal close, which drains commands and releases resources. let _admission = self.inner.close_lock.lock().await; self.ensure_open()?; - if let Some(spill_dir) = request.spill_dir.as_deref() - && !self - .inner - .command_output_scope - .snapshot() - .is_readable(spill_dir) - { - return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf())); - } let cwd = self.resolve(&request.cwd); let scope = self.inner.scope.snapshot(); if !scope.is_readable(&cwd) @@ -1868,48 +1823,45 @@ impl WorkdirSession for LocalWorkdirSession { } let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed); let handle = CommandHandle(format!("command-{id}")); - let (completion_tx, completion) = watch::channel(false); - let command_id = handle.0.clone(); - let telemetry = self.inner.command_telemetry.clone(); - let command_environment = self.inner.command_environment.clone(); - let (cancel, cancel_rx) = watch::channel(false); - // A session drop only schedules command cancellation. Keep its Runtime - // resources (including cleanup exclusion) alive until the task actually - // finishes or its future is dropped, not just until the handle disappears. + let mut commands = self.inner.commands.lock().await; + // Retain bounded retry tombstones, evicting only fully delivered output. + if commands.len() >= command_transport::MAX_COMMANDS { + let mut evict = None; + for (id, command) in commands.iter() { + if command.task.is_finished() + && command + .transport + .try_lock() + .is_ok_and(|state| state.terminal_delivered()) + { + evict = Some(id.clone()); + break; + } + } + if let Some(id) = evict { + commands.remove(&id); + self.inner.command_telemetry.remove(&id); + } else { + return Err(WorkdirError::Unavailable( + "command transport capacity reached; drain existing commands".into(), + )); + } + } let resources = self .inner .resources .lock() .unwrap_or_else(|error| error.into_inner()) .clone(); - let task = tokio::spawn(async move { - let output = run_command( - cwd, - request, - command_id, - telemetry, - command_environment, - cancel_rx, - resources, - ) - .await; - let _ = completion_tx.send(true); - output - }); - let mut commands = self.inner.commands.lock().await; - if let Err(error) = self.ensure_open() { - let _ = cancel.send(true); - task.abort(); - return Err(error); - } - commands.insert( + let command = command_transport::start( + cwd, + request, handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, + self.inner.command_telemetry.clone(), + self.inner.command_environment.clone(), + resources, ); + commands.insert(handle.0.clone(), command); Ok(handle) } @@ -1919,17 +1871,11 @@ impl WorkdirSession for LocalWorkdirSession { let command = commands .get(&handle.0) .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; - Ok(match command { - LocalCommand::Running { task, .. } if !task.is_finished() => CommandStatus::Running, - LocalCommand::Running { .. } => self - .inner - .command_telemetry - .snapshot() - .into_iter() - .find(|snapshot| snapshot.command_id == handle.0) - .map_or(CommandStatus::Completed, |snapshot| snapshot.status), - LocalCommand::Completed(output) => output.status, - }) + Ok(command + .status + .borrow() + .as_ref() + .map_or(CommandStatus::Running, |terminal| terminal.status)) } async fn command_output( @@ -1937,70 +1883,22 @@ impl WorkdirSession for LocalWorkdirSession { request: CommandOutputRequest, ) -> Result { self.ensure_capability(WorkdirSessionCapability::Command)?; - let command = loop { - self.ensure_open()?; - let mut commands = self.inner.commands.lock().await; - let Some(command) = commands.get(&request.handle.0) else { - return Err(WorkdirError::UnknownCommand(request.handle.0.clone())); - }; - let completion = match command { - LocalCommand::Running { completion, .. } - if !*completion.borrow() && completion.has_changed().is_ok() => - { - Some(completion.clone()) - } - _ => None, - }; - if let Some(mut completion) = completion { - if !request.wait { - return Ok(CommandOutput { - status: CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, - }); - } - drop(commands); - let _ = completion.changed().await; - continue; - } - if !request.wait - && matches!(command, LocalCommand::Running { task, .. } if !task.is_finished()) - { - return Ok(CommandOutput { - status: CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, - }); - } - break commands - .remove(&request.handle.0) - .expect("command checked above"); - }; - - let output = match command { - LocalCommand::Running { task, .. } => task - .await - .map_err(|error| WorkdirError::Unavailable(error.to_string()))??, - LocalCommand::Completed(output) => output, - }; - let page = command_output_page(&output, request.cursor, request.limit); - if page.next_cursor.is_some() { - let mut commands = self.inner.commands.lock().await; - if !self.inner.closed.load(Ordering::Acquire) { - commands.insert(request.handle.0, LocalCommand::Completed(output)); - } - } else { + self.ensure_open()?; + let transport = self + .inner + .commands + .lock() + .await + .get(&request.handle.0) + .ok_or_else(|| WorkdirError::UnknownCommand(request.handle.0.clone()))? + .transport + .clone(); + let result = transport.lock().await.read(&request).await; + self.ensure_open()?; + if result.as_ref().is_ok_and(|page| page.terminal.is_some()) { self.inner.command_telemetry.remove(&request.handle.0); } - Ok(page) + result } async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { @@ -2010,12 +1908,7 @@ impl WorkdirSession for LocalWorkdirSession { let command = commands .get(&handle.0) .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; - match command { - LocalCommand::Running { task, cancel, .. } if !task.is_finished() => { - Some(cancel.clone()) - } - _ => None, - } + Some(command.cancel.clone()) }; if let Some(cancel) = cancel { let _ = cancel.send(true); @@ -2055,13 +1948,9 @@ impl WorkdirSession for LocalWorkdirSession { .collect::>() }; for command in commands { - match command { - LocalCommand::Running { task, cancel, .. } => { - let _ = cancel.send(true); - let _ = task.await; - } - LocalCommand::Completed(_) => {} - } + let _ = command.cancel.send(true); + command.transport.lock().await.close(); + let _ = command.task.await; } if let Ok(mut resources) = self.inner.resources.lock() { resources.clear(); @@ -2070,27 +1959,6 @@ impl WorkdirSession for LocalWorkdirSession { } } -fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> CommandOutput { - let total_chars = output.content.chars().count(); - let start = cursor.min(total_chars); - let content = output - .content - .chars() - .skip(start) - .take(limit.max(1)) - .collect::(); - let end = start + content.chars().count(); - CommandOutput { - status: output.status, - exit_code: output.exit_code, - timed_out: output.timed_out, - content, - next_cursor: (end < total_chars).then_some(end), - truncated: output.truncated || end < total_chars, - output_path: output.output_path.clone(), - } -} - // Discovery of an absent route is not a stale-validator execution attempt. fn checkout_observe_io(path: &WorkdirPath, error: std::io::Error) -> WorkdirError { if matches!( @@ -2216,167 +2084,6 @@ fn terminate_process_group(process_group_id: u32) { #[cfg(not(unix))] fn terminate_process_group(_process_group_id: u32) {} -async fn run_command( - cwd: PathBuf, - request: CommandRequest, - command_id: String, - telemetry: CommandTelemetry, - command_environment: BTreeMap, - mut cancel: watch::Receiver, - resources: Vec>, -) -> Result { - let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; - let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; - let stdout_path = stdout.into_temp_path(); - let stderr_path = stderr.into_temp_path(); - let stdout_file = std::fs::File::create(&stdout_path) - .map_err(|error| WorkdirError::io(&stdout_path, error))?; - let stderr_file = std::fs::File::create(&stderr_path) - .map_err(|error| WorkdirError::io(&stderr_path, error))?; - - telemetry.started(&command_id, request.tool_call_id.clone()); - let mut command = Command::new("bash"); - command - .arg("-c") - .arg(&request.command) - .current_dir(&cwd) - .envs(command_environment) - .stdin(Stdio::null()) - .stdout(Stdio::from(stdout_file)) - .stderr(Stdio::from(stderr_file)) - .kill_on_drop(true); - #[cfg(unix)] - command.as_std_mut().process_group(0); - let mut child = match command.spawn() { - Ok(child) => child, - Err(error) => { - telemetry.terminal(&command_id, CommandStatus::Failed, None); - return Err(WorkdirError::io(&cwd, error)); - } - }; - let mut process_group = CommandProcessGroupGuard::new(child.id()); - - let mut stdout_reader = - std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?; - let mut stderr_reader = - std::fs::File::open(&stderr_path).map_err(|error| WorkdirError::io(&stderr_path, error))?; - let mut stdout_decoder = CommandOutputDecoder::default(); - let mut stderr_decoder = CommandOutputDecoder::default(); - let mut timeout = Box::pin(tokio::time::sleep(Duration::from_secs( - request.timeout_secs.max(1), - ))); - let mut interval = tokio::time::interval(Duration::from_millis(50)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - interval.tick().await; - - let (status, exit_code) = loop { - tokio::select! { - exit = child.wait() => { - let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?; - process_group.terminate(); - break ( - if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, - exit.code(), - ); - } - _ = &mut timeout => { - process_group.terminate(); - let _ = child.start_kill(); - let exit_code = child.wait().await.ok().and_then(|status| status.code()); - break (CommandStatus::TimedOut, exit_code); - } - changed = cancel.changed() => { - if changed.is_err() || *cancel.borrow() { - process_group.terminate(); - let _ = child.start_kill(); - let exit_code = child.wait().await.ok().and_then(|status| status.code()); - break (CommandStatus::Cancelled, exit_code); - } - } - _ = interval.tick() => { - publish_available_output( - &mut stdout_reader, - &mut stdout_decoder, - &telemetry, - &command_id, - CommandStream::Stdout, - &stdout_path, - false, - )?; - publish_available_output( - &mut stderr_reader, - &mut stderr_decoder, - &telemetry, - &command_id, - CommandStream::Stderr, - &stderr_path, - false, - )?; - } - } - }; - - publish_available_output( - &mut stdout_reader, - &mut stdout_decoder, - &telemetry, - &command_id, - CommandStream::Stdout, - &stdout_path, - true, - )?; - publish_available_output( - &mut stderr_reader, - &mut stderr_decoder, - &telemetry, - &command_id, - CommandStream::Stderr, - &stderr_path, - true, - )?; - telemetry.terminal(&command_id, status, exit_code); - - let (content, truncated) = - read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?; - let output_path = match (truncated, request.spill_dir) { - (true, Some(spill_dir)) => { - let stdout_path = stdout_path.to_path_buf(); - let stderr_path = stderr_path.to_path_buf(); - Some( - spawn_resource_owned_blocking(resources.clone(), move || { - persist_command_output(&stdout_path, &stderr_path, &spill_dir) - }) - .await - .map_err(|error| { - WorkdirError::Unavailable(format!("Bash output spill task failed: {error}")) - })??, - ) - } - _ => None, - }; - Ok(CommandOutput { - status, - exit_code, - timed_out: status == CommandStatus::TimedOut, - content, - next_cursor: None, - truncated, - output_path, - }) -} - -// Blocking spills cannot be aborted with their async waiter. Transfer resource -// ownership into the actual filesystem task so cleanup waits for its completion. -fn spawn_resource_owned_blocking( - resources: Vec>, - effect: impl FnOnce() -> T + Send + 'static, -) -> JoinHandle { - tokio::task::spawn_blocking(move || { - let _resources = resources; - effect() - }) -} - #[derive(Debug, Default)] struct CommandOutputDecoder { read_offset: u64, @@ -2384,38 +2091,6 @@ struct CommandOutputDecoder { pending: Vec, } -fn publish_available_output( - file: &mut std::fs::File, - decoder: &mut CommandOutputDecoder, - telemetry: &CommandTelemetry, - command_id: &str, - stream: CommandStream, - path: &Path, - flush: bool, -) -> Result<(), WorkdirError> { - file.seek(SeekFrom::Start(decoder.read_offset)) - .map_err(|error| WorkdirError::io(path, error))?; - loop { - let mut buffer = vec![0; COMMAND_EVENT_CHUNK_BYTES]; - let read = file - .read(&mut buffer) - .map_err(|error| WorkdirError::io(path, error))?; - if read == 0 { - publish_decoded_output(decoder, telemetry, command_id, stream, flush); - return Ok(()); - } - decoder.pending.extend_from_slice(&buffer[..read]); - decoder.read_offset = decoder.read_offset.saturating_add(read as u64); - publish_decoded_output(decoder, telemetry, command_id, stream, false); - if read < COMMAND_EVENT_CHUNK_BYTES { - if flush { - publish_decoded_output(decoder, telemetry, command_id, stream, true); - } - return Ok(()); - } - } -} - fn publish_decoded_output( decoder: &mut CommandOutputDecoder, telemetry: &CommandTelemetry, @@ -2442,7 +2117,7 @@ fn publish_decoded_output( } /// Return the byte prefix that can be decoded now without replacing a valid -/// UTF-8 scalar whose remaining bytes may arrive in a later file read. Definite +/// UTF-8 scalar whose remaining bytes may arrive in a later pipe read. Definite /// invalid sequences remain in the prefix and are rendered lossily, preserving /// the existing arbitrary-byte output behavior. fn stable_utf8_prefix_len(bytes: &[u8]) -> usize { @@ -2462,110 +2137,6 @@ fn stable_utf8_prefix_len(bytes: &[u8]) -> usize { inspected } -fn persist_command_output( - stdout_path: &Path, - stderr_path: &Path, - spill_dir: &Path, -) -> Result { - std::fs::create_dir_all(spill_dir).map_err(|error| WorkdirError::io(spill_dir, error))?; - let mut artifact = tempfile::Builder::new() - .prefix("bash-") - .suffix(".log") - .tempfile_in(spill_dir) - .map_err(|error| WorkdirError::io(spill_dir, error))?; - let artifact_path = artifact.path().to_path_buf(); - - let mut stdout = - std::fs::File::open(stdout_path).map_err(|error| WorkdirError::io(stdout_path, error))?; - let stdout_len = stdout - .metadata() - .map_err(|error| WorkdirError::io(stdout_path, error))? - .len(); - std::io::copy(&mut stdout, &mut artifact) - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - - let mut stderr = - std::fs::File::open(stderr_path).map_err(|error| WorkdirError::io(stderr_path, error))?; - let stderr_len = stderr - .metadata() - .map_err(|error| WorkdirError::io(stderr_path, error))? - .len(); - if stdout_len > 0 && stderr_len > 0 { - stdout - .seek(SeekFrom::End(-1)) - .map_err(|error| WorkdirError::io(stdout_path, error))?; - let mut last = [0_u8; 1]; - stdout - .read_exact(&mut last) - .map_err(|error| WorkdirError::io(stdout_path, error))?; - if last[0] != b'\n' { - artifact - .write_all(b"\n") - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - } - } - std::io::copy(&mut stderr, &mut artifact) - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - artifact - .flush() - .map_err(|error| WorkdirError::io(&artifact_path, error))?; - artifact - .keep() - .map(|(_, path)| path) - .map_err(|error| WorkdirError::io(&artifact_path, error.error)) -} - -fn read_command_output_files( - stdout_path: &Path, - stderr_path: &Path, - limit: usize, -) -> Result<(String, bool), WorkdirError> { - let stdout_size = std::fs::metadata(stdout_path) - .map_err(|error| WorkdirError::io(stdout_path, error))? - .len() as usize; - let stderr_size = std::fs::metadata(stderr_path) - .map_err(|error| WorkdirError::io(stderr_path, error))? - .len() as usize; - let total = stdout_size.saturating_add(stderr_size); - let stderr_budget = stderr_size.min(limit / 2); - let stdout_budget = stdout_size.min(limit.saturating_sub(stderr_budget)); - let remaining = limit.saturating_sub(stdout_budget + stderr_budget); - let stdout_budget = (stdout_budget + remaining.min(stdout_size - stdout_budget)).min(limit); - let stderr_budget = limit.saturating_sub(stdout_budget).min(stderr_size); - - let stdout = read_tail(stdout_path, stdout_budget)?; - let stderr = read_tail(stderr_path, stderr_budget)?; - let mut content = String::new(); - if !stdout.is_empty() { - content.push_str(&String::from_utf8_lossy(&stdout)); - } - if !stderr.is_empty() { - if !content.is_empty() && !content.ends_with('\n') { - content.push('\n'); - } - content.push_str(&String::from_utf8_lossy(&stderr)); - } - Ok((content, total > limit)) -} - -fn read_tail(path: &Path, limit: usize) -> Result, WorkdirError> { - if limit == 0 { - return Ok(Vec::new()); - } - let mut file = std::fs::File::open(path).map_err(|error| WorkdirError::io(path, error))?; - let len = file - .metadata() - .map_err(|error| WorkdirError::io(path, error))? - .len(); - let start = len.saturating_sub(limit as u64); - file.seek(SeekFrom::Start(start)) - .map_err(|error| WorkdirError::io(path, error))?; - let mut bytes = Vec::with_capacity((len - start) as usize); - file.read_to_end(&mut bytes) - .map_err(|error| WorkdirError::io(path, error))?; - Ok(bytes) -} - /// Return the first symlink component in `path`, if one exists. /// /// The function only inspects existing path components. It intentionally uses @@ -2942,11 +2513,12 @@ mod tests { let command = WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_owned(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -2955,7 +2527,7 @@ mod tests { let waiting_session = session.clone(); let waiting_command = command.clone(); let waiter = tokio::spawn(async move { - WorkdirSession::command_output( + crate::command_test_support::CollectCommand::collect_command_output( &waiting_session, CommandOutputRequest { handle: waiting_command, @@ -3640,17 +3212,18 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd && printf provider-command".into(), timeout_secs: 5, - output_limit: 4096, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -3742,11 +3315,12 @@ mod tests { let error = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf hidden".into(), timeout_secs: 5, - output_limit: 1024, + cwd, - spill_dir: None, + tool_call_id: None, }, ) @@ -3756,319 +3330,6 @@ mod tests { assert!(matches!(error, WorkdirError::OutOfScope(path) if path == outside.path())); } - #[tokio::test] - async fn command_rejects_spill_directory_without_read_scope() { - let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - - let error = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "printf hidden".into(), - timeout_secs: 5, - output_limit: 1, - cwd: WorkdirPath::root(), - spill_dir: Some(spill.path().to_path_buf()), - tool_call_id: None, - }, - ) - .await - .unwrap_err(); - - assert!(matches!(error, WorkdirError::OutOfScope(path) if path == spill.path())); - } - - #[tokio::test] - async fn truncated_command_output_is_retained_in_the_requested_spill_directory() { - let dir = TempDir::new().unwrap(); - let spill = TempDir::new().unwrap(); - let scope = Scope::from_config(&ScopeConfig { - allow: vec![ - ScopeRule { - target: dir.path().to_path_buf(), - permission: Permission::Write, - recursive: true, - symlink_policy: Default::default(), - }, - ScopeRule { - target: spill.path().to_path_buf(), - permission: Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }, - ], - deny: Vec::new(), - }) - .unwrap(); - let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf()); - let handle = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(), - timeout_secs: 5, - output_limit: 64, - cwd: WorkdirPath::root(), - spill_dir: Some(spill.path().to_path_buf()), - tool_call_id: None, - }, - ) - .await - .unwrap(); - let output = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle, - cursor: 0, - limit: 4096, - wait: true, - }, - ) - .await - .unwrap(); - - assert!(output.truncated); - let output_path = output.output_path.expect("retained output path"); - assert_eq!(output_path.parent(), Some(spill.path())); - let retained = std::fs::read_to_string(&output_path).unwrap(); - assert!(retained.starts_with("line-000\n")); - assert!(retained.ends_with("FINAL-NEEDLE\n")); - assert_eq!(retained.lines().count(), 201); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - assert_eq!( - std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } - } - - #[tokio::test] - async fn completed_command_output_can_be_read_in_bounded_unicode_pages() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = WorkdirSession::start_command( - &workdir, - CommandRequest { - command: "printf 'aéz'".into(), - timeout_secs: 5, - output_limit: 1024, - cwd: WorkdirPath::root(), - spill_dir: None, - tool_call_id: None, - }, - ) - .await - .unwrap(); - let first = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle: handle.clone(), - cursor: 0, - limit: 2, - wait: true, - }, - ) - .await - .unwrap(); - assert_eq!(first.content, "aé"); - assert_eq!(first.next_cursor, Some(2)); - - let second = WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle: handle.clone(), - cursor: first.next_cursor.unwrap(), - limit: 2, - wait: false, - }, - ) - .await - .unwrap(); - assert_eq!(second.content, "z"); - assert_eq!(second.next_cursor, None); - assert!(matches!( - WorkdirSession::command_status(&workdir, handle).await, - Err(WorkdirError::UnknownCommand(_)) - )); - } - - #[test] - fn command_output_decoder_preserves_utf8_split_across_file_reads() { - let dir = TempDir::new().unwrap(); - let path = dir.path().join("command.out"); - let mut first_write = vec![b'a'; COMMAND_EVENT_CHUNK_BYTES - 1]; - first_write.push(0xe2); - std::fs::write(&path, first_write).unwrap(); - - let telemetry = CommandTelemetry::new(); - let mut events = telemetry.subscribe(); - telemetry.started("command-utf8", None); - let mut decoder = CommandOutputDecoder::default(); - let mut reader = std::fs::File::open(&path).unwrap(); - publish_available_output( - &mut reader, - &mut decoder, - &telemetry, - "command-utf8", - CommandStream::Stdout, - &path, - false, - ) - .unwrap(); - assert_eq!(decoder.pending, vec![0xe2]); - - let mut writer = std::fs::OpenOptions::new() - .append(true) - .open(&path) - .unwrap(); - writer.write_all(&[0x82, 0xac]).unwrap(); - writer.flush().unwrap(); - publish_available_output( - &mut reader, - &mut decoder, - &telemetry, - "command-utf8", - CommandStream::Stdout, - &path, - false, - ) - .unwrap(); - - let output = std::iter::from_fn(|| events.try_recv().ok()) - .filter_map(|event| match event { - CommandEvent::Output { - stream: CommandStream::Stdout, - content, - .. - } => Some(content), - _ => None, - }) - .collect::(); - assert_eq!(output.len(), COMMAND_EVENT_CHUNK_BYTES - 1 + "€".len()); - assert!(output.ends_with('€')); - assert!(!output.contains('\u{fffd}')); - assert!(decoder.pending.is_empty()); - } - - #[tokio::test] - async fn command_output_does_not_rewait_before_join_handle_finishes() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = CommandHandle("command-completion-race".into()); - let (completion_tx, completion) = watch::channel(false); - let completion_observer = completion_tx.clone(); - let (cancel, _cancel_rx) = watch::channel(false); - let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>(); - let (completion_sent_tx, completion_sent_rx) = tokio::sync::oneshot::channel::<()>(); - let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); - let task = tokio::spawn(async move { - start_rx.await.unwrap(); - completion_tx.send(true).unwrap(); - completion_sent_tx.send(()).unwrap(); - release_rx.await.unwrap(); - Ok(CommandOutput { - status: CommandStatus::Completed, - exit_code: Some(0), - timed_out: false, - content: "done".into(), - next_cursor: None, - truncated: false, - output_path: None, - }) - }); - workdir.inner.commands.lock().await.insert( - handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, - ); - - let waiting_workdir = workdir.clone(); - let waiting_handle = handle.clone(); - let waiter = tokio::spawn(async move { - WorkdirSession::command_output( - &waiting_workdir, - CommandOutputRequest { - handle: waiting_handle, - cursor: 0, - limit: 1024, - wait: true, - }, - ) - .await - }); - for _ in 0..100 { - if completion_observer.receiver_count() >= 2 { - break; - } - tokio::task::yield_now().await; - } - assert_eq!( - completion_observer.receiver_count(), - 2, - "waiter must subscribe before completion is published" - ); - - start_tx.send(()).unwrap(); - completion_sent_rx.await.unwrap(); - tokio::task::yield_now().await; - assert!( - !waiter.is_finished(), - "command_output should await the task after observing completion state" - ); - release_tx.send(()).unwrap(); - - let output = tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("command output must not wait for a second completion notification") - .unwrap() - .unwrap(); - assert_eq!(output.status, CommandStatus::Completed); - assert_eq!(output.content, "done"); - } - - #[tokio::test] - async fn command_output_does_not_wait_forever_when_completion_sender_drops() { - let dir = TempDir::new().unwrap(); - let workdir = make_fs(&dir); - let handle = CommandHandle("command-completion-drop".into()); - let (completion_tx, completion) = watch::channel(false); - let (cancel, _cancel_rx) = watch::channel(false); - let task: JoinHandle> = tokio::spawn(async move { - drop(completion_tx); - panic!("simulated command task panic"); - }); - workdir.inner.commands.lock().await.insert( - handle.0.clone(), - LocalCommand::Running { - task, - completion, - cancel, - }, - ); - - let result = tokio::time::timeout( - Duration::from_secs(1), - WorkdirSession::command_output( - &workdir, - CommandOutputRequest { - handle, - cursor: 0, - limit: 1024, - wait: true, - }, - ), - ) - .await - .expect("closed completion channel must wake the waiter"); - assert!(matches!(result, Err(WorkdirError::Unavailable(_)))); - } - #[tokio::test] async fn provider_streams_bounded_command_lifecycle_and_distinct_output() { let dir = TempDir::new().unwrap(); @@ -4078,11 +3339,12 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: Some("tool-7".into()), }, ) @@ -4160,7 +3422,7 @@ mod tests { assert_eq!(snapshot[0].stdout.content, "readydone"); assert_eq!(snapshot[0].stderr.content, "warning"); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -4183,17 +3445,18 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 1, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle: handle.clone(), @@ -4241,11 +3504,12 @@ mod tests { WorkdirSession::start_command( start_session.as_ref(), CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "true".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4275,50 +3539,6 @@ mod tests { ); } - #[tokio::test] - async fn blocking_output_spill_retains_resources_after_waiter_is_cancelled() { - #[derive(Debug)] - struct Resource(Option>); - impl Drop for Resource { - fn drop(&mut self) { - let _ = self.0.take().unwrap().send(()); - } - } - let (released, mut release) = tokio::sync::oneshot::channel(); - let resources: Vec> = - vec![Arc::new(Resource(Some(released)))]; - let (entered, started) = tokio::sync::oneshot::channel(); - let (finish, wait) = std::sync::mpsc::channel(); - // Exercise the resource-transfer policy at the same blocking boundary - // used by output persistence, without filesystem timing assumptions. - let waiter = tokio::spawn(async move { - spawn_resource_owned_blocking(resources, move || { - entered.send(()).unwrap(); - wait.recv_timeout(Duration::from_secs(5)).unwrap(); - }) - .await - .unwrap(); - }); - tokio::time::timeout(Duration::from_secs(5), started) - .await - .unwrap() - .unwrap(); - waiter.abort(); - assert!(waiter.await.unwrap_err().is_cancelled()); - assert!( - matches!( - release.try_recv(), - Err(tokio::sync::oneshot::error::TryRecvError::Empty) - ), - "blocking spill lost its resources when the waiter was cancelled" - ); - finish.send(()).unwrap(); - tokio::time::timeout(Duration::from_secs(5), release) - .await - .unwrap() - .unwrap(); - } - #[tokio::test(flavor = "current_thread")] async fn session_drop_retains_resources_until_command_task_cancellation_completes() { #[derive(Debug)] @@ -4342,11 +3562,12 @@ mod tests { WorkdirSession::start_command( &session, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "true".into(), timeout_secs: 5, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4401,12 +3622,13 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "(sleep 0.4; echo leaked > child-marker) & echo ready > child-ready; wait" .into(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4423,7 +3645,7 @@ mod tests { WorkdirSession::cancel_command(&workdir, handle.clone()) .await .unwrap(); - let output = WorkdirSession::command_output( + let output = crate::command_test_support::CollectCommand::collect_command_output( &workdir, CommandOutputRequest { handle, @@ -4449,11 +3671,12 @@ mod tests { let handle = WorkdirSession::start_command( &workdir, CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }, ) @@ -4468,7 +3691,7 @@ mod tests { let waiting_workdir = workdir.clone(); let waiting_handle = handle.clone(); let waiter = tokio::spawn(async move { - WorkdirSession::command_output( + crate::command_test_support::CollectCommand::collect_command_output( &waiting_workdir, CommandOutputRequest { handle: waiting_handle, @@ -4489,9 +3712,11 @@ mod tests { .unwrap() .unwrap(); assert_eq!(output.status, CommandStatus::Cancelled); - assert!(matches!( - WorkdirSession::command_status(&workdir, handle).await, - Err(WorkdirError::UnknownCommand(_)) - )); + assert_eq!( + WorkdirSession::command_status(&workdir, handle) + .await + .unwrap(), + CommandStatus::Cancelled + ); } } diff --git a/crates/workdir/src/local/command_transport.rs b/crates/workdir/src/local/command_transport.rs new file mode 100644 index 000000000..5b800c0ab --- /dev/null +++ b/crates/workdir/src/local/command_transport.rs @@ -0,0 +1,377 @@ +//! Captured process bytes use a bounded queue, never the lossy Console channel. +//! Pipe backpressure bounds unread process output without temporary-file growth. +//! A replay page survives a lost HTTP response until its next cursor is sent. + +use super::*; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::sync::mpsc; + +const QUEUE_CHUNKS: usize = 8; +pub(super) const MAX_COMMANDS: usize = 64; + +#[derive(Debug)] +enum Message { + Bytes(crate::CommandByteChunk), +} + +#[derive(Debug)] +pub(super) struct LocalCommand { + pub task: JoinHandle<()>, + pub cancel: watch::Sender, + pub status: watch::Receiver>, + pub transport: Arc>, +} + +#[derive(Debug)] +pub(super) struct Transport { + receiver: mpsc::Receiver, + pending: Option, + cursor: u64, + replay: Option<(u64, CommandOutput)>, + terminal: Option, + terminal_status: watch::Receiver>, +} + +impl Transport { + pub async fn read( + &mut self, + request: &CommandOutputRequest, + ) -> Result { + if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) { + return Err(WorkdirError::Conflict( + "command byte limit is outside transport bounds".into(), + )); + } + if let Some((cursor, page)) = &self.replay { + if request.cursor == *cursor { + if page + .chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + > request.limit + { + return Err(WorkdirError::Conflict( + "retry byte limit is smaller than retained page".into(), + )); + } + return Ok(page.clone()); + } + if request.cursor != page.next_cursor { + return Err(WorkdirError::Conflict( + "command cursor is not the current page acknowledgement".into(), + )); + } + self.cursor = page.next_cursor; + self.replay = None; + } + if request.cursor != self.cursor { + return Err(WorkdirError::Conflict( + "command cursor was acknowledged or is ahead of output".into(), + )); + } + let mut page = CommandOutput { + chunks: Vec::new(), + next_cursor: self.cursor, + terminal: self.terminal.clone(), + }; + let mut remaining = request.limit; + loop { + let message = if let Some(chunk) = self.pending.take() { + Some(Message::Bytes(chunk)) + } else if page.chunks.is_empty() && page.terminal.is_none() && request.wait { + self.receiver.recv().await + } else { + match self.receiver.try_recv() { + Ok(message) => Some(message), + Err(mpsc::error::TryRecvError::Empty) => break, + Err(mpsc::error::TryRecvError::Disconnected) => None, + } + }; + match message { + Some(Message::Bytes(mut chunk)) => { + let count = remaining.min(chunk.bytes.len()); + if count < chunk.bytes.len() { + let tail = chunk.bytes.split_off(count); + self.pending = Some(crate::CommandByteChunk { + sequence: chunk.sequence + count as u64, + stream: chunk.stream, + offset: chunk.offset + count as u64, + bytes: tail, + }); + } + page.next_cursor += count as u64; + remaining -= count; + page.chunks.push(chunk); + if remaining == 0 { + break; + } + } + None if page.terminal.is_some() => break, + None => { + let terminal = self.terminal_status.borrow().clone().ok_or_else(|| { + WorkdirError::Unavailable( + "command transport ended before terminal fence".into(), + ) + })?; + self.terminal = Some(terminal.clone()); + page.terminal = Some(terminal); + break; + } + } + } + // An empty poll must not become a permanently replayed empty page. + if !page.chunks.is_empty() || page.terminal.is_some() { + self.replay = Some((self.cursor, page.clone())); + } + Ok(page) + } + + pub fn terminal_delivered(&self) -> bool { + self.replay + .as_ref() + .is_some_and(|(_, page)| page.terminal.is_some()) + } + + pub fn close(&mut self) { + self.receiver.close(); + } +} + +pub(super) fn start( + cwd: PathBuf, + request: CommandRequest, + command_id: String, + telemetry: CommandTelemetry, + environment: BTreeMap, + resources: Vec>, +) -> LocalCommand { + let (sender, receiver) = mpsc::channel(QUEUE_CHUNKS); + let (cancel, cancel_rx) = watch::channel(false); + let (status_tx, status) = watch::channel(None); + let transport = Arc::new(Mutex::new(Transport { + receiver, + pending: None, + cursor: 0, + replay: None, + terminal: None, + terminal_status: status.clone(), + })); + let task = tokio::spawn(async move { + let _resources = resources; + let terminal = execute( + cwd, + request, + &command_id, + &telemetry, + environment, + cancel_rx, + &sender, + ) + .await; + telemetry.terminal(&command_id, terminal.status, terminal.exit_code); + // Closing the byte queue fences this already-published terminal, without + // requiring spare queue capacity or a receiver to release task resources. + let _ = status_tx.send(Some(terminal)); + }); + LocalCommand { + task, + cancel, + status, + transport, + } +} + +#[derive(Default)] +struct ByteAccounting { + stdout: u64, + stderr: u64, + stdout_delivered: u64, + stderr_delivered: u64, +} + +impl ByteAccounting { + fn terminal(&self, status: CommandStatus, exit_code: Option) -> crate::CommandTerminal { + crate::CommandTerminal { + status, + exit_code, + stdout_bytes: self.stdout, + stderr_bytes: self.stderr, + stdout_delivered_bytes: self.stdout_delivered, + stderr_delivered_bytes: self.stderr_delivered, + truncation_reason: (self.stdout > self.stdout_delivered + || self.stderr > self.stderr_delivered) + .then_some(crate::CommandOutputTruncation::ByteBudget), + } + } +} + +async fn pump( + mut stdout: R, + mut stderr: impl AsyncRead + Unpin, + sender: &mpsc::Sender, + telemetry: &CommandTelemetry, + command_id: &str, + mut remaining: u64, + accounting: &mut ByteAccounting, +) -> std::io::Result<()> { + let mut stdout_done = false; + let mut stderr_done = false; + let mut stdout_decoder = CommandOutputDecoder::default(); + let mut stderr_decoder = CommandOutputDecoder::default(); + let mut sequence = 0; + let mut stdout_buffer = [0; COMMAND_EVENT_CHUNK_BYTES]; + let mut stderr_buffer = [0; COMMAND_EVENT_CHUNK_BYTES]; + while !stdout_done || !stderr_done { + // After quota, drain locally even if nobody acknowledges the queue. + // Before quota, reserve first to retain bounded pipe backpressure. + let permit = if remaining > 0 { + let Ok(permit) = sender.reserve().await else { + return Ok(()); + }; + Some(permit) + } else { + None + }; + let (stream, result) = tokio::select! { + result = stdout.read(&mut stdout_buffer), if !stdout_done => (CommandStream::Stdout, result), + result = stderr.read(&mut stderr_buffer), if !stderr_done => (CommandStream::Stderr, result), + }; + let buffer = match stream { + CommandStream::Stdout => &stdout_buffer, + CommandStream::Stderr => &stderr_buffer, + }; + let count = result?; + let decoder = match stream { + CommandStream::Stdout => &mut stdout_decoder, + CommandStream::Stderr => &mut stderr_decoder, + }; + if count == 0 { + match stream { + CommandStream::Stdout => stdout_done = true, + CommandStream::Stderr => stderr_done = true, + } + publish_decoded_output(decoder, telemetry, command_id, stream, true); + continue; + } + let (seen, delivered) = match stream { + CommandStream::Stdout => (&mut accounting.stdout, &mut accounting.stdout_delivered), + CommandStream::Stderr => (&mut accounting.stderr, &mut accounting.stderr_delivered), + }; + *seen = seen.saturating_add(count as u64); + let captured = remaining.min(count as u64) as usize; + if captured > 0 { + let offset = *delivered; + decoder.read_offset += captured as u64; + decoder.pending.extend_from_slice(&buffer[..captured]); + publish_decoded_output(decoder, telemetry, command_id, stream, false); + permit + .expect("reserved while budget remains") + .send(Message::Bytes(crate::CommandByteChunk { + sequence, + stream, + offset, + bytes: buffer[..captured].to_vec(), + })); + *delivered += captured as u64; + sequence += captured as u64; + remaining -= captured as u64; + } + } + Ok(()) +} + +async fn execute( + cwd: PathBuf, + request: CommandRequest, + command_id: &str, + telemetry: &CommandTelemetry, + environment: BTreeMap, + mut cancel: watch::Receiver, + sender: &mpsc::Sender, +) -> crate::CommandTerminal { + telemetry.started(command_id, request.tool_call_id); + let mut command = Command::new("bash"); + command + .arg("-c") + .arg(request.command) + .current_dir(cwd) + .envs(environment) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(unix)] + command.as_std_mut().process_group(0); + let mut accounting = ByteAccounting::default(); + let Ok(mut child) = command.spawn() else { + return accounting.terminal(CommandStatus::Failed, None); + }; + let mut process_group = CommandProcessGroupGuard::new(child.id()); + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); + let (status, exit_code) = { + let output = pump( + stdout, + stderr, + sender, + telemetry, + command_id, + request.transport_byte_budget, + &mut accounting, + ); + tokio::pin!(output); + let mut drained = false; + let timeout = tokio::time::sleep(Duration::from_secs(request.timeout_secs.max(1))); + tokio::pin!(timeout); + let terminal = loop { + tokio::select! { + result = &mut output, if !drained => { + drained = true; + if result.is_err() || sender.is_closed() { + process_group.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; + break (CommandStatus::Failed, None); + } + } + exit = child.wait() => { + process_group.terminate(); + break match exit { + Ok(exit) => (if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, exit.code()), + Err(_) => (CommandStatus::Failed, None), + }; + } + _ = &mut timeout => { + process_group.terminate(); + let _ = child.start_kill(); + let code = child.wait().await.ok().and_then(|exit| exit.code()); + break (CommandStatus::TimedOut, code); + } + _ = sender.closed() => { + process_group.terminate(); + let _ = child.start_kill(); + let _ = child.wait().await; + break (CommandStatus::Cancelled, None); + } + changed = cancel.changed() => { + if changed.is_err() || *cancel.borrow() { + process_group.terminate(); + let _ = child.start_kill(); + let code = child.wait().await.ok().and_then(|exit| exit.code()); + break (CommandStatus::Cancelled, code); + } + } + } + }; + // Even after kill, preserve the bounded pipe tail before publishing terminal. + if !drained { + let _ = output.await; + } + terminal + }; + accounting.terminal(status, exit_code) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workdir/src/local/command_transport/tests.rs b/crates/workdir/src/local/command_transport/tests.rs new file mode 100644 index 000000000..796a1f93f --- /dev/null +++ b/crates/workdir/src/local/command_transport/tests.rs @@ -0,0 +1,490 @@ +//! Process/pipe integration through the production local provider. Temporary roots +//! and provider tasks are RAII-owned; byte pulls and bounded timeouts synchronize tests. + +use super::*; +use tempfile::TempDir; + +struct Harness { + session: LocalWorkdirSession, + _root: TempDir, +} + +impl Harness { + fn new() -> Self { + let root = TempDir::new().unwrap(); + let session = LocalWorkdirSession::new( + Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + ); + Self { + session, + _root: root, + } + } + + async fn start(&self, command: &str, timeout_secs: u64) -> CommandHandle { + self.start_budget(command, timeout_secs, crate::MAX_COMMAND_TRANSPORT_BYTES) + .await + } + + fn request(command: &str, timeout_secs: u64, budget: u64) -> CommandRequest { + CommandRequest { + transport_byte_budget: budget, + command: command.into(), + timeout_secs, + cwd: WorkdirPath::root(), + tool_call_id: None, + } + } + + async fn start_budget(&self, command: &str, timeout_secs: u64, budget: u64) -> CommandHandle { + self.session + .start_command(Self::request(command, timeout_secs, budget)) + .await + .unwrap() + } + + async fn page(&self, handle: &CommandHandle, cursor: u64, limit: usize) -> CommandOutput { + tokio::time::timeout( + Duration::from_secs(10), + self.session.command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit, + wait: true, + }), + ) + .await + .expect("bounded command pull") + .unwrap() + } + + async fn collect( + &self, + handle: &CommandHandle, + mut cursor: u64, + ) -> (Vec, crate::CommandTerminal) { + let mut chunks = Vec::new(); + loop { + let page = self + .page(handle, cursor, crate::MAX_COMMAND_READ_BYTES) + .await; + assert!( + page.chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= crate::MAX_COMMAND_READ_BYTES + ); + cursor = page.next_cursor; + chunks.extend(page.chunks); + assert!(chunks.iter().map(|chunk| chunk.bytes.len()).sum::() <= 8 * 1024 * 1024); + if let Some(terminal) = page.terminal { + return (chunks, terminal); + } + } + } +} + +fn stream_bytes(chunks: &[crate::CommandByteChunk], stream: CommandStream) -> Vec { + let mut bytes = Vec::new(); + for chunk in chunks.iter().filter(|chunk| chunk.stream == stream) { + assert_eq!(chunk.offset, bytes.len() as u64); + bytes.extend_from_slice(&chunk.bytes); + } + bytes +} + +#[tokio::test] +async fn high_single_line_output_survives_absent_and_lagged_console_subscribers() { + for lagged in [false, true] { + let harness = Harness::new(); + let _lagged_receiver = lagged.then(|| harness.session.subscribe_command_events().unwrap()); + let handle = harness.start("head -c 3145728 /dev/zero | tr '\\0' x; head -c 65536 /dev/zero | tr '\\0' e >&2; exit 7", 20).await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + assert_eq!(terminal.status, CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(7)); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + vec![b'x'; 3145728] + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + vec![b'e'; 65536] + ); + let mut cursor = 0; + for chunk in chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + } + harness.session.close().await.unwrap(); + } +} + +#[tokio::test] +async fn one_byte_pages_preserve_raw_utf8_invalid_bytes_and_replay_until_acknowledged() { + let harness = Harness::new(); + let handle = harness + .start( + "printf '\\342\\202\\254\\377\\000'; printf '\\346\\227\\245' >&2", + 5, + ) + .await; + let mut chunks = Vec::new(); + let mut cursor = 0; + loop { + let page = harness.page(&handle, cursor, 1).await; + assert_eq!(harness.page(&handle, cursor, 1).await, page); + cursor = page.next_cursor; + chunks.extend(page.chunks); + if let Some(terminal) = page.terminal { + assert_eq!(terminal.status, CommandStatus::Completed); + break; + } + } + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + b"\xe2\x82\xac\xff\0" + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + b"\xe6\x97\xa5" + ); + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1, + wait: false + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn cancellation_while_backpressured_drains_bytes_before_terminal_fence() { + let harness = Harness::new(); + let handle = harness.start("printf ready; exec yes x", 30).await; + let first = harness.page(&handle, 0, 1).await; + harness + .session + .cancel_command(handle.clone()) + .await + .unwrap(); + let (mut chunks, terminal) = harness.collect(&handle, first.next_cursor).await; + let mut all = first.chunks; + all.append(&mut chunks); + assert!(stream_bytes(&all, CommandStream::Stdout).starts_with(b"ready")); + assert_eq!(terminal.status, CommandStatus::Cancelled); + let final_page = harness + .page( + &handle, + all.iter().map(|chunk| chunk.bytes.len() as u64).sum(), + 1, + ) + .await; + assert!(final_page.chunks.is_empty()); + assert_eq!(final_page.terminal, Some(terminal)); +} + +#[tokio::test] +async fn timeout_and_session_close_finalize_without_an_output_subscriber() { + let harness = Harness::new(); + let handle = harness + .start("printf ready; exec tail -f /dev/null", 1) + .await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + assert_eq!(stream_bytes(&chunks, CommandStream::Stdout), b"ready"); + assert_eq!(terminal.status, CommandStatus::TimedOut); + let handle = harness.start("exec yes x", 30).await; + // Receiving one byte establishes actual process output, without a timing sleep. + harness.page(&handle, 0, 1).await; + tokio::time::timeout(Duration::from_secs(5), harness.session.close()) + .await + .unwrap() + .unwrap(); + assert!(harness.session.command_status(handle).await.is_err()); +} + +#[tokio::test] +async fn interleaved_streams_keep_independent_offsets_and_bounded_read_validation() { + let harness = Harness::new(); + let handle = harness + .start( + "for ((i=0;i<100;i++)); do printf 'out%s;' \"$i\"; printf 'err%s;' \"$i\" >&2; done", + 5, + ) + .await; + for limit in [0, crate::MAX_COMMAND_READ_BYTES + 1] { + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 0, + limit, + wait: false + }) + .await + .is_err() + ); + } + let (chunks, terminal) = harness.collect(&handle, 0).await; + let expected = |prefix| { + (0..100) + .map(|i| format!("{prefix}{i};")) + .collect::() + .into_bytes() + }; + assert_eq!( + stream_bytes(&chunks, CommandStream::Stdout), + expected("out") + ); + assert_eq!( + stream_bytes(&chunks, CommandStream::Stderr), + expected("err") + ); + assert_eq!(terminal.status, CommandStatus::Completed); +} + +#[tokio::test] +async fn byte_budget_zero_exact_partial_and_shared_keep_seen_and_delivered_distinct() { + let harness = Harness::new(); + for (command, budget, stdout_seen, stderr_seen, exit_code) in [ + ("printf abc; printf defg >&2", 0, 3, 4, 0), + ("printf abc", 3, 3, 0, 0), + ("printf abcdefg", 3, 7, 0, 0), + ("printf abcdefg; exit 7", 3, 7, 0, 7), + ( + "head -c 32768 /dev/zero; head -c 32768 /dev/zero >&2", + 12345, + 32768, + 32768, + 0, + ), + ] { + let handle = harness.start_budget(command, 5, budget).await; + let (chunks, terminal) = harness.collect(&handle, 0).await; + let stdout = stream_bytes(&chunks, CommandStream::Stdout); + let stderr = stream_bytes(&chunks, CommandStream::Stderr); + let mut cursor = 0; + for chunk in &chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + } + assert_eq!(cursor, budget.min(stdout_seen + stderr_seen)); + assert_eq!(terminal.stdout_bytes, stdout_seen); + assert_eq!(terminal.stderr_bytes, stderr_seen); + assert_eq!(terminal.stdout_delivered_bytes, stdout.len() as u64); + assert_eq!(terminal.stderr_delivered_bytes, stderr.len() as u64); + assert_eq!(terminal.exit_code, Some(exit_code)); + assert_eq!( + terminal.status, + if exit_code == 0 { + CommandStatus::Completed + } else { + CommandStatus::Failed + } + ); + assert_eq!( + terminal.truncation_reason, + (cursor < stdout_seen + stderr_seen) + .then_some(crate::CommandOutputTruncation::ByteBudget) + ); + } +} + +#[tokio::test] +async fn exhausted_budget_finishes_without_receiver_and_keeps_timeout_cancel_distinct() { + let harness = Harness::new(); + for budget in [0, 1, (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64] { + let handle = harness + .start_budget("head -c 1048576 /dev/zero; printf error >&2", 5, budget) + .await; + let mut status = harness.session.inner.commands.lock().await[&handle.0] + .status + .clone(); + let terminal = tokio::time::timeout( + Duration::from_secs(5), + status.wait_for(|value| value.is_some()), + ) + .await + .unwrap() + .unwrap() + .clone() + .unwrap(); + assert_eq!(terminal.status, CommandStatus::Completed); + assert_eq!(terminal.stdout_bytes, 1048576); + assert_eq!(terminal.stderr_bytes, 5); + assert_eq!( + terminal.stdout_delivered_bytes + terminal.stderr_delivered_bytes, + budget + ); + // Full capture queue cannot retain the process task or its resources. + { + let mut commands = harness.session.inner.commands.lock().await; + tokio::time::timeout( + Duration::from_secs(5), + &mut commands.get_mut(&handle.0).unwrap().task, + ) + .await + .unwrap() + .unwrap(); + } + // Only now acknowledge the captured queue and terminal fence. + let (_, delivered_terminal) = harness.collect(&handle, 0).await; + assert_eq!(delivered_terminal, terminal); + } + for (cancel, expected) in [ + (false, CommandStatus::TimedOut), + (true, CommandStatus::Cancelled), + ] { + let handle = harness + .start_budget( + "printf ready-extra; head -c 32768 /dev/zero; exec tail -f /dev/null", + 1, + 5, + ) + .await; + let first = harness.page(&handle, 0, 5).await; + assert_eq!(first.next_cursor, 5); + if cancel { + harness + .session + .cancel_command(handle.clone()) + .await + .unwrap(); + } + let (rest, terminal) = harness.collect(&handle, first.next_cursor).await; + assert!(rest.is_empty()); + assert_eq!(terminal.status, expected); + assert_eq!(terminal.stdout_delivered_bytes, 5); + assert!(terminal.stdout_bytes >= 11); + assert_eq!( + terminal.truncation_reason, + Some(crate::CommandOutputTruncation::ByteBudget) + ); + if !cancel { + assert_eq!(terminal.stdout_bytes, 32779); + assert_eq!( + terminal.truncation_reason, + Some(crate::CommandOutputTruncation::ByteBudget) + ); + } + } +} + +#[tokio::test] +async fn budget_replay_preserves_unique_bytes_and_rejects_future_and_old_cursors() { + let harness = Harness::new(); + let handle = harness.start_budget("printf abcdefghij", 5, 7).await; + let first = harness.page(&handle, 0, 2).await; + for _ in 0..3 { + assert_eq!(harness.page(&handle, 0, 2).await, first); + } + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 8, + limit: 2, + wait: false, + }) + .await + .is_err() + ); + let (rest, terminal) = harness.collect(&handle, first.next_cursor).await; + let mut all = first.chunks; + all.extend(rest); + assert_eq!(stream_bytes(&all, CommandStream::Stdout), b"abcdefg"); + assert_eq!(terminal.stdout_bytes, 10); + assert_eq!(terminal.stdout_delivered_bytes, 7); + assert!( + harness + .session + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 2, + wait: false, + }) + .await + .is_err() + ); +} + +#[tokio::test] +async fn invalid_budget_is_rejected_before_process_start() { + let harness = Harness::new(); + assert!(matches!( + harness + .session + .start_command(Harness::request( + "touch must-not-exist", + 5, + crate::MAX_COMMAND_TRANSPORT_BYTES + 1, + )) + .await, + Err(WorkdirError::InvalidArgument(_)) + )); + assert!(!harness._root.path().join("must-not-exist").exists()); + assert!(harness.session.inner.commands.lock().await.is_empty()); +} + +#[tokio::test] +async fn slow_receiver_backpressures_before_quota_but_full_queue_does_not_block_discard() { + use std::future::Future; + use std::task::Poll; + let telemetry = CommandTelemetry::new(); + let input = vec![b'x'; 1024 * 1024]; + for budget in [ + crate::MAX_COMMAND_TRANSPORT_BYTES, + (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64, + ] { + let (sender, mut receiver) = mpsc::channel(QUEUE_CHUNKS); + let mut accounting = ByteAccounting::default(); + { + let output = pump( + input.as_slice(), + &b""[..], + &sender, + &telemetry, + "test", + budget, + &mut accounting, + ); + tokio::pin!(output); + let polled = std::future::poll_fn(|cx| Poll::Ready(output.as_mut().poll(cx))).await; + if budget == crate::MAX_COMMAND_TRANSPORT_BYTES { + assert!(polled.is_pending(), "a slow receiver must stop pipe reads"); + } else { + assert!( + matches!(polled, Poll::Ready(Ok(()))), + "discard must not reserve queue capacity" + ); + } + } + assert_eq!(receiver.len(), QUEUE_CHUNKS); + assert_eq!( + accounting.stdout_delivered, + (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64 + ); + assert_eq!( + accounting.stdout, + if budget == crate::MAX_COMMAND_TRANSPORT_BYTES { + accounting.stdout_delivered + } else { + input.len() as u64 + } + ); + let mut queued_bytes = 0; + while let Ok(Message::Bytes(chunk)) = receiver.try_recv() { + queued_bytes += chunk.bytes.len(); + } + assert_eq!(queued_bytes, QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES); + } +} diff --git a/crates/workdir/src/operation.rs b/crates/workdir/src/operation.rs index 3c2eb2318..7e180e7aa 100644 --- a/crates/workdir/src/operation.rs +++ b/crates/workdir/src/operation.rs @@ -1,5 +1,3 @@ -use std::path::PathBuf; - use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -8,18 +6,18 @@ use serde::{Deserialize, Serialize}; pub struct CommandHandle(pub String); #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct CommandRequest { pub command: String, #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub timeout_secs: u64, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub output_limit: usize, + /// Aggregate unique-byte capture allowance, independent of consumer storage. + /// Providers drain and count excess bytes without delivering their payload. + #[schemars(range(min = 0, max = 67_108_864_u64))] + pub transport_byte_budget: u64, /// Workdir-relative command directory. Providers validate it against the /// active session before process start. pub cwd: fs_operation::FsPath, - /// Provider-local directory where complete output is retained when the - /// inline result exceeds `output_limit`. - pub spill_dir: Option, /// Optional caller-owned correlation id. Bash supplies its tool-call id so /// user-facing command telemetry can update the corresponding Console row /// without exposing provider/session handles. @@ -27,12 +25,23 @@ pub struct CommandRequest { pub tool_call_id: Option, } +impl CommandRequest { + pub fn validate(&self) -> Result<(), crate::WorkdirError> { + if self.transport_byte_budget > MAX_COMMAND_TRANSPORT_BYTES { + return Err(crate::WorkdirError::InvalidArgument( + "command transport byte budget exceeds provider maximum".into(), + )); + } + Ok(()) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct CommandOutputRequest { pub handle: CommandHandle, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub cursor: usize, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + #[schemars(range(min = 0, max = 67_108_864_u64))] + pub cursor: u64, + #[schemars(range(min = 1, max = 65_536_usize))] pub limit: usize, pub wait: bool, } @@ -107,16 +116,150 @@ pub enum CommandEvent { }, } +/// Sequence is the merged transport byte position. Offset addresses raw bytes +/// within the named stream; neither ordering nor offsets depend on UTF-8 decoding. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] -pub struct CommandOutput { +pub struct CommandByteChunk { + pub sequence: u64, + pub stream: CommandStream, + pub offset: u64, + pub bytes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CommandOutputTruncation { + ByteBudget, +} + +/// Raw totals include discarded pipe bytes. Delivered totals count only prefixes +/// admitted to the transport queue, not page reads or retries by a consumer. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct CommandTerminal { pub status: CommandStatus, - #[schemars(range(min = -2147483648_i32, max = 2147483647_i32))] pub exit_code: Option, - pub timed_out: bool, - pub content: String, - #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] - pub next_cursor: Option, - pub truncated: bool, - /// Complete output retained by the provider when `truncated` is true. - pub output_path: Option, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub stdout_delivered_bytes: u64, + pub stderr_delivered_bytes: u64, + pub truncation_reason: Option, +} + +/// Sending next_cursor acknowledges this page. Only the current page may be +/// retried; acknowledged bytes expire. Terminal fences all captured bytes, +/// including on timeout/cancel. Consumers must pull while the process runs. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CommandOutput { + pub chunks: Vec, + pub next_cursor: u64, + pub terminal: Option, +} + +impl CommandOutput { + pub(crate) fn transport_bounds_valid(&self) -> bool { + let mut end = None; + let mut stream_ends = [None, None]; + for chunk in &self.chunks { + let stream_index = match chunk.stream { + CommandStream::Stdout => 0, + CommandStream::Stderr => 1, + }; + if chunk.bytes.is_empty() + || chunk.offset > chunk.sequence + || stream_ends[stream_index].is_some_and(|end| chunk.offset != end) + || end.is_some_and(|end| chunk.sequence != end) + || chunk + .sequence + .checked_add(chunk.bytes.len() as u64) + .is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES) + || chunk + .offset + .checked_add(chunk.bytes.len() as u64) + .is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES) + { + return false; + } + end = chunk.sequence.checked_add(chunk.bytes.len() as u64); + stream_ends[stream_index] = chunk.offset.checked_add(chunk.bytes.len() as u64); + } + self.next_cursor <= MAX_COMMAND_TRANSPORT_BYTES + && end.is_none_or(|end| end == self.next_cursor) + && self.terminal.as_ref().is_none_or(|terminal| { + let truncated = terminal.stdout_bytes > terminal.stdout_delivered_bytes + || terminal.stderr_bytes > terminal.stderr_delivered_bytes; + stream_ends[0].is_none_or(|end| end == terminal.stdout_delivered_bytes) + && stream_ends[1].is_none_or(|end| end == terminal.stderr_delivered_bytes) + && terminal.status != CommandStatus::Running + && terminal.stdout_delivered_bytes <= terminal.stdout_bytes + && terminal.stderr_delivered_bytes <= terminal.stderr_bytes + && terminal + .stdout_delivered_bytes + .checked_add(terminal.stderr_delivered_bytes) + == Some(self.next_cursor) + && terminal.truncation_reason + == truncated.then_some(CommandOutputTruncation::ByteBudget) + }) + } +} + +pub const MAX_COMMAND_TRANSPORT_BYTES: u64 = 64 * 1024 * 1024; + +pub const MAX_COMMAND_READ_BYTES: usize = 64 * 1024; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_wire_rejects_legacy_output_host_paths() { + let command = serde_json::json!({ + "command": "printf bytes", "timeout_secs": 1, "cwd": "", "transport_byte_budget": 0 + }); + assert!(serde_json::from_value::(command.clone()).is_ok()); + for field in ["spill_dir", "output_limit"] { + let mut legacy = command.clone(); + legacy[field] = serde_json::json!("/host/private"); + assert!(serde_json::from_value::(legacy).is_err()); + } + let output = serde_json::json!({"chunks": [], "next_cursor": 0, "terminal": null, "output_path": "/host/private"}); + assert!(serde_json::from_value::(output).is_err()); + } + + #[test] + fn command_budget_wire_requires_field_and_checks_provider_hard_bound() { + let wire = serde_json::json!({"command": "true", "timeout_secs": 1, "cwd": ""}); + assert!(serde_json::from_value::(wire.clone()).is_err()); + for budget in [ + 0, + MAX_COMMAND_TRANSPORT_BYTES, + MAX_COMMAND_TRANSPORT_BYTES + 1, + ] { + let mut value = wire.clone(); + value["transport_byte_budget"] = serde_json::json!(budget); + let request: CommandRequest = serde_json::from_value(value).unwrap(); + assert_eq!( + request.validate().is_ok(), + budget <= MAX_COMMAND_TRANSPORT_BYTES + ); + } + let mut negative = wire; + negative["transport_byte_budget"] = serde_json::json!(-1); + assert!(serde_json::from_value::(negative).is_err()); + assert_eq!( + serde_json::to_value(CommandOutputTruncation::ByteBudget).unwrap(), + "byte_budget" + ); + let schema = serde_json::to_value(schemars::schema_for!(CommandRequest)).unwrap(); + assert_eq!( + schema["properties"]["transport_byte_budget"]["maximum"], + MAX_COMMAND_TRANSPORT_BYTES + ); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&serde_json::json!("transport_byte_budget")) + ); + } } diff --git a/crates/workdir/src/router.rs b/crates/workdir/src/router.rs index 9eb190121..362504eab 100644 --- a/crates/workdir/src/router.rs +++ b/crates/workdir/src/router.rs @@ -341,7 +341,9 @@ impl WorkdirSession for RoutedWorkdirSession { let _active = self.state.enter()?; let handle = request.handle.clone(); let output = self.state.session.command_output(request).await?; - self.state.mark_terminal(&handle, output.status); + if let Some(terminal) = &output.terminal { + self.state.mark_terminal(&handle, terminal.status); + } Ok(output) } @@ -789,11 +791,12 @@ mod tests { .unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf done".to_string(), timeout_secs: 5, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -823,11 +826,12 @@ mod tests { let routed = router.attach(alias, Arc::clone(&underlying)).unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -850,11 +854,12 @@ mod tests { let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap(); let handle = routed .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 1024, + cwd: crate::WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index a47d5b600..9a54e818c 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -10,6 +10,9 @@ //! provider-side before name enumeration, descent, or content reads. Returned coordinates are not //! prefixed with cwd again. +#[cfg(test)] +use crate::command_test_support::CollectCommand as _; + use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -124,6 +127,7 @@ impl WorkdirToolBroker { next_lease_id: AtomicU64::new(1), close_lock: Arc::new(tokio::sync::Mutex::new(())), owned_commands: Arc::new(Mutex::new(HashSet::new())), + command_reads: Mutex::new(HashMap::new()), pending_command_events: Arc::new(Mutex::new(HashMap::new())), starting_tool_calls: Arc::new(Mutex::new(HashSet::new())), forwarded_starts: Arc::new(Mutex::new(HashSet::new())), @@ -230,25 +234,48 @@ impl WorkdirScopeLease { .source .cancel_command(handle.clone()) .await; - let terminal = self + let read_cursor = self .broker .authority - .source - .command_output(CommandOutputRequest { - handle, - cursor: 0, - limit: 1, - wait: true, - }) - .await; + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .get(&command_id) + .cloned() + .expect("owned command cursor"); + let mut read_cursor = read_cursor.lock().await; + let mut cursor = *read_cursor; + let terminal = loop { + let output = self + .broker + .authority + .source + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: crate::MAX_COMMAND_READ_BYTES, + wait: true, + }) + .await; + match output { + Ok(output) => { + cursor = output.next_cursor; + *read_cursor = cursor; + if let Some(terminal) = output.terminal { + break Ok(terminal); + } + } + Err(error) => break Err(error), + } + }; match (cancel, terminal) { (_, Ok(output)) => { self.broker.authority.publish_terminal_if_missing( &command_id, output.status, output.exit_code, - output.next_cursor.unwrap_or(output.content.len()) as u64, - &output.content, + cursor, + "", ); self.broker .authority @@ -582,7 +609,12 @@ struct ScopedWorkdirSession { scope_lock: tokio::sync::Mutex<()>, next_lease_id: AtomicU64, close_lock: Arc>, + // Live cleanup responsibility ends at terminal delivery. Read ownership is + // retained separately so a lost terminal response can still be replayed. owned_commands: Arc>>, + // Starting commands prunes completed entries at the bounded replay ceiling; + // current scope validity is still checked before every read or retry. + command_reads: Mutex>>>, pending_command_events: Arc>>>, starting_tool_calls: Arc>>, forwarded_starts: Arc>>, @@ -700,6 +732,11 @@ impl ScopedWorkdirSession { .lock() .expect("scoped command set mutex poisoned") .contains(&handle.0) + || self + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .contains_key(&handle.0) { Ok(()) } else { @@ -1013,6 +1050,7 @@ impl ScopedWorkdirSession { next_lease_id: AtomicU64::new(1), close_lock: close_lock.clone(), owned_commands, + command_reads: Mutex::new(HashMap::new()), pending_command_events, starting_tool_calls, forwarded_starts, @@ -1410,6 +1448,16 @@ impl WorkdirSession for ScopedWorkdirSession { .lock() .expect("scoped command set mutex poisoned"); owned.insert(handle.0.clone()); + { + let mut reads = self + .command_reads + .lock() + .expect("command cursor mutex poisoned"); + if reads.len() >= 64 { + reads.retain(|id, _| owned.contains(id)); + } + reads.insert(handle.0.clone(), Arc::new(tokio::sync::Mutex::new(0))); + } if let Some(tool_call_id) = &tool_call_id { self.starting_tool_calls .lock() @@ -1460,14 +1508,28 @@ impl WorkdirSession for ScopedWorkdirSession { ) -> Result { self.ensure_owned_command(&request.handle)?; let command_id = request.handle.0.clone(); + let read_cursor = self + .command_reads + .lock() + .expect("command cursor mutex poisoned") + .get(&command_id) + .cloned(); + let mut read_cursor = match &read_cursor { + Some(cursor) => Some(cursor.lock().await), + None => None, + }; + self.ensure_owned_command(&request.handle)?; let output = self.source.command_output(request).await?; - if !matches!(output.status, CommandStatus::Running) { + if let Some(cursor) = &mut read_cursor { + **cursor = output.next_cursor; + } + if let Some(terminal) = &output.terminal { self.publish_terminal_if_missing( &command_id, - output.status, - output.exit_code, - output.next_cursor.unwrap_or(output.content.len()) as u64, - &output.content, + terminal.status, + terminal.exit_code, + output.next_cursor, + "", ); self.owned_commands .lock() @@ -2083,20 +2145,21 @@ mod tests { session: &WorkdirSessionHandle, command: impl Into, tool_call_id: impl Into, - ) -> CommandOutput { + ) -> crate::command_test_support::CollectedCommand { let handle = session .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: command.into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some(tool_call_id.into()), }) .await .unwrap(); session - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, @@ -2115,11 +2178,12 @@ mod tests { .expect("delegation wrapper must preserve command observation"); let handle = parent .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf ready; sleep 0.2; printf done".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("tool-delegated".into()), }) .await @@ -2142,7 +2206,7 @@ mod tests { assert_eq!(snapshots[0].stdout.content, "ready"); let output = parent - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 1024, @@ -2182,11 +2246,12 @@ mod tests { ); let error = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: None, }) .await @@ -2214,11 +2279,12 @@ mod tests { let handle = first .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "pwd; sleep 0.2".into(), timeout_secs: 5, - output_limit: 4096, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("first-command".into()), }) .await @@ -2242,7 +2308,7 @@ mod tests { )); let output = first - .command_output(CommandOutputRequest { + .collect_command_output(CommandOutputRequest { handle, cursor: 0, limit: 4096, @@ -2319,11 +2385,12 @@ mod tests { assert!(matches!( child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf denied".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("read-only-command".into()), }) .await, @@ -2625,11 +2692,12 @@ mod tests { assert!(matches!( child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf revoked".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("revoked-child-command".into()), }) .await, @@ -2727,11 +2795,12 @@ mod tests { for index in 0..MAX_SCOPED_COMMANDS { child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some(format!("command-{index}")), }) .await @@ -2740,11 +2809,12 @@ mod tests { let error = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("command-over-limit".into()), }) .await @@ -2774,11 +2844,12 @@ mod tests { let command = tokio::spawn(async move { command_child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("racing-command".into()), }) .await @@ -2820,11 +2891,12 @@ mod tests { let mut events = child.subscribe_command_events().unwrap(); let handle = child .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "sleep 30; printf leaked > marker".into(), timeout_secs: 60, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("owned-command".into()), }) .await @@ -3015,11 +3087,12 @@ mod tests { assert!(matches!( parent .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, command: "printf closed".into(), timeout_secs: 5, - output_limit: 1024, + cwd: FsPath::root(), - spill_dir: None, + tool_call_id: Some("closed-parent-command".into()), }) .await, @@ -3031,4 +3104,180 @@ mod tests { "child result after parent close: {child_result:?}" ); } + #[tokio::test] + async fn scoped_byte_cursor_survives_partial_ack_then_revocation_and_terminal_retry() { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("child")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("child", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let tool = child.tool_session(); + let handle = tool + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf ready; exec yes x".into(), + timeout_secs: 10, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + for _ in 0..2 { + let page = tool + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 1, + wait: true, + }) + .await + .unwrap(); + cursor = page.next_cursor; + } + tokio::time::timeout(std::time::Duration::from_secs(5), child.close()) + .await + .unwrap() + .unwrap(); + assert!( + tool.command_output(CommandOutputRequest { + handle, + cursor, + limit: 1, + wait: false + }) + .await + .is_err() + ); + let handle = parent + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf parent".into(), + timeout_secs: 5, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 1024, + wait: true, + }; + let page = parent.command_output(request.clone()).await.unwrap(); + assert_eq!(parent.command_output(request).await.unwrap(), page); + cursor = page.next_cursor; + if page.terminal.is_some() { + break; + } + } + } + #[tokio::test] + async fn scoped_terminal_response_loss_replays_without_duplicate_capture_and_rejects_invalid_cursors() + { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join("child")).unwrap(); + let parent = session(root.path()); + let child = parent + .scope(request("child", WorkdirToolScopePermission::Write)) + .await + .unwrap(); + let tool = child.tool_session(); + let handle = tool + .start_command(CommandRequest { + transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES, + command: "printf abcdef".into(), + timeout_secs: 5, + cwd: FsPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let mut captured = Vec::new(); + let terminal_request = loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 2, + wait: true, + }; + // Simulate losing each produced HTTP response, including terminal: + // append only the retried page, never both copies. + let lost = tool.command_output(request.clone()).await.unwrap(); + let replay = tool.command_output(request.clone()).await.unwrap(); + assert_eq!(replay, lost); + for chunk in &replay.chunks { + captured.extend_from_slice(&chunk.bytes); + } + cursor = replay.next_cursor; + if let Some(terminal) = replay.terminal { + assert_eq!(terminal.status, CommandStatus::Completed); + break request; + } + }; + assert_eq!(captured, b"abcdef"); + assert!( + !child + .broker + .authority + .owned_commands + .lock() + .unwrap() + .contains(&handle.0) + ); + assert!( + child + .broker + .authority + .command_reads + .lock() + .unwrap() + .contains_key(&handle.0) + ); + assert_eq!( + tool.command_status(handle.clone()).await.unwrap(), + CommandStatus::Completed + ); + for invalid in [0, cursor + 1] { + assert!( + tool.command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: invalid, + limit: 2, + wait: false + }) + .await + .is_err() + ); + } + // Invalid cursor requests do not destroy the retained terminal page. + assert!( + tool.command_output(terminal_request) + .await + .unwrap() + .terminal + .is_some() + ); + child.close().await.unwrap(); + assert!( + tool.command_output(CommandOutputRequest { + handle, + cursor, + limit: 2, + wait: false + }) + .await + .is_err() + ); + }) + .await + .expect("bounded scoped terminal replay"); + } } diff --git a/crates/workdir/tests/command_denial_diagnostics.rs b/crates/workdir/tests/command_denial_diagnostics.rs index cfc4c5231..8db54686d 100644 --- a/crates/workdir/tests/command_denial_diagnostics.rs +++ b/crates/workdir/tests/command_denial_diagnostics.rs @@ -103,11 +103,12 @@ impl WorkdirSession for CommandFailureSession { } fn command() -> CommandRequest { CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, command: "fake command, never executed".into(), timeout_secs: 1, - output_limit: 100, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, } } diff --git a/crates/workdir/tests/denial_reasons.rs b/crates/workdir/tests/denial_reasons.rs index 53c64d5dd..41b031f7a 100644 --- a/crates/workdir/tests/denial_reasons.rs +++ b/crates/workdir/tests/denial_reasons.rs @@ -167,11 +167,12 @@ async fn read_only_and_scoped_capability_refusals_do_not_collapse() { assert_reason( child .start_command(workdir::CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, command: "must-not-run".into(), timeout_secs: 1, - output_limit: 1, + cwd: WorkdirPath::root(), - spill_dir: None, + tool_call_id: None, }) .await diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index cbc0ad393..0eba6a589 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -56,6 +56,7 @@ server-api = { path = "../server-api" } workdir.workspace = true [dev-dependencies] +workdir = { workspace = true, features = ["http-client"] } futures.workspace = true agen.workspace = true serial_test = "3.4.0" diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index a916a5f69..57b7d0eb6 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -67,7 +67,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::net::TcpListener; use workdir::{ - CommandOutput, CommandStatus, WorkdirSessionHandle, dispatch_workdir_session_operation, + CommandOutput, WorkdirSessionHandle, dispatch_workdir_session_operation, http::{ OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId, WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult, @@ -914,13 +914,9 @@ async fn run_workdir_session_operation( { Ok(result) => result?, Err(_) => CommandOutput { - exit_code: None, - timed_out: false, - status: CommandStatus::Running, - content: String::new(), - next_cursor: Some(cursor), - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: cursor, + terminal: None, }, }; WorkdirSessionOperationResult::CommandOutput(output) @@ -2426,6 +2422,7 @@ pub enum RuntimeHttpServerError { #[cfg(test)] mod tests { + mod command_transport_tests; #[cfg(feature = "fs-store")] mod session_material_tests; diff --git a/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs new file mode 100644 index 000000000..643e722ab --- /dev/null +++ b/crates/worker-runtime/src/http_server/tests/command_transport_tests.rs @@ -0,0 +1,408 @@ +//! Loopback integration: production RemoteWorkdirSession, signed HTTP middleware, +//! command operation handler, local process pipes, and JSON DTOs. Only opening the +//! pre-seeded session is a fixture; no Runtime Worker/materializer is claimed here. + +use super::*; +use std::time::Duration; +use workdir::http::{ + RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture, +}; +use workdir::{CommandOutputRequest, CommandRequest, CommandStream, WorkdirSession}; + +struct SignedAuthorization { + identity: RuntimeIdentityMaterial, + trust: WorkspaceIssuerTrustRecord, +} +impl std::fmt::Debug for SignedAuthorization { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SignedAuthorization") + } +} +impl WorkdirHttpAuthorization for SignedAuthorization { + fn bearer_token( + &self, + method: String, + path: String, + body: Vec, + ) -> WorkdirHttpAuthorizationFuture { + let claims = signed_claims(&self.trust, &method, &path, &body); + let token = + issue_workspace_capability_token(&self.identity.signing_key().unwrap(), &claims) + .unwrap(); + Box::pin(async move { Ok(token) }) + } +} + +struct Harness { + server: tokio::task::JoinHandle<()>, + session: WorkdirSessionHandle, + _root: tempfile::TempDir, + remote: RemoteWorkdirSession, + client: reqwest::Client, + operation_url: url::Url, + authorization: Arc, +} +impl Drop for Harness { + fn drop(&mut self) { + self.server.abort(); + } +} +impl Harness { + async fn raw_operation(&self, operation: serde_json::Value) -> reqwest::Response { + let body = serde_json::to_vec(&serde_json::json!({"operation": operation})).unwrap(); + let token = self + .authorization + .bearer_token( + "POST".into(), + self.operation_url.path().into(), + body.clone(), + ) + .await + .unwrap(); + self.client + .post(self.operation_url.clone()) + .bearer_auth(token) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + } + + async fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound( + Workdir::new("wd-transport"), + root.path().to_path_buf(), + root.path().to_path_buf(), + SharedScope::new(Scope::writable(root.path()).unwrap()), + WorkdirSessionCapabilities::ALL, + )); + let identity = RuntimeIdentityMaterial::generate("transport-key").unwrap(); + let trust = workspace_trust(&identity); + let state = RuntimeHttpState { + runtime: Runtime::new_memory(), + local_token: None, + workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))), + workdir_sessions: Arc::new(Mutex::new(HashMap::from([( + "session-transport".into(), + RuntimeHttpWorkdirSession { + owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"), + session: session.clone(), + }, + )]))), + ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")), + }; + let app = Router::new() + .route( + "/v1/working-directories/{id}/sessions", + post(|| async { + Json(OpenWorkdirSessionResponse { + session_id: WorkdirSessionId::new("session-transport").unwrap(), + workdir_id: Workdir::new("wd-transport").id().clone(), + capabilities: WorkdirSessionCapabilities::ALL, + }) + }), + ) + .route( + "/v1/workdir-sessions/{session_id}/operations", + post(run_workdir_session_operation), + ) + .route( + "/v1/workdir-sessions/{session_id}", + delete(close_workdir_session), + ) + .with_state(state.clone()) + .layer(middleware::from_fn_with_state(state, require_runtime_auth)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base: url::Url = format!("http://{}", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + RUNTIME_WORKSPACE_SCOPE_HEADER, + "workspace-a".parse().unwrap(), + ); + let client = reqwest::Client::builder() + .no_proxy() + .default_headers(headers) + .build() + .unwrap(); + let authorization = Arc::new(SignedAuthorization { identity, trust }); + let operation_url = base + .join("/v1/workdir-sessions/session-transport/operations") + .unwrap(); + let remote = RemoteWorkdirSession::open_with_authorization( + client.clone(), + base, + authorization.clone(), + Workdir::new("wd-transport").id().clone(), + OpenWorkdirSessionRequest::default(), + ) + .await + .unwrap(); + Self { + server, + session, + _root: root, + remote, + client, + operation_url, + authorization, + } + } +} + +#[tokio::test] +async fn real_http_command_pages_preserve_binary_streams_replay_and_nonzero_exit() { + tokio::time::timeout(Duration::from_secs(20), async { + let harness = Harness::new().await; + let handle = harness.remote.start_command(CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + command: "head -c 2097152 /dev/zero | tr '\\0' x; printf '\\342\\202\\254\\377\\000'; printf 'stderr\\346\\227\\245' >&2; exit 9".into(), + timeout_secs: 15, cwd: WorkdirPath::root(), tool_call_id: Some("http-tool-call".into()), + }).await.unwrap(); + let mut cursor = 0; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let terminal = loop { + let request = CommandOutputRequest { handle: handle.clone(), cursor, limit: 16384, wait: true }; + let page = harness.remote.command_output(request.clone()).await.unwrap(); + assert_eq!(harness.remote.command_output(request).await.unwrap(), page); + assert!(page.chunks.iter().map(|chunk| chunk.bytes.len()).sum::() <= 16384); + for chunk in page.chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + let target = match chunk.stream { CommandStream::Stdout => &mut stdout, CommandStream::Stderr => &mut stderr }; + assert_eq!(chunk.offset, target.len() as u64); + target.extend(chunk.bytes); + } + assert_eq!(cursor, page.next_cursor); + if let Some(terminal) = page.terminal { break terminal; } + }; + let mut expected = vec![b'x'; 2097152]; + expected.extend_from_slice(b"\xe2\x82\xac\xff\0"); + assert_eq!(stdout, expected); + assert_eq!(stderr, b"stderr\xe6\x97\xa5"); + assert_eq!(terminal.status, workdir::CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(9)); + assert!(harness.remote.command_output(CommandOutputRequest { handle, cursor: 0, limit: 1, wait: false }).await.is_err()); + harness.remote.close().await.unwrap(); + assert!(harness.session.command_snapshot().is_empty()); + }).await.expect("bounded HTTP command transport"); +} + +#[tokio::test] +async fn real_http_cancel_drains_tail_before_terminal_and_timeout_is_distinct() { + tokio::time::timeout(Duration::from_secs(15), async { + let harness = Harness::new().await; + for (command, timeout, cancel, expected) in [ + ( + "printf ready; exec yes x", + 10, + true, + workdir::CommandStatus::Cancelled, + ), + ( + "printf ready; exec tail -f /dev/null", + 1, + false, + workdir::CommandStatus::TimedOut, + ), + ] { + let handle = harness + .remote + .start_command(CommandRequest { + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + command: command.into(), + timeout_secs: timeout, + cwd: WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let first = harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 5, + wait: true, + }) + .await + .unwrap(); + assert_eq!( + first + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.iter().copied()) + .collect::>(), + b"ready" + ); + cursor = first.next_cursor; + if cancel { + harness.remote.cancel_command(handle.clone()).await.unwrap(); + } + loop { + let page = harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }) + .await + .unwrap(); + cursor = page.next_cursor; + if let Some(terminal) = page.terminal { + assert_eq!(terminal.status, expected); + break; + } + } + } + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded cancellation and timeout over HTTP"); +} + +#[tokio::test] +async fn real_http_command_budget_bounds_unique_payload_and_reports_larger_seen_totals() { + tokio::time::timeout(Duration::from_secs(20), async { + let harness = Harness::new().await; + for budget in [0, 12345] { + let handle = harness + .remote + .start_command(CommandRequest { + command: "head -c 1048576 /dev/zero; head -c 65536 /dev/zero >&2; exit 9" + .into(), + timeout_secs: 10, + transport_byte_budget: budget, + cwd: WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let mut cursor = 0; + let mut delivered = [0_u64; 2]; + let terminal = loop { + let request = CommandOutputRequest { + handle: handle.clone(), + cursor, + limit: 997, + wait: true, + }; + let page = harness + .remote + .command_output(request.clone()) + .await + .unwrap(); + assert_eq!(harness.remote.command_output(request).await.unwrap(), page); + for chunk in page.chunks { + assert_eq!(chunk.sequence, cursor); + cursor += chunk.bytes.len() as u64; + let index = match chunk.stream { + CommandStream::Stdout => 0, + CommandStream::Stderr => 1, + }; + assert_eq!(chunk.offset, delivered[index]); + delivered[index] += chunk.bytes.len() as u64; + } + assert_eq!(cursor, page.next_cursor); + assert!( + cursor <= budget, + "RemoteWorkdirSession must not receive bytes beyond budget" + ); + if let Some(terminal) = page.terminal { + break terminal; + } + }; + assert_eq!(cursor, budget); + assert_eq!(terminal.stdout_bytes, 1048576); + assert_eq!(terminal.stderr_bytes, 65536); + assert_eq!(terminal.stdout_delivered_bytes, delivered[0]); + assert_eq!(terminal.stderr_delivered_bytes, delivered[1]); + assert_eq!( + terminal.truncation_reason, + Some(workdir::CommandOutputTruncation::ByteBudget) + ); + assert_eq!(terminal.status, workdir::CommandStatus::Failed); + assert_eq!(terminal.exit_code, Some(9)); + assert!( + harness + .remote + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: budget + 1, + limit: 1, + wait: false, + }) + .await + .is_err() + ); + if budget > 0 { + assert!( + harness + .remote + .command_output(CommandOutputRequest { + handle, + cursor: 0, + limit: 1, + wait: false, + }) + .await + .is_err() + ); + } + } + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded HTTP byte-budget transport"); +} + +#[tokio::test] +async fn real_http_rejects_invalid_or_legacy_budget_requests_before_process_start() { + tokio::time::timeout(Duration::from_secs(10), async { + let harness = Harness::new().await; + let request = CommandRequest { + command: "touch must-not-exist".into(), + timeout_secs: 1, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES + 1, + cwd: WorkdirPath::root(), + tool_call_id: None, + }; + let oversize = + serde_json::to_value(WorkdirSessionOperation::CommandStart(request)).unwrap(); + assert_eq!( + harness.raw_operation(oversize.clone()).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + let mut missing = oversize.clone(); + // CommandStart is externally tagged by operation / request. + let object = missing.as_object_mut().unwrap(); + let request = object.get_mut("request").unwrap().as_object_mut().unwrap(); + request.remove("transport_byte_budget"); + assert_eq!( + harness.raw_operation(missing).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + let mut legacy = oversize; + let request = legacy["request"].as_object_mut().unwrap(); + request.insert("transport_byte_budget".into(), serde_json::json!(0)); + request.insert("spill_dir".into(), serde_json::json!("/private/host")); + assert_eq!( + harness.raw_operation(legacy).await.status(), + reqwest::StatusCode::BAD_REQUEST + ); + assert!(!harness._root.path().join("must-not-exist").exists()); + harness.remote.close().await.unwrap(); + }) + .await + .expect("bounded HTTP budget admission"); +} diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 3b3a92e65..a632d81c8 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -147,7 +147,6 @@ use worker::{ Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, - bash_output_dir_for_worker_id, }; #[cfg(feature = "ws-server")] @@ -1090,29 +1089,24 @@ fn runtime_local_workdir_session( root: &Path, cwd: &Path, scope: manifest::SharedScope, - command_output_scope: manifest::SharedScope, capabilities: WorkdirSessionCapabilities, command_environment: std::collections::BTreeMap, resources: Vec>, ) -> WorkdirSessionHandle { - Arc::new( - LocalWorkdirSession::materialized_bound_with_environment_and_command_output_scope( - Workdir::new(workdir_id), - root.to_path_buf(), - cwd.to_path_buf(), - scope, - command_output_scope, - capabilities, - command_environment, - resources, - ), - ) + Arc::new(LocalWorkdirSession::materialized_bound_with_environment( + Workdir::new(workdir_id), + root.to_path_buf(), + cwd.to_path_buf(), + scope, + capabilities, + command_environment, + resources, + )) } fn runtime_local_workdir_router( attachments: &BTreeMap, capabilities: &BTreeMap, - command_output_scope: manifest::SharedScope, authorization_client: Option>, ) -> Result, String> { let router = Arc::new(WorkdirSessionRouter::new()); @@ -1127,7 +1121,6 @@ fn runtime_local_workdir_router( binding.root(), binding.cwd(), scope, - command_output_scope.clone(), capabilities, binding.command_environment(), binding @@ -1153,7 +1146,6 @@ fn restored_workdir_router( local_attachments: &BTreeMap, logical_attachments: &[crate::catalog::LogicalWorkdirAttachment], claims: &[crate::catalog::WorkingDirectoryAttachmentClaim], - scope: manifest::SharedScope, workspace_client: Arc, ) -> Result, String> { let mut local_capabilities = local_attachments @@ -1173,7 +1165,6 @@ fn restored_workdir_router( let router = runtime_local_workdir_router( local_attachments, &local_capabilities, - scope, Some(workspace_client.clone()), )?; for attachment in logical_attachments { @@ -1529,7 +1520,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let store = CombinedStore::new(session_store, worker_metadata_store); let run_dir = self.worker_run_dir(&request.worker_ref)?; - let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id); let bootstrap = WorkerBootstrap::new( manifest, store, @@ -1538,7 +1528,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { filesystem_authority, WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), - bash_output_dir, }, self.controller_transport, ); @@ -1583,7 +1572,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { worker.bind_workdir_sessions(runtime_local_workdir_router( &request.workdir_attachments, &workdir_capabilities, - worker.scope().clone(), Some(worker.workspace_client_handle()), )?); if let (Some(runtime_id), Some(workspace_id)) = @@ -1851,7 +1839,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { &request.workdir_attachments, &request.logical_workdir_attachments, &request.request.workdir_attachments, - worker.scope().clone(), worker.workspace_client_handle(), )?; worker.bind_workdir_sessions(workdir_sessions); @@ -1888,13 +1875,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let workspace_client = worker.workspace_client_handle(); let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?; - let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id); let cleanup_sessions = worker.workdir_sessions(); let started = PreparedWorker::new( worker, WorkerBootstrapLayout::RuntimeManagedRun { run_dir: run_dir.clone(), - bash_output_dir, }, self.controller_transport, ) @@ -3768,7 +3753,6 @@ where working_directory_id, binding.root(), binding.cwd(), - scope.clone(), scope, WorkdirSessionCapabilities::ALL, binding.command_environment(), @@ -6817,8 +6801,6 @@ mod tests { #[test] fn restored_router_uses_workspace_proxy_for_logical_only_attachment() { - let root = tempfile::tempdir().unwrap(); - let scope = manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap()); let client = WorkerWorkspaceContext::local_filesystem(Some( WorkspaceId::new("workspace-a").unwrap(), )) @@ -6833,7 +6815,6 @@ mod tests { capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, }], &[], - scope, client, ) .unwrap(); @@ -6878,7 +6859,6 @@ mod tests { .unwrap(); let alias = WorkdirAttachmentAlias::new("docs").unwrap(); let workdir_id = binding.working_directory.id.clone(); - let command_output_scope = manifest::SharedScope::new(manifest::Scope::empty()); let attachments = BTreeMap::from([(alias.clone(), binding)]); let logical = [LogicalWorkdirAttachment { alias: alias.clone(), @@ -6890,9 +6870,7 @@ mod tests { )) .client_handle(); - let router = - restored_workdir_router(&attachments, &logical, &[], command_output_scope, client) - .unwrap(); + let router = restored_workdir_router(&attachments, &logical, &[], client).unwrap(); let session = router.session(&alias).unwrap(); assert_eq!( @@ -7579,14 +7557,10 @@ mod tests { ) .await .map_err(|err| err.to_string())?; - let bash_output_dir = self.runtime_base.join("bash-output"); - let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed( - worker, - &self.runtime_base, - &bash_output_dir, - ) - .await - .map_err(|err| err.to_string())?; + let (handle, shutdown_rx) = + WorkerController::spawn_runtime_managed(worker, &self.runtime_base) + .await + .map_err(|err| err.to_string())?; Ok(RuntimeWorkerController { handle, shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))), @@ -8710,7 +8684,6 @@ mod tests { "working-directory-42", root.path(), root.path(), - spawned_scope.clone(), spawned_scope, WorkdirSessionCapabilities::READ_ONLY, Default::default(), @@ -8721,7 +8694,6 @@ mod tests { "working-directory-42", root.path(), root.path(), - restored_scope.clone(), restored_scope, WorkdirSessionCapabilities::READ_ONLY, Default::default(), @@ -10246,9 +10218,7 @@ mod tests { let alias = WorkdirAttachmentAlias::new("checkout").unwrap(); let attachments = BTreeMap::from([(alias, binding.clone())]); - let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap()); - let router = - runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope, None).unwrap(); + let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), None).unwrap(); session_clone.close().await.unwrap(); assert_eq!( materializer.cleanup_working_directory(id).unwrap_err().code, diff --git a/crates/worker/examples/worker_protocol.rs b/crates/worker/examples/worker_protocol.rs index 53ec846d7..b893515da 100644 --- a/crates/worker/examples/worker_protocol.rs +++ b/crates/worker/examples/worker_protocol.rs @@ -47,9 +47,7 @@ async fn main() -> Result<(), Box> { let worker = worker::Worker::from_manifest_toml(&toml, store).await?; let runtime_tmp = tempfile::tempdir()?; - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, _shutdown_rx) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?; + let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?; // Check initial status via shared state println!("[shared_state] {}", handle.shared_state.status_json()); diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index 7fc1fbe4c..cb7771a86 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -17,28 +17,9 @@ use manifest::WorkerManifest; #[derive(Debug, Clone)] pub enum WorkerBootstrapLayout { /// A direct Worker rooted below the supplied runtime base directory. - Direct { - runtime_base: PathBuf, - bash_output_dir: PathBuf, - }, + Direct { runtime_base: PathBuf }, /// A runtime-managed Worker with an exact persisted run directory. - RuntimeManagedRun { - run_dir: PathBuf, - bash_output_dir: PathBuf, - }, -} - -/// Return the temporary Bash spill directory owned by a stable Worker identity. -/// -/// The directory deliberately lives outside session/run-generation storage so a -/// restarted controller for the same Worker keeps the same readable artifact -/// boundary. -pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf { - std::env::temp_dir() - .join("yoi") - .join("workers") - .join(worker_id.to_string()) - .join("bash-output") + RuntimeManagedRun { run_dir: PathBuf }, } /// Construction and controller inputs that are stable for one Worker launch. @@ -286,27 +267,12 @@ where { let cleanup_sessions = worker.workdir_sessions(); let controller = match layout { - WorkerBootstrapLayout::Direct { - runtime_base, - bash_output_dir, - } => { - WorkerController::spawn_with_transport_owned( - worker, - &runtime_base, - &bash_output_dir, - transport, - ) - .await + WorkerBootstrapLayout::Direct { runtime_base } => { + WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await } - WorkerBootstrapLayout::RuntimeManagedRun { - run_dir, - bash_output_dir, - } => { + WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => { WorkerController::spawn_runtime_managed_run_with_transport_owned( - worker, - &run_dir, - &bash_output_dir, - transport, + worker, &run_dir, transport, ) .await } @@ -327,22 +293,3 @@ where } } } - -#[cfg(test)] -mod tests { - use super::bash_output_dir_for_worker_id; - - #[test] - fn bash_output_directory_is_stable_per_worker_below_system_temp() { - let path = bash_output_dir_for_worker_id("019c1234-worker"); - - assert_eq!( - path, - std::env::temp_dir() - .join("yoi") - .join("workers") - .join("019c1234-worker") - .join("bash-output") - ); - } -} diff --git a/crates/worker/src/checkout_http_tests.rs b/crates/worker/src/checkout_http_tests.rs index 57c2bcb95..9c50f3d0f 100644 --- a/crates/worker/src/checkout_http_tests.rs +++ b/crates/worker/src/checkout_http_tests.rs @@ -31,11 +31,18 @@ enum WriteResponseFault { LostBody, } +#[derive(Clone, Copy)] +enum CommandResponseFault { + LostTerminal, + EmptyWaiting, +} + struct ProviderState { session: WorkdirSessionHandle, requests: Mutex>, operations: Mutex>, write_fault: Mutex>, + command_fault: Mutex>, observe_refusal: Mutex, } @@ -152,6 +159,21 @@ async fn operate( source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())), }); } + if let WorkdirSessionOperation::CommandOutput(output) = &request.operation { + if matches!( + *state.command_fault.lock().unwrap(), + Some(CommandResponseFault::EmptyWaiting) + ) { + return AxumJson(workdir::http::WorkdirSessionOperationResult::CommandOutput( + workdir::CommandOutput { + chunks: vec![], + next_cursor: output.cursor, + terminal: None, + }, + )) + .into_response(); + } + } let result = dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await; match result { @@ -159,6 +181,15 @@ async fn operate( Ok(result) => { let fault = if is_write { state.write_fault.lock().unwrap().take() + } else if matches!(&result, workdir::http::WorkdirSessionOperationResult::CommandOutput(output) if output.terminal.is_some()) + { + let mut command_fault = state.command_fault.lock().unwrap(); + if matches!(*command_fault, Some(CommandResponseFault::LostTerminal)) { + command_fault.take(); + Some(WriteResponseFault::LostBody) + } else { + None + } } else { None }; @@ -237,6 +268,7 @@ impl HttpFixture { requests: Mutex::new(Vec::new()), operations: Mutex::new(Vec::new()), write_fault: Mutex::new(None), + command_fault: Mutex::new(None), observe_refusal: Mutex::new(false), }); let app = Router::new() @@ -801,6 +833,88 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit ); } +// Full collection/projection integration: production HTTP client, local process pipes, +// Bash collector/store, WIP codec and common file dispatch. Only HTTP response loss is injected. +#[tokio::test] +async fn remote_native_bash_retains_streams_quota_and_replays_lost_terminal_through_common_files() { + watchdog(async { + let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await; + let remote = Arc::new(f.open(TOKEN).await.unwrap()); + let router = Arc::new(WorkdirSessionRouter::new()); + router.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote.clone()).unwrap(); + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None).unwrap(); + let view = tools::bash_output::BashOutputView::new(); + crate::feature::builtin::bash::mount(&mut registry, router.clone(), view, None).unwrap(); + let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("remote-bash"), 0).unwrap(); + *f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::LostTerminal); + let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":r"printf 'first\nneedle\n'; printf '%200000s' ''; printf '\377€' >&2; exit 7","cwd":"."}}), Default::default()).await.unwrap(); + f.public_output(run.content.as_ref().unwrap()); + let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap(); + assert_eq!(result["metadata"]["stdout_saved"], 200013); + assert_eq!(result["metadata"]["stderr_saved"], 4); + assert_eq!(result["metadata"]["terminal"]["exit_code"], 7); + assert_eq!(result["metadata"]["terminal"]["transport_complete"], true); + assert_eq!(result["metadata"]["storage_truncated"], false); + assert_eq!(result["preview_omitted"], true); + for name in ["stdout_entry", "stderr_entry", "order_entry"] { + runtime.inspect(result[name].as_str().unwrap().into(), false).await.unwrap(); + } + let stdout = result["stdout_entry"].as_str().unwrap(); + let search = runtime.invoke(stdout.into(), crate::file_operation::Contract::SearchText.reference(), "search".into(), json!({"pattern":"needle","limit":2}), Default::default()).await.unwrap(); + assert!(search.content.unwrap().contains("needle")); + let stderr = runtime.invoke(result["stderr_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"limit":4}), Default::default()).await.unwrap(); + let stderr: Json = serde_json::from_str(&stderr.content.unwrap()).unwrap(); + use base64::Engine as _; + assert_eq!(base64::engine::general_purpose::STANDARD.decode(stderr["data"].as_str().unwrap()).unwrap(), [vec![255], "€".as_bytes().to_vec()].concat()); + let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf '%4194303s' ''; printf '€'","cwd":"."}}), Default::default()).await.unwrap(); + let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap(); + assert_eq!(result["metadata"]["stdout_saved"], 4194304); + assert_eq!(result["metadata"]["stdout_seen"], 4194306); + assert_eq!(result["metadata"]["transport_truncation"], "byte_budget"); + assert_eq!(result["metadata"]["storage_truncated"], true); + let tail = runtime.invoke(result["stdout_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"offset":4194303,"limit":4}), Default::default()).await.unwrap(); + let tail: Json = serde_json::from_str(&tail.content.unwrap()).unwrap(); + assert_eq!(base64::engine::general_purpose::STANDARD.decode(tail["data"].as_str().unwrap()).unwrap(), vec![0xe2]); + assert_eq!(tail["eof"], true); + assert_eq!(f.state.operations.lock().unwrap().iter().filter(|op| matches!(op, WorkdirSessionOperation::CommandStart(_))).count(), 2); + assert!(f.state.command_fault.lock().unwrap().is_none()); + router.close_all().await.unwrap(); + f.assert_endpoints(); + }).await; +} + +#[tokio::test] +async fn waiting_remote_command_rejects_empty_response_without_http_busy_loop() { + watchdog(async { + let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await; + let remote = f.open(TOKEN).await.unwrap(); + *f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::EmptyWaiting); + let error = remote + .command_output(workdir::CommandOutputRequest { + handle: workdir::CommandHandle("unused".into()), + cursor: 0, + limit: 1024, + wait: true, + }) + .await + .unwrap_err(); + assert!(matches!(error, WorkdirError::Unavailable(_))); + assert_eq!( + f.state + .operations + .lock() + .unwrap() + .iter() + .filter(|op| matches!(op, WorkdirSessionOperation::CommandOutput(_))) + .count(), + 1 + ); + remote.close().await.unwrap(); + }) + .await; +} + #[tokio::test] async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() { use base64::Engine as _; diff --git a/crates/worker/src/checkout_wip_tests.rs b/crates/worker/src/checkout_wip_tests.rs index e3fcabc72..0577b22f2 100644 --- a/crates/worker/src/checkout_wip_tests.rs +++ b/crates/worker/src/checkout_wip_tests.rs @@ -477,7 +477,11 @@ async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_repla let mut registry = WipMountRegistry::new(); crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) .unwrap(); - for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) { + for def in tools::routed_builtin_tools( + router, + tools::Tracker::new(), + tools::bash_output::BashOutputView::new(), + ) { let (meta, tool) = def(); let p = compatibility_projection(meta, tool, None).unwrap(); registry.mount(p).unwrap(); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 1c03115c4..5a6ed379a 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -692,7 +692,6 @@ impl WorkerController { pub async fn spawn( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -701,7 +700,6 @@ impl WorkerController { let (handle, shutdown, _task) = Self::spawn_inner( worker, runtime_base, - bash_output_dir, false, None, WorkerControllerTransport::UnixSocket, @@ -715,7 +713,7 @@ impl WorkerController { pub async fn spawn_with_transport( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where @@ -723,42 +721,32 @@ impl WorkerController { St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { let (handle, shutdown, _task) = - Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport) - .await?; + Self::spawn_with_transport_owned(worker, runtime_base, transport).await?; Ok((handle, shutdown)) } pub(crate) async fn spawn_with_transport_owned( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - Self::spawn_inner( - worker, - runtime_base, - bash_output_dir, - false, - None, - transport, - ) - .await + Self::spawn_inner(worker, runtime_base, false, None, transport).await } /// Spawn a Worker owned by `worker-runtime`. /// - /// The controller uses an ephemeral directory for Unix sockets while tool - /// spill artifacts use the separately supplied Worker-owned temporary path. + /// The controller uses an ephemeral directory for Unix sockets. Bash output is + /// owned by its live Feature instance, independently of filesystem layout. /// Runtime-managed Workers do not write legacy pid/status/manifest liveness /// projections. pub async fn spawn_runtime_managed( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -767,7 +755,6 @@ impl WorkerController { let (handle, shutdown, _task) = Self::spawn_inner( worker, runtime_base, - bash_output_dir, true, None, WorkerControllerTransport::UnixSocket, @@ -780,7 +767,6 @@ impl WorkerController { pub async fn spawn_runtime_managed_run( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, @@ -789,7 +775,6 @@ impl WorkerController { Self::spawn_runtime_managed_run_with_transport( worker, run_dir, - bash_output_dir, WorkerControllerTransport::UnixSocket, ) .await @@ -800,27 +785,23 @@ impl WorkerController { pub async fn spawn_runtime_managed_run_with_transport( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error> where C: LlmClient + Clone + 'static, St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static, { - let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned( - worker, - run_dir, - bash_output_dir, - transport, - ) - .await?; + let (handle, shutdown, _task) = + Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport) + .await?; Ok((handle, shutdown)) } pub(crate) async fn spawn_runtime_managed_run_with_transport_owned( worker: Worker, run_dir: &Path, - bash_output_dir: &Path, + transport: WorkerControllerTransport, ) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error> where @@ -830,21 +811,13 @@ impl WorkerController { let parent = run_dir .parent() .ok_or_else(|| std::io::Error::other("run path has no parent"))?; - Self::spawn_inner( - worker, - parent, - bash_output_dir, - true, - Some(run_dir), - transport, - ) - .await + Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await } async fn spawn_inner( worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + runtime_managed: bool, runtime_run: Option<&Path>, transport: WorkerControllerTransport, @@ -857,7 +830,6 @@ impl WorkerController { let result = Self::spawn_initialized( worker, runtime_base, - bash_output_dir, runtime_managed, runtime_run, transport, @@ -874,7 +846,7 @@ impl WorkerController { async fn spawn_initialized( mut worker: Worker, runtime_base: &Path, - bash_output_dir: &Path, + runtime_managed: bool, runtime_run: Option<&Path>, transport: WorkerControllerTransport, @@ -888,8 +860,7 @@ impl WorkerController { .map_err(std::io::Error::other)?; // === 1. Initialization (channels / RuntimeDir / worker-immutable - // snapshots / SpawnedWorkerRegistry / alerter attach / - // bash-output scope) === + // snapshots / SpawnedWorkerRegistry / alerter attach) === let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let (method_tx, method_rx) = mpsc::channel::(32); let (working_event_tx, _) = broadcast::channel::(256); @@ -897,8 +868,8 @@ impl WorkerController { let in_flight = InFlightEvents::new(working_event_tx.clone()); worker.attach_in_flight_events(in_flight.clone()); - // Runtime directory is created before tool registration because it owns - // bounded tool artifacts, and before initial status/history writes consume + // Runtime directory is created before tool registration and before + // initial status/history writes consume // the greeting we build after registration is complete. let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run { RuntimeDir::create_worker_run(run_dir).await? @@ -935,26 +906,6 @@ impl WorkerController { worker.attach_internal_worker_registry(spawned_registry.clone()); worker.attach_working_event_tx(working_event_tx.clone()); - // Bash spill artifacts are owned by the stable Worker identity rather - // than a controller session/run generation. Push a recursive - // `allow(Read)` for the exact tool output path into the Worker's shared - // runtime scope so the Workdir session and system prompt stay aligned. - let bash_output_dir = bash_output_dir.to_path_buf(); - std::fs::create_dir_all(&bash_output_dir).map_err(|e| { - std::io::Error::other(format!( - "create bash output dir {}: {e}", - bash_output_dir.display() - )) - })?; - worker - .add_scope_rules([manifest::ScopeRule { - target: bash_output_dir.clone(), - permission: manifest::Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }]) - .map_err(std::io::Error::other)?; - // === 1.5. Direct writer wiring === // // Engine callbacks fire `on_history_append` for each assistant @@ -974,7 +925,6 @@ impl WorkerController { // === 3. Tool registration (builtin / memory / spawn-orchestration) === let fs_for_view = register_worker_tools( &mut worker, - bash_output_dir, runtime_base.to_path_buf(), spawned_registry.clone(), Some(method_tx.downgrade()), @@ -1440,7 +1390,7 @@ fn validate_memory_lifecycle_targets( /// the shared state. pub(crate) async fn register_worker_tools( worker: &mut Worker, - bash_output_dir: PathBuf, + runtime_base: PathBuf, spawned_registry: Arc, parent_method_tx: Option>, @@ -1493,6 +1443,7 @@ where let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none(); let prompts = worker.prompts().clone(); let tracker = tools::Tracker::new(); + let bash_output = spawned_registry.bash_output(); { let paste_store = worker.store().clone(); let paste_session_id = worker.session_id(); @@ -1515,13 +1466,13 @@ where tools::routed_builtin_tools_for_current_capabilities( workdir_sessions.clone(), tracker.clone(), - bash_output_dir.clone(), + bash_output.clone(), ) } else { tools::routed_builtin_tools( workdir_sessions.clone(), tracker.clone(), - bash_output_dir.clone(), + bash_output.clone(), ) }; worker.engine_mut().register_tools(workdir_tools); @@ -1918,6 +1869,20 @@ where ) })?; } + if wip_mode { + crate::feature::builtin::bash::mount( + &mut wip_mount_registry, + worker.workdir_sessions(), + bash_output.clone(), + wip_permissions.clone(), + ) + .map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("mount Bash output WIP projection: {error}"), + ) + })?; + } if wip_mode && let Some(feature) = &workdir_wip_feature { crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip( &mut wip_mount_registry, @@ -2048,7 +2013,6 @@ where spawner_workspace_context, parent_notifications, runtime_base.clone(), - bash_output_dir.clone(), spawner_workspace_root, workdir_tool_broker, spawned_registry.clone(), @@ -3334,6 +3298,8 @@ async fn controller_loop( } } + spawned_registry.bash_output().revoke(); + if let Some(observer) = command_observer.take() { observer.abort(); let _ = observer.await; @@ -4234,7 +4200,6 @@ mod tests { .descriptor(); let registered = register_worker_tools( &mut worker, - root.path().join("bash-output"), root.path().join("runtime"), SpawnedWorkerRegistry::new_for_internal_services(), None, diff --git a/crates/worker/src/controller_subject_release_tests.rs b/crates/worker/src/controller_subject_release_tests.rs index 3cda546fe..d57dc4530 100644 --- a/crates/worker/src/controller_subject_release_tests.rs +++ b/crates/worker/src/controller_subject_release_tests.rs @@ -64,7 +64,6 @@ async fn prepared( WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 88745da61..15f15407b 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode { return ExitCode::FAILURE; } }; - let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name); + let started = match start_worker_controller( worker, WorkerBootstrapLayout::Direct { runtime_base: runtime_base.clone(), - bash_output_dir, }, WorkerControllerTransport::UnixSocket, ) diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index a68b8f992..529cfa8a0 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -5,6 +5,7 @@ //! an external plugin-loading surface. pub mod backend_job_result; +pub(crate) mod bash; pub mod chat_invocation; pub mod drive; pub mod flow_transition; diff --git a/crates/worker/src/feature/builtin/bash.rs b/crates/worker/src/feature/builtin/bash.rs new file mode 100644 index 000000000..ecd94428a --- /dev/null +++ b/crates/worker/src/feature/builtin/bash.rs @@ -0,0 +1,451 @@ +//! Bash Feature projection. The view is instance-owned and shared only by explicit parent +//! routing. No output is indexable; committed result entries directly address immutable files. +//! Root namespace knowledge grants nothing: every publication and read checks the current view. + +use crate::{ + file_operation::{self, Contract}, + permission::permission_action_for, + wip::{ + WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler, + WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount, + WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json, + }, +}; +use agen::tool::{Tool, ToolError}; +use async_trait::async_trait; +use manifest::{ToolPermissionAction, ToolPermissionConfig}; +use serde_json::{Value as Json, json}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, sync::Arc}; +use tools::{BashTool, bash_output::BashOutputView}; +use wip_protocol::{ + Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, + OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode, + ReturnDeclaration, TypeExpr, Value, +}; +use workdir::{CommandStream, WorkdirSessionRouter}; + +const ROOT: &str = "/bash-output"; + +pub(crate) fn mount( + registry: &mut WipMountRegistry, + router: Arc, + view: BashOutputView, + permissions: Option, +) -> Result<(), WipMountError> { + registry.allocate_namespace("bash", "bash-output")?; + let provider = Arc::new(Provider { + service: BashTool::new(router, view.clone()), + view, + permissions, + incarnation: uuid::Uuid::now_v7().to_string(), + }); + registry.mount(provider.root())?; + registry.replace_compatibility_tools(ROOT, ["Bash"])?; + registry.mount_subtree(WipSubtreeMount { + root: ROOT.into(), + provider: Arc::new(provider), + }) +} + +struct Provider { + service: BashTool, + view: BashOutputView, + permissions: Option, + incarnation: String, +} +impl Provider { + fn allowed(&self, tool: &str, args: &Json) -> bool { + self.view.is_active() + && self + .permissions + .as_ref() + .is_none_or(|p| permission_action_for(p, tool, args) == ToolPermissionAction::Allow) + } + fn may_allow(&self, tool: &str) -> bool { + if !self.view.is_active() { + return false; + } + let Some(p) = &self.permissions else { + return true; + }; + for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) { + if rule.action == ToolPermissionAction::Allow { + return true; + } + if rule.pattern == "*" { + return false; + } + } + p.default_action == ToolPermissionAction::Allow + } + fn root(self: &Arc) -> WipProjection { + let interface = contextual_reference("yoi.bash/command/v1", ROOT); + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: Some(Documentation { + summary: "Run Bash with bounded ephemeral output; retained bytes are accessed via result entries, not listed".into(), + details: None, + }), + types: vec![], + operations: vec![OperationDeclaration { + name: "run".into(), + documentation: None, + parameters: vec![ParameterDeclaration { + name: "input".into(), + required: true, + documentation: Some(Documentation { + summary: "Bash input, validated by the existing Tool argument contract".into(), + details: Some(BashTool::input_schema().to_string()), + }), + r#type: TypeExpr::Json, + }], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Record { + fields: vec![ + field("summary", TypeExpr::String), + field("stdout_preview", TypeExpr::String), + field("stderr_preview", TypeExpr::String), + field("preview_omitted", TypeExpr::Boolean), + field("metadata", TypeExpr::Json), + field("stdout_entry", TypeExpr::Entry), + field("stderr_entry", TypeExpr::Entry), + field("order_entry", TypeExpr::Entry), + ], + }, + }, + }], + }; + WipProjection { + route: ROOT.into(), + capability: "bash:command".into(), + kind: WipProjectionKind::Native, + object: Object { + name: "bash-output".into(), + description: Some("Bash command and nonindexable retained output".into()), + interfaces: vec![interface.clone()], + r#ref: Some(format!("bash:{}", self.incarnation)), + validator: Some(self.incarnation.as_bytes().to_vec()), + }, + interface, + interface_validator: Some(Sha256::digest(format!("{descriptor:?}")).to_vec()), + descriptor, + handler: Arc::new(Run { + provider: self.clone(), + }), + } + } + fn parse(&self, path: &str) -> Option<(String, Option)> { + let rest = path.strip_prefix("/bash-output/")?; + let (id, stream) = rest.split_once('/')?; + if uuid::Uuid::parse_str(id).ok()?.to_string() != id { + return None; + } + let stream = match stream { + "stdout" => Some(CommandStream::Stdout), + "stderr" => Some(CommandStream::Stderr), + "order" => None, + _ => return None, + }; + Some((id.into(), stream)) + } +} + +#[async_trait] +impl WipSubtreeProvider for Arc { + async fn publication(&self, path: &str) -> Result, ProtocolError> { + if !self.view.is_active() { + return Ok(None); + } + if path == ROOT { + let projection = self.root(); + return Ok(Some(WipPublication { + scope: Some(projection.object.clone()), + projection, + })); + } + let Some((id, stream)) = self.parse(path) else { + return Ok(None); + }; + let Some(metadata) = self.view.metadata(&id) else { + return Ok(None); + }; + if !self.may_allow("Read") && !self.may_allow("Grep") { + return Ok(None); + } + let mut contracts = vec![]; + if self.may_allow("Read") { + contracts.extend([Contract::ReadLines, Contract::ReadBytes]); + } + if self.may_allow("Grep") { + contracts.push(Contract::SearchText); + } + let primary = contracts[0]; + let interface = primary.reference(); + let descriptor = primary.descriptor(); + let validator = Sha256::digest(format!("{}:{id}:{stream:?}", self.incarnation)).to_vec(); + let mut projection = WipProjection { + route: path.into(), + capability: "bash:output".into(), + kind: WipProjectionKind::Native, + object: Object { + name: path.rsplit('/').next().unwrap().into(), + description: Some(format!( + "Read-only output component (stdout={} bytes, stderr={} bytes); text is UTF-8 lossy; storage_truncated={}", + metadata.stdout_saved, metadata.stderr_saved, metadata.storage_truncated + )), + interfaces: vec![interface.clone()], + r#ref: Some(format!("bash:{}:{id}:{stream:?}", self.incarnation)), + validator: Some(validator.clone()), + }, + interface, + descriptor: descriptor.clone(), + interface_validator: Some(primary.validator()), + handler: Arc::new(File { + provider: self.clone(), + id, + stream, + path: path.into(), + validator, + }), + }; + file_operation::expose(&mut projection, contracts); + Ok(Some(WipPublication { + projection, + scope: None, + })) + } + async fn children(&self, _: &str) -> Result, ProtocolError> { + Ok(vec![]) + } +} + +struct Run { + provider: Arc, +} +#[async_trait] +impl WipOperationHandler for Run { + fn is_visible(&self) -> bool { + self.provider.view.is_active() + } + fn operation_available(&self, op: &str) -> bool { + op == "run" && self.provider.may_allow("Bash") + } + async fn call( + &self, + op: &str, + args: &BTreeMap, + ctx: WipCallContext, + ) -> Result { + if op != "run" { + return Err(fail( + ProtocolErrorCode::OperationNotFound, + "unknown Bash operation", + )); + } + let input = args + .get("input") + .ok_or_else(|| fail(ProtocolErrorCode::InvalidArguments, "input is required"))?; + let input = + wip_to_json(input).map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?; + if !self.provider.allowed("Bash", &input) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "Bash not permitted", + )); + } + let result = self + .provider + .service + .execute_result(&input.to_string(), ctx.execution) + .await + .map_err(tool_error)?; + if result.cancelled { + return Err(WipOperationError::Cancelled(result.tool_output())); + } + if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) { + return Err(WipOperationError::Interrupted(result.tool_output())); + } + // Entry fields are typed by the native Descriptor. No display-string parsing is involved. + Ok(WipOperationOutput::native(Value::Record(BTreeMap::from([ + ("summary".into(), Value::String(result.summary.clone())), + ( + "stdout_preview".into(), + Value::String(result.stdout_preview.clone()), + ), + ( + "stderr_preview".into(), + Value::String(result.stderr_preview.clone()), + ), + ( + "preview_omitted".into(), + Value::Boolean(result.preview_omitted), + ), + ("stdout_entry".into(), Value::String(result.stdout_entry())), + ("stderr_entry".into(), Value::String(result.stderr_entry())), + ("order_entry".into(), Value::String(result.order_entry())), + ( + "metadata".into(), + json_to_wip(&serde_json::to_value(result.metadata).unwrap()) + .map_err(|e| fail(ProtocolErrorCode::Internal, &e))?, + ), + ])))) + } + async fn cancel(&self, ctx: &agen::tool::ToolExecutionContext) -> Result<(), ToolError> { + self.provider.service.cancel_execution(ctx).await + } +} + +struct File { + provider: Arc, + id: String, + stream: Option, + path: String, + validator: Vec, +} +impl File { + fn order_text(&self) -> Result { + use std::fmt::Write; + let ranges = self + .provider + .view + .order(&self.id) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?; + let mut text = String::new(); + for r in ranges { + writeln!(&mut text, "{:?} {} {}", r.stream, r.start, r.end).unwrap(); + } + Ok(text) + } +} +#[async_trait] +impl WipOperationHandler for File { + fn is_visible(&self) -> bool { + self.provider.view.metadata(&self.id).is_some() + } + fn object_validator(&self) -> Option> { + self.is_visible().then(|| self.validator.clone()) + } + fn operation_available(&self, op: &str) -> bool { + match op { + "read_lines" | "read_bytes" | "read" => self.provider.may_allow("Read"), + "search_text" | "search" => self.provider.may_allow("Grep"), + _ => false, + } + } + async fn call( + &self, + op: &str, + args: &BTreeMap, + _: WipCallContext, + ) -> Result { + if !self.operation_available(op) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "output operation not permitted", + )); + } + let mut input = wip_to_json(&Value::Record(args.clone())) + .map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?; + let tool = if op == "search_text" { + input["path"] = json!(self.path); + "Grep" + } else { + input["file_path"] = json!(self.path); + "Read" + }; + if !self.provider.allowed(tool, &input) { + return Err(fail( + ProtocolErrorCode::PermissionDenied, + "output operation not permitted", + )); + } + let metadata = self + .provider + .view + .metadata(&self.id) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?; + if op == "read_bytes" { + let (offset, limit) = file_operation::byte_range(args)?; + let (bytes, total) = if let Some(stream) = self.stream { + let total = if stream == CommandStream::Stdout { + metadata.stdout_saved + } else { + metadata.stderr_saved + }; + if offset > total as u64 { + return Err(fail( + ProtocolErrorCode::InvalidArguments, + "byte offset exceeds output length", + )); + } + let bytes = self + .provider + .view + .read(&self.id, stream, offset as usize, limit) + .ok_or_else(|| { + fail(ProtocolErrorCode::NotFound, "output expired or revoked") + })?; + (bytes, total) + } else { + let text = self.order_text()?; + if offset > text.len() as u64 { + return Err(fail( + ProtocolErrorCode::InvalidArguments, + "byte offset exceeds output length", + )); + } + ( + text.as_bytes()[offset as usize..(offset as usize + limit).min(text.len())] + .to_vec(), + text.len(), + ) + }; + let eof = offset + bytes.len() as u64 == total as u64; + return Ok(WipOperationOutput::native_with_validator( + file_operation::bytes_result(args, &bytes, eof)?, + self.validator.clone(), + )); + } + let (offset, limit) = file_operation::window(args, op == "search_text")?; + let value = if let Some(stream) = self.stream { + let (text, more) = self + .provider + .view + .line_window(&self.id, stream, offset, limit) + .ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))? + .map_err(|e| fail(ProtocolErrorCode::ResourceLimitExceeded, &e))?; + file_operation::page_result(op, args, &text, offset, more)? + } else { + file_operation::text_result(op, args, &self.order_text()?)? + }; + Ok(WipOperationOutput::native_with_validator( + value, + self.validator.clone(), + )) + } +} + +fn fail(code: ProtocolErrorCode, message: &str) -> WipOperationError { + WipOperationError::Protocol(ProtocolError { + code, + message: message.into(), + }) +} +fn tool_error(error: ToolError) -> WipOperationError { + match error { + ToolError::InvalidArgument(_) => fail(ProtocolErrorCode::InvalidArguments, "invalid Bash arguments"), + ToolError::Cancelled(output) => WipOperationError::Cancelled(output), + ToolError::Interrupted(output) => WipOperationError::Interrupted(output), + _ => WipOperationError::OutcomeUnknown("Bash execution failed; effects or process termination may be unconfirmed; do not automatically restart the command".into()), + } +} +fn field(name: &str, r#type: TypeExpr) -> FieldDeclaration { + FieldDeclaration { + name: name.into(), + required: true, + documentation: None, + r#type, + } +} diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 8cd5477be..ad35374e2 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { } } - async fn start_command( - &self, - mut request: CommandRequest, - ) -> Result { - // `spill_dir` belongs to the Worker host running BashTool. This remote - // WorkdirSession provider cannot safely resolve or write that host path. - request.spill_dir = None; + async fn start_command(&self, request: CommandRequest) -> Result { match self.operate(WorkdirSessionOperation::CommandStart(request))? { WorkdirSessionOperationResult::CommandStart(result) => Ok(result), _ => Err(Self::mismatch("command_start")), @@ -1660,9 +1654,9 @@ mod tests { .start_command(CommandRequest { command: "true".to_string(), timeout_secs: 120, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker-local/bash-output".into()), tool_call_id: Some("call-1".to_string()), }) .await @@ -1675,7 +1669,7 @@ mod tests { assert_eq!(body["operation"]["operation"], "command_start"); assert_eq!(body["operation"]["request"]["command"], "true"); assert_eq!(body["operation"]["request"]["cwd"], ""); - assert!(body["operation"]["request"]["spill_dir"].is_null()); + assert!(body["operation"]["request"].get("spill_dir").is_none()); assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1"); assert!( !requests[0] @@ -1942,9 +1936,9 @@ mod tests { .start_command(CommandRequest { command: "printf done".to_string(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 402728e21..c64dd2b59 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -40,7 +40,7 @@ mod worker; pub use bootstrap::{ BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError, - WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller, + WorkerBootstrapLayout, start_worker_controller, }; pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate}; pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle}; diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 05317f491..443e86281 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord { pub(crate) fn new( worker_name: String, scope_delegated: Vec, - workdir_tool_scope: WorkdirScopeLeaseSet, + workdir_tool_scope: Arc, #[cfg(test)] installed_tools: Vec, session: InternalWorkerSessionHandle, child_registry: Arc, @@ -97,7 +97,7 @@ impl InternalSpawnedWorkerRecord { Self { worker_name: worker_name.clone(), scope_delegated, - workdir_tool_scope: Arc::new(workdir_tool_scope), + workdir_tool_scope, #[cfg(test)] installed_tools: installed_tools.into(), published: Arc::new(super::published::PublishedProtocol::new( @@ -329,6 +329,7 @@ pub struct SpawnedWorkerRegistry { pending_internal_notify: Notify, internal_spawn_cleanup_failed: AtomicBool, parent_scope: Option, + bash_output: Mutex, parent_protocol: Mutex, String)>>, protocol_publish_lock: Arc>, } @@ -340,6 +341,14 @@ pub struct SpawnedWorkerRegistryLoad { } impl SpawnedWorkerRegistry { + pub(crate) fn bash_output(&self) -> tools::bash_output::BashOutputView { + self.bash_output.lock().unwrap().clone() + } + + pub(crate) fn inherit_bash_output(&self, view: tools::bash_output::BashOutputView) { + *self.bash_output.lock().unwrap() = view; + } + pub(crate) fn new_for_internal_services() -> Arc { Arc::new(Self { internal_records: std::sync::Mutex::new(Vec::new()), @@ -352,6 +361,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -370,6 +380,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: None, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -387,6 +398,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope: Some(parent_scope), + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }) @@ -473,6 +485,7 @@ impl SpawnedWorkerRegistry { pending_internal_notify: Notify::new(), internal_spawn_cleanup_failed: AtomicBool::new(false), parent_scope, + bash_output: Mutex::new(tools::bash_output::BashOutputView::new()), parent_protocol: Mutex::new(None), protocol_publish_lock: Arc::new(Mutex::new(())), }), @@ -966,6 +979,10 @@ impl SpawnedWorkerRegistry { "an in-flight Internal SubWorker failed cleanup during shutdown", )); } + let output = self.bash_output(); + if !output.is_root() { + output.revoke(); + } first_error.map_or(Ok(()), Err) } @@ -1111,6 +1128,7 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option, registry: Arc, @@ -358,7 +358,7 @@ impl SubWorkerSpawnTool { workspace_context, parent_notifications, runtime_base, - bash_output_dir, + workspace_root, workdir_tool_broker, registry, @@ -430,15 +430,6 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; let workdir_rules = parse_workdir_scope(&input.scope)?; - let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name); - tokio::fs::create_dir_all(&child_bash_output_dir) - .await - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "create Internal Worker Bash output directory {}: {error}", - child_bash_output_dir.display() - )) - })?; let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?; let tool_scopes = routed_workdir_tool_scopes( workdir_tool_broker, @@ -452,6 +443,7 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| { ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}")) })?; + let workdir_scope = Arc::new(workdir_scope); let child_workdir_tool_broker = workdir_scope.broker_router(); let spawn_selector = @@ -552,24 +544,17 @@ impl Tool for SubWorkerSpawnTool { ) .await .map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?; - child - .add_scope_rules([ScopeRule { - target: child_bash_output_dir.clone(), - permission: manifest::Permission::Read, - recursive: true, - symlink_policy: Default::default(), - }]) - .map_err(|error| { - ToolError::ExecutionFailed(format!( - "grant Internal Worker Bash output scope: {error}" - )) - })?; let child_scope = child.scope().clone(); child.bind_workdir_sessions(workdir_scope.session_router()); let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope); + let output_lease = workdir_scope.clone(); + child_registry.inherit_bash_output( + self.registry + .bash_output() + .child(Arc::new(move || output_lease.is_active())), + ); register_worker_tools( &mut child, - child_bash_output_dir, self.runtime_base.clone(), child_registry.clone(), None, @@ -1052,7 +1037,7 @@ pub(crate) fn sub_worker_spawn_tool( workspace_context: crate::worker::WorkerWorkspaceContext, parent_notifications: ParentNotificationTarget, runtime_base: PathBuf, - bash_output_dir: PathBuf, + workspace_root: PathBuf, workdir_tool_broker: Option, registry: Arc, @@ -1064,7 +1049,6 @@ pub(crate) fn sub_worker_spawn_tool( workspace_context, parent_notifications, runtime_base, - bash_output_dir, workspace_root, workdir_tool_broker, registry, @@ -1078,7 +1062,7 @@ fn sub_worker_spawn_tool_impl( workspace_context: crate::worker::WorkerWorkspaceContext, parent_notifications: ParentNotificationTarget, runtime_base: PathBuf, - bash_output_dir: PathBuf, + workspace_root: PathBuf, workdir_tool_broker: Option, registry: Arc, @@ -1110,7 +1094,6 @@ fn sub_worker_spawn_tool_impl( workspace_context.clone(), parent_notifications.clone(), runtime_base.clone(), - bash_output_dir.clone(), workspace_root.clone(), workdir_tool_broker.clone(), registry.clone(), @@ -1374,7 +1357,6 @@ enabled = false ParentNotificationTarget::Durable(Arc::new(|_| {})), ), runtime.path().to_path_buf(), - bash_output_dir.clone(), workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), @@ -1425,12 +1407,7 @@ enabled = false .await .expect("spawn project reviewer as Internal Worker"); assert!(output.summary.contains("internal worker `reviewer-child`")); - assert!( - bash_output_dir - .join("sub-workers") - .join("reviewer-child") - .is_dir() - ); + assert!(!bash_output_dir.join("sub-workers").exists()); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); let record = registry .get_internal("reviewer-child") @@ -1460,6 +1437,27 @@ enabled = false record.session.wait_until_idle().await, crate::internal_worker::InternalWorkerSessionStatus::Idle ); + let child_output_view = record.child_registry.bash_output(); + let child_bash = tools::BashTool::new( + record.workdir_tool_scope.session_router(), + child_output_view.clone(), + ); + let child_output = child_bash + .execute_result( + r#"{"command":"printf retained-child-evidence","cwd":"."}"#, + Default::default(), + ) + .await + .unwrap(); + let output_id = child_output.metadata.id.clone(); + assert!(child_output_view.metadata(&output_id).is_some()); + assert_eq!( + registry + .bash_output() + .read(&output_id, workdir::CommandStream::Stdout, 0, 65536) + .unwrap(), + b"retained-child-evidence" + ); assert_eq!(calls.load(Ordering::SeqCst), 1); assert!(!observed_parent_write_revoked.load(Ordering::SeqCst)); assert!(observed_instruction_override.load(Ordering::SeqCst)); @@ -1563,6 +1561,8 @@ enabled = false !record.workdir_tool_scope.is_active(), "stopped child must revoke cloned scoped sessions" ); + assert!(child_output_view.metadata(&output_id).is_none()); + assert!(registry.bash_output().metadata(&output_id).is_some()); assert!(registry.get_internal("reviewer-child").is_some()); assert!( @@ -1573,6 +1573,7 @@ enabled = false .is_some() ); assert!(registry.get_internal("reviewer-child").is_none()); + assert!(registry.bash_output().metadata(&output_id).is_some()); assert!(spawner_scope.snapshot().is_writable(&workspace_root)); fail_requests.store(false, Ordering::SeqCst); @@ -1672,7 +1673,6 @@ enabled = false ), ParentNotificationTarget::Durable(Arc::new(|_| {})), runtime.path().to_path_buf(), - bash_output_dir, workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), @@ -1805,7 +1805,6 @@ enabled = false ParentNotificationTarget::Durable(Arc::new(|_| {})), ), runtime.path().to_path_buf(), - bash_output_dir.clone(), workspace_root.clone(), Some(workdir_tool_broker), registry.clone(), diff --git a/crates/worker/src/subjektiv.rs b/crates/worker/src/subjektiv.rs index 40d4836c6..2009679ce 100644 --- a/crates/worker/src/subjektiv.rs +++ b/crates/worker/src/subjektiv.rs @@ -919,7 +919,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash-output"), }, WorkerControllerTransport::InProcess, ) @@ -1113,7 +1112,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) @@ -1279,7 +1277,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), }, WorkerControllerTransport::InProcess, ) @@ -1339,7 +1336,6 @@ enabled = true WorkerFilesystemAuthority::None, WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash-output"), }, WorkerControllerTransport::InProcess, ) diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index fe48b30ee..400436304 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -7126,3 +7126,6 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 0); } } + +#[cfg(test)] +mod bash_output_tests; diff --git a/crates/worker/src/wip/bash_output_tests.rs b/crates/worker/src/wip/bash_output_tests.rs new file mode 100644 index 000000000..21f2b88bc --- /dev/null +++ b/crates/worker/src/wip/bash_output_tests.rs @@ -0,0 +1,510 @@ +//! Native Bash output integration through real WIP Client/Host codec and local process pipes. +//! No telemetry/storage fake: only the model client is absent. Runtime HTTP transport is tested +//! by worker-runtime's command_transport_tests. Each fixture owns its Workdir and ephemeral view. +use super::*; +use crate::file_operation::Contract; +use tempfile::TempDir; +use tools::bash_output::BashOutputView; +use workdir::{ + LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter, +}; + +struct Fixture { + _dir: TempDir, + router: Arc, + view: BashOutputView, + runtime: WipRuntime, +} +impl Fixture { + fn new() -> Self { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("real.txt"), "first\n雪 needle\nlast\n").unwrap(); + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(dir.path()).unwrap(), + dir.path().into(), + )); + let router = Arc::new(WorkdirSessionRouter::new()); + router + .attach(WorkdirAttachmentAlias::new("main").unwrap(), session) + .unwrap(); + let view = BashOutputView::new(); + let runtime = runtime(router.clone(), view.clone()); + Self { + _dir: dir, + router, + view, + runtime, + } + } + async fn run(&self, command: &str) -> Json { + value( + self.runtime + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":command,"cwd":"."}}), + Default::default(), + ) + .await + .unwrap(), + ) + } +} +fn runtime(router: Arc, view: BashOutputView) -> WipRuntime { + let mut registry = WipMountRegistry::new(); + crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None) + .unwrap(); + crate::feature::builtin::bash::mount(&mut registry, router, view, None).unwrap(); + WipRuntime::new(WipHost::new(registry), SecurityContext::new("bash-test"), 0).unwrap() +} +fn value(output: ToolOutput) -> Json { + serde_json::from_str(&output.content.unwrap()).unwrap() +} +async fn invoke( + runtime: &WipRuntime, + path: &str, + contract: Contract, + operation: &str, + args: Json, +) -> Result { + runtime + .invoke( + path.into(), + contract.reference(), + operation.into(), + args, + Default::default(), + ) + .await + .map(value) +} + +#[tokio::test] +async fn native_bash_entries_reuse_checkout_contract_for_direct_inspect_read_search() { + let f = Fixture::new(); + let result = f + .run("printf 'first\n雪 needle\nlast\n'; printf error >&2; exit 3") + .await; + assert_eq!(result["metadata"]["terminal"]["exit_code"], 3); + let stdout = result["stdout_entry"].as_str().unwrap(); + assert!(stdout.starts_with("/bash-output/")); + // The native result Descriptor gives these coordinates TypeExpr::Entry; Object inspection + // contains capabilities and metadata, not the process output body. + let projection = f + .runtime + .host + .projection_live("/bash-output") + .await + .unwrap() + .unwrap(); + let TypeExpr::Record { fields } = &projection.descriptor.operations[0].returns.r#type else { + panic!("native record"); + }; + assert_eq!( + fields + .iter() + .find(|v| v.name == "stdout_entry") + .unwrap() + .r#type, + TypeExpr::Entry + ); + let inspect = f.runtime.inspect(stdout.into(), false).await.unwrap(); + assert!(!inspect.content.unwrap().contains("雪 needle")); + for path in ["/checkouts/main/real.txt", stdout] { + let read = invoke( + &f.runtime, + path, + Contract::ReadLines, + "read", + json!({"offset":1,"limit":1}), + ) + .await + .unwrap(); + assert!(read["content"].as_str().unwrap().contains("雪 needle")); + assert_eq!(read["next_offset"], 2); + let search = invoke( + &f.runtime, + path, + Contract::SearchText, + "search", + json!({"offset":0,"limit":2,"pattern":"needle"}), + ) + .await + .unwrap(); + assert_eq!(search["matches"][0]["line"], 2); + assert_eq!(search["next_offset"], 2); + assert_eq!(search["truncated"], true); + } + for name in ["stdout_entry", "stderr_entry", "order_entry"] { + assert_eq!( + fields.iter().find(|v| v.name == name).unwrap().r#type, + TypeExpr::Entry + ); + f.runtime + .inspect(result[name].as_str().unwrap().into(), false) + .await + .unwrap(); + } + let stderr = invoke( + &f.runtime, + result["stderr_entry"].as_str().unwrap(), + Contract::ReadLines, + "read", + json!({}), + ) + .await + .unwrap(); + assert!(stderr["content"].as_str().unwrap().contains("error")); + let order = invoke( + &f.runtime, + result["order_entry"].as_str().unwrap(), + Contract::ReadLines, + "read", + json!({}), + ) + .await + .unwrap(); + assert!(order["content"].as_str().unwrap().contains("Stdout")); + assert!(order["content"].as_str().unwrap().contains("Stderr")); + assert_eq!(result["metadata"]["ordering"], "provider_read_observation"); + let tree = value( + f.runtime + .tree("/bash-output".into(), 8, true) + .await + .unwrap(), + ); + assert!(!tree.to_string().contains(stdout)); + assert!( + invoke( + &f.runtime, + stdout, + Contract::Write, + "write", + json!({"content":"no"}) + ) + .await + .is_err() + ); + assert!( + invoke( + &f.runtime, + stdout, + Contract::Edit, + "edit", + json!({"old_string":"first","new_string":"no"}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn common_byte_reader_handles_long_lines_raw_invalid_utf8_boundaries_and_eof() { + let f = Fixture::new(); + let result = f.run("printf '%100000s' ''; printf '€\\377'").await; + let path = result["stdout_entry"].as_str().unwrap(); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadLines, + "read", + json!({"limit":1}) + ) + .await + .is_err() + ); + for (offset, expected) in [ + (99999, vec![b' ', 0xe2]), + (100001, vec![0x82, 0xac]), + (100003, vec![0xff]), + ] { + let read = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":offset,"limit":2}), + ) + .await + .unwrap(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(read["data"].as_str().unwrap()) + .unwrap(); + assert_eq!(bytes, expected); + assert_eq!(read["next_offset"], offset + expected.len() as u64); + } + let eof = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":100004,"limit":2}), + ) + .await + .unwrap(); + assert_eq!(eof["data"], ""); + assert_eq!(eof["eof"], true); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":100005}) + ) + .await + .is_err() + ); + assert!( + invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"limit":32769}) + ) + .await + .is_err() + ); +} + +#[tokio::test] +async fn publication_and_invoke_recheck_child_authority_and_root_teardown_invalidates_old_refs() { + let f = Fixture::new(); + let lease = Arc::new(std::sync::atomic::AtomicBool::new(true)); + let child = f.view.child({ + let lease = lease.clone(); + Arc::new(move || lease.load(std::sync::atomic::Ordering::Acquire)) + }); + let child_runtime = runtime(f.router.clone(), child.clone()); + let result = value( + child_runtime + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"printf child-evidence","cwd":"."}}), + Default::default(), + ) + .await + .unwrap(), + ); + let path = result["stdout_entry"].as_str().unwrap(); + child_runtime.inspect(path.into(), false).await.unwrap(); + let sibling = runtime(f.router.clone(), f.view.child(Arc::new(|| true))); + let unrelated = runtime(f.router.clone(), BashOutputView::new()); + assert!(sibling.inspect(path.into(), false).await.is_err()); + assert!(unrelated.inspect(path.into(), false).await.is_err()); + lease.store(false, std::sync::atomic::Ordering::Release); + assert!( + invoke(&child_runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + assert!( + invoke( + &child_runtime, + path, + Contract::SearchText, + "search", + json!({"pattern":"child"}) + ) + .await + .is_err() + ); + assert!( + invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_ok() + ); + f.view.revoke(); + assert!( + invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + let restored = runtime(f.router.clone(), BashOutputView::new()); + assert!(restored.inspect(path.into(), false).await.is_err()); +} + +#[tokio::test] +async fn native_timeout_and_cancellation_keep_terminal_evidence_instead_of_success() { + let f = Fixture::new(); + let session = f.router.only_session().unwrap(); + let mut events = session.subscribe_command_events().unwrap(); + let runtime = Arc::new(f.runtime); + let context = agen::tool::ToolExecutionContext::new("native-cancel", "native-attempt", 0); + let running = runtime.clone(); + let executing_context = context.clone(); + let execution = tokio::spawn(async move { + running + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"printf before; while :; do :; done","cwd":"."}}), + executing_context, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + if matches!( + events.recv().await.unwrap(), + workdir::CommandEvent::Output { .. } + ) { + break; + } + } + }) + .await + .unwrap(); + runtime.cancel(&context).await.unwrap(); + let ToolError::Cancelled(output) = + tokio::time::timeout(std::time::Duration::from_secs(5), execution) + .await + .unwrap() + .unwrap() + .unwrap_err() + else { + panic!("expected cancelled terminal output") + }; + let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap(); + let id = body["output"]["id"].as_str().unwrap(); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert_eq!(body["output"]["terminal"]["status"], "cancelled"); + assert_eq!( + f.view + .read(id, workdir::CommandStream::Stdout, 0, 20) + .unwrap(), + b"before" + ); + assert!( + invoke( + &runtime, + body["stdout_entry"].as_str().unwrap(), + Contract::ReadBytes, + "read", + json!({}) + ) + .await + .is_ok() + ); + let error = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf timeout; while :; do :; done","cwd":".","timeout":1}}), Default::default()).await.unwrap_err(); + let ToolError::Interrupted(output) = error else { + panic!("expected timeout evidence") + }; + let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(body["output"]["terminal"]["status"], "timed_out"); + assert_eq!(body["output"]["terminal"]["transport_complete"], true); + assert!( + f.view + .metadata(body["output"]["id"].as_str().unwrap()) + .is_some() + ); + session.close().await.unwrap(); +} + +#[tokio::test] +async fn common_read_and_search_permissions_are_independent_from_bash_execution() { + let f = Fixture::new(); + let result = f.run("printf needle").await; + let path = result["stdout_entry"].as_str().unwrap(); + let mut registry = WipMountRegistry::new(); + crate::feature::builtin::bash::mount( + &mut registry, + f.router.clone(), + f.view.clone(), + Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![manifest::ToolPermissionRule { + tool: "Grep".into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }], + }), + ) + .unwrap(); + let restricted = WipRuntime::new( + WipHost::new(registry), + SecurityContext::new("restricted"), + 0, + ) + .unwrap(); + restricted.inspect(path.into(), false).await.unwrap(); + assert!( + invoke(&restricted, path, Contract::ReadBytes, "read", json!({})) + .await + .is_err() + ); + assert!( + invoke(&restricted, path, Contract::ReadLines, "read", json!({})) + .await + .is_err() + ); + let found = invoke( + &restricted, + path, + Contract::SearchText, + "search", + json!({"pattern":"needle"}), + ) + .await + .unwrap(); + assert!(found["matches"].as_array().unwrap().len() == 1); + assert!( + restricted + .invoke( + "/bash-output".into(), + contextual_reference("yoi.bash/command/v1", "/bash-output"), + "run".into(), + json!({"input":{"command":"touch denied","cwd":"."}}), + Default::default() + ) + .await + .is_err() + ); + assert!(!f._dir.path().join("denied").exists()); +} + +#[tokio::test] +async fn multibyte_long_line_search_refuses_instead_of_skipping_an_unread_suffix() { + let f = Fixture::new(); + let result = f + .run("printf '雪%.0s' {1..22000}; printf 'needle\nnext\n'") + .await; + let path = result["stdout_entry"].as_str().unwrap(); + let error = invoke( + &f.runtime, + path, + Contract::SearchText, + "search", + json!({"pattern":"needle","offset":0,"limit":1}), + ) + .await + .unwrap_err(); + assert!( + error.to_string().contains("ResourceLimitExceeded"), + "{error}" + ); + // No successful page claims to advance past a partial line. Raw bytes can still + // retrieve its unread suffix using the same common file contract. + let suffix = invoke( + &f.runtime, + path, + Contract::ReadBytes, + "read", + json!({"offset":66000,"limit":7}), + ) + .await + .unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(suffix["data"].as_str().unwrap()) + .unwrap(), + b"needle\n" + ); + assert_eq!(suffix["next_offset"], 66007); +} diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index dc27dd70f..bc35e18cd 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -17589,12 +17589,10 @@ permission = "write" let layout = if runtime_managed { crate::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: temp.path().join("runtime/runs/1"), - bash_output_dir: temp.path().join("bash"), } } else { crate::WorkerBootstrapLayout::Direct { runtime_base: temp.path().join("runtime"), - bash_output_dir: temp.path().join("bash"), } }; crate::WorkerBootstrap::new( diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 2aeb8f521..26caca683 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -1495,11 +1495,9 @@ async fn post_compact_pause_bills_partial_without_rearming_or_consuming_correlat let session_id = worker.session_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle .send(Method::submit_text( @@ -1705,11 +1703,9 @@ async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() { let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await; let session_id = worker.session_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle @@ -1830,11 +1826,9 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { let source_session = worker.session_id(); let source_segment = worker.segment_id(); let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, shutdown_receiver) = - WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path()) + .await + .unwrap(); let mut rx = handle.subscribe(); handle @@ -1996,8 +1990,7 @@ async fn controller_compact_method_publishes_progress_and_clear() { ]); let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await; let runtime_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_tmp.path().join("bash-output"); - let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir) + let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path()) .await .unwrap(); let mut rx = handle.subscribe(); diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index cca6df2a2..3d8ed17cb 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -323,8 +323,7 @@ async fn spawn_controller(worker: Worker) -> WorkerHandle let tmp = tempfile::tempdir().unwrap(); let runtime_base = tmp.path().to_owned(); std::mem::forget(tmp); - let bash_output_dir = runtime_base.join("bash-output"); - let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir) + let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base) .await .unwrap(); handle @@ -354,13 +353,9 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() { ) .await .unwrap(); - let (handle, shutdown_rx) = WorkerController::spawn( - worker, - &root.path().join("runtime"), - &root.path().join("bash-output"), - ) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, &root.path().join("runtime")) + .await + .unwrap(); let mut rx = handle.subscribe(); let file = handle .upload_file_with_context( @@ -420,24 +415,20 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() { } #[tokio::test] -async fn controller_grants_read_scope_for_exact_bash_output_directory() { +async fn controller_does_not_grant_or_create_a_model_visible_bash_output_directory() { let worker = make_worker(MockClient::new(simple_text_events())).await; let shared_scope = worker.scope().clone(); let runtime_base = tempfile::tempdir().unwrap(); - let worker_tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output"); + let original_rules = shared_scope.snapshot().allow_rules().to_vec(); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); - assert!(bash_output_dir.is_dir()); - assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| { - rule.target == bash_output_dir - && rule.permission == manifest::Permission::Read - && rule.recursive - })); + assert_eq!( + shared_scope.snapshot().allow_rules(), + original_rules.as_slice() + ); assert!(!handle.runtime_dir.path().join("bash-output").exists()); handle @@ -463,9 +454,9 @@ async fn shutdown_closes_bound_workdir_session() { .start_command(CommandRequest { command: "sleep 30".to_owned(), timeout_secs: 60, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await @@ -473,11 +464,9 @@ async fn shutdown_closes_bound_workdir_session() { worker.bind_single_workdir_session(Some(Arc::clone(&session))); let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -513,9 +502,9 @@ async fn controller_filters_workdir_command_not_owned_by_worker() { .start_command(CommandRequest { command: "sleep 0.2".to_owned(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: Some("foreign-tool-call".into()), }) .await @@ -541,7 +530,10 @@ async fn controller_filters_workdir_command_not_owned_by_worker() { }) .await .unwrap(); - assert_eq!(output.status, workdir::CommandStatus::Completed); + assert_eq!( + output.terminal.unwrap().status, + workdir::CommandStatus::Completed + ); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -750,9 +742,8 @@ async fn controller_startup_failure_closes_bound_workdir_session() { let invalid_runtime_base = runtime_base.path().join("not-a-directory"); std::fs::write(&invalid_runtime_base, "file").unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); assert!( - WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir) + WorkerController::spawn(worker, &invalid_runtime_base) .await .is_err() ); @@ -761,9 +752,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() { .start_command(CommandRequest { command: "printf unreachable".to_owned(), timeout_secs: 5, - output_limit: 1024, + + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await, @@ -1571,8 +1562,7 @@ permission = "write" let client = MockClient::new(simple_text_events()); let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0; let tmp = tempfile::tempdir().unwrap(); - let bash_output_dir = tmp.path().join("bash-output"); - let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await; + let result = WorkerController::spawn(worker, tmp.path()).await; assert!( result.is_ok(), "feature exposure must not imply delegation authority" @@ -2945,11 +2935,9 @@ async fn status_json_reflects_worker_name() { async fn shutdown_closes_method_admission_before_terminal_confirmation() { let worker = make_worker(MockClient::new(simple_text_events())).await; let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, mut shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, mut shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); handle .send(Method::Shutdown { command: worker_command(&handle), @@ -2984,11 +2972,9 @@ async fn shutdown_joins_socket_server_with_active_connection() { let worker = make_worker(MockClient::new(simple_text_events())).await; let runtime_base = tempfile::tempdir().unwrap(); - let bash_output_dir = runtime_base.path().join("bash-output"); - let (handle, shutdown_rx) = - WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir) - .await - .unwrap(); + let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path()) + .await + .unwrap(); let socket_path = handle.runtime_dir.socket_path(); let _connection = UnixStream::connect(&socket_path).await.unwrap(); diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index fc6a302fe..c46c54926 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option { } pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { - let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; - cookie_header.split(';').find_map(|pair| { - let (name, value) = pair.trim().split_once('=')?; - (name == cookie_name && !value.is_empty()).then(|| value.to_string()) - }) + unique_cookie(headers, cookie_name) + .ok() + .flatten() + .filter(|value| !value.is_empty()) +} + +// Cookie has no path/domain metadata on requests. Duplicates (including identical values +// and multiple Cookie headers) cannot be disambiguated safely after cookie tossing. +fn unique_cookie( + headers: &HeaderMap, + cookie_name: &str, +) -> std::result::Result, ()> { + let mut found = None; + for header in headers.get_all(header::COOKIE) { + let header = header.to_str().map_err(|_| ())?; + for pair in header.split(';') { + let pair = pair.trim(); + let Some((name, value)) = pair.split_once('=') else { + if pair == cookie_name { + return Err(()); + } + continue; + }; + if name == cookie_name { + if found.is_some() { + return Err(()); + } + found = Some(value.to_owned()); + } + } + } + Ok(found) } pub async fn resolve_request_actor( @@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync( bearer: Option, session: Option, ) -> Result> { + Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor)) +} + +fn resolve_credentials_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session: Option, +) -> Result)>> { if let Some(token) = bearer { let token_hash = token_hash(&token); if let Some(api_token) = store.resolve_api_token(&token_hash)? { @@ -132,7 +167,10 @@ pub(crate) fn resolve_request_actor_sync( return Ok(None); } store.mark_api_token_used(&token_hash, &now_rfc3339())?; - return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken); + return Ok( + actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken)? + .map(|actor| (actor, None)), + ); } } @@ -141,13 +179,95 @@ pub(crate) fn resolve_request_actor_sync( if is_expired(&session.expires_at) { return Ok(None); } - return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession); + return Ok( + actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession)? + .map(|actor| (actor, Some(session.expires_at))), + ); } } Ok(None) } +// This marker is a non-authenticating reference to the existing non-revoked session row. +// It outlives the auth cookie so Max-Age expiry remains distinguishable from a new browser. +// No second session store is introduced; clearing all cookies necessarily loses this evidence. +// Keep the established 14-day authentication lifetime. The additional week is only a bounded +// return-navigation window (including a week away); it never extends authentication. The store +// reclaims expired rows past this same boundary on subsequent session issuance. Neither whoami +// nor other reads refresh the marker or the row's absolute expiry. +pub const BROWSER_SESSION_SECONDS: i64 = 14 * 24 * 60 * 60; +pub const SESSION_EXPIRY_GRACE_SECONDS: i64 = 7 * 24 * 60 * 60; + +pub fn session_marker_cookie_name(cookie_name: &str) -> String { + format!("{cookie_name}_expiry") +} + +pub async fn resolve_whoami( + store: std::sync::Arc, + headers: &HeaderMap, + cookie_name: &str, +) -> Result { + let bearer = parse_bearer(headers); + // Even an empty/invalid auth cookie takes precedence over an older marker. + // An ambiguous primary is invalid, not absent; never fall back to an older marker. + let session = unique_cookie(headers, cookie_name).unwrap_or_else(|()| Some(String::new())); + let marker = parse_cookie(headers, &session_marker_cookie_name(cookie_name)); + let executor = store.db_executor(); + executor + .run(move || resolve_whoami_sync(store.as_ref(), bearer, session, marker)) + .await +} + +fn resolve_whoami_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session_token: Option, + marker: Option, +) -> Result { + use server_api::{BrowserSessionState, WhoamiResponse}; + let anonymous = || WhoamiResponse { + actor: None, + session: BrowserSessionState::Anonymous {}, + }; + // Preserve the existing API-token precedence and fallback rules, including expired tokens. + if let Some((actor, expires_at)) = + resolve_credentials_sync(store, bearer, session_token.clone())? + { + return Ok(WhoamiResponse { + actor: Some(actor), + session: BrowserSessionState::Authenticated { expires_at }, + }); + } + let hash = match session_token { + Some(token) => token_hash(&token), + None => match marker { + Some(hash) => hash, + None => return Ok(anonymous()), + }, + }; + let Some(session) = store.resolve_browser_session(&hash)? else { + return Ok(anonymous()); + }; + if store.get_user(&session.user_id)?.is_none() { + return Ok(anonymous()); + } + if within_session_expiry_grace(&session.expires_at, Utc::now()) { + return Ok(WhoamiResponse { + actor: None, + session: BrowserSessionState::Expired {}, + }); + } + // A marker for a still-valid session cannot restore authentication after partial deletion. + Ok(anonymous()) +} + +fn within_session_expiry_grace(expires_at: &str, now: DateTime) -> bool { + DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| { + expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS) + }) +} + fn actor_for_user( store: &S, user_id: &str, @@ -274,6 +394,13 @@ mod tests { .unwrap(); assert_eq!(actor.user_id, "user-auth"); assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken); + let whoami = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!( + whoami.session, + server_api::BrowserSessionState::Authenticated { expires_at: None } + ); assert!( store .resolve_api_token(&hash) @@ -363,20 +490,119 @@ mod tests { assert!(futures::poll!(queued.as_mut()).is_pending()); let mut headers = HeaderMap::new(); headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + let actor_result = tokio::time::timeout( + std::time::Duration::from_secs(1), + resolve_request_actor(store.clone(), &headers, "session"), + ) + .await; let result = tokio::time::timeout( std::time::Duration::from_secs(1), - resolve_request_actor(store, &headers, "session"), + resolve_whoami(store, &headers, "session"), ) .await; release.send(()).unwrap(); blocker.await.unwrap().unwrap(); queued.await.unwrap(); + assert!(matches!( + actor_result.expect("actor lookup rejects capacity"), + Err(Error::DbCapacityExceeded) + )); assert!(matches!( result.expect("capacity rejection must not wait for queued SQL"), Err(Error::DbCapacityExceeded) )); } + #[tokio::test] + async fn whoami_classifies_only_live_store_backed_session_expiry() { + use server_api::BrowserSessionState; + let store = seeded_store(); + for (secret, offset, revoked) in [ + ("recent", -60, false), + ("old", -SESSION_EXPIRY_GRACE_SECONDS - 60, false), + ("revoked", -60, true), + ] { + store + .create_browser_session(&BrowserSessionRecord { + session_id: format!("session-{secret}"), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: "1".into(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: revoked.then(|| now_rfc3339()), + }) + .unwrap(); + } + let cases = [ + (String::new(), BrowserSessionState::Anonymous {}), + ("session=unknown".into(), BrowserSessionState::Anonymous {}), + ("session=recent".into(), BrowserSessionState::Expired {}), + ( + format!("session_expiry={}", token_hash("recent")), + BrowserSessionState::Expired {}, + ), + ( + format!("session_expiry={}", token_hash("session-secret")), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=unknown".into(), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=recent".into(), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=unknown; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ("session=revoked".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("revoked")), + BrowserSessionState::Anonymous {}, + ), + ("session=old".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("old")), + BrowserSessionState::Anonymous {}, + ), + ( + format!( + "session=session-secret; session_expiry={}", + token_hash("recent") + ), + BrowserSessionState::Authenticated { + expires_at: Some("9999-01-01T00:00:00Z".into()), + }, + ), + ]; + for (cookie, expected) in cases { + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, cookie.parse().unwrap()); + let response = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!(response.session, expected, "cookie: {cookie}"); + assert_eq!( + response.actor.is_some(), + matches!(expected, BrowserSessionState::Authenticated { .. }) + ); + if !matches!(expected, BrowserSessionState::Authenticated { .. }) { + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + } + } + } + #[test] fn user_code_uses_the_uuid_random_tail() { let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID"); @@ -395,4 +621,136 @@ mod tests { ); assert_eq!(code, code.to_ascii_uppercase()); } + + #[test] + fn expiry_grace_includes_expiry_but_excludes_its_absolute_end() { + let expiry = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + for (offset, expected) in [ + (-1, false), + (0, true), + (SESSION_EXPIRY_GRACE_SECONDS - 1, true), + (SESSION_EXPIRY_GRACE_SECONDS, false), + ] { + assert_eq!( + within_session_expiry_grace( + &expiry.to_rfc3339(), + expiry + Duration::seconds(offset) + ), + expected + ); + } + assert!(!within_session_expiry_grace("invalid", expiry)); + } + + #[tokio::test] + async fn duplicate_cookies_never_select_an_actor_or_promote_an_ambiguous_marker() { + use server_api::BrowserSessionState; + let store = seeded_store(); + let expired_hash = token_hash("expired-duplicate"); + store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-duplicate".into(), + token_hash: expired_hash.clone(), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(-60)), + revoked_at: None, + }) + .unwrap(); + for cookies in [ + vec!["session=session-secret; session=unknown".to_owned()], + vec!["session=session-secret; session=session-secret".to_owned()], + vec![ + "session=session-secret".to_owned(), + "session=unknown".to_owned(), + ], + vec![format!( + "session=; session=session-secret; session_expiry={expired_hash}" + )], + vec![format!( + "session_expiry={expired_hash}; session_expiry={expired_hash}" + )], + vec![format!("session; session_expiry={expired_hash}")], + ] { + let mut headers = HeaderMap::new(); + for cookie in cookies { + headers.append(header::COOKIE, cookie.parse().unwrap()); + } + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + assert_eq!( + resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Anonymous {} + ); + } + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + format!("session=session-secret; session_expiry={expired_hash}") + .parse() + .unwrap(), + ); + assert!(matches!( + resolve_whoami(store, &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Authenticated { .. } + )); + } + + #[test] + fn session_issuance_reclaims_only_rows_beyond_recovery_grace() { + let store = seeded_store(); + let record = |secret: &str, offset| BrowserSessionRecord { + session_id: secret.into(), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: None, + }; + store + .create_browser_session(&record("past-grace", -SESSION_EXPIRY_GRACE_SECONDS - 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_some() + ); + store + .create_browser_session(&record("within-grace", -SESSION_EXPIRY_GRACE_SECONDS + 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_none() + ); + store + .create_browser_session(&record("new-session", BROWSER_SESSION_SECONDS)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("within-grace")) + .unwrap() + .is_some() + ); + assert!( + store + .resolve_browser_session(&token_hash("new-session")) + .unwrap() + .is_some() + ); + } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b8d83ca20..4b0c860cd 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -477,6 +477,7 @@ struct WorkdirCommandSession { attachment_alias: String, source: WorkdirSessionHandle, provider_handle: CommandHandle, + terminal_at: Option, } enum RegisteredWorkdirSession { @@ -517,6 +518,7 @@ impl WorkdirSessionRegistry { source: WorkdirSessionHandle, provider_handle: CommandHandle, ) -> CommandHandle { + self.prune_terminal_commands(&worker); let external_handle = loop { let candidate = CommandHandle(Uuid::now_v7().to_string()); if !self @@ -532,6 +534,7 @@ impl WorkdirSessionRegistry { attachment_alias, source, provider_handle, + terminal_at: None, }, ); external_handle @@ -549,6 +552,45 @@ impl WorkdirSessionRegistry { .cloned() } + fn active_command_count(&self, worker: &RuntimeWorkerRef) -> usize { + self.commands + .iter() + .filter(|((owner, _), command)| owner == worker && command.terminal_at.is_none()) + .count() + } + + fn mark_terminal(&mut self, worker: &RuntimeWorkerRef, external_handle: &CommandHandle) { + if let Some(command) = self + .commands + .get_mut(&(worker.clone(), external_handle.clone())) + { + command + .terminal_at + .get_or_insert_with(std::time::Instant::now); + } + self.prune_terminal_commands(worker); + } + + fn prune_terminal_commands(&mut self, worker: &RuntimeWorkerRef) { + let mut terminal = self + .commands + .iter() + .filter_map(|((owner, handle), command)| { + (owner == worker) + .then_some(command.terminal_at) + .flatten() + .map(|at| (handle.clone(), at)) + }) + .collect::>(); + terminal.sort_by_key(|(_, at)| *at); + let excess = terminal + .len() + .saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS); + for (handle, _) in terminal.into_iter().take(excess) { + self.commands.remove(&(worker.clone(), handle)); + } + } + fn take_worker_commands_for_alias( &mut self, worker: &RuntimeWorkerRef, @@ -897,7 +939,27 @@ struct ExternalProviderWorkdirSession { audit: Option, closed: std::sync::atomic::AtomicBool, close_lock: tokio::sync::Mutex<()>, - active_commands: tokio::sync::Mutex>, + active_commands: tokio::sync::Mutex>>, +} + +const MAX_RETAINED_TERMINAL_COMMANDS: usize = 128; +const MAX_ACTIVE_SERVER_COMMANDS: usize = 128; + +struct ExternalProviderCommandState { + handle: workdir::CommandHandle, + // Serialize output ACKs, not cancellation: cancel must unblock a waiting reader. + cursor: tokio::sync::Mutex, + terminal_at: Mutex>, +} + +impl ExternalProviderCommandState { + fn new(handle: workdir::CommandHandle) -> Self { + Self { + handle, + cursor: tokio::sync::Mutex::new(0), + terminal_at: Mutex::new(None), + } + } } impl std::fmt::Debug for ExternalProviderWorkdirSession { @@ -988,17 +1050,24 @@ impl ExternalProviderWorkdirSession { "External Workdir grant has expired".to_string(), )); } - let _admission = self - .connection - .admission - .clone() - .acquire_owned() - .await - .map_err(|_| { - workdir::WorkdirError::Unavailable( - "External Workdir provider admission is closed".to_string(), - ) - })?; + // Cancellation must reach the provider even when waiting output readers + // occupy every data-operation slot. The socket separately bounds control requests. + let _admission = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) { + None + } else { + Some( + self.connection + .admission + .clone() + .acquire_owned() + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir provider admission is closed".to_string(), + ) + })?, + ) + }; if self .connection .expires_at @@ -1075,10 +1144,10 @@ impl ExternalProviderWorkdirSession { } } - async fn provider_command_handle( + async fn provider_command( &self, external_handle: &workdir::CommandHandle, - ) -> std::result::Result { + ) -> std::result::Result, workdir::WorkdirError> { if self.closed.load(Ordering::Acquire) { return Err(workdir::WorkdirError::SessionClosed); } @@ -1090,51 +1159,111 @@ impl ExternalProviderWorkdirSession { .ok_or_else(|| workdir::WorkdirError::UnknownCommand(external_handle.0.clone())) } + async fn provider_command_handle( + &self, + external_handle: &workdir::CommandHandle, + ) -> std::result::Result { + Ok(self.provider_command(external_handle).await?.handle.clone()) + } + async fn remove_active_command_if_matches( &self, external_handle: &workdir::CommandHandle, - provider_handle: &workdir::CommandHandle, + command: &Arc, ) { let mut commands = self.active_commands.lock().await; - if commands.get(&external_handle.0) == Some(provider_handle) { + if commands + .get(&external_handle.0) + .is_some_and(|current| Arc::ptr_eq(current, command)) + { commands.remove(&external_handle.0); } } + async fn prune_terminal_commands(&self) { + let mut commands = self.active_commands.lock().await; + let mut terminal = commands + .iter() + .filter_map(|(key, command)| { + command + .terminal_at + .lock() + .unwrap() + .map(|at| (key.clone(), at)) + }) + .collect::>(); + terminal.sort_by_key(|(_, at)| *at); + let excess = terminal + .len() + .saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS); + for (key, _) in terminal.into_iter().take(excess) { + commands.remove(&key); + } + } + async fn terminate_provider_command( &self, - provider_handle: workdir::CommandHandle, + command: Arc, + reused_handle: bool, ) -> std::result::Result<(), workdir::WorkdirError> { - // Cancellation is best effort because the command may already be terminal. - // Terminal output is the proof that the provider reaped the process. + if !reused_handle && command.terminal_at.lock().unwrap().is_some() { + return Ok(()); + } + // Do not acquire the output lock until cancellation has unblocked any reader. + // The reader records its last known cursor before cleanup acknowledges bytes. let _ = self .operate(WorkdirSessionOperation::CommandCancel( - provider_handle.clone(), + command.handle.clone(), )) .await; - match self - .operate(WorkdirSessionOperation::CommandOutput( - workdir::CommandOutputRequest { - handle: provider_handle, - cursor: 0, - limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES, - wait: true, - }, - )) - .await? - { - WorkdirSessionOperationResult::CommandOutput(output) - if output.status != workdir::CommandStatus::Running => - { - Ok(()) - } - WorkdirSessionOperationResult::CommandOutput(_) => { - Err(workdir::WorkdirError::Unavailable( - "External Workdir provider did not confirm command termination".to_string(), - )) - } - _ => Err(Self::mismatch("command_output")), + let mut cursor = + tokio::time::timeout(std::time::Duration::from_secs(30), command.cursor.lock()) + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir reader did not stop after cancellation".to_string(), + ) + })?; + if !reused_handle && command.terminal_at.lock().unwrap().is_some() { + return Ok(()); } + let drain = async { + loop { + match self + .operate(WorkdirSessionOperation::CommandOutput( + workdir::CommandOutputRequest { + handle: command.handle.clone(), + cursor: *cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }, + )) + .await? + { + WorkdirSessionOperationResult::CommandOutput(output) => { + *cursor = output.next_cursor; + if output.terminal.is_some() { + *command.terminal_at.lock().unwrap() = Some(std::time::Instant::now()); + return Ok(()); + } + if output.chunks.is_empty() { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir provider did not confirm command termination" + .to_string(), + )); + } + } + _ => return Err(Self::mismatch("command_output")), + } + } + }; + tokio::time::timeout(std::time::Duration::from_secs(30), drain) + .await + .map_err(|_| { + workdir::WorkdirError::Unavailable( + "External Workdir command drain timed out".to_string(), + ) + })? } fn mismatch(expected: &str) -> workdir::WorkdirError { @@ -1412,17 +1541,27 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } async fn start_command( &self, - mut request: workdir::CommandRequest, + request: workdir::CommandRequest, ) -> std::result::Result { self.ensure_capability(workdir::WorkdirSessionCapability::Command)?; let _close_guard = self.close_lock.lock().await; if self.closed.load(Ordering::Acquire) { return Err(workdir::WorkdirError::SessionClosed); } - // Bash supplies a Worker-host spill directory. It is neither meaningful - // nor safe to send to a client-hosted provider, so External commands use - // bounded inline output and report truncation without a provider path. - request.spill_dir = None; + self.prune_terminal_commands().await; + if self + .active_commands + .lock() + .await + .values() + .filter(|command| command.terminal_at.lock().unwrap().is_none()) + .count() + >= MAX_ACTIVE_SERVER_COMMANDS + { + return Err(workdir::WorkdirError::Unavailable( + "External Workdir active command limit reached".to_string(), + )); + } match self .operate(WorkdirSessionOperation::CommandStart(request)) .await? @@ -1432,7 +1571,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { let commands = self.active_commands.lock().await; commands .iter() - .filter(|(_, active)| *active == &provider_handle) + .filter(|(_, active)| active.handle == provider_handle) .map(|(external, _)| external.clone()) .collect::>() }; @@ -1441,20 +1580,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { // generation. Remove every ambiguous route before attempting // cleanup so no caller can address the wrong process. let mut commands = self.active_commands.lock().await; + let mut quarantined = None; for external_handle in duplicate_handles { - commands.remove(&external_handle); + if let Some(command) = commands.remove(&external_handle) { + quarantined.get_or_insert(command); + } } let quarantine_handle = workdir::CommandHandle(format!("quarantined-command-{}", Uuid::now_v7())); - commands.insert(quarantine_handle.0.clone(), provider_handle.clone()); + let command = quarantined.expect("duplicate command route disappeared"); + *command.terminal_at.lock().unwrap() = None; + commands.insert(quarantine_handle.0.clone(), command.clone()); drop(commands); if self - .terminate_provider_command(provider_handle.clone()) + .terminate_provider_command(command.clone(), true) .await .is_ok() { - self.remove_active_command_if_matches(&quarantine_handle, &provider_handle) + self.remove_active_command_if_matches(&quarantine_handle, &command) .await; + } else { + // An earlier read of the reused handle cannot establish that + // the newly started, ambiguous process has been cleaned up. + *command.terminal_at.lock().unwrap() = None; } return Err(workdir::WorkdirError::Unavailable( "External Workdir provider reused an active command handle".to_string(), @@ -1462,10 +1610,10 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { } let external_handle = workdir::CommandHandle(format!("external-command-{}", Uuid::now_v7())); - self.active_commands - .lock() - .await - .insert(external_handle.0.clone(), provider_handle); + self.active_commands.lock().await.insert( + external_handle.0.clone(), + Arc::new(ExternalProviderCommandState::new(provider_handle)), + ); Ok(external_handle) } _ => Err(Self::mismatch("command_start")), @@ -1491,21 +1639,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { ) -> std::result::Result { self.ensure_capability(workdir::WorkdirSessionCapability::Command)?; let external_handle = request.handle.clone(); - let provider_handle = self.provider_command_handle(&external_handle).await?; - request.handle = provider_handle.clone(); + let command = self.provider_command(&external_handle).await?; + let mut cursor = command.cursor.lock().await; + request.handle = command.handle.clone(); + // If the response is lost, this request cursor still replays the provider page. match self .operate(WorkdirSessionOperation::CommandOutput(request)) - .await? + .await { - WorkdirSessionOperationResult::CommandOutput(output) => { - if output.status != workdir::CommandStatus::Running && output.next_cursor.is_none() - { - self.remove_active_command_if_matches(&external_handle, &provider_handle) - .await; + Ok(WorkdirSessionOperationResult::CommandOutput(output)) => { + *cursor = output.next_cursor; + if output.terminal.is_some() { + command + .terminal_at + .lock() + .unwrap() + .get_or_insert_with(std::time::Instant::now); } + drop(cursor); + self.prune_terminal_commands().await; Ok(output) } - _ => Err(Self::mismatch("command_output")), + Ok(_) => Err(Self::mismatch("command_output")), + Err(error) => Err(error), } } async fn cancel_command( @@ -1542,21 +1698,25 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { }) .collect::>(); let mut cleanup_errors = Vec::new(); - for (external_handle, provider_handle) in commands { + for (external_handle, command) in commands { // Detach/session replacement is terminal authority for commands // started through this exact provider generation. Do not report a // successful close until the provider confirms terminal output. match self - .terminate_provider_command(provider_handle.clone()) + .terminate_provider_command(command.clone(), false) .await { Ok(()) => { - self.remove_active_command_if_matches(&external_handle, &provider_handle) + self.remove_active_command_if_matches(&external_handle, &command) .await; } Err(error) => { - let still_active = self.active_commands.lock().await.get(&external_handle.0) - == Some(&provider_handle); + let still_active = self + .active_commands + .lock() + .await + .get(&external_handle.0) + .is_some_and(|current| Arc::ptr_eq(current, &command)); if still_active { cleanup_errors.push(error.to_string()); } @@ -6877,6 +7037,7 @@ async fn attach_server_request_context( } fn generated_auth_contract_router(service: ServerApiContractService) -> Router { + let auth = auth_public_config(&service.auth_api().config); let service = Arc::new(service); let origin_context = Router::new() .merge(server_api::server_api_axum::auth_activation_options( @@ -6908,7 +7069,6 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { .merge(server_api::server_api_axum::auth_device_login_approve( service.clone(), )) - .merge(server_api::server_api_axum::auth_whoami(service.clone())) .layer(middleware::from_fn_with_state( service.as_ref().clone(), attach_server_request_context, @@ -6929,12 +7089,20 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { service.clone(), )) .merge(server_api::server_api_axum::auth_logout(service.clone())) + .merge( + server_api::server_api_axum::auth_whoami(service.clone()) + .layer(middleware::from_fn(attach_server_origin_context)), + ) .merge(server_api::server_api_axum::auth_device_login_start( service.clone(), )) .merge(server_api::server_api_axum::auth_device_login_poll(service)) .merge(origin_context) .merge(actor_context) + .layer(middleware::from_fn_with_state( + auth, + append_session_marker_cookie, + )) .layer(middleware::map_response( |mut response: Response| async move { // Current role/policy and authentication responses must not be cached @@ -6947,6 +7115,62 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { )) } +// The typed auth operations expose one Set-Cookie. At the HTTP boundary, pair only a real +// session Set-Cookie with a non-authenticating expiry marker, including login and logout. +// Mirroring its attributes keeps Secure/Path/Domain/SameSite aligned without a second policy. +async fn append_session_marker_cookie( + State(auth): State, + request: Request, + next: Next, +) -> Response { + let mut response = next.run(request).await; + let marker = response + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .find_map(|value| { + let value = value.to_str().ok()?; + let (pair, attributes) = value.split_once(';')?; + let (name, token) = pair.split_once('=')?; + if name != auth.cookie_name { + return None; + } + let max_age = attributes + .split(';') + .find_map(|attr| attr.trim().strip_prefix("Max-Age=")?.parse::().ok())?; + let (hash, marker_age) = if max_age <= 0 { + (String::new(), 0) + } else { + ( + token_hash(token), + max_age + crate::auth::SESSION_EXPIRY_GRACE_SECONDS, + ) + }; + let attributes = attributes + .split(';') + .map(|attr| { + if attr.trim().starts_with("Max-Age=") { + format!(" Max-Age={marker_age}") + } else { + attr.to_owned() + } + }) + .collect::>() + .join(";"); + HeaderValue::from_str(&format!( + "{}={hash};{attributes}", + crate::auth::session_marker_cookie_name(name) + )) + .ok() + }); + if let Some(marker) = marker { + response + .headers_mut() + .append(axum::http::header::SET_COOKIE, marker); + } + response +} + fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router { let service = Arc::new(service); Router::new() @@ -8178,9 +8402,15 @@ impl server_api::ServerApi for ServerApiContractService { &self, context: server_api::ServerRequestContext, ) -> std::result::Result { - Ok(WhoamiResponse { - actor: context.actor, - }) + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + crate::auth::resolve_whoami( + api.store.clone(), + &headers, + &auth_public_config(&api.config).cookie_name, + ) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error()) } async fn auth_logout( @@ -20971,6 +21201,9 @@ async fn scoped_execute_current_worker_workdir_operation( &api, &worker, &target_workdir, Some(&_session_guard), ).await?; context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id)); + if api.workdir_sessions.lock().unwrap().active_command_count(&worker) >= MAX_ACTIVE_SERVER_COMMANDS { + return Err(InternalWorkdirOperationError::Provider(workdir_operation_transport_error(workdir::WorkdirError::Unavailable("Worker active command limit reached".to_string())))); + } context.stage = "session_open"; let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?; context.stage = "provider_dispatch"; @@ -21012,14 +21245,16 @@ async fn scoped_execute_current_worker_workdir_operation( .map_err(workdir_operation_transport_error)? } WorkdirSessionOperation::CommandOutput(mut output) => { + let external_handle = output.handle.clone(); let (session, provider_handle) = - current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context).await?; + current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context).await?; output.handle = provider_handle; - session - .command_output(output) - .await - .map(WorkdirSessionOperationResult::CommandOutput) - .map_err(workdir_operation_transport_error)? + let page = session.command_output(output).await.map_err(workdir_operation_transport_error)?; + if page.terminal.is_some() { + // Retain the binding for a retry after an unknown HTTP outcome. + api.workdir_sessions.lock().unwrap().mark_terminal(&worker, &external_handle); + } + WorkdirSessionOperationResult::CommandOutput(page) } WorkdirSessionOperation::CommandCancel(external_handle) => { let (session, provider_handle) = @@ -27041,7 +27276,12 @@ async fn serve_external_workdir_provider( })); continue; } - if pending.len() >= 16 { + let pending_limit = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) { + 16 + MAX_ACTIVE_SERVER_COMMANDS + } else { + 16 + }; + if pending.len() >= pending_limit { let _ = response.send(Err(WorkdirTransportError { denial_reason: None, code: workdir::http::WorkdirTransportErrorCode::Unavailable, @@ -30510,14 +30750,14 @@ fn issue_browser_session( token_hash: token_hash(&session_token), user_id: user.user_id.clone(), created_at: crate::auth::now_rfc3339(), - expires_at: rfc3339_after(Duration::days(14)), + expires_at: rfc3339_after(Duration::seconds(crate::auth::BROWSER_SESSION_SECONDS)), revoked_at: None, })?; let auth = auth_public_config(&api.config); let cookie = session_set_cookie( session_cookie_policy(&auth), &session_token, - 14 * 24 * 60 * 60, + crate::auth::BROWSER_SESSION_SECONDS, ); Ok(( AuthUserResponse { @@ -30713,6 +30953,16 @@ fn logout_browser_session( .store .revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; } + // A missing auth cookie after Max-Age must not prevent explicit logout from revoking + // the recovery reference. This hash is never accepted as an authentication credential. + if let Some(hash) = parse_cookie( + &headers, + &crate::auth::session_marker_cookie_name(&auth.cookie_name), + ) { + let _ = api + .store + .revoke_browser_session(&hash, &crate::auth::now_rfc3339())?; + } Ok(( LogoutResponse { status: LogoutStatus::LoggedOut, @@ -37246,6 +37496,7 @@ mod tests { mod api_logging_tests; mod auth_logging_tests; + mod command_transport_tests; mod drive_api_tests; mod drive_tests; mod drive_web_roundtrip_tests; @@ -37596,9 +37847,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "touch forbidden".to_string(), timeout_secs: 1, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -37747,9 +37997,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf forbidden".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -37801,9 +38050,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf routed".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker/private/spill".into()), tool_call_id: Some("call-1".to_string()), }) .await @@ -37819,10 +38067,7 @@ mod tests { }; assert!(matches!( operation, - WorkdirSessionOperation::CommandStart(workdir::CommandRequest { - spill_dir: None, - .. - }) + WorkdirSessionOperation::CommandStart(workdir::CommandRequest { .. }) )); let provider_handle = workdir::CommandHandle("command-1".to_string()); response @@ -37875,13 +38120,9 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Running, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: None, }, ))) .unwrap(); @@ -37954,13 +38195,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -37989,9 +38239,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let provider_handle = workdir::CommandHandle("command-reused".to_string()); @@ -38048,13 +38297,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -38105,9 +38363,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let provider_handle = workdir::CommandHandle("command-1".to_string()); @@ -39006,9 +39263,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf forbidden".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await; @@ -39029,9 +39285,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf attached".to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await @@ -39134,13 +39389,22 @@ mod tests { response .send(Ok(WorkdirSessionOperationResult::CommandOutput( workdir::CommandOutput { - status: workdir::CommandStatus::Cancelled, - exit_code: None, - timed_out: false, - content: String::new(), - next_cursor: None, - truncated: false, - output_path: None, + chunks: Vec::new(), + next_cursor: 0, + terminal: Some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + + stdout_bytes: 0, + + stderr_bytes: 0, + + stdout_delivered_bytes: 0, + + stderr_delivered_bytes: 0, + + truncation_reason: None, + }), }, ))) .unwrap(); @@ -39226,11 +39490,9 @@ mod tests { }; let (mut old_provider, _) = connect_async(&url).await.unwrap(); - let old_registration = serde_json::to_string(®ister(1)).unwrap().replacen( - r#""version":2"#, - r#""version":1"#, - 1, - ); + let mut old_registration = serde_json::to_value(register(1)).unwrap(); + old_registration["version"] = serde_json::json!(1); + let old_registration = serde_json::to_string(&old_registration).unwrap(); old_provider .send(Message::Text(old_registration.into())) .await @@ -39244,7 +39506,10 @@ mod tests { .reason .contains("unsupported External Workdir protocol version 1") ); - assert!(frame.reason.contains("requires 2")); + assert!(frame.reason.contains(&format!( + "requires {}", + workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + ))); let (mut smaller_provider, _) = connect_async(&url).await.unwrap(); let mut smaller_registration = serde_json::to_value(register(1)).unwrap(); @@ -39485,37 +39750,106 @@ mod tests { fs::read_to_string(provider_root.path().join("created.txt")).unwrap(), "created" ); + let binary_handle = broker_session + .start_command(workdir::CommandRequest { + command: "head -c 196609 /dev/zero".to_string(), + timeout_secs: 30, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, + cwd: workdir::WorkdirPath::root(), + tool_call_id: None, + }) + .await + .unwrap(); + let (terminal_request, terminal_page) = + tokio::time::timeout(std::time::Duration::from_secs(5), async { + let mut cursor = 0; + let mut bytes = Vec::new(); + loop { + let request = workdir::CommandOutputRequest { + handle: binary_handle.clone(), + cursor, + limit: workdir::MAX_COMMAND_READ_BYTES, + wait: true, + }; + let page = broker_session + .command_output(request.clone()) + .await + .unwrap(); + for chunk in &page.chunks { + assert_eq!(chunk.stream, workdir::CommandStream::Stdout); + assert_eq!(chunk.sequence, bytes.len() as u64); + assert_eq!(chunk.offset, bytes.len() as u64); + bytes.extend_from_slice(&chunk.bytes); + } + assert!( + page.chunks + .iter() + .map(|chunk| chunk.bytes.len()) + .sum::() + <= workdir::MAX_COMMAND_READ_BYTES + ); + cursor = page.next_cursor; + if let Some(terminal) = &page.terminal { + assert_eq!(terminal.status, workdir::CommandStatus::Completed); + assert_eq!(terminal.exit_code, Some(0)); + assert_eq!(bytes, vec![0; 196609]); + break (request, page); + } + } + }) + .await + .expect("incremental provider output did not terminate"); + assert_eq!( + broker_session + .command_output(terminal_request.clone()) + .await + .unwrap(), + terminal_page + ); + for cursor in [0, terminal_page.next_cursor + 1] { + assert!( + broker_session + .command_output(workdir::CommandOutputRequest { + cursor, + ..terminal_request.clone() + }) + .await + .is_err() + ); + } + let command_handle = broker_session .start_command(workdir::CommandRequest { - command: "sleep 30 & child=$!; echo $child > child.pid; wait $child".to_string(), + command: "sleep 30 & child=$!; echo $child > child.pid; printf ready; wait $child" + .to_string(), timeout_secs: 30, - output_limit: 1024, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker/private/spill".into()), tool_call_id: Some("call-e2e".to_string()), }) .await .unwrap(); - for _ in 0..100 { - if provider_root.path().join("child.pid").is_file() { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - assert!(provider_root.path().join("child.pid").is_file()); - let command_output = broker_session .command_output(workdir::CommandOutputRequest { handle: command_handle, cursor: 0, limit: 1024, - wait: false, + wait: true, }) .await .unwrap(); - assert_eq!(command_output.status, workdir::CommandStatus::Running); - assert_eq!(command_output.output_path, None); + assert!(command_output.terminal.is_none()); + + assert_eq!( + command_output + .chunks + .iter() + .flat_map(|chunk| chunk.bytes.clone()) + .collect::>(), + b"ready" + ); + assert!(provider_root.path().join("child.pid").is_file()); let Json(revoked) = scoped_revoke_external_workdir_grant( State(api.clone()), @@ -40718,9 +41052,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "printf ready; sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: Some("tool-call-command-session".to_string()), }) .await @@ -40767,7 +41100,7 @@ mod tests { }) .await .unwrap(); - assert!(matches!(output.status, workdir::CommandStatus::Running)); + assert!(output.terminal.is_none()); assert!(matches!( command .source @@ -40779,14 +41112,21 @@ mod tests { let waiting_source = command.source.clone(); let waiting_handle = command.provider_handle.clone(); let waiting = tokio::spawn(async move { - waiting_source - .command_output(workdir::CommandOutputRequest { - handle: waiting_handle, - cursor: output.next_cursor.unwrap_or(0), - limit: 4096, - wait: true, - }) - .await + let mut cursor = output.next_cursor; + loop { + let page = waiting_source + .command_output(workdir::CommandOutputRequest { + handle: waiting_handle.clone(), + cursor, + limit: 4096, + wait: true, + }) + .await?; + cursor = page.next_cursor; + if page.terminal.is_some() { + return Ok::<_, workdir::WorkdirError>(page); + } + } }); tokio::task::yield_now().await; command @@ -40799,7 +41139,7 @@ mod tests { .expect("cancel should unblock a waiting output read") .unwrap() .unwrap(); - assert!(!matches!(terminal.status, workdir::CommandStatus::Running)); + assert!(terminal.terminal.is_some()); let registered = registry.take_worker(&worker); assert_eq!(registered.len(), 1); @@ -40832,9 +41172,8 @@ mod tests { let request = || workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }; let left_provider = left.start_command(request()).await.unwrap(); @@ -53796,6 +54135,7 @@ mod tests { .await .unwrap(); assert_eq!(registration_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(registration_response.headers(), &cookie_name); let registration_cookie = registration_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53833,6 +54173,12 @@ mod tests { .unwrap(), ) .unwrap(); + assert_eq!(registered_whoami["session"]["kind"], "authenticated"); + assert!( + registered_whoami["session"]["expires_at"] + .as_str() + .is_some() + ); assert_eq!( registered_whoami["actor"]["handle"], "generated-passkey-user" @@ -53887,6 +54233,7 @@ mod tests { .await .unwrap(); assert_eq!(login_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(login_response.headers(), &cookie_name); let login_cookie = login_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53971,6 +54318,169 @@ mod tests { assert!(logged_out_whoami["actor"].is_null()); } + // Real generated auth routes, DB executor and SQLite; no auth resolution is mocked. + #[tokio::test] + async fn whoami_expiry_marker_survives_auth_cookie_loss_but_logout_revokes_it() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + seed_test_api_token(api.store.as_ref(), "expiry-router"); + let user = api.store.get_user("user-expiry-router").unwrap().unwrap(); + let auth = auth_public_config(&api.config); + let cookie_name = auth.cookie_name; + let hash = token_hash("expired-router-secret"); + api.store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-router-session".into(), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::minutes(-1)), + revoked_at: None, + }) + .unwrap(); + let marker = format!( + "{}={hash}", + crate::auth::session_marker_cookie_name(&cookie_name) + ); + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api.clone())); + for (cookie, kind) in [ + (String::new(), "anonymous"), + (format!("{cookie_name}=expired-router-secret"), "expired"), + (marker.clone(), "expired"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + assert!( + !response + .headers() + .contains_key(axum::http::header::SET_COOKIE) + ); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["actor"], Value::Null); + assert_eq!(value["session"]["kind"], kind); + } + let denied = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/addition/options") + .header(axum::http::header::COOKIE, &marker) + .header(ORIGIN, &auth.origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + // Preserve the operation's existing auth_required/403 contract; no HTML redirect. + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert!(!denied.headers().contains_key(axum::http::header::LOCATION)); + let logout = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, &marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::OK); + let cleared: Vec<_> = logout + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(cleared.len(), 2); + assert!( + cleared + .iter() + .all(|value| value.contains("Max-Age=0") && value.contains("HttpOnly")) + ); + assert!(cleared.iter().any(|value| value.starts_with(&format!( + "{}=;", + crate::auth::session_marker_cookie_name(&cookie_name) + )))); + assert!(api.store.resolve_browser_session(&hash).unwrap().is_none()); + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["session"]["kind"], "anonymous"); + } + + fn assert_auth_response_has_expiry_marker(headers: &HeaderMap, cookie_name: &str) { + let cookies: Vec<_> = headers + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); + let auth = cookies + .iter() + .find(|value| value.starts_with(&format!("{cookie_name}="))) + .unwrap(); + let (_, token) = auth.split(';').next().unwrap().split_once('=').unwrap(); + let marker = cookies + .iter() + .find(|value| { + value.starts_with(&format!( + "{}=", + crate::auth::session_marker_cookie_name(cookie_name) + )) + }) + .unwrap(); + let expected = token_hash(token); + assert_eq!( + parse_cookie( + &{ + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::COOKIE, marker.parse().unwrap()); + headers + }, + &crate::auth::session_marker_cookie_name(cookie_name) + ), + Some(expected) + ); + assert!(auth.contains(&format!("Max-Age={}", crate::auth::BROWSER_SESSION_SECONDS))); + assert!(marker.contains(&format!( + "Max-Age={}", + crate::auth::BROWSER_SESSION_SECONDS + crate::auth::SESSION_EXPIRY_GRACE_SECONDS + ))); + for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Secure"] { + assert_eq!( + auth.contains(attribute), + marker.contains(attribute), + "{attribute}" + ); + } + } + #[tokio::test] async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { for (scheme, secure) in [("http", false), ("https", true)] { @@ -60359,9 +60869,8 @@ mod tests { .start_command(workdir::CommandRequest { command: "sleep 30".to_string(), timeout_secs: 60, - output_limit: 4096, + transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES, cwd: workdir::WorkdirPath::root(), - spill_dir: None, tool_call_id: None, }) .await diff --git a/crates/workspace-server/src/server/tests/api_logging_tests.rs b/crates/workspace-server/src/server/tests/api_logging_tests.rs index 4c64ba89f..0b2320b17 100644 --- a/crates/workspace-server/src/server/tests/api_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/api_logging_tests.rs @@ -473,11 +473,10 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() { )); // Seed an already registered command, without launching a real process. let provider_handle = CommandHandle("logging-command".to_string()); - session - .active_commands - .lock() - .await - .insert(provider_handle.0.clone(), provider_handle.clone()); + session.active_commands.lock().await.insert( + provider_handle.0.clone(), + Arc::new(ExternalProviderCommandState::new(provider_handle.clone())), + ); let handle = fixture .api .workdir_sessions @@ -733,7 +732,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t Reason::ScopedCapabilityDenied, ), ( - json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}), + json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "transport_byte_budget": 1048576, "cwd": "", "tool_call_id": secret}}), "command_start", "denied", Reason::ScopedCapabilityDenied, @@ -955,8 +954,8 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc serde_json::to_vec(&json!({ "target_workdir": "checkout", "operation": {"operation": "command_start", "request": { - "command": "body-secret", "timeout_secs": 1, "output_limit": 1024, - "cwd": "", "spill_dir": null, "tool_call_id": "body-secret" + "command": "body-secret", "timeout_secs": 1, "transport_byte_budget": 1048576, + "cwd": "", "tool_call_id": "body-secret" }} })) .unwrap(), @@ -1084,8 +1083,8 @@ async fn saturated_host_before_command_start_returns_availability_without_a_prov serde_json::to_vec(&json!({ "target_workdir": "checkout", "operation": {"operation": "command_start", "request": { - "command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024, - "cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched" + "command": "not-dispatched", "timeout_secs": 1, "transport_byte_budget": 1048576, + "cwd": "", "tool_call_id": "not-dispatched" }} })) .unwrap(), diff --git a/crates/workspace-server/src/server/tests/command_transport_tests.rs b/crates/workspace-server/src/server/tests/command_transport_tests.rs new file mode 100644 index 000000000..af9d6416b --- /dev/null +++ b/crates/workspace-server/src/server/tests/command_transport_tests.rs @@ -0,0 +1,223 @@ +//! Provider-bridge lifecycle tests. The production bridge runs against a controlled +//! provider boundary; the websocket/CLI-pump test in the parent module covers real byte transport. +use super::*; +use workdir::WorkdirSession; + +fn command_fixture() -> ( + Arc, + tokio::sync::mpsc::Receiver, +) { + let (sender, receiver) = tokio::sync::mpsc::channel(16); + let session = Arc::new(ExternalProviderWorkdirSession::new( + Arc::new(ExternalProviderConnection { + grant_id: "command-grant".into(), + workdir_id: "command-workdir".into(), + provider_instance_id: "command-provider".into(), + generation: 1, + expires_at: None, + capabilities: workdir::WorkdirSessionCapabilities::COMMAND_ONLY, + admission: Arc::new(tokio::sync::Semaphore::new(1)), + shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sender, + }), + None, + )); + (session, receiver) +} + +fn page(cursor: u64, bytes: &[u8], terminal: bool) -> workdir::CommandOutput { + workdir::CommandOutput { + chunks: if bytes.is_empty() { + Vec::new() + } else { + vec![workdir::CommandByteChunk { + sequence: cursor, + stream: workdir::CommandStream::Stdout, + offset: cursor, + bytes: bytes.to_vec(), + }] + }, + next_cursor: cursor + bytes.len() as u64, + terminal: terminal.then_some(workdir::CommandTerminal { + status: workdir::CommandStatus::Cancelled, + exit_code: None, + stdout_bytes: cursor + bytes.len() as u64, + stderr_bytes: 0, + stdout_delivered_bytes: cursor + bytes.len() as u64, + stderr_delivered_bytes: 0, + truncation_reason: None, + }), + } +} + +async fn receive( + receiver: &mut tokio::sync::mpsc::Receiver, +) -> ( + WorkdirSessionOperation, + tokio::sync::oneshot::Sender< + std::result::Result, + >, +) { + let command = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv()) + .await + .unwrap() + .unwrap(); + let ExternalProviderCommand::Operation { + operation, + response, + .. + } = command + else { + panic!("expected provider operation") + }; + (operation, response) +} + +#[tokio::test] +async fn close_cancels_waiting_reader_then_drains_from_latest_cursor() { + let (session, mut receiver) = command_fixture(); + session.active_commands.lock().await.insert( + "external".into(), + Arc::new(ExternalProviderCommandState::new(CommandHandle( + "provider".into(), + ))), + ); + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 0, + limit: 3, + wait: true, + }) + .await + }) + }; + let (_, response) = receive(&mut receiver).await; + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 0, b"one", false, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap().next_cursor, 3); + + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 3, + limit: 3, + wait: true, + }) + .await + }) + }; + let (_, pending_read) = receive(&mut receiver).await; + let closer = { + let session = session.clone(); + tokio::spawn(async move { session.close().await }) + }; + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandCancel(ref handle) if handle.0 == "provider") + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandCancel)) + .unwrap(); + pending_read + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 3, b"two", false, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap().next_cursor, 6); + + for (cursor, bytes, terminal) in [(6, &b"end"[..], false), (9, &b""[..], true)] { + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == cursor && request.wait) + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + cursor, bytes, terminal, + )))) + .unwrap(); + } + tokio::time::timeout(std::time::Duration::from_secs(2), closer) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test] +async fn terminal_output_retains_same_binding_for_current_page_replay() { + let (session, mut receiver) = command_fixture(); + session.active_commands.lock().await.insert( + "external".into(), + Arc::new(ExternalProviderCommandState::new(CommandHandle( + "provider".into(), + ))), + ); + for _ in 0..2 { + let reader = { + let session = session.clone(); + tokio::spawn(async move { + session + .command_output(workdir::CommandOutputRequest { + handle: CommandHandle("external".into()), + cursor: 0, + limit: 3, + wait: true, + }) + .await + }) + }; + let (operation, response) = receive(&mut receiver).await; + assert!( + matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == 0) + ); + response + .send(Ok(WorkdirSessionOperationResult::CommandOutput(page( + 0, b"end", true, + )))) + .unwrap(); + assert_eq!(reader.await.unwrap().unwrap(), page(0, b"end", true)); + } + assert_eq!(session.active_commands.lock().await.len(), 1); +} + +#[tokio::test] +async fn terminal_registry_retention_is_bounded_and_excludes_active_commands() { + let (source, _) = command_fixture(); + let worker = RuntimeWorkerRef::new("runtime", "worker"); + let mut registry = WorkdirSessionRegistry::default(); + let active = registry.register_command( + worker.clone(), + "checkout".into(), + source.clone(), + CommandHandle("active".into()), + ); + let mut last = None; + for index in 0..MAX_RETAINED_TERMINAL_COMMANDS + 4 { + let handle = registry.register_command( + worker.clone(), + "checkout".into(), + source.clone(), + CommandHandle(format!("terminal-{index}")), + ); + registry.mark_terminal(&worker, &handle); + last = Some(handle); + } + assert_eq!(registry.active_command_count(&worker), 1); + assert_eq!(registry.commands.len(), MAX_RETAINED_TERMINAL_COMMANDS + 1); + assert!(registry.command(&worker, "checkout", &active).is_some()); + assert!( + registry + .command(&worker, "checkout", &last.unwrap()) + .is_some() + ); +} diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs index 2f5c36ca8..ae622fe8b 100644 --- a/crates/workspace-server/src/server_assignment_attention_tests.rs +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -466,7 +466,6 @@ impl AttentionWorkerFactory { worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)), worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), - bash_output_dir: root.join("bash-output"), }, worker::WorkerControllerTransport::InProcess, ) diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index 5d397814a..af1948942 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -799,7 +799,6 @@ impl DeliveryWorkerFactory { worker::WorkerFilesystemAuthority::None, worker::WorkerBootstrapLayout::RuntimeManagedRun { run_dir: root.join(uuid::Uuid::now_v7().to_string()), - bash_output_dir: root.join("bash-output"), }, worker::WorkerControllerTransport::InProcess, ) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b47bab7b5..d742f0ec6 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> { self.with_immediate_transaction(|conn| { activation::require_active(conn, &record.user_id)?; + // Retain the real row throughout the non-authenticating recovery window, not + // forever. Issuance amortizes reclamation without extending marker lifetime or + // introducing another store/scheduler. Inactive stores do not grow between issues. + let cutoff = (chrono::Utc::now() + - chrono::Duration::seconds(crate::auth::SESSION_EXPIRY_GRACE_SECONDS)) + .to_rfc3339(); + conn.execute( + "DELETE FROM browser_sessions WHERE julianday(expires_at) <= julianday(?1) OR julianday(expires_at) IS NULL", + params![cutoff], + )?; conn.execute( r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, diff --git a/openapi/server-api.json b/openapi/server-api.json index a1b294cb7..0efcf586e 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -443,6 +443,56 @@ "additionalProperties": false, "type": "object" }, + "BrowserSessionState": { + "description": "Browser navigation evidence, not an authorization grant or a generic 401 classification.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "anonymous", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "authenticated", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "expired", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, "BrowserTicketState": { "enum": [ "open", @@ -15092,8 +15142,14 @@ "type": "null" } ] + }, + "session": { + "$ref": "#/components/schemas/BrowserSessionState" } }, + "required": [ + "session" + ], "type": "object" }, "WorkerBusyState": { @@ -18099,7 +18155,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01" + "x-yoi-source-digest": "sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083" }, "openapi": "3.1.0", "paths": { diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index 9638ae45a..b308eeb49 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -19,6 +19,8 @@ sha2.workspace = true tempfile.workspace = true [dev-dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } +yoi-workspace-server.workspace = true server-api.workspace = true webauthn-rs-proto = "0.5.5" webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/tests/e2e/browser/auth_sessions.ts b/tests/e2e/browser/auth_sessions.ts new file mode 100644 index 000000000..226ae9e76 --- /dev/null +++ b/tests/e2e/browser/auth_sessions.ts @@ -0,0 +1,435 @@ +/// +// WorkerE2eEnvironment所有の実Backendへ全APIをproxyし、本番static shellを配信する。 +// fixture-serverと同じSPA配信方式。製品APIのwhoami、認証、認可、Set-Cookieは置換しない。 +// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。 +// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。 +import { assert, assertEquals } from "@std/assert"; +import { extname, join, normalize } from "@std/path"; +import { type Browser, type BrowserContext, chromium } from "playwright"; + +const [backend, origin, build, handle] = Deno.args; +if (!backend || !origin || !build || !handle) { + throw new Error( + "Expected isolated Backend URL, browser origin, production build, handle", + ); +} +await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。 +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", + ".ico": "image/x-icon", +}; +const server = Deno.serve({ + hostname: "127.0.0.1", + port: Number(new URL(origin).port), + onListen() {}, +}, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/__e2e/expired-session" && request.method === "POST") { + if (request.headers.get("origin") !== origin) { + return new Response(null, { status: 403 }); + } + // Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。 + const fixture = await bridge("expired-session", {}) as { + set_cookies: string[]; + }; + const headers = new Headers({ "cache-control": "no-store" }); + for (const cookie of fixture.set_cookies) { + headers.append("set-cookie", cookie); + } + return new Response(null, { status: 204, headers }); + } + if ( + url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET" + ) { + // HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。 + const present = request.headers.get("cookie")?.split(";").some((pair) => + pair.trim().split("=", 1)[0] === "yoi_workspace_session" + ) ?? false; + return Response.json(present, { headers: { "cache-control": "no-store" } }); + } + if (url.pathname.startsWith("/api/")) { + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("connection"); + // OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。 + return await fetch(new URL(url.pathname + url.search, backend), { + method: request.method, + headers, + body: ["GET", "HEAD"].includes(request.method) + ? undefined + : await request.arrayBuffer(), + redirect: "manual", + }); + } + const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); + if (relative.startsWith("..")) { + return new Response("not found", { status: 404 }); + } + let file = join(build, relative); + try { + if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); + return new Response(await Deno.readFile(file), { + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, + }); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + if (url.pathname.startsWith("/_app/")) { + return new Response("not found", { status: 404 }); + } + return new Response(await Deno.readFile(join(build, "index.html")), { + headers: { "content-type": mime[".html"] }, + }); + } +}); + +// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。 +const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream()) + .getReader(); +let pending = ""; +async function bridge(operation: string, options: unknown): Promise { + await Deno.stdout.write( + new TextEncoder().encode(JSON.stringify({ operation, options }) + "\n"), + ); + while (!pending.includes("\n")) { + const { done, value } = await input.read(); + if (done) throw new Error("ResidentAuthenticator disconnected"); + pending += value; + if (pending.length > 1024 * 1024) throw new Error("Oversized credential"); + } + const newline = pending.indexOf("\n"); + const credential = JSON.parse(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + return credential; +} +let browser: Browser | undefined; +let context: BrowserContext | undefined; +const terminate = () => { + void (async () => { + await browser?.close(); + await server.shutdown(); + Deno.exit(143); + })(); +}; +Deno.addSignalListener("SIGTERM", terminate); +try { + browser = await chromium.launch({ headless: true, timeout: 15_000 }); + context = await browser.newContext(); + context.setDefaultTimeout(10_000); + await context.route( + "**/*", + (route) => + new URL(route.request().url()).origin === origin + ? route.continue() + : route.abort(), + ); + await context.exposeBinding( + "residentCredential", + (_source, operation: string, options: unknown) => + bridge(operation, options), + ); + await context.addInitScript(() => { + const encode = (value: unknown): unknown => { + if (value instanceof ArrayBuffer) { + return btoa(String.fromCharCode(...new Uint8Array(value))) + .replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + } + if (Array.isArray(value)) return value.map(encode); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, encode(item)]), + ); + } + return value; + }; + const bytes = (value: string) => { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + return Uint8Array.from( + atob(base64), + (character) => character.charCodeAt(0), + ).buffer; + }; + for (const operation of ["create", "get"] as const) { + Object.defineProperty(navigator.credentials, operation, { + configurable: true, + value: async ( + options: CredentialCreationOptions & CredentialRequestOptions, + ) => { + const bridge = (window as unknown as { + residentCredential: ( + operation: string, + options: unknown, + ) => Promise<{ + id: string; + rawId: string; + response: Record; + extensions?: unknown; + }>; + }).residentCredential; + const wire = await bridge(operation, { + publicKey: encode(options.publicKey), + }); + const response = Object.fromEntries( + Object.entries(wire.response).map(( + [key, value], + ) => [key, typeof value === "string" ? bytes(value) : value]), + ); + return { + id: wire.id, + rawId: bytes(wire.rawId), + type: "public-key", + getClientExtensionResults: () => wire.extensions ?? {}, + response: { + ...response, + getTransports: () => wire.response.transports ?? [], + }, + }; + }, + }); + } + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const at = async (path: string) => { + await page.waitForURL((url) => + url.origin === origin && url.pathname + url.search + url.hash === path + ); + console.error(`navigation-ready: ${new URL(path, origin).pathname}`); + }; + const whoami = async () => { + const response = await page.request.get(`${origin}/api/auth/whoami`); + assertEquals(response.status(), 200); + return await response.json(); + }; + const account = "/account?view=passkeys&filter=a%2Fb#selected"; + const privateRequests: string[] = []; + const observePrivate = (request: { url(): string }) => { + const path = new URL(request.url()).pathname; + if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) { + privateRequests.push(path); + } + }; + page.on("request", observePrivate); + await page.goto(origin + account); + await at("/"); + await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor(); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals( + privateRequests, + [], + "anonymous gate must run before private loaders", + ); + page.off("request", observePrivate); + + await page.goto( + `${origin}/register?${new URLSearchParams({ returnTo: account })}`, + ); + await page.getByLabel("Account ID (handle)", { exact: true }).fill(handle); + await page.getByLabel("Display name", { exact: true }).fill( + "Real browser E2E", + ); + const registration = page.waitForResponse((response) => + new URL(response.url()).pathname === + "/api/auth/passkeys/registration/complete" + ); + await page.getByRole("button", { name: "Register new account", exact: true }) + .click(); + assertEquals((await registration).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + const identity = await whoami(); + assertEquals(identity.actor?.handle, handle); + assertEquals(identity.session.kind, "authenticated"); + assert(typeof identity.session.expires_at === "string"); + const cookies = await context.cookies(origin); + const session = cookies.find((cookie) => + cookie.name === "yoi_workspace_session" + ); + const marker = cookies.find((cookie) => + cookie.name === "yoi_workspace_session_expiry" + ); + assert(session && marker, "Backend must issue both cookies"); + for (const cookie of [session, marker]) { + assert(cookie.httpOnly); + assertEquals(cookie.sameSite, "Lax"); + } + const seconds = (Date.parse(identity.session.expires_at) / 1000) - + Date.now() / 1000; + assert(seconds > 14 * 86400 - 60 && seconds <= 14 * 86400); + assert( + Math.abs(session.expires - Date.parse(identity.session.expires_at) / 1000) < + 2, + ); + assert(Math.abs(marker.expires - session.expires - 7 * 86400) < 2); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + await page.reload(); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // 明示logoutは実Serverのrevoke + 両cookie削除を通る。 + await page.getByRole("button", { name: "Log out", exact: true }).click(); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + const afterLogout = await context.cookies(origin); + assert( + !afterLogout.some((cookie) => + [session.name, marker.name].includes(cookie.name) + ), + ); + const revoked = await page.request.get(`${origin}/api/auth/whoami`, { + headers: { + cookie: + `${session.name}=${session.value}; ${marker.name}=${marker.value}`, + }, + }); + assertEquals((await revoked.json()).session, { kind: "anonymous" }); + await page.goto(origin + account); + await at("/"); + + // 実discoverable loginで、確認済みの認証成功後のみpathname/query/fragmentへ復帰する。 + await page.goto( + `${origin}/login?${new URLSearchParams({ returnTo: account })}`, + ); + await page.reload(); + const login = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await login).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + assertEquals((await whoami()).actor?.handle, handle); + + // 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、 + // 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。 + const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25"; + const catalogResponse = () => + page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/workspaces" && + response.request().method() === "GET" + ); + const initialCatalog = catalogResponse(); + await page.goto(origin + expiredTarget); + assertEquals((await initialCatalog).status(), 200); + await at(expiredTarget); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const issuedStatus = await page.evaluate(async () => { + const response = await fetch("/__e2e/expired-session", { + method: "POST", + cache: "no-store", + }); + return response.status; + }); + assertEquals(issuedStatus, 204); + const initiallyPresent = await page.evaluate(async () => + await (await fetch("/__e2e/primary-cookie-present", { cache: "no-store" })) + .json() + ); + assertEquals( + initiallyPresent, + true, + "short-lived primary must first reach Chromium", + ); + await page.evaluate(async () => { + const deadline = performance.now() + 10_000; + do { + const response = await fetch("/__e2e/primary-cookie-present", { + cache: "no-store", + }); + if (response.ok && await response.json() === false) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (performance.now() < deadline); + throw new Error("Primary session cookie did not naturally expire"); + }); + // 値をassertionへ渡さず、cookie jarにもprimaryが消えmarkerが残ることを確認する。 + const expiredCookieNames = (await context.cookies(origin)).map((cookie) => + cookie.name + ); + assert(!expiredCookieNames.includes(session.name)); + assert(expiredCookieNames.includes(marker.name)); + assertEquals(await whoami(), { + actor: null, + session: { kind: "expired" }, + }); + privateRequests.length = 0; + page.on("request", observePrivate); + const expiredWhoami = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/whoami" + ); + await page.reload(); + const expiredResponse = await expiredWhoami; + assertEquals(expiredResponse.status(), 200); + // SPAがloginへ移った後の旧document bodyをCDPから読み直さない。 + // 分類本文は直前/復帰待ち後にも同じ実Backendから取得し、browser要求はstatusを観測する。 + const expiredLogin = `/login?${new URLSearchParams({ + returnTo: expiredTarget, + })}`; + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "expired reload must gate the real private catalog loader", + ); + assertEquals(await whoami(), { actor: null, session: { kind: "expired" } }); + await page.reload(); + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "login reload must not lose or privately load returnTo", + ); + page.off("request", observePrivate); + const recoveredCatalog = catalogResponse(); + const relogin = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await relogin).status(), 200); + await at(expiredTarget); + assertEquals((await recoveredCatalog).status(), 200); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const recovered = await whoami(); + assertEquals(recovered.session.kind, "authenticated"); + assertEquals(recovered.actor?.user_id, identity.actor.user_id); + assertEquals(recovered.actor?.handle, handle); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // fixtureでprimaryだけを除去。Server時計は進めていないため、markerのみでは + // authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。 + await context.clearCookies({ name: session.name }); + const partial = await whoami(); + assertEquals(partial, { actor: null, session: { kind: "anonymous" } }); + await page.reload(); + await at("/"); + await context.clearCookies(); + await page.goto(origin + account); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals(errors, []); +} finally { + await context?.close(); + await browser?.close(); + await server.shutdown(); + Deno.removeSignalListener("SIGTERM", terminate); +} +await Deno.stdout.write(new TextEncoder().encode('{"operation":"done"}\n')); +// 親はstdout EOFで完了を確定する。stdin streamのprefetch取消を待つと相互待ちになる。 +Deno.exit(0); diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index 95dce869b..7195bb3bb 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -1,11 +1,12 @@ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 //! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 -//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。 +//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(OS/物理認証器は対象外)。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 -//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 -//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、 -//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。 +//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 +//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが +//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 +//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。 use std::error::Error; use std::fs::OpenOptions; @@ -29,6 +30,9 @@ mod resident_model; use resident_model::ResidentAuthenticator; use yoi_e2e::WorkerE2eEnvironment; +#[path = "auth_passkeys/browser_sessions.rs"] +mod browser_sessions; + type Result = std::result::Result>; const BROWSER_ORIGIN: &str = "http://localhost:5173"; diff --git a/tests/e2e/tests/auth_passkeys/browser_sessions.rs b/tests/e2e/tests/auth_passkeys/browser_sessions.rs new file mode 100644 index 000000000..7207e4124 --- /dev/null +++ b/tests/e2e/tests/auth_passkeys/browser_sessions.rs @@ -0,0 +1,233 @@ +//! 実Backend・本番static shell・Chromium cookie jarの接続を検証する。 +//! navigator.credentialsだけを共有ResidentAuthenticatorの署名へ接続する。 +//! 期限切れケースは実登録userへ公開store APIで固定expiry sessionを作り、 +//! 本番cookie生成関数の短命primary/猶予markerをfixture HTTPで配信する。 +//! SQL直書き・resolver/APIの置換・製品clockの追加はしない。 +//! 実発行14日cookieの属性/DTOと短命fixtureの自然消失/復帰を分けて検証する。 +//! OS/物理認証器と14日経過したServer時計は検証しない。 +//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite: +//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored` +//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。 +use super::*; +use std::io::{BufRead, BufReader, Read}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Instant; +use yoi_workspace_server::auth::{ + SESSION_EXPIRY_GRACE_SECONDS, SessionCookiePolicy, mint_secret, new_id, + session_marker_cookie_name, session_set_cookie, token_hash, +}; +use yoi_workspace_server::store::{BrowserSessionRecord, ControlPlaneStore, SqliteWorkspaceStore}; + +// 戻り値はprivate IPC→fixture HTTPのSet-Cookieだけに使い、assert/debug/artifactへ出さない。 +fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result { + // default環境や任意pathへ接続せず、このenvironmentが起動した既存DBだけを開く。 + let database = environment + .server_data_dir + .join("server.db") + .canonicalize()?; + if !database.starts_with(environment.root.canonicalize()?) { + return Err("expired-session fixture DB is outside its environment".into()); + } + let store = SqliteWorkspaceStore::open(database)?; + let user = store + .get_user_by_handle(&environment.account_handle)? + .ok_or("browser registration must create the fixture user first")?; + let token = mint_secret("yoi_sess"); + let hash = token_hash(&token); + let now = chrono::Utc::now(); + // Server時計を動かさず、既に期限切れだが実猶予内のrecordを公開store APIで保存する。 + store.create_browser_session(&BrowserSessionRecord { + session_id: new_id("session"), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: (now - chrono::Duration::days(14) - chrono::Duration::seconds(60)).to_rfc3339(), + expires_at: (now - chrono::Duration::seconds(60)).to_rfc3339(), + revoked_at: None, + })?; + let policy = SessionCookiePolicy { + cookie_name: "yoi_workspace_session", + path: "/", + domain: None, + secure: false, + }; + let marker_name = session_marker_cookie_name(policy.cookie_name); + Ok(json!({"set_cookies": [ + session_set_cookie(policy, &token, 2), + session_set_cookie(SessionCookiePolicy { + cookie_name: &marker_name, + ..policy + }, &hash, SESSION_EXPIRY_GRACE_SECONDS - 60), + ]})) +} + +struct BrowserProcess(Option); +impl BrowserProcess { + fn child(&mut self) -> &mut Child { + self.0.as_mut().expect("browser process retained") + } +} +impl Drop for BrowserProcess { + fn drop(&mut self) { + let Some(mut child) = self.0.take() else { + return; + }; + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + let pid = child.id() as libc::pid_t; + // Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。 + unsafe { + libc::kill(pid, libc::SIGTERM); + } + let (send, receive) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _ = send.send(child.wait()); + }); + if receive.recv_timeout(Duration::from_secs(3)).is_err() { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + let _ = waiter.join(); + } +} + +#[test] +#[ignore = "requires production Web assets, Deno and Playwright Chromium"] +fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result +{ + // Backend/process/DBの所有は既存harnessに限定する。Web buildは呼出元が用意する。 + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let build = root.join("web/workspace/build"); + if !build.join("index.html").is_file() { + return Err("build web/workspace production assets before selecting browser E2E".into()); + } + let mut environment = WorkerE2eEnvironment::new()?; + let reservation = TcpListener::bind("127.0.0.1:0")?; + let port = reservation.local_addr()?.port(); + let origin = format!("http://localhost:{port}"); + std::fs::write( + &environment.server_config, + format!("[browser]\npublic_url = \"{origin}\"\n"), + )?; + environment.start_empty_backend()?; + let admin = + environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?; + let bearer = admin["access_token"] + .as_str() + .ok_or("missing setup token")?; + let base = environment + .server_url + .as_deref() + .ok_or("missing Backend URL")?; + success( + http_client()? + .put(format!("{base}/api/hosting/settings")) + .bearer_auth(bearer) + .json(&json!({"self_registration_enabled": true})) + .send()?, + )?; + drop(reservation); + let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?; + let mut process = BrowserProcess(Some( + Command::new("deno") + .current_dir(&root) + .args([ + "run", + "--config", + "tools/web-ux/deno.json", + "--no-lock", + "--allow-env", + "--allow-net", + "--allow-read", + "--allow-write", + "--allow-run", + "--allow-sys", + "tests/e2e/browser/auth_sessions.ts", + base, + &origin, + build.to_str().ok_or("non-UTF8 build path")?, + &environment.account_handle, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(stderr) + .spawn()?, + )); + let stdout = process + .child() + .stdout + .take() + .ok_or("missing browser stdout")?; + let mut stdin = process + .child() + .stdin + .take() + .ok_or("missing browser stdin")?; + let (send, receive) = mpsc::sync_channel(4); + // bounded NDJSONは実WebAuthn options/署名とfixture cookieを運び、artifactには記録しない。 + let reader = std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.by_ref().take(1024 * 1024).read_line(&mut line) { + Ok(0) => break, + Ok(_) if line.ends_with('\n') => { + if send.send(Ok(line)).is_err() { + break; + } + } + _ => { + let _ = send.send(Err("invalid or oversized browser IPC")); + break; + } + } + } + }); + let deadline = Instant::now() + Duration::from_secs(120); + let mut authenticator = ResidentAuthenticator::new(true); + let mut completed = false; + loop { + let message = match receive.recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(message) => message.map_err(std::io::Error::other)?, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(_) => return Err("browser E2E timed out; see browser.stderr.log".into()), + }; + let request: Value = serde_json::from_str(&message)?; + let credential = match request["operation"].as_str() { + Some("create") => serde_json::to_value(authenticator.do_registration( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("get") => serde_json::to_value(authenticator.do_authentication( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("expired-session") => expired_session_fixture(&environment)?, + Some("done") => { + completed = true; + continue; + } + _ => return Err("unexpected browser IPC operation".into()), + }; + serde_json::to_writer(&mut stdin, &credential)?; + writeln!(stdin)?; + stdin.flush()?; + } + reader.join().map_err(|_| "browser reader panicked")?; + assert!( + completed, + "browser did not complete; see browser.stderr.log" + ); + assert!( + process.child().wait()?.success(), + "browser E2E failed; see browser.stderr.log" + ); + drop(process); + let report = environment.cleanup()?; + assert!(report.cleanup_success); + Ok(()) +} diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 774a2852b..788ded8eb 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -19,7 +19,9 @@ export type AuthenticatedUser = { user_id: string, account_id: string, handle: s export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, }; -export type WhoamiResponse = { actor: RequestActor | null, }; +export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", }; + +export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, }; export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 4d8a78ea5..3ebfe5b20 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) +// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 9437b3e7c..d3f934117 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) +// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index c99bf5f4a..769a81763 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) +// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 8ed7a0fe8..048338ce9 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) +// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index b9eb0cc56..fdb476264 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) +// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 00c09e774..95bd9c108 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) +// Input contract: 0.1.0 (sha256:7c4ecff64866c760633454bd9ed66fcf25e16956a5152b97ab91576bb79fe083) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 27773b1bc..02dc0f39e 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -1,3 +1,7 @@ +import { + browserSession, + recheckAfterUnauthorized, +} from "#lib/workspace/auth/session-state.ts"; import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts"; import type { SkillCatalogResponse, @@ -102,7 +106,11 @@ export async function loadJson( ): Promise> { let stage: "request" | "decode" | "validate" = "request"; try { + const authenticationGeneration = browserSession.authenticationGeneration; const response = await fetchFn(path, init); + if (response.status === 401) { + recheckAfterUnauthorized(authenticationGeneration); + } if (!response.ok) { if (policy) { await response.body?.cancel(); diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index 2dafcddd9..46e41c345 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -2,7 +2,8 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey } from './api.ts'; + import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey, logout } from './api.ts'; + import { CLEAR_RETURN_EVENT } from './session-state.ts'; import { BrowserAuth } from './browser-session.svelte.ts'; import { loadRegistrationPolicy } from './hosting.ts'; import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts'; @@ -58,11 +59,16 @@ } } + async function forget() { + await auth.run('Logging out…', 'Logged out.', logout, 'logout'); + } onMount(() => { + const clearReturn = () => { void goto(page.url.pathname, { replaceState: true }); }; + window.addEventListener(CLEAR_RETURN_EVENT, clearReturn); void auth.refresh(); void refreshPolicy(); window.addEventListener('focus', refreshPolicy); - return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); }; + return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); window.removeEventListener(CLEAR_RETURN_EVENT, clearReturn); }; }); @@ -74,11 +80,16 @@ {:else if auth.session.kind === 'error'} + {:else if auth.actor}

Logged in as {auth.actor.display_name} @{auth.actor.handle}.

Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'} Open Account {:else} + {#if auth.session.session.kind === 'expired'} +

Your browser session expired. Log in to return to the requested page.

+ + {/if} {#if mode === 'register'} {#if policyLoading}

Checking registration availability…

diff --git a/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte new file mode 100644 index 000000000..e910913e6 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte @@ -0,0 +1,71 @@ + + +{#if !protectedPage} + {@render children()} +{:else} + {#if session.kind === 'loading'} +

Loading session…

+ {:else if session.kind === 'error'} +
+ + + Yoi home +
+ {/if} + {#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted} + + + {/if} +{/if} diff --git a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts index ad24e6937..3cbac0ddf 100644 --- a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts @@ -63,3 +63,12 @@ export function credentialFixture(authentication = false): Credential { }, } as unknown as Credential; } + +export function whoamiPayload(actor: unknown = null) { + return { + actor, + session: actor === null + ? { kind: "anonymous" } + : { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }; +} diff --git a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts index f956e48fa..d69840eb2 100644 --- a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts +++ b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts @@ -1,16 +1,21 @@ -import { AuthRequestError, loadWhoami } from "./api.ts"; +import { AuthRequestError } from "./api.ts"; +import { + browserSession, + observeBrowserSession, + sessionMutation, +} from "./session-state.ts"; import { notifySessionChanged } from "./hosting.ts"; -import type { RequestActor } from "./model.ts"; +import type { RequestActor, WhoamiResponse } from "./model.ts"; export type BrowserSession = | { kind: "loading" } | { kind: "error" } - | { kind: "ready"; actor: RequestActor | null }; + | ({ kind: "ready" } & WhoamiResponse); export function authErrorMessage(error: unknown): string { if (error instanceof AuthRequestError) { if (error.status === 401) { - return "Your session or authentication challenge expired. Log in again or restart the Passkey operation."; + return "Authentication request was rejected. Check your session or restart the Passkey operation."; } if (error.status === 400 || error.status === 410) { return "The request or authentication challenge is invalid or expired. Restart the operation."; @@ -35,61 +40,77 @@ export class BrowserAuth { busy = $state(false); error = $state(""); status = $state(""); - private request = 0; + private unsubscribe: () => void; private disposed = false; private controller: AbortController | null = null; + constructor() { + observeBrowserSession(); + this.unsubscribe = browserSession.subscribe((snapshot) => { + // 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。 + if (this.busy && snapshot.kind === "loading") return; + if (!this.disposed) { + this.session = snapshot.kind === "ready" + ? { kind: "ready", ...snapshot.value } + : snapshot; + } + }); + } + get actor(): RequestActor | null { return this.session.kind === "ready" ? this.session.actor : null; } async refresh(): Promise { if (this.busy || this.disposed) return; - await this.readSession(); - } - - private async readSession(): Promise { - const current = ++this.request; - this.session = { kind: "loading" }; - try { - const result = await loadWhoami(); - if (this.disposed || current !== this.request) return; - this.session = { kind: "ready", actor: result.actor }; - } catch { - if (this.disposed || current !== this.request) return; - this.session = { kind: "error" }; - } + await browserSession.refresh(); } async run( pending: string, success: string, operation: (signal: AbortSignal) => Promise, + intent: "authentication" | "logout" = "authentication", ): Promise { - if (this.busy || this.disposed || this.session.kind !== "ready") { + if ( + this.busy || this.disposed || + (intent !== "logout" && this.session.kind !== "ready") + ) { return false; } this.busy = true; - ++this.request; + sessionMutation(intent); + const authenticationGeneration = browserSession.authenticationGeneration; const controller = new AbortController(); this.controller = controller; this.error = ""; this.status = pending; try { await operation(controller.signal); - if (this.disposed) return false; + const superseded = + authenticationGeneration !== browserSession.authenticationGeneration; + sessionMutation(intent); + const confirmationGeneration = browserSession.authenticationGeneration; + await browserSession.refresh(); + if ( + this.disposed || superseded || + confirmationGeneration !== browserSession.authenticationGeneration + ) { + if (!this.disposed) this.status = ""; + return false; + } notifySessionChanged(); - await this.readSession(); - if (this.disposed) return false; const confirmed = this.session.kind === "ready"; this.status = confirmed ? success : ""; return confirmed; } catch (error) { - if (this.disposed) return false; - this.error = authErrorMessage(error); - this.status = ""; + if (!this.disposed) { + this.error = authErrorMessage(error); + this.status = ""; + } // 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 - await this.readSession(); + sessionMutation(intent); + await browserSession.refresh(); return false; } finally { if (!this.disposed) { @@ -101,7 +122,7 @@ export class BrowserAuth { dispose(): void { this.disposed = true; - ++this.request; + this.unsubscribe(); this.controller?.abort(); } } diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts index 177db4b8f..4c17c2df3 100644 --- a/web/workspace/src/lib/workspace/auth/model.test.ts +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -118,10 +118,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () }); Deno.test("whoami rejects unknown auth methods and extra fields", () => { - assertEquals(parseWhoamiResponse({ actor: null }), { actor: null }); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }), + { actor: null, session: { kind: "anonymous" } }, + ); assertThrows( () => parseWhoamiResponse({ + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, actor: { user_id: "user-1", account_id: "account-1", @@ -233,3 +237,34 @@ Deno.test("passkey credential conversion fails closed on malformed payloads", () "unknown credential type", ); }); + +Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", () => { + const actor = { + user_id: "u", + account_id: "a", + handle: "alice", + display_name: "Alice", + auth_method: "browser_session", + }; + for ( + const payload of [ + { actor, session: { kind: "anonymous" } }, + { actor, session: { kind: "expired" } }, + { + actor: null, + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }, + { actor, session: { kind: "authenticated", expires_at: null } }, + { actor, session: { kind: "authenticated", expires_at: "tomorrow" } }, + { actor: null, session: { kind: "future" } }, + { + actor: null, + session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" }, + }, + ] + ) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami"); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "expired" } }), + { actor: null, session: { kind: "expired" } }, + ); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.ts b/web/workspace/src/lib/workspace/auth/model.ts index a52b2e5a4..5923f32b8 100644 --- a/web/workspace/src/lib/workspace/auth/model.ts +++ b/web/workspace/src/lib/workspace/auth/model.ts @@ -463,12 +463,40 @@ function parseActor(value: unknown, path: string): RequestActor { export function parseWhoamiResponse(value: unknown): WhoamiResponse { const response = asRecord(value, "whoami"); - requireExactKeys(response, "whoami", ["actor"]); - return { - actor: response.actor === null + requireExactKeys(response, "whoami", ["actor", "session"]); + const actor = response.actor === null + ? null + : parseActor(response.actor, "whoami.actor"); + const session = asRecord(response.session, "whoami.session"); + if (session.kind === "authenticated") { + requireExactKeys(session, "whoami.session", ["kind", "expires_at"]); + if (actor === null) { + invalid("whoami", "authenticated session requires actor"); + } + const expiry = session.expires_at === null ? null - : parseActor(response.actor, "whoami.actor"), - }; + : boundedString(session.expires_at, "whoami.session.expires_at"); + if ( + (expiry !== null && + (!/^\d{4}-\d\d-\d\dT/.test(expiry) || + !Number.isFinite(Date.parse(expiry)))) || + (actor.auth_method === "browser_session" && expiry === null) + ) { + invalid( + "whoami.session.expires_at", + "must identify the server session expiry", + ); + } + return { actor, session: { kind: "authenticated", expires_at: expiry } }; + } + requireExactKeys(session, "whoami.session", ["kind"]); + if ( + actor !== null || + (session.kind !== "anonymous" && session.kind !== "expired") + ) { + invalid("whoami", "inconsistent actor or session state"); + } + return { actor: null, session: { kind: session.kind } }; } export function parseAuthPublicConfig(value: unknown): AuthPublicConfig { diff --git a/web/workspace/src/lib/workspace/auth/page-session.ts b/web/workspace/src/lib/workspace/auth/page-session.ts new file mode 100644 index 000000000..4ea824ddc --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/page-session.ts @@ -0,0 +1,28 @@ +import { error, redirect } from "@sveltejs/kit"; +import { browserSession, observeBrowserSession } from "./session-state.ts"; +import { isPublicPage, loginUrl } from "./routes.ts"; + +// root loadとそのparentを待つ各loaderが同じ確認を共有する。 +// SSRは無効。fragmentはHTTP/Serverに依存せず、browserの最終URLから回収する。 +export async function requirePageSession(url: URL): Promise { + observeBrowserSession(); + if (isPublicPage(url.pathname)) return; + const snapshot = await browserSession.refresh(); + if (snapshot !== browserSession.snapshot) return requirePageSession(url); + if (snapshot.kind !== "ready") { + error( + 503, + "Your session could not be loaded. Login status is unknown. Retry this page.", + ); + } + if (snapshot.value.session.kind === "authenticated") return; + if (snapshot.value.session.kind === "anonymous") redirect(307, "/"); + const target = new URL(url); + if ( + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ) { + target.hash = location.hash; + } + redirect(307, loginUrl(target.pathname + target.search + target.hash)); +} diff --git a/web/workspace/src/lib/workspace/auth/routes.test.ts b/web/workspace/src/lib/workspace/auth/routes.test.ts index c80b69926..feee08cd7 100644 --- a/web/workspace/src/lib/workspace/auth/routes.test.ts +++ b/web/workspace/src/lib/workspace/auth/routes.test.ts @@ -12,16 +12,16 @@ function assertEquals( } import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; -Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => { +Deno.test("return destination preserves query and fragment only on known app screens", () => { for ( const [input, expected] of [ ["/", "/dashboard"], - ["/dashboard?query=discard#fragment", "/dashboard"], + ["/dashboard?query=keep#fragment", "/dashboard?query=keep#fragment"], ["/account", "/account"], ["/account?returnTo=//evil.invalid#secret", "/account"], [ "/w/workspace-id/tickets/T-123?filter=all#entry", - "/w/workspace-id/tickets/T-123", + "/w/workspace-id/tickets/T-123?filter=all#entry", ], [ "/login/device?user_code=ABCD-EFGH", @@ -31,7 +31,7 @@ Deno.test("return destination permits known app screens without forwarding arbit "/login/device?code=abcd-1234&returnTo=//evil.invalid", "/login/device?user_code=abcd-1234", ], - ["/login/device?user_code=%252f%252fevil.invalid", "/login/device"], + ["/login/device?user_code=%252f%252fevil.invalid", "/dashboard"], ] ) assertEquals(safeReturnTo(input), expected, input); }); @@ -64,6 +64,11 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic "/account\t", "/account\u0000", "/account%00", + "/account?q=%255c", + "/account?q=%0A", + "/account#%5c", + "/account?q=%ZZ", + "/account?q=%252f%252fevil.invalid", "/api/auth/logout", "/hosting/setup", "/not-an-app-route", @@ -86,3 +91,38 @@ Deno.test("login links encode one validated destination and discard nested auth "/login?returnTo=%2Fdashboard", ); }); + +Deno.test("return destination accepts canonical UTF-8 resource slugs but not encoded ASCII or delimiters", () => { + const target = `/w/team/tickets/T-123-${ + encodeURIComponent("未ログイン") + }?view=comments#entry-2`; + assertEquals(safeReturnTo(target), target); + for ( + const [input, expected] of [ + [ + "/w/team/tickets?filter=100%25#entry", + "/w/team/tickets?filter=100%25#entry", + ], + [ + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + ], + [ + "/w/team/tickets/未?query=a%2fb#entry%2f2", + "/w/team/tickets/%E6%9C%AA?query=a%2fb#entry%2f2", + ], + ] + ) { + assertEquals(safeReturnTo(input), expected); + } + for ( + const input of [ + "/w/team/tickets/%54-123", + "/w/team/tickets/T-123%2fother", + "/w/team/tickets/%E0%80%AF", + "/w/team/tickets/%252f", + ] + ) { + assertEquals(safeReturnTo(input), "/dashboard"); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/routes.ts b/web/workspace/src/lib/workspace/auth/routes.ts index 595f8d44e..5324ee5a6 100644 --- a/web/workspace/src/lib/workspace/auth/routes.ts +++ b/web/workspace/src/lib/workspace/auth/routes.ts @@ -11,13 +11,25 @@ export function safeReturnTo(value: string | null): string { } const rawPath = value.split(/[?#]/, 1)[0]; if ( - rawPath.includes("%") || rawPath.includes("//") || + /%[0-7][0-9a-f]/i.test(rawPath) || rawPath.includes("//") || rawPath.split("/").some((part) => part === "." || part === "..") ) { return AUTHENTICATED_HOME; } let url: URL; + let decodedPath: string; try { + decodedPath = decodeURIComponent(rawPath); + // pathは一度だけdecodeして許可画面を判定する。UTF-8 escapeの大小や生Unicodeで + // origin/区切りは変わらないため、再encodeとの文字列一致は求めない。 + // query/fragmentはpathの権限を持たないdata。単独の%25は保持し、次のdecodeで + // 別文字へ変わる二重escapeとcontrol/backslashだけを拒否する。 + const decoded = decodeURIComponent(value); + if ( + /[\\\u0000-\u001f\u007f]/u.test(decoded) || /%[0-9a-f]{2}/i.test(decoded) + ) { + return AUTHENTICATED_HOME; + } url = new URL(value, "https://app.invalid"); } catch { return AUTHENTICATED_HOME; @@ -33,9 +45,12 @@ export function safeReturnTo(value: string | null): string { } if ( url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" || - /^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname) + ["/hosting/settings", "/hosting/accounts"].includes(url.pathname) || + /^\/w\/[a-zA-Z0-9_-]+(?:\/[\p{Letter}\p{Number}_/-]*)?$/u.test(decodedPath) ) { - return url.pathname; + // 認証導線の入れ子は復帰先へ持ち込まない。検索条件とfragmentはURLとして保持する。 + if (url.searchParams.has("returnTo")) return url.pathname; + return url.pathname + url.search + url.hash; } return AUTHENTICATED_HOME; } @@ -53,7 +68,29 @@ export function authEntryUrl( })}`; } +export function isPublicPage(path: string): boolean { + return [ + "/", + "/login", + "/register", + "/activate", + "/login/device", + "/hosting/setup", + ] + .includes(path); +} + export function isAuthPage(path: string): boolean { return ["/login", "/register", "/account", "/activate", "/login/device"] .includes(path); } + +// Universal loadのURLにfragmentがなくても同じ表示中URLならbrowserから補完する。 +export function browserUrlSuffix(url: URL): string { + const hash = + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ? location.hash + : url.hash; + return url.search + hash; +} diff --git a/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts new file mode 100644 index 000000000..19bf093ef --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { createRawSnippet } from "svelte"; +import SessionBoundary from "./SessionBoundary.svelte"; +import { browserSession } from "./session-state.ts"; +import { authUser, whoamiPayload } from "./api.test-fixtures.ts"; +import { goto } from "$app/navigation"; +const page = vi.hoisted(() => ({ + url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"), +})); +vi.mock("$app/state", () => ({ page })); +vi.mock( + "$app/navigation", + () => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }), +); +afterEach(() => { + cleanup(); + browserSession.invalidate(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); +const actor = { ...authUser, auth_method: "browser_session" }; +const children = createRawSnippet(() => ({ + render: () => "

Private fragment

", +})); +async function admitted() { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(whoamiPayload(actor))), + ); + await browserSession.refresh(); + render(SessionBoundary, { children }); + await screen.findByText("Private fragment"); +} + +test("active expired state removes private content and preserves browser query and fragment", async () => { + await admitted(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ actor: null, session: { kind: "expired" } }) + ), + ); + await browserSession.refresh(); + await waitFor(() => + expect(screen.queryByText("Private fragment")).toBeNull() + ); + expect(goto).toHaveBeenCalledWith( + "/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry", + { replaceState: true }, + ); +}); + +test("unknown state hides and inerts admitted content instead of declaring anonymous", async () => { + await admitted(); + let release!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn(() => + new Promise((resolve) => { + release = resolve; + }) + ), + ); + const checking = browserSession.refresh(); + await screen.findByText("Loading session…"); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + release(new Response(null, { status: 503 })); + await checking; + await screen.findByText( + "Your session could not be loaded. Login status is unknown.", + ); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + expect(goto).not.toHaveBeenCalled(); +}); + +test("another tab changing actor cannot reveal content loaded for the old actor", async () => { + await admitted(); + const reload = vi.spyOn(location, "reload").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + whoamiPayload({ + ...actor, + user_id: "different-user", + account_id: "different-account", + }), + ) + ), + ); + await browserSession.refresh(); + await waitFor(() => expect(reload).toHaveBeenCalledOnce()); + expect(screen.queryByText("Private fragment")).toBeNull(); +}); + +test("a child mount session recheck does not unmount and restart its own loader", async () => { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor))); + vi.stubGlobal("fetch", fetcher); + await browserSession.refresh(); + let mounts = 0; + const checkingChild = createRawSnippet(() => ({ + render: () => "

Mounted session reader

", + setup: () => { + ++mounts; + void browserSession.refresh(); + }, + })); + render(SessionBoundary, { children: checkingChild }); + await waitFor(() => expect(browserSession.snapshot.kind).toBe("ready")); + expect(screen.getByText("Mounted session reader").closest("[hidden]")) + .toBeNull(); + expect(mounts).toBe(1); + expect(fetcher).toHaveBeenCalledTimes(2); +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts new file mode 100644 index 000000000..5ef8ddf6a --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +// whoami境界だけを代替し、epoch競合/再確認契機を決定的に検査する。 +// cookieと実storeの一致はServer/browser結合suiteが所有する。 +import { afterEach, expect, test, vi } from "vitest"; +import { SessionState } from "./session-state.ts"; +import type { WhoamiResponse } from "./model.ts"; +import { authUser } from "./api.test-fixtures.ts"; + +const states: SessionState[] = []; +afterEach(() => { + for (const state of states.splice(0)) state.dispose(); + vi.useRealTimers(); + localStorage.clear(); +}); +const anonymous: WhoamiResponse = { + actor: null, + session: { kind: "anonymous" }, +}; +const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } }; +const authenticated = ( + expires_at: string | null = "2099-01-01T00:00:00Z", +): WhoamiResponse => ({ + actor: { ...authUser, auth_method: "browser_session" }, + session: { kind: "authenticated", expires_at }, +}); +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function state(read: () => Promise) { + const result = new SessionState(read); + states.push(result); + return result; +} + +test("concurrent session checks share one authoritative read without classifying loading as anonymous", async () => { + const response = gate(); + const read = vi.fn(() => response.promise); + const session = state(read); + const first = session.refresh(); + const second = session.refresh(); + expect(session.snapshot.kind).toBe("loading"); + expect(read).toHaveBeenCalledTimes(1); + response.resolve(expired); + await Promise.all([first, second]); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("delayed expired response cannot replace a new login in the same tab", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + session.invalidate(); + await session.refresh(); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("storage generation fences responses even before the other tab storage event is delivered", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + localStorage.setItem( + "yoi.browser-auth-generation", + JSON.stringify({ id: "new-login", reason: "authentication" }), + ); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("active deadline rechecks the server and reports failures as unknown, never inferred expiry", async () => { + vi.useFakeTimers(); + const read = vi.fn<() => Promise>().mockResolvedValueOnce( + authenticated(new Date(Date.now() + 5_000).toISOString()), + ).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired); + const session = state(read); + await session.refresh(); + await vi.advanceTimersByTimeAsync(5_000); + expect(session.snapshot).toEqual({ kind: "error" }); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => { + for (const value of [authenticated(), anonymous]) { + const session = state(async () => value); + session.invalidate(); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value }); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.ts b/web/workspace/src/lib/workspace/auth/session-state.ts new file mode 100644 index 000000000..2ff372940 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.ts @@ -0,0 +1,153 @@ +/// +import { loadWhoami } from "./api.ts"; +import type { WhoamiResponse } from "./model.ts"; + +export type SessionSnapshot = + | { kind: "loading" } + | { kind: "error" } + | { kind: "ready"; value: WhoamiResponse }; + +// 世代は権限ではなく、認証操作より前に開始した応答を捨てるためだけに使う。 +// Cookieの実状態は常にwhoamiが所有し、tab間通知や一般401からexpiredを推測しない。 +const GENERATION_KEY = "yoi.browser-auth-generation"; +export class SessionState { + snapshot: SessionSnapshot = { kind: "loading" }; + private generation = 0; + private pending: Promise | null = null; + private listeners = new Set<(state: SessionSnapshot) => void>(); + private timer: ReturnType | undefined; + + get authenticationGeneration(): number { + return this.generation; + } + + constructor(private readonly read = loadWhoami) {} + + subscribe(listener: (state: SessionSnapshot) => void): () => void { + this.listeners.add(listener); + listener(this.snapshot); + return () => this.listeners.delete(listener); + } + + private publish(snapshot: SessionSnapshot): void { + this.snapshot = snapshot; + clearTimeout(this.timer); + if ( + snapshot.kind === "ready" && + snapshot.value.session.kind === "authenticated" + ) { + const expiry = snapshot.value.session.expires_at; + if (expiry !== null) { + // Timerは再照会の契機。local clockだけで期限切れと断定しない。 + const remaining = Date.parse(expiry) - Date.now(); + this.timer = setTimeout(() => { + void this.refresh(); + }, Math.min(2_147_483_647, Math.max(1_000, remaining))); + } + } + for (const listener of this.listeners) listener(snapshot); + } + + invalidate(): void { + ++this.generation; + this.pending = null; + this.publish({ kind: "loading" }); + } + + async refresh(): Promise { + if (this.pending) return this.pending; + const generation = this.generation; + const stamp = storageStamp(); + this.publish({ kind: "loading" }); + const pending = (async () => { + let result: SessionSnapshot; + try { + result = { kind: "ready", value: await this.read() }; + } catch { + result = { kind: "error" }; + } + if (generation !== this.generation || stamp !== storageStamp()) { + // 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。 + if (generation !== this.generation) { + if (this.pending) return await this.pending; + if (this.snapshot.kind !== "loading") return this.snapshot; + } + // 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。 + if (generation === this.generation) this.invalidate(); + return await this.refresh(); + } + this.pending = null; + this.publish(result); + return result; + })(); + this.pending = pending; + return pending; + } + + dispose(): void { + clearTimeout(this.timer); + this.listeners.clear(); + ++this.generation; + } +} + +function storageStamp(): string | null { + try { + return globalThis.localStorage?.getItem(GENERATION_KEY) ?? null; + } catch { + return null; + } +} + +export const browserSession = new SessionState(); + +export function sessionMutation( + reason: "authentication" | "logout" = "authentication", +): void { + browserSession.invalidate(); + const message = JSON.stringify({ id: crypto.randomUUID(), reason }); + try { + localStorage.setItem(GENERATION_KEY, message); + } catch { /* 保存拒否でも同tab fenceは維持。 */ } + if (reason === "logout") clearReturnTo(); +} + +export const CLEAR_RETURN_EVENT = "yoi:clear-auth-return"; +function clearReturnTo(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(CLEAR_RETURN_EVENT)); + } +} + +let installed = false; +export function recheckAfterUnauthorized( + authenticationGeneration: number, +): void { + if ( + installed && + authenticationGeneration === browserSession.authenticationGeneration + ) void browserSession.refresh(); +} +export function observeBrowserSession(): void { + if (installed || typeof window === "undefined") return; + installed = true; + const refresh = () => { + void browserSession.refresh(); + }; + window.addEventListener("focus", refresh); + window.addEventListener("pageshow", refresh); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) refresh(); + }); + window.addEventListener("storage", (event) => { + if (event.key !== GENERATION_KEY && event.key !== null) return; + browserSession.invalidate(); + try { + if ( + event.key === null || + JSON.parse(event.newValue ?? "null")?.reason === "logout" + ) clearReturnTo(); + } catch { /* 不明な通知も権限ではなく再照会だけ。 */ } + refresh(); + }); +} diff --git a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts index b892e0086..559b97782 100644 --- a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts +++ b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom import { afterEach, expect, test, vi } from "vitest"; +import { browserSession, sessionMutation } from "./session-state.ts"; import { BrowserAuth } from "./browser-session.svelte.ts"; import { addPasskey, loginWithPasskey, logout } from "./api.ts"; import { SESSION_CHANGED_EVENT } from "./hosting.ts"; @@ -13,6 +14,7 @@ import { const sessions: BrowserAuth[] = []; afterEach(() => { for (const auth of sessions.splice(0)) auth.dispose(); + browserSession.invalidate(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -33,6 +35,9 @@ function session() { function whoami(authenticated: boolean) { return Response.json({ + session: authenticated + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: authenticated ? { ...authUser, auth_method: "browser_session" } : null, @@ -98,7 +103,11 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut expect(await auth.run("pending", "done", operation)).toBe(false); expect(operation).not.toHaveBeenCalled(); await auth.refresh(); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); }); for ( @@ -117,6 +126,7 @@ for ( ); const auth = session(); const first = auth.refresh(); + browserSession.invalidate(); const second = auth.refresh(); latest.resolve( latestStatus === 200 @@ -146,13 +156,21 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async () const oldResponse = gate(); fetcher.mockReturnValueOnce(oldResponse.promise); const oldRefresh = auth.refresh(); - await auth.refresh(); - expect(auth.actor?.handle).toBe("alice"); - expect(await auth.run("Logging out", "Logged out", logout)).toBe(true); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe( + true, + ); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); oldResponse.resolve(whoami(true)); await oldRefresh; - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Logged out"); }); @@ -190,7 +208,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet options.resolve(Response.json(loginOptions)); await confirmingStarted.promise; expect(auth.busy).toBe(true); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); await auth.refresh(); expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) @@ -220,7 +238,7 @@ for (const status of [400, 401, 410]) { expect(auth.status).toBe(""); expect(auth.error).toMatch( status === 401 - ? /Your session or authentication challenge expired/ + ? /Authentication request was rejected/ : /The request or authentication challenge is invalid or expired/, ); expect(auth.session.kind).toBe("ready"); @@ -270,7 +288,11 @@ for ( expect(auth.error).not.toContain("secret credential detail"); expect(auth.status).toBe(""); expect(auth.busy).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", @@ -338,7 +360,7 @@ for (const rejected of [false, true]) { }); } -test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => { +test("dispose aborts WebAuthn without accepting late credentials, while shared session is rechecked", async () => { const { fetcher, get } = boundary(); const auth = session(); await auth.refresh(); @@ -360,12 +382,17 @@ test("dispose aborts WebAuthn and ignores a late credential without completion, expect(signal.aborted).toBe(true); credential.resolve(credentialFixture(true)); expect(await run).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Waiting"); expect(auth.error).toBe(""); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -409,6 +436,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not "/api/auth/whoami", "/api/auth/passkeys/login/options", "/api/auth/passkeys/login/complete", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -443,15 +471,73 @@ test("dispose during the post-operation whoami prevents its late actor from beco (signal) => loginWithPasskey(signal), ); await started.promise; - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.busy).toBe(true); auth.dispose(); response.resolve(whoami(true)); expect(await run).toBe(false); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.actor).toBeNull(); }); +for (const newer of ["logout", "another-actor"] as const) { + test(`confirmation superseded by ${newer} cannot report success or navigate using the old return URL`, async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + const confirming = gate(); + const started = gate(); + const request = fetcher.getMockImplementation()!; + fetcher.mockImplementation((input, init) => { + if (String(input) === "/api/auth/whoami") { + started.resolve(); + return confirming.promise; + } + return request(input, init); + }); + const changed = vi.fn(); + window.addEventListener(SESSION_CHANGED_EVENT, changed); + const navigate = vi.fn(); + try { + const run = auth.run("Waiting", "Logged in", loginWithPasskey).then( + (confirmed) => { + if (confirmed && auth.actor) navigate("/account?old=return#entry"); + return confirmed; + }, + ); + await started.promise; + sessionMutation(newer === "logout" ? "logout" : "authentication"); + fetcher.mockResolvedValueOnce( + newer === "logout" ? whoami(false) : Response.json({ + actor: { + ...authUser, + user_id: "other-user", + account_id: "other-account", + handle: "bob", + auth_method: "browser_session", + }, + session: { + kind: "authenticated", + expires_at: "2099-01-01T00:00:00Z", + }, + }), + ); + await browserSession.refresh(); + confirming.resolve(whoami(true)); + expect(await run).toBe(false); + expect(auth.actor?.handle ?? null).toBe( + newer === "logout" ? null : "bob", + ); + expect(auth.status).toBe(""); + expect(auth.busy).toBe(false); + expect(changed).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(SESSION_CHANGED_EVENT, changed); + } + }); +} + for (const purpose of ["login", "addition"] as const) { test(`${purpose} null credential reports cancellation and never sends completion`, async () => { const { fetcher, get, create } = boundary(purpose === "addition"); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.test.ts b/web/workspace/src/lib/workspace/navigation/deferred.test.ts index 238567212..63b3c85cc 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.test.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.test.ts @@ -21,6 +21,7 @@ Deno.test("deferred load returns before API completion and snapshots navigation const api = gate(); const event = { params: { id: "first" }, + parent: async () => ({}), url: new URL("https://example.test/first?q=old"), }; const load = deferPage(async ({ params, url }: typeof event) => ({ @@ -54,9 +55,12 @@ Deno.test("deferred redirects and HTTP failures are handled results, not unhandl ], ] as const ) { - const load = deferPage((_event: { params: {}; url: URL }) => run()); + const load = deferPage(( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => run()); const result = await load({ params: {}, + parent: async () => ({}), url: new URL("https://example.test/"), }).deferredPage; assert( @@ -94,3 +98,39 @@ Deno.test("resolved data preserves page overrides without waiting for the global result.data.workspaceId === "team", ); }); + +Deno.test("private deferred loader starts only after parent admission and never on parent failure", async () => { + for (const outcome of ["admit", "anonymous", "expired", "error"] as const) { + const admission = gate(); + let calls = 0; + const load = deferPage( + async ( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => { + ++calls; + return {}; + }, + ); + const result = load({ + params: {}, + url: new URL("https://app.invalid/w/a/tickets"), + parent: async () => { + await admission.promise; + if (outcome === "anonymous") redirect(307, "/"); + if (outcome === "expired") { + redirect(307, "/login?returnTo=%2Fw%2Fa%2Ftickets"); + } + if (outcome === "error") { + error(503, "Unknown session"); + } + return {}; + }, + }); + await Promise.resolve(); + assert(calls === 0); + admission.release(); + const settled = await result.deferredPage; + assert(calls === (outcome === "admit" ? 1 : 0)); + assert(settled.ok === (outcome === "admit")); + } +}); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.ts b/web/workspace/src/lib/workspace/navigation/deferred.ts index c3baaf145..6e71ab849 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.ts @@ -39,7 +39,11 @@ export async function settle( } export function deferPage< - E extends { params: Record; url: URL }, + E extends { + params: Record; + url: URL; + parent: () => Promise; + }, T extends object, >( loader: (event: E) => T | Promise, @@ -51,11 +55,13 @@ export function deferPage< params: { ...event.params }, url: new URL(event.url.href), }; - try { - return { deferredPage: settle(loader(snapshot)) }; - } catch (error) { - return { deferredPage: settle(Promise.reject(error)) }; - } + // SvelteKitはparent/childを並列開始する。deferしても認証前fetchを開始しない。 + return { + deferredPage: settle((async () => { + await snapshot.parent(); + return await loader(snapshot); + })()), + }; }; } diff --git a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts index 905d3fe88..695e5841f 100644 --- a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts @@ -18,6 +18,9 @@ for ( const fetcher = vi.fn().mockImplementation(async (input) => { if (String(input) === "/api/auth/whoami") { return Response.json({ + session: actor + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: actor ? { ...authUser, auth_method: "browser_session" } : null, }); } diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 401c24047..bbaab7c93 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -4,6 +4,7 @@ // navigationの構成はWorkspace identityやpermissionの根拠にはしない。 // Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。 // MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。 + import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte'; import { page } from '$app/state'; import { setContext } from 'svelte'; import { MediaQuery } from 'svelte/reactivity'; @@ -96,6 +97,7 @@ }); + {#if standalone}
{@render children()} @@ -147,6 +149,7 @@
{/if} +