fix: allow parent commands during write delegation
This commit is contained in:
@@ -5,9 +5,7 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use workdir::{
|
use workdir::{CommandHandle, CommandOutputRequest, CommandRequest, WorkdirSessionHandle};
|
||||||
CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
const DEFAULT_TIMEOUT_SECS: u64 = 120;
|
||||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||||
@@ -16,10 +14,6 @@ const INLINE_BYTE_BUDGET: usize = 12 * 1024;
|
|||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct BashParams {
|
struct BashParams {
|
||||||
command: String,
|
command: String,
|
||||||
/// Optional logical working directory relative to the bound session cwd.
|
|
||||||
/// Supplying it lets delegation guards prove the command is disjoint from child write scopes.
|
|
||||||
#[serde(default)]
|
|
||||||
cwd: Option<String>,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
timeout: Option<u64>,
|
timeout: Option<u64>,
|
||||||
}
|
}
|
||||||
@@ -57,18 +51,11 @@ impl Tool for BashTool {
|
|||||||
.timeout
|
.timeout
|
||||||
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
.unwrap_or(DEFAULT_TIMEOUT_SECS)
|
||||||
.clamp(1, MAX_TIMEOUT_SECS);
|
.clamp(1, MAX_TIMEOUT_SECS);
|
||||||
let cwd = params
|
|
||||||
.cwd
|
|
||||||
.as_deref()
|
|
||||||
.map(WorkdirPath::new)
|
|
||||||
.transpose()
|
|
||||||
.map_err(crate::ToolsError::from)?;
|
|
||||||
let cmd_summary = truncate_for_summary(¶ms.command);
|
let cmd_summary = truncate_for_summary(¶ms.command);
|
||||||
let handle = self
|
let handle = self
|
||||||
.session
|
.session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: params.command,
|
command: params.command,
|
||||||
cwd,
|
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
output_limit: INLINE_BYTE_BUDGET,
|
output_limit: INLINE_BYTE_BUDGET,
|
||||||
tool_call_id: Some(ctx.call_id),
|
tool_call_id: Some(ctx.call_id),
|
||||||
|
|||||||
@@ -390,18 +390,6 @@ async fn bash_inherits_workdir_cwd() {
|
|||||||
assert_eq!(actual, expected);
|
assert_eq!(actual, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn bash_uses_explicit_logical_cwd() {
|
|
||||||
let (dir, _spill, reg) = setup();
|
|
||||||
std::fs::create_dir_all(dir.path().join("nested")).unwrap();
|
|
||||||
let bash = reg.get("Bash");
|
|
||||||
let out = call(&bash, json!({ "command": "pwd", "cwd": "nested" })).await;
|
|
||||||
let body = out.content.unwrap();
|
|
||||||
let actual = std::fs::canonicalize(body.trim()).unwrap();
|
|
||||||
let expected = std::fs::canonicalize(dir.path().join("nested")).unwrap();
|
|
||||||
assert_eq!(actual, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bash_provider_output_does_not_expose_internal_paths() {
|
async fn bash_provider_output_does_not_expose_internal_paths() {
|
||||||
let (_dir, spill, reg) = setup();
|
let (_dir, spill, reg) = setup();
|
||||||
|
|||||||
+120
-277
@@ -251,43 +251,8 @@ impl DelegatingWorkdirSession {
|
|||||||
self.ensure_path(path, WorkdirDelegationPermission::Write)
|
self.ensure_path(path, WorkdirDelegationPermission::Write)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_command_cwd(&self, cwd: Option<&FsPath>) -> Result<FsPath, WorkdirError> {
|
fn ensure_command(&self) -> Result<(), WorkdirError> {
|
||||||
match cwd {
|
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")
|
||||||
Some(cwd) => self.resolve_path(cwd),
|
|
||||||
None => Ok(self.cwd.clone()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_command_start(&self, cwd: &FsPath) -> Result<(), WorkdirError> {
|
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?;
|
|
||||||
if let Some(scope) = &self.scope
|
|
||||||
&& !scope.iter().any(|rule| {
|
|
||||||
rule.permission == WorkdirDelegationPermission::Write
|
|
||||||
&& rule_allows_path(rule, cwd, WorkdirDelegationPermission::Write)
|
|
||||||
})
|
|
||||||
{
|
|
||||||
return Err(WorkdirError::Denied(format!(
|
|
||||||
"command cwd `{cwd}` is outside the delegated write scope"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut leases = self
|
|
||||||
.child_write_leases
|
|
||||||
.lock()
|
|
||||||
.expect("workdir delegation lease mutex poisoned");
|
|
||||||
leases.retain(|_, lease| lease.validity.upgrade().is_some_and(|v| v.is_active()));
|
|
||||||
if leases.values().any(|lease| {
|
|
||||||
lease.rules.iter().any(|rule| {
|
|
||||||
rule.permission == WorkdirDelegationPermission::Write
|
|
||||||
&& command_cwd_overlaps_rule(cwd, rule)
|
|
||||||
})
|
|
||||||
}) {
|
|
||||||
Err(WorkdirError::Denied(format!(
|
|
||||||
"command cwd `{cwd}` overlaps a child write delegation"
|
|
||||||
)))
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
||||||
@@ -517,20 +482,13 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
|||||||
self.source.grep(request).await
|
self.source.grep(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_command(
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
&self,
|
self.ensure_command()?;
|
||||||
mut request: CommandRequest,
|
|
||||||
) -> Result<CommandHandle, WorkdirError> {
|
|
||||||
let cwd = self.resolve_command_cwd(request.cwd.as_ref())?;
|
|
||||||
self.ensure_command_start(&cwd)?;
|
|
||||||
if !self.source.transports_delegation_context() {
|
|
||||||
request.cwd = Some(cwd);
|
|
||||||
}
|
|
||||||
self.source.start_command(request).await
|
self.source.start_command(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?;
|
self.ensure_command()?;
|
||||||
self.source.command_status(handle).await
|
self.source.command_status(handle).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,12 +496,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
|
|||||||
&self,
|
&self,
|
||||||
request: CommandOutputRequest,
|
request: CommandOutputRequest,
|
||||||
) -> Result<CommandOutput, WorkdirError> {
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?;
|
self.ensure_command()?;
|
||||||
self.source.command_output(request).await
|
self.source.command_output(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?;
|
self.ensure_command()?;
|
||||||
self.source.cancel_command(handle).await
|
self.source.cancel_command(handle).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -671,11 +629,6 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn command_cwd_overlaps_rule(cwd: &FsPath, rule: &WorkdirDelegationRule) -> bool {
|
|
||||||
rule_allows_path(rule, cwd, WorkdirDelegationPermission::Write)
|
|
||||||
|| Path::new(rule.target.as_str()).starts_with(Path::new(cwd.as_str()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rule_allows_path(
|
fn rule_allows_path(
|
||||||
rule: &WorkdirDelegationRule,
|
rule: &WorkdirDelegationRule,
|
||||||
path: &FsPath,
|
path: &FsPath,
|
||||||
@@ -780,6 +733,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_command(
|
||||||
|
session: &WorkdirSessionHandle,
|
||||||
|
command: impl Into<String>,
|
||||||
|
tool_call_id: impl Into<String>,
|
||||||
|
) -> CommandOutput {
|
||||||
|
let handle = session
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: command.into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some(tool_call_id.into()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
session
|
||||||
|
.command_output(CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 1024,
|
||||||
|
wait: true,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn delegation_capable_session_forwards_command_telemetry() {
|
async fn delegation_capable_session_forwards_command_telemetry() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
@@ -790,7 +768,6 @@ mod tests {
|
|||||||
let handle = parent
|
let handle = parent
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "printf ready; sleep 0.2; printf done".into(),
|
command: "printf ready; sleep 0.2; printf done".into(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: Some("tool-delegated".into()),
|
tool_call_id: Some("tool-delegated".into()),
|
||||||
@@ -874,6 +851,18 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(child.scoped_session.subscribe_command_events().is_none());
|
assert!(child.scoped_session.subscribe_command_events().is_none());
|
||||||
assert!(child.scoped_session.command_snapshot().is_empty());
|
assert!(child.scoped_session.command_snapshot().is_empty());
|
||||||
|
assert!(matches!(
|
||||||
|
child
|
||||||
|
.scoped_session
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "printf denied".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("read-only-command".into()),
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::Denied(_))
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -945,18 +934,6 @@ mod tests {
|
|||||||
.await,
|
.await,
|
||||||
Err(WorkdirError::Denied(_))
|
Err(WorkdirError::Denied(_))
|
||||||
));
|
));
|
||||||
assert!(matches!(
|
|
||||||
parent
|
|
||||||
.start_command(CommandRequest {
|
|
||||||
command: "printf escaped".into(),
|
|
||||||
cwd: Some(fs_path("granted/outside")),
|
|
||||||
timeout_secs: 5,
|
|
||||||
output_limit: 1024,
|
|
||||||
tool_call_id: Some("symlink-cwd".into()),
|
|
||||||
})
|
|
||||||
.await,
|
|
||||||
Err(WorkdirError::Denied(message)) if message.contains("traverses a symlink")
|
|
||||||
));
|
|
||||||
parent
|
parent
|
||||||
.write(write("secret/parent", "still-authoritative"))
|
.write(write("secret/parent", "still-authoritative"))
|
||||||
.await
|
.await
|
||||||
@@ -964,7 +941,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_lease_blocks_only_overlapping_parent_command_cwds_until_release() {
|
async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
fs::create_dir_all(root.path().join("leased")).unwrap();
|
fs::create_dir_all(root.path().join("leased")).unwrap();
|
||||||
fs::create_dir_all(root.path().join("other")).unwrap();
|
fs::create_dir_all(root.path().join("other")).unwrap();
|
||||||
@@ -978,86 +955,24 @@ mod tests {
|
|||||||
.capabilities
|
.capabilities
|
||||||
.supports(WorkdirSessionCapability::Command)
|
.supports(WorkdirSessionCapability::Command)
|
||||||
);
|
);
|
||||||
let command = child
|
let child_output = run_command(
|
||||||
.scoped_session
|
&child.scoped_session,
|
||||||
.start_command(CommandRequest {
|
"printf child-command",
|
||||||
command: "pwd; printf child-command".into(),
|
"delegated-child-command",
|
||||||
cwd: None,
|
)
|
||||||
timeout_secs: 5,
|
.await;
|
||||||
output_limit: 1024,
|
assert_eq!(child_output.content, "child-command");
|
||||||
tool_call_id: Some("delegated-child-command".into()),
|
let parent_output = run_command(
|
||||||
})
|
&parent,
|
||||||
.await
|
"printf parent-write > leased/from-command; printf parent-command",
|
||||||
.unwrap();
|
"parent-command-during-child-write",
|
||||||
let command_output = child
|
)
|
||||||
.scoped_session
|
.await;
|
||||||
.command_output(CommandOutputRequest {
|
assert_eq!(parent_output.status, CommandStatus::Completed);
|
||||||
handle: command,
|
assert_eq!(parent_output.content, "parent-command");
|
||||||
cursor: 0,
|
assert_eq!(
|
||||||
limit: 1024,
|
fs::read_to_string(root.path().join("leased/from-command")).unwrap(),
|
||||||
wait: true,
|
"parent-write"
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
command_output.content.ends_with("leased\nchild-command"),
|
|
||||||
"child command must run from its delegated cwd: {}",
|
|
||||||
command_output.content
|
|
||||||
);
|
|
||||||
let denied = parent
|
|
||||||
.start_command(CommandRequest {
|
|
||||||
command: "printf parent-command".into(),
|
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
|
||||||
output_limit: 1024,
|
|
||||||
tool_call_id: Some("blocked-parent-command".into()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
denied,
|
|
||||||
WorkdirError::Denied(message)
|
|
||||||
if message.contains("command cwd `.` overlaps a child write delegation")
|
|
||||||
));
|
|
||||||
let denied = parent
|
|
||||||
.start_command(CommandRequest {
|
|
||||||
command: "printf still-denied".into(),
|
|
||||||
cwd: Some(fs_path("leased")),
|
|
||||||
timeout_secs: 5,
|
|
||||||
output_limit: 1024,
|
|
||||||
tool_call_id: Some("overlapping-parent-command".into()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(
|
|
||||||
denied,
|
|
||||||
WorkdirError::Denied(message)
|
|
||||||
if message.contains("command cwd `leased` overlaps a child write delegation")
|
|
||||||
));
|
|
||||||
|
|
||||||
let unrelated = parent
|
|
||||||
.start_command(CommandRequest {
|
|
||||||
command: "pwd; printf parent-command".into(),
|
|
||||||
cwd: Some(fs_path("other")),
|
|
||||||
timeout_secs: 5,
|
|
||||||
output_limit: 1024,
|
|
||||||
tool_call_id: Some("unrelated-parent-command".into()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let unrelated_output = parent
|
|
||||||
.command_output(CommandOutputRequest {
|
|
||||||
handle: unrelated,
|
|
||||||
cursor: 0,
|
|
||||||
limit: 1024,
|
|
||||||
wait: true,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
unrelated_output.content.ends_with("other\nparent-command"),
|
|
||||||
"parent command must run from its explicit disjoint cwd: {}",
|
|
||||||
unrelated_output.content
|
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -1071,26 +986,18 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
child.release();
|
child.release();
|
||||||
let resumed = parent
|
assert!(matches!(
|
||||||
|
child
|
||||||
|
.scoped_session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "printf resumed".into(),
|
command: "printf revoked".into(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: Some("resumed-parent-command".into()),
|
tool_call_id: Some("revoked-child-command".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await,
|
||||||
.unwrap();
|
Err(WorkdirError::SessionClosed)
|
||||||
let resumed_output = parent
|
));
|
||||||
.command_output(CommandOutputRequest {
|
|
||||||
handle: resumed,
|
|
||||||
cursor: 0,
|
|
||||||
limit: 1024,
|
|
||||||
wait: true,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(resumed_output.content, "resumed");
|
|
||||||
parent
|
parent
|
||||||
.write(write("leased/parent", "parent"))
|
.write(write("leased/parent", "parent"))
|
||||||
.await
|
.await
|
||||||
@@ -1143,150 +1050,75 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nested_write_delegation_uses_each_session_cwd_without_widening_scope() {
|
async fn nested_write_leases_do_not_block_command_capable_ancestors() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
fs::create_dir_all(root.path().join("top/nested")).unwrap();
|
fs::create_dir_all(root.path().join("docs/sub")).unwrap();
|
||||||
fs::create_dir_all(root.path().join("top/peer")).unwrap();
|
|
||||||
fs::create_dir_all(root.path().join("other")).unwrap();
|
|
||||||
let root_session = session(root.path());
|
let root_session = session(root.path());
|
||||||
let child = root_session
|
let child = root_session
|
||||||
.delegate(request("top", WorkdirDelegationPermission::Write))
|
.delegate(request("docs", WorkdirDelegationPermission::Write))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let nested = child
|
let nested = child
|
||||||
.scoped_session
|
.scoped_session
|
||||||
.delegate(WorkdirDelegationRequest {
|
.delegate(request("docs/sub", WorkdirDelegationPermission::Write))
|
||||||
rules: vec![WorkdirDelegationRule {
|
|
||||||
target: fs_path("top/nested"),
|
|
||||||
permission: WorkdirDelegationPermission::Write,
|
|
||||||
recursive: true,
|
|
||||||
}],
|
|
||||||
cwd: fs_path("top/nested"),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let nested_handle = nested
|
for (session, label) in [
|
||||||
.scoped_session
|
(&root_session, "root"),
|
||||||
.start_command(CommandRequest {
|
(&child.scoped_session, "child"),
|
||||||
command: "pwd; printf nested".into(),
|
(&nested.scoped_session, "nested"),
|
||||||
cwd: None,
|
] {
|
||||||
timeout_secs: 5,
|
let output = run_command(
|
||||||
output_limit: 1024,
|
session,
|
||||||
tool_call_id: Some("nested-command".into()),
|
format!("printf {label}"),
|
||||||
})
|
format!("{label}-command-during-nested-write"),
|
||||||
.await
|
)
|
||||||
.unwrap();
|
.await;
|
||||||
let nested_output = nested
|
assert_eq!(output.status, CommandStatus::Completed);
|
||||||
.scoped_session
|
assert_eq!(output.content, label);
|
||||||
.command_output(CommandOutputRequest {
|
}
|
||||||
handle: nested_handle,
|
|
||||||
cursor: 0,
|
|
||||||
limit: 1024,
|
|
||||||
wait: true,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(nested_output.content.ends_with("top/nested\nnested"));
|
|
||||||
|
|
||||||
let denied = child
|
|
||||||
.scoped_session
|
|
||||||
.start_command(CommandRequest {
|
|
||||||
command: "printf blocked".into(),
|
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
|
||||||
output_limit: 1024,
|
|
||||||
tool_call_id: Some("nested-overlap".into()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap_err();
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
denied,
|
root_session.write(write("docs/root", "blocked")).await,
|
||||||
WorkdirError::Denied(message)
|
Err(WorkdirError::Denied(_))
|
||||||
if message.contains("command cwd `top` overlaps a child write delegation")
|
|
||||||
));
|
));
|
||||||
|
assert!(matches!(
|
||||||
let peer_handle = child
|
child
|
||||||
.scoped_session
|
.scoped_session
|
||||||
.start_command(CommandRequest {
|
.write(write("sub/child", "blocked"))
|
||||||
command: "pwd; printf peer".into(),
|
.await,
|
||||||
cwd: Some(fs_path("peer")),
|
Err(WorkdirError::Denied(_))
|
||||||
timeout_secs: 5,
|
));
|
||||||
output_limit: 1024,
|
nested
|
||||||
tool_call_id: Some("nested-peer".into()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let peer_output = child
|
|
||||||
.scoped_session
|
.scoped_session
|
||||||
.command_output(CommandOutputRequest {
|
.write(write("nested", "allowed"))
|
||||||
handle: peer_handle,
|
|
||||||
cursor: 0,
|
|
||||||
limit: 1024,
|
|
||||||
wait: true,
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(peer_output.content.ends_with("top/peer\npeer"));
|
|
||||||
|
|
||||||
let outside_handle = root_session
|
|
||||||
.start_command(CommandRequest {
|
|
||||||
command: "pwd; printf outside".into(),
|
|
||||||
cwd: Some(fs_path("other")),
|
|
||||||
timeout_secs: 5,
|
|
||||||
output_limit: 1024,
|
|
||||||
tool_call_id: Some("root-outside".into()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let outside_output = root_session
|
|
||||||
.command_output(CommandOutputRequest {
|
|
||||||
handle: outside_handle,
|
|
||||||
cursor: 0,
|
|
||||||
limit: 1024,
|
|
||||||
wait: true,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(outside_output.content.ends_with("other\noutside"));
|
|
||||||
|
|
||||||
nested.release();
|
nested.release();
|
||||||
child.release();
|
child.release();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reapplied_delegation_chain_preserves_command_cwd() {
|
async fn reapplied_write_delegation_chain_forwards_command_lifecycle() {
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
fs::create_dir_all(root.path().join("delegated")).unwrap();
|
fs::create_dir_all(root.path().join("delegated")).unwrap();
|
||||||
let parent = session(root.path());
|
|
||||||
let applied = apply_delegation_chain(
|
let applied = apply_delegation_chain(
|
||||||
parent,
|
session(root.path()),
|
||||||
[request("delegated", WorkdirDelegationPermission::Write)],
|
[request("delegated", WorkdirDelegationPermission::Write)],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let scoped = &applied.scoped_session;
|
|
||||||
|
|
||||||
let handle = scoped
|
let output = run_command(
|
||||||
.start_command(CommandRequest {
|
&applied.scoped_session,
|
||||||
command: "pwd; printf reapplied".into(),
|
"printf reapplied",
|
||||||
cwd: None,
|
"reapplied-command",
|
||||||
timeout_secs: 5,
|
)
|
||||||
output_limit: 1024,
|
.await;
|
||||||
tool_call_id: Some("reapplied-command".into()),
|
assert_eq!(output.status, CommandStatus::Completed);
|
||||||
})
|
assert_eq!(output.content, "reapplied");
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let output = scoped
|
|
||||||
.command_output(CommandOutputRequest {
|
|
||||||
handle,
|
|
||||||
cursor: 0,
|
|
||||||
limit: 1024,
|
|
||||||
wait: true,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(output.content.ends_with("delegated\nreapplied"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1333,6 +1165,17 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
parent.close().await.unwrap();
|
parent.close().await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
parent
|
||||||
|
.start_command(CommandRequest {
|
||||||
|
command: "printf closed".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1024,
|
||||||
|
tool_call_id: Some("closed-parent-command".into()),
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::SessionClosed)
|
||||||
|
));
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
child.scoped_session.read(read("a")).await,
|
child.scoped_session.read(read("a")).await,
|
||||||
Err(WorkdirError::SessionClosed)
|
Err(WorkdirError::SessionClosed)
|
||||||
|
|||||||
@@ -548,40 +548,6 @@ impl LocalWorkdirSession {
|
|||||||
self.inner.root.join(path.as_str())
|
self.inner.root.join(path.as_str())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_command_cwd(&self, cwd: Option<&WorkdirPath>) -> Result<PathBuf, WorkdirError> {
|
|
||||||
let Some(cwd) = cwd else {
|
|
||||||
return Ok(self.inner.cwd.clone());
|
|
||||||
};
|
|
||||||
let host_cwd = self.resolve(cwd);
|
|
||||||
let canonical_root = self
|
|
||||||
.inner
|
|
||||||
.root
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
|
|
||||||
let expected = if cwd.is_root() {
|
|
||||||
canonical_root
|
|
||||||
} else {
|
|
||||||
canonical_root.join(cwd.as_str())
|
|
||||||
};
|
|
||||||
let resolved = host_cwd
|
|
||||||
.canonicalize()
|
|
||||||
.map_err(|error| WorkdirError::io(&host_cwd, error))?;
|
|
||||||
if resolved != expected {
|
|
||||||
return Err(WorkdirError::Denied(format!(
|
|
||||||
"command cwd `{cwd}` traverses a symlink"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let scope = self.inner.scope.snapshot();
|
|
||||||
if !scope.is_readable(&resolved)
|
|
||||||
|| !std::fs::metadata(&resolved).is_ok_and(|metadata| metadata.is_dir())
|
|
||||||
{
|
|
||||||
return Err(WorkdirError::Denied(format!(
|
|
||||||
"command cwd `{cwd}` is not a readable Workdir directory"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(resolved)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -727,7 +693,7 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
self.ensure_open()?;
|
self.ensure_open()?;
|
||||||
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
||||||
let handle = CommandHandle(format!("command-{id}"));
|
let handle = CommandHandle(format!("command-{id}"));
|
||||||
let cwd = self.resolve_command_cwd(request.cwd.as_ref())?;
|
let cwd = self.inner.cwd.clone();
|
||||||
let (completion_tx, completion) = watch::channel(false);
|
let (completion_tx, completion) = watch::channel(false);
|
||||||
let command_id = handle.0.clone();
|
let command_id = handle.0.clone();
|
||||||
let telemetry = self.inner.command_telemetry.clone();
|
let telemetry = self.inner.command_telemetry.clone();
|
||||||
@@ -1472,7 +1438,6 @@ mod tests {
|
|||||||
&session,
|
&session,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
@@ -1999,7 +1964,6 @@ mod tests {
|
|||||||
&workdir,
|
&workdir,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
command: "pwd && printf provider-command".into(),
|
command: "pwd && printf provider-command".into(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
@@ -2035,7 +1999,6 @@ mod tests {
|
|||||||
&workdir,
|
&workdir,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
command: "printf 'aéz'".into(),
|
command: "printf 'aéz'".into(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
@@ -2259,7 +2222,6 @@ mod tests {
|
|||||||
&workdir,
|
&workdir,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: Some("tool-7".into()),
|
tool_call_id: Some("tool-7".into()),
|
||||||
@@ -2363,7 +2325,6 @@ mod tests {
|
|||||||
&workdir,
|
&workdir,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
@@ -2433,7 +2394,6 @@ mod tests {
|
|||||||
&workdir,
|
&workdir,
|
||||||
CommandRequest {
|
CommandRequest {
|
||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use fs_operation::FsPath;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -8,10 +7,6 @@ pub struct CommandHandle(pub String);
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct CommandRequest {
|
pub struct CommandRequest {
|
||||||
pub command: String,
|
pub command: String,
|
||||||
/// Optional logical working directory relative to the calling session's cwd.
|
|
||||||
/// Providers must resolve and validate it before starting the process.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cwd: Option<FsPath>,
|
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
pub output_limit: usize,
|
pub output_limit: usize,
|
||||||
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
||||||
|
|||||||
@@ -230,7 +230,6 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
let command = session
|
let command = session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
@@ -272,7 +271,6 @@ async fn controller_projects_workdir_command_events_and_snapshot_state() {
|
|||||||
let command = session
|
let command = session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: Some("tool-command-1".into()),
|
tool_call_id: Some("tool-command-1".into()),
|
||||||
@@ -380,7 +378,6 @@ async fn controller_refreshes_command_snapshot_after_high_output_provider_lag()
|
|||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "dd if=/dev/zero bs=8192 count=300 2>/dev/null | tr '\\0' x; sleep 5"
|
command: "dd if=/dev/zero bs=8192 count=300 2>/dev/null | tr '\\0' x; sleep 5"
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 10,
|
timeout_secs: 10,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: Some("tool-high-output".into()),
|
tool_call_id: Some("tool-high-output".into()),
|
||||||
@@ -455,7 +452,6 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
session
|
session
|
||||||
.start_command(CommandRequest {
|
.start_command(CommandRequest {
|
||||||
command: "printf unreachable".to_owned(),
|
command: "printf unreachable".to_owned(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
|
|||||||
@@ -15271,7 +15271,6 @@ mod tests {
|
|||||||
let provider_handle = source
|
let provider_handle = source
|
||||||
.start_command(workdir::CommandRequest {
|
.start_command(workdir::CommandRequest {
|
||||||
command: "printf ready; sleep 30".to_string(),
|
command: "printf ready; sleep 30".to_string(),
|
||||||
cwd: None,
|
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
tool_call_id: Some("tool-call-command-session".to_string()),
|
tool_call_id: Some("tool-call-command-session".to_string()),
|
||||||
|
|||||||
Reference in New Issue
Block a user