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:
@@ -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());
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 _;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -7126,3 +7126,6 @@ mod tests {
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bash_output_tests;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user