Merge remote-tracking branch 'origin/develop' into ticket/t-768-subject-grants

# Conflicts:
#	crates/worker-runtime/src/worker_backend.rs
#	openapi/server-api.json
#	web/workspace/src/lib/generated/companion-api.ts
#	web/workspace/src/lib/generated/repository-api.ts
#	web/workspace/src/lib/generated/runtime-api.ts
#	web/workspace/src/lib/generated/ticket-api.ts
#	web/workspace/src/lib/generated/worker-launch-api.ts
This commit is contained in:
2026-10-20 06:24:11 +09:00
116 changed files with 9151 additions and 2358 deletions
+1 -3
View File
@@ -47,9 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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());
+6 -59
View File
@@ -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")
);
}
}
+114
View File
@@ -31,11 +31,18 @@ enum WriteResponseFault {
LostBody,
}
#[derive(Clone, Copy)]
enum CommandResponseFault {
LostTerminal,
EmptyWaiting,
}
struct ProviderState {
session: WorkdirSessionHandle,
requests: Mutex<Vec<(String, String)>>,
operations: Mutex<Vec<WorkdirSessionOperation>>,
write_fault: Mutex<Option<WriteResponseFault>>,
command_fault: Mutex<Option<CommandResponseFault>>,
observe_refusal: Mutex<bool>,
}
@@ -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 _;
+5 -1
View File
@@ -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();
+37 -72
View File
@@ -692,7 +692,6 @@ impl WorkerController {
pub async fn spawn<C, St>(
worker: Worker<C, St>,
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<C, St>(
worker: Worker<C, St>,
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<C, St>(
worker: Worker<C, St>,
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<C, St>(
worker: Worker<C, St>,
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<C, St>(
worker: Worker<C, St>,
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<C, St>(
worker: Worker<C, St>,
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<C, St>(
worker: Worker<C, St>,
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<C, St>(
worker: Worker<C, St>,
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<C, St>(
mut worker: Worker<C, St>,
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::<Method>(32);
let (working_event_tx, _) = broadcast::channel::<Event>(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<C, St>(
worker: &mut Worker<C, St>,
bash_output_dir: PathBuf,
runtime_base: PathBuf,
spawned_registry: Arc<SpawnedWorkerRegistry>,
parent_method_tx: Option<mpsc::WeakSender<Method>>,
@@ -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<C, St>(
}
}
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,
@@ -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,
)
+1 -2
View File
@@ -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,
)
+1
View File
@@ -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;
+451
View File
@@ -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<WorkdirSessionRouter>,
view: BashOutputView,
permissions: Option<ToolPermissionConfig>,
) -> 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<ToolPermissionConfig>,
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<Self>) -> 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<CommandStream>)> {
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<Provider> {
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, 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<Vec<String>, ProtocolError> {
Ok(vec![])
}
}
struct Run {
provider: Arc<Provider>,
}
#[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<String, Value>,
ctx: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
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<Provider>,
id: String,
stream: Option<CommandStream>,
path: String,
validator: Vec<u8>,
}
impl File {
fn order_text(&self) -> Result<String, WipOperationError> {
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<Vec<u8>> {
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<String, Value>,
_: WipCallContext,
) -> Result<WipOperationOutput, WipOperationError> {
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,
}
}
@@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
}
}
async fn start_command(
&self,
mut request: CommandRequest,
) -> Result<CommandHandle, WorkdirError> {
// `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<CommandHandle, WorkdirError> {
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
+1 -1
View File
@@ -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};
+27 -7
View File
@@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord {
pub(crate) fn new(
worker_name: String,
scope_delegated: Vec<ScopeRule>,
workdir_tool_scope: WorkdirScopeLeaseSet,
workdir_tool_scope: Arc<WorkdirScopeLeaseSet>,
#[cfg(test)] installed_tools: Vec<String>,
session: InternalWorkerSessionHandle,
child_registry: Arc<SpawnedWorkerRegistry>,
@@ -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<SharedScope>,
bash_output: Mutex<tools::bash_output::BashOutputView>,
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
protocol_publish_lock: Arc<Mutex<()>>,
}
@@ -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<Self> {
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<Eve
impl Drop for SpawnedWorkerRegistry {
fn drop(&mut self) {
self.bash_output.get_mut().unwrap().revoke();
let Some(parent_scope) = &self.parent_scope else {
return;
};
@@ -1297,11 +1315,13 @@ mod tests {
InternalSpawnedWorkerRecord::new(
name.into(),
Vec::new(),
workdir::WorkdirScopeLeaseSet::from_single(
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
delegation,
)
.unwrap(),
Arc::new(
workdir::WorkdirScopeLeaseSet::from_single(
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
delegation,
)
.unwrap(),
),
Vec::new(),
session,
registry(),
+37 -38
View File
@@ -310,7 +310,7 @@ pub struct SubWorkerSpawnTool {
/// Runtime-owned root used for Internal Worker controller state.
runtime_base: PathBuf,
/// Parent Worker-owned temporary root used for bounded Bash spill output.
bash_output_dir: PathBuf,
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
workspace_root: PathBuf,
@@ -345,7 +345,7 @@ impl SubWorkerSpawnTool {
workspace_context: crate::worker::WorkerWorkspaceContext,
parent_notifications: ParentNotificationTarget,
runtime_base: PathBuf,
bash_output_dir: PathBuf,
workspace_root: PathBuf,
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
registry: Arc<SpawnedWorkerRegistry>,
@@ -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<WorkdirToolBrokerRouter>,
registry: Arc<SpawnedWorkerRegistry>,
@@ -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<WorkdirToolBrokerRouter>,
registry: Arc<SpawnedWorkerRegistry>,
@@ -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(),
-4
View File
@@ -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,
)
+3
View File
@@ -7126,3 +7126,6 @@ mod tests {
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
}
#[cfg(test)]
mod bash_output_tests;
+510
View File
@@ -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<WorkdirSessionRouter>,
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<WorkdirSessionRouter>, 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<Json, ToolError> {
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);
}
-2
View File
@@ -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(
+10 -17
View File
@@ -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();
+34 -48
View File
@@ -323,8 +323,7 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> 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();