feat: add external workdir command permissions
This commit is contained in:
@@ -11,6 +11,7 @@ http-client = ["dep:reqwest"]
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
fs-operation.workspace = true
|
||||
libc.workspace = true
|
||||
manifest.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
|
||||
schemars.workspace = true
|
||||
|
||||
+102
-21
@@ -26,6 +26,17 @@ pub const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000;
|
||||
const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
||||
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000;
|
||||
|
||||
fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
|
||||
!handle.0.is_empty()
|
||||
&& handle.0.len() <= MAX_PROTOCOL_ID_BYTES
|
||||
&& handle.0.trim() == handle.0
|
||||
&& !handle.0.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Fail-closed wire version for the External Workdir provider protocol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -123,10 +134,9 @@ fn is_root_relative(path: &WorkdirPath) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the filesystem-only subset shared by read-only and read-write
|
||||
/// External Workdirs. Capability enforcement remains a separate grant/session
|
||||
/// boundary; command operations never enter the provider protocol.
|
||||
fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
/// Validate the bounded operation subset available to External Workdirs.
|
||||
/// Capability enforcement remains a separate grant/session boundary.
|
||||
fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
let path_is_valid = match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(request) => {
|
||||
is_root_relative(&request.path)
|
||||
@@ -182,17 +192,34 @@ fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<
|
||||
.checked_add(request.new_string.len())
|
||||
.is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES)
|
||||
}
|
||||
WorkdirSessionOperation::CommandStart(_)
|
||||
| WorkdirSessionOperation::CommandStatus(_)
|
||||
| WorkdirSessionOperation::CommandOutput(_)
|
||||
| WorkdirSessionOperation::CommandCancel(_) => {
|
||||
return Err("command operations are not available to an External Workdir".to_string());
|
||||
WorkdirSessionOperation::CommandStart(request) => {
|
||||
!request.command.is_empty()
|
||||
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs)
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit)
|
||||
&& is_root_relative(&request.cwd)
|
||||
&& request.spill_dir.is_none()
|
||||
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
|
||||
!tool_call_id.is_empty()
|
||||
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
|
||||
&& !tool_call_id.chars().any(char::is_control)
|
||||
})
|
||||
}
|
||||
WorkdirSessionOperation::CommandStatus(handle)
|
||||
| WorkdirSessionOperation::CommandCancel(handle) => valid_command_handle(handle),
|
||||
WorkdirSessionOperation::CommandOutput(request) => {
|
||||
valid_command_handle(&request.handle)
|
||||
&& request.cursor <= MAX_EXTERNAL_COMMAND_CURSOR
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.limit)
|
||||
}
|
||||
};
|
||||
if path_is_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("External Workdir operation paths must be root-relative".to_string())
|
||||
Err(
|
||||
"External Workdir operation exceeds protocol bounds or uses an invalid path"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +242,7 @@ impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
|
||||
validate_filesystem_operation(&operation)?;
|
||||
validate_external_operation(&operation)?;
|
||||
Ok(Self(operation))
|
||||
}
|
||||
}
|
||||
@@ -239,7 +266,7 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> boo
|
||||
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
|
||||
}
|
||||
|
||||
fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
let result_is_valid = match result {
|
||||
WorkdirSessionOperationResult::AuthorizeScope
|
||||
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
||||
@@ -273,14 +300,16 @@ fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<
|
||||
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
||||
&& result.replacements <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandStart(_)
|
||||
| WorkdirSessionOperationResult::CommandStatus(_)
|
||||
| WorkdirSessionOperationResult::CommandOutput(_)
|
||||
| WorkdirSessionOperationResult::CommandCancel => {
|
||||
return Err(
|
||||
"command operation results are not available to an External Workdir".to_string(),
|
||||
);
|
||||
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
|
||||
WorkdirSessionOperationResult::CommandStatus(_) => true,
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||
&& output
|
||||
.next_cursor
|
||||
.is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR)
|
||||
&& output.output_path.is_none()
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandCancel => true,
|
||||
};
|
||||
if result_is_valid {
|
||||
Ok(())
|
||||
@@ -308,7 +337,7 @@ impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
|
||||
validate_filesystem_result(&result)?;
|
||||
validate_external_result(&result)?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
@@ -544,7 +573,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filesystem_protocol_rejects_host_paths_and_commands_but_allows_bounded_mutations() {
|
||||
fn protocol_rejects_host_paths_and_unbounded_commands_but_allows_bounded_operations() {
|
||||
let absolute = WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
|
||||
offset: 0,
|
||||
@@ -563,6 +592,29 @@ mod tests {
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||
|
||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
command: "printf bounded".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: Some("tool-call-1".to_string()),
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_ok());
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
|
||||
CommandRequest {
|
||||
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest {
|
||||
path: WorkdirPath::new("created.txt").unwrap(),
|
||||
@@ -589,6 +641,35 @@ mod tests {
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
}
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: true,
|
||||
output_path: Some("/provider/private/output".into()),
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,10 +2,10 @@ use crate::http::{
|
||||
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||
};
|
||||
use crate::{
|
||||
BoundedReadLimits, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest,
|
||||
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, WorkdirError,
|
||||
WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
||||
WriteRequest,
|
||||
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
|
||||
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
||||
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
||||
WorkdirSessionCapability, WriteRequest,
|
||||
};
|
||||
|
||||
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
|
||||
@@ -162,6 +162,73 @@ async fn external_local_provider_read_write_is_filesystem_only() {
|
||||
assert!(!root.path().join("command-ran").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_command_permission_is_explicit_and_independent_from_file_access() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("item.txt"), "private").unwrap();
|
||||
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
|
||||
let session = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new("external-workdir-command"),
|
||||
pinned,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
session.capabilities(),
|
||||
WorkdirSessionCapabilities::COMMAND_ONLY
|
||||
);
|
||||
assert!(matches!(
|
||||
session
|
||||
.read(ReadRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read))
|
||||
));
|
||||
|
||||
let handle = session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf command-ok".to_string(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let output = session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.content, "command-ok");
|
||||
assert_eq!(output.exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_write_category_never_implicitly_enables_read() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
|
||||
let error = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new("external-workdir-invalid-write"),
|
||||
pinned,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
WorkdirSessionCapabilities::WRITE_ONLY,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("WRITE requires READ"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -134,6 +134,16 @@ impl WorkdirSessionCapabilities {
|
||||
bits: Self::READ | Self::GLOB | Self::GREP,
|
||||
};
|
||||
|
||||
/// Operator-facing WRITE category without implicitly granting READ or COMMAND.
|
||||
pub const WRITE_ONLY: Self = Self {
|
||||
bits: Self::WRITE | Self::EDIT,
|
||||
};
|
||||
|
||||
/// Operator-facing COMMAND category without implicitly granting file access.
|
||||
pub const COMMAND_ONLY: Self = Self {
|
||||
bits: Self::COMMAND,
|
||||
};
|
||||
|
||||
/// Filesystem read/write authority without process execution.
|
||||
///
|
||||
/// External Workdirs use this capability set so operator-approved file
|
||||
@@ -391,3 +401,23 @@ impl From<fs_operation::FsError> for WorkdirError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod capability_tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(capabilities: WorkdirSessionCapabilities) -> u8 {
|
||||
serde_json::to_value(capabilities).unwrap()["bits"]
|
||||
.as_u64()
|
||||
.unwrap() as u8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_categories_preserve_operation_bit_values() {
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::READ_ONLY), 25);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::WRITE_ONLY), 6);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::READ_WRITE), 31);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::COMMAND_ONLY), 32);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
|
||||
}
|
||||
}
|
||||
|
||||
+138
-8
@@ -11,6 +11,8 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Debug;
|
||||
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -762,6 +764,42 @@ impl LocalWorkdirSession {
|
||||
Self::external_read_write_pinned(workdir, root, read_limits)
|
||||
}
|
||||
|
||||
/// Construct an External provider session with an exact operator-approved
|
||||
/// READ/WRITE/COMMAND category set. WRITE requires READ so callers can
|
||||
/// satisfy the read-before-write contract; COMMAND remains independent.
|
||||
pub fn external_with_capabilities_pinned(
|
||||
workdir: Workdir,
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
let read = WorkdirSessionCapabilities::READ_ONLY;
|
||||
let read_write = WorkdirSessionCapabilities::READ_WRITE;
|
||||
let command = WorkdirSessionCapabilities::COMMAND_ONLY;
|
||||
if ![
|
||||
read,
|
||||
read_write,
|
||||
command,
|
||||
read.union(command),
|
||||
read_write.union(command),
|
||||
]
|
||||
.contains(&capabilities)
|
||||
{
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"External Workdir capabilities must contain at least one of READ or COMMAND, and WRITE requires READ"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let permission = if capabilities.supports(WorkdirSessionCapability::Write) {
|
||||
Permission::Write
|
||||
} else {
|
||||
// Command execution still needs provider-internal read access to
|
||||
// validate and enter its cwd. This scope is not an exposed READ grant.
|
||||
Permission::Read
|
||||
};
|
||||
Self::external_pinned(workdir, pinned, read_limits, permission, capabilities)
|
||||
}
|
||||
|
||||
/// Construct a read-only provider session from a root pinned before remote
|
||||
/// grant creation.
|
||||
pub fn external_read_only_pinned(
|
||||
@@ -769,11 +807,10 @@ impl LocalWorkdirSession {
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
Self::external_with_capabilities_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Read,
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
)
|
||||
}
|
||||
@@ -785,11 +822,10 @@ impl LocalWorkdirSession {
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
Self::external_with_capabilities_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Write,
|
||||
WorkdirSessionCapabilities::READ_WRITE,
|
||||
)
|
||||
}
|
||||
@@ -1556,6 +1592,45 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CommandProcessGroupGuard {
|
||||
process_group_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl CommandProcessGroupGuard {
|
||||
fn new(process_group_id: Option<u32>) -> Self {
|
||||
Self { process_group_id }
|
||||
}
|
||||
|
||||
fn terminate(&mut self) {
|
||||
let Some(process_group_id) = self.process_group_id.take() else {
|
||||
return;
|
||||
};
|
||||
terminate_process_group(process_group_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CommandProcessGroupGuard {
|
||||
fn drop(&mut self) {
|
||||
self.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn terminate_process_group(process_group_id: u32) {
|
||||
let Ok(process_group_id) = i32::try_from(process_group_id) else {
|
||||
return;
|
||||
};
|
||||
// The command is spawned as its own process-group leader below. A negative
|
||||
// pid addresses that exact group, including descendants that outlive bash.
|
||||
unsafe {
|
||||
libc::kill(-process_group_id, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn terminate_process_group(_process_group_id: u32) {}
|
||||
|
||||
async fn run_command(
|
||||
cwd: PathBuf,
|
||||
request: CommandRequest,
|
||||
@@ -1574,7 +1649,8 @@ async fn run_command(
|
||||
.map_err(|error| WorkdirError::io(&stderr_path, error))?;
|
||||
|
||||
telemetry.started(&command_id, request.tool_call_id.clone());
|
||||
let mut child = match Command::new("bash")
|
||||
let mut command = Command::new("bash");
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(&request.command)
|
||||
.current_dir(&cwd)
|
||||
@@ -1582,15 +1658,17 @@ async fn run_command(
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
{
|
||||
.kill_on_drop(true);
|
||||
#[cfg(unix)]
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
telemetry.terminal(&command_id, CommandStatus::Failed, None);
|
||||
return Err(WorkdirError::io(&cwd, error));
|
||||
}
|
||||
};
|
||||
let mut process_group = CommandProcessGroupGuard::new(child.id());
|
||||
|
||||
let mut stdout_reader =
|
||||
std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?;
|
||||
@@ -1609,18 +1687,21 @@ async fn run_command(
|
||||
tokio::select! {
|
||||
exit = child.wait() => {
|
||||
let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||
process_group.terminate();
|
||||
break (
|
||||
if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed },
|
||||
exit.code(),
|
||||
);
|
||||
}
|
||||
_ = &mut timeout => {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||
break (CommandStatus::TimedOut, exit_code);
|
||||
}
|
||||
changed = cancel.changed() => {
|
||||
if changed.is_err() || *cancel.borrow() {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||
break (CommandStatus::Cancelled, exit_code);
|
||||
@@ -3473,6 +3554,55 @@ mod tests {
|
||||
assert!(released.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cancellation_kills_command_process_group_descendants() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let workdir = make_fs(&dir);
|
||||
let handle = WorkdirSession::start_command(
|
||||
&workdir,
|
||||
CommandRequest {
|
||||
command: "(sleep 0.4; echo leaked > child-marker) & echo ready > child-ready; wait"
|
||||
.into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
if dir.path().join("child-ready").exists() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
assert!(dir.path().join("child-ready").exists());
|
||||
|
||||
WorkdirSession::cancel_command(&workdir, handle.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let output = WorkdirSession::command_output(
|
||||
&workdir,
|
||||
CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.status, CommandStatus::Cancelled);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
assert!(
|
||||
!dir.path().join("child-marker").exists(),
|
||||
"cancelled command descendant survived its process group"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_cancels_active_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user