feat: spill long bash output to worker temp storage
This commit is contained in:
@@ -177,6 +177,8 @@ mod tests {
|
|||||||
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
||||||
assert!(FsPath::new("src/lib.rs").is_ok());
|
assert!(FsPath::new("src/lib.rs").is_ok());
|
||||||
assert!(FsPath::new("/tmp/file").is_err());
|
assert!(FsPath::new("/tmp/file").is_err());
|
||||||
|
assert!(FsPath::new_scoped("/tmp/file").is_ok());
|
||||||
|
assert!(FsPath::new_scoped("/tmp/../secret").is_err());
|
||||||
assert!(FsPath::new("../file").is_err());
|
assert!(FsPath::new("../file").is_err());
|
||||||
assert!(FsPath::new("src\\lib.rs").is_err());
|
assert!(FsPath::new("src\\lib.rs").is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::FsError;
|
use crate::FsError;
|
||||||
|
|
||||||
/// Logical path relative to the bound Workdir root.
|
/// Scope-checked filesystem path. Relative paths resolve below the bound
|
||||||
|
/// Workdir root; absolute paths require an explicit matching scope rule.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct FsPath(String);
|
pub struct FsPath(String);
|
||||||
@@ -16,11 +17,30 @@ impl<'de> Deserialize<'de> for FsPath {
|
|||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let value = String::deserialize(deserializer)?;
|
let value = String::deserialize(deserializer)?;
|
||||||
Self::new(&value).map_err(serde::de::Error::custom)
|
Self::new_scoped(&value).map_err(serde::de::Error::custom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsPath {
|
impl FsPath {
|
||||||
|
/// Construct a path for a scope-checked operation that may target an
|
||||||
|
/// explicitly granted absolute path outside the provider root.
|
||||||
|
pub fn new_scoped(value: impl Into<String>) -> Result<Self, FsError> {
|
||||||
|
let value = value.into();
|
||||||
|
if !Path::new(&value).is_absolute() {
|
||||||
|
return Self::new(value);
|
||||||
|
}
|
||||||
|
if value.contains('\\') {
|
||||||
|
return Err(FsError::InvalidPath(value));
|
||||||
|
}
|
||||||
|
if Path::new(&value)
|
||||||
|
.components()
|
||||||
|
.any(|component| component == Component::ParentDir)
|
||||||
|
{
|
||||||
|
return Err(FsError::InvalidPath(value));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn root() -> Self {
|
pub fn root() -> Self {
|
||||||
Self(String::new())
|
Self(String::new())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use session_store::{
|
|||||||
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
|
use worker::bootstrap::{
|
||||||
|
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
|
||||||
|
};
|
||||||
use worker::controller::WorkerControllerTransport;
|
use worker::controller::WorkerControllerTransport;
|
||||||
use worker::ipc::protocol_session::{
|
use worker::ipc::protocol_session::{
|
||||||
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
||||||
@@ -115,6 +117,7 @@ impl StandaloneHost {
|
|||||||
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||||
let runtime_base = store.runtime_dir(worker_id);
|
let runtime_base = store.runtime_dir(worker_id);
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
let mut bootstrap = WorkerBootstrap::new(
|
||||||
bootstrap_manifest,
|
bootstrap_manifest,
|
||||||
@@ -122,7 +125,10 @@ impl StandaloneHost {
|
|||||||
launch.prompt_catalog,
|
launch.prompt_catalog,
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::Direct { runtime_base },
|
WorkerBootstrapLayout::Direct {
|
||||||
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
);
|
);
|
||||||
if let Some(model_client) = model_client {
|
if let Some(model_client) = model_client {
|
||||||
@@ -208,6 +214,7 @@ impl StandaloneHost {
|
|||||||
);
|
);
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||||
let runtime_base = store.runtime_dir(worker_id);
|
let runtime_base = store.runtime_dir(worker_id);
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
let mut bootstrap = WorkerBootstrap::new(
|
||||||
manifest,
|
manifest,
|
||||||
@@ -215,7 +222,10 @@ impl StandaloneHost {
|
|||||||
worker::PromptCatalogSource::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::Direct { runtime_base },
|
WorkerBootstrapLayout::Direct {
|
||||||
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
);
|
);
|
||||||
if let Some(model_client) = model_client {
|
if let Some(model_client) = model_client {
|
||||||
|
|||||||
+134
-6
@@ -21,6 +21,7 @@ struct BashParams {
|
|||||||
|
|
||||||
pub(crate) struct BashTool {
|
pub(crate) struct BashTool {
|
||||||
session: WorkdirSessionHandle,
|
session: WorkdirSessionHandle,
|
||||||
|
output_dir: PathBuf,
|
||||||
state: Arc<Mutex<BashExecutionState>>,
|
state: Arc<Mutex<BashExecutionState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ impl Tool for BashTool {
|
|||||||
command: params.command,
|
command: params.command,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
output_limit: INLINE_BYTE_BUDGET,
|
output_limit: INLINE_BYTE_BUDGET,
|
||||||
|
spill_dir: Some(self.output_dir.clone()),
|
||||||
tool_call_id: Some(call_id.clone()),
|
tool_call_id: Some(call_id.clone()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -183,10 +185,15 @@ impl Tool for BashTool {
|
|||||||
let content = if output.content.is_empty() {
|
let content = if output.content.is_empty() {
|
||||||
None
|
None
|
||||||
} else if output.truncated {
|
} else if output.truncated {
|
||||||
Some(format!(
|
let notice = match output.output_path {
|
||||||
"[showing bounded WorkdirSession command output; additional output was truncated]\n{}",
|
Some(path) => format!(
|
||||||
output.content
|
"[showing bounded WorkdirSession command output; full output saved to {}]",
|
||||||
))
|
path.display()
|
||||||
|
),
|
||||||
|
None => "[showing bounded WorkdirSession command output; additional output was truncated]"
|
||||||
|
.to_owned(),
|
||||||
|
};
|
||||||
|
Some(format!("{notice}\n{}", output.content))
|
||||||
} else {
|
} else {
|
||||||
Some(output.content)
|
Some(output.content)
|
||||||
};
|
};
|
||||||
@@ -259,16 +266,137 @@ fn truncate_for_summary(command: &str) -> String {
|
|||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDefinition {
|
pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(BashParams);
|
let schema = schemars::schema_for!(BashParams);
|
||||||
let meta = ToolMeta::new("Bash")
|
let meta = ToolMeta::new("Bash")
|
||||||
.description("Execute a shell command in the bound Workdir. Process start, bounded output, timeout and cancellation are owned by the WorkdirSession provider. This is not a sandbox.")
|
.description("Execute a shell command in the bound Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by the WorkdirSession provider. This is not a sandbox.")
|
||||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
|
output_dir: output_dir.clone(),
|
||||||
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||||
});
|
});
|
||||||
(meta, tool)
|
(meta, tool)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
|
||||||
|
|
||||||
|
use super::bash_tool;
|
||||||
|
use crate::{grep::grep_tool, read::read_tool, tracker::Tracker};
|
||||||
|
|
||||||
|
fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle {
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
ScopeRule {
|
||||||
|
target: root.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
ScopeRule {
|
||||||
|
target: output.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn long_output_is_spilled_and_available_to_read_and_grep() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let output = TempDir::new().unwrap();
|
||||||
|
let session = session_with_output_scope(&root, &output);
|
||||||
|
let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())();
|
||||||
|
let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'";
|
||||||
|
let result = bash
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({ "command": command }).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rendered = result.content.expect("bounded Bash output");
|
||||||
|
let artifact = std::fs::read_dir(output.path())
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.expect("artifact entry")
|
||||||
|
.unwrap()
|
||||||
|
.path();
|
||||||
|
|
||||||
|
assert!(rendered.contains("full output saved to"));
|
||||||
|
assert!(rendered.contains(&artifact.display().to_string()));
|
||||||
|
let retained = std::fs::read_to_string(&artifact).unwrap();
|
||||||
|
assert!(retained.starts_with("line-0000\n"));
|
||||||
|
assert!(retained.ends_with("FINAL-NEEDLE\n"));
|
||||||
|
assert_eq!(retained.lines().count(), 2001);
|
||||||
|
|
||||||
|
let (_, read) = read_tool(session.clone(), Tracker::new())();
|
||||||
|
let read_result = read
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"file_path": artifact,
|
||||||
|
"offset": 2000,
|
||||||
|
"limit": 1,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
read_result
|
||||||
|
.content
|
||||||
|
.expect("Read content")
|
||||||
|
.contains("FINAL-NEEDLE")
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, grep) = grep_tool(session)();
|
||||||
|
let grep_result = grep
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"pattern": "FINAL-NEEDLE",
|
||||||
|
"path": artifact,
|
||||||
|
"output_mode": "content",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let grep_content = grep_result.content.expect("Grep content");
|
||||||
|
assert!(
|
||||||
|
grep_content.contains("FINAL-NEEDLE"),
|
||||||
|
"unexpected Grep content: {grep_content:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn short_output_does_not_leave_a_spill_artifact() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let output = TempDir::new().unwrap();
|
||||||
|
let session = session_with_output_scope(&root, &output);
|
||||||
|
let (_, bash) = bash_tool(session, output.path().to_path_buf())();
|
||||||
|
|
||||||
|
let result = bash
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({ "command": "printf short" }).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.content.as_deref(), Some("short"));
|
||||||
|
assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ enum OutputMode {
|
|||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct GrepParams {
|
struct GrepParams {
|
||||||
pattern: String,
|
pattern: String,
|
||||||
/// Logical Workdir-relative file or directory to search. Defaults to the Workdir root.
|
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -61,7 +61,7 @@ impl Tool for GrepTool {
|
|||||||
let params: GrepParams = serde_json::from_str(input_json)
|
let params: GrepParams = serde_json::from_str(input_json)
|
||||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
|
||||||
let path = match params.path {
|
let path = match params.path {
|
||||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
|
||||||
None => WorkdirPath::root(),
|
None => WorkdirPath::root(),
|
||||||
};
|
};
|
||||||
let mode = match params.output_mode.unwrap_or_default() {
|
let mode = match params.output_mode.unwrap_or_default() {
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
|||||||
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
||||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||||
Directories cannot be read. The file must be read before Write or Edit can \
|
Directories cannot be read. The file must be read before Write or Edit can \
|
||||||
modify it. Paths are relative to the bound Workdir.";
|
modify it. Paths are Workdir-relative unless an absolute path is explicitly readable.";
|
||||||
|
|
||||||
const DEFAULT_LIMIT: usize = 2000;
|
const DEFAULT_LIMIT: usize = 2000;
|
||||||
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
|
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub(crate) struct ReadParams {
|
pub(crate) struct ReadParams {
|
||||||
/// Logical path relative to the bound Workdir root.
|
/// Workdir-relative path, or an absolute path covered by readable scope.
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
/// 0-based line offset from the start. Defaults to 0.
|
/// 0-based line offset from the start. Defaults to 0.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -47,7 +47,7 @@ impl Tool for ReadTool {
|
|||||||
let offset = params.offset.unwrap_or(0);
|
let offset = params.offset.unwrap_or(0);
|
||||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||||
|
|
||||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
let path = WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?;
|
||||||
tracing::debug!(path = %path, offset, limit, "Read");
|
tracing::debug!(path = %path, offset, limit, "Read");
|
||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
|
|||||||
@@ -224,20 +224,23 @@ async fn very_long_single_line() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn absolute_path_is_rejected() {
|
async fn absolute_path_requires_matching_read_scope() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
|
let outside = tempfile::tempdir().unwrap();
|
||||||
|
let outside_file = outside.path().join("outside.txt");
|
||||||
|
std::fs::write(&outside_file, "secret").unwrap();
|
||||||
let read = reg.get("Read");
|
let read = reg.get("Read");
|
||||||
let err = read
|
let err = read
|
||||||
.execute(
|
.execute(
|
||||||
&json!({ "file_path": dir.path().join("outside.txt") }).to_string(),
|
&json!({ "file_path": outside_file }).to_string(),
|
||||||
Default::default(),
|
Default::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains("invalid logical filesystem path"),
|
msg.contains("outside allowed scope"),
|
||||||
"absolute path was not rejected as invalid: {msg}"
|
"absolute path escaped readable scope: {msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -394,14 +394,21 @@ async fn bash_inherits_workdir_cwd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bash_provider_output_does_not_expose_internal_paths() {
|
async fn bash_provider_output_exposes_readable_retained_path() {
|
||||||
let (_dir, spill, reg) = setup();
|
let (_dir, spill, reg) = setup();
|
||||||
let bash = reg.get("Bash");
|
let bash = reg.get("Bash");
|
||||||
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
||||||
let body = out.content.unwrap();
|
let body = out.content.unwrap();
|
||||||
assert!(body.contains("bounded WorkdirSession command output"));
|
assert!(body.contains("bounded WorkdirSession command output"));
|
||||||
assert!(!body.contains(spill.path().to_str().unwrap()));
|
assert!(body.contains("full output saved to"));
|
||||||
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
assert!(body.contains(spill.path().to_str().unwrap()));
|
||||||
|
let artifact = std::fs::read_dir(spill.path())
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.expect("retained output")
|
||||||
|
.unwrap()
|
||||||
|
.path();
|
||||||
|
assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -743,6 +743,7 @@ mod tests {
|
|||||||
command: command.into(),
|
command: command.into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some(tool_call_id.into()),
|
tool_call_id: Some(tool_call_id.into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -770,6 +771,7 @@ mod tests {
|
|||||||
command: "printf ready; sleep 0.2; printf done".into(),
|
command: "printf ready; sleep 0.2; printf done".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-delegated".into()),
|
tool_call_id: Some("tool-delegated".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -858,6 +860,7 @@ mod tests {
|
|||||||
command: "printf denied".into(),
|
command: "printf denied".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("read-only-command".into()),
|
tool_call_id: Some("read-only-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -993,6 +996,7 @@ mod tests {
|
|||||||
command: "printf revoked".into(),
|
command: "printf revoked".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("revoked-child-command".into()),
|
tool_call_id: Some("revoked-child-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -1171,6 +1175,7 @@ mod tests {
|
|||||||
command: "printf closed".into(),
|
command: "printf closed".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("closed-parent-command".into()),
|
tool_call_id: Some("closed-parent-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
|
|||||||
+231
-3
@@ -10,9 +10,7 @@
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
#[cfg(test)]
|
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||||
use std::io::Write as _;
|
|
||||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -691,6 +689,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
||||||
self.ensure_open()?;
|
self.ensure_open()?;
|
||||||
|
if let Some(spill_dir) = request.spill_dir.as_deref()
|
||||||
|
&& !self.inner.scope.snapshot().is_readable(spill_dir)
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
|
||||||
|
}
|
||||||
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.inner.cwd.clone();
|
let cwd = self.inner.cwd.clone();
|
||||||
@@ -776,6 +779,7 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
content: String::new(),
|
content: String::new(),
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
drop(commands);
|
drop(commands);
|
||||||
@@ -792,6 +796,7 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
content: String::new(),
|
content: String::new(),
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break commands
|
break commands
|
||||||
@@ -901,6 +906,7 @@ fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> C
|
|||||||
content,
|
content,
|
||||||
next_cursor: (end < total_chars).then_some(end),
|
next_cursor: (end < total_chars).then_some(end),
|
||||||
truncated: output.truncated || end < total_chars,
|
truncated: output.truncated || end < total_chars,
|
||||||
|
output_path: output.output_path.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,6 +1065,22 @@ async fn run_command(
|
|||||||
|
|
||||||
let (content, truncated) =
|
let (content, truncated) =
|
||||||
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
|
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
|
||||||
|
let output_path = match (truncated, request.spill_dir) {
|
||||||
|
(true, Some(spill_dir)) => {
|
||||||
|
let stdout_path = stdout_path.to_path_buf();
|
||||||
|
let stderr_path = stderr_path.to_path_buf();
|
||||||
|
Some(
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
persist_command_output(&stdout_path, &stderr_path, &spill_dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
WorkdirError::Unavailable(format!("Bash output spill task failed: {error}"))
|
||||||
|
})??,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
Ok(CommandOutput {
|
Ok(CommandOutput {
|
||||||
status,
|
status,
|
||||||
exit_code,
|
exit_code,
|
||||||
@@ -1066,6 +1088,7 @@ async fn run_command(
|
|||||||
content,
|
content,
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated,
|
truncated,
|
||||||
|
output_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,6 +1177,59 @@ fn stable_utf8_prefix_len(bytes: &[u8]) -> usize {
|
|||||||
inspected
|
inspected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_command_output(
|
||||||
|
stdout_path: &Path,
|
||||||
|
stderr_path: &Path,
|
||||||
|
spill_dir: &Path,
|
||||||
|
) -> Result<PathBuf, WorkdirError> {
|
||||||
|
std::fs::create_dir_all(spill_dir).map_err(|error| WorkdirError::io(spill_dir, error))?;
|
||||||
|
let mut artifact = tempfile::Builder::new()
|
||||||
|
.prefix("bash-")
|
||||||
|
.suffix(".log")
|
||||||
|
.tempfile_in(spill_dir)
|
||||||
|
.map_err(|error| WorkdirError::io(spill_dir, error))?;
|
||||||
|
let artifact_path = artifact.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut stdout =
|
||||||
|
std::fs::File::open(stdout_path).map_err(|error| WorkdirError::io(stdout_path, error))?;
|
||||||
|
let stdout_len = stdout
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| WorkdirError::io(stdout_path, error))?
|
||||||
|
.len();
|
||||||
|
std::io::copy(&mut stdout, &mut artifact)
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
|
||||||
|
let mut stderr =
|
||||||
|
std::fs::File::open(stderr_path).map_err(|error| WorkdirError::io(stderr_path, error))?;
|
||||||
|
let stderr_len = stderr
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| WorkdirError::io(stderr_path, error))?
|
||||||
|
.len();
|
||||||
|
if stdout_len > 0 && stderr_len > 0 {
|
||||||
|
stdout
|
||||||
|
.seek(SeekFrom::End(-1))
|
||||||
|
.map_err(|error| WorkdirError::io(stdout_path, error))?;
|
||||||
|
let mut last = [0_u8; 1];
|
||||||
|
stdout
|
||||||
|
.read_exact(&mut last)
|
||||||
|
.map_err(|error| WorkdirError::io(stdout_path, error))?;
|
||||||
|
if last[0] != b'\n' {
|
||||||
|
artifact
|
||||||
|
.write_all(b"\n")
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::io::copy(&mut stderr, &mut artifact)
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
artifact
|
||||||
|
.flush()
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
artifact
|
||||||
|
.keep()
|
||||||
|
.map(|(_, path)| path)
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error.error))
|
||||||
|
}
|
||||||
|
|
||||||
fn read_command_output_files(
|
fn read_command_output_files(
|
||||||
stdout_path: &Path,
|
stdout_path: &Path,
|
||||||
stderr_path: &Path,
|
stderr_path: &Path,
|
||||||
@@ -1440,6 +1516,7 @@ mod tests {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1966,6 +2043,7 @@ mod tests {
|
|||||||
command: "pwd && printf provider-command".into(),
|
command: "pwd && printf provider-command".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1991,6 +2069,151 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicitly_scoped_absolute_artifact_can_be_read_and_grepped() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let spill = TempDir::new().unwrap();
|
||||||
|
let artifact = spill.path().join("bash-output.log");
|
||||||
|
std::fs::write(&artifact, "first\nFINAL-NEEDLE\nlast\n").unwrap();
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
ScopeRule {
|
||||||
|
target: dir.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
ScopeRule {
|
||||||
|
target: spill.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf());
|
||||||
|
let artifact_path = WorkdirPath::new_scoped(artifact.to_string_lossy()).unwrap();
|
||||||
|
|
||||||
|
let read = WorkdirSession::read(
|
||||||
|
&workdir,
|
||||||
|
ReadRequest {
|
||||||
|
path: artifact_path.clone(),
|
||||||
|
offset: 1,
|
||||||
|
limit: 1,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(String::from_utf8(read.bytes).unwrap(), "FINAL-NEEDLE\n");
|
||||||
|
|
||||||
|
let grep = WorkdirSession::grep(
|
||||||
|
&workdir,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "FINAL-NEEDLE".into(),
|
||||||
|
path: artifact_path,
|
||||||
|
glob: None,
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: crate::GrepOutputMode::Content,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(grep.match_count, 1);
|
||||||
|
assert!(grep.output.contains("FINAL-NEEDLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn command_rejects_spill_directory_without_read_scope() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let spill = TempDir::new().unwrap();
|
||||||
|
let workdir = make_fs(&dir);
|
||||||
|
|
||||||
|
let error = WorkdirSession::start_command(
|
||||||
|
&workdir,
|
||||||
|
CommandRequest {
|
||||||
|
command: "printf hidden".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1,
|
||||||
|
spill_dir: Some(spill.path().to_path_buf()),
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, WorkdirError::OutOfScope(path) if path == spill.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn truncated_command_output_is_retained_in_the_requested_spill_directory() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let spill = TempDir::new().unwrap();
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
ScopeRule {
|
||||||
|
target: dir.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
ScopeRule {
|
||||||
|
target: spill.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf());
|
||||||
|
let handle = WorkdirSession::start_command(
|
||||||
|
&workdir,
|
||||||
|
CommandRequest {
|
||||||
|
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 64,
|
||||||
|
spill_dir: Some(spill.path().to_path_buf()),
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let output = WorkdirSession::command_output(
|
||||||
|
&workdir,
|
||||||
|
CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 4096,
|
||||||
|
wait: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.truncated);
|
||||||
|
let output_path = output.output_path.expect("retained output path");
|
||||||
|
assert_eq!(output_path.parent(), Some(spill.path()));
|
||||||
|
let retained = std::fs::read_to_string(&output_path).unwrap();
|
||||||
|
assert!(retained.starts_with("line-000\n"));
|
||||||
|
assert!(retained.ends_with("FINAL-NEEDLE\n"));
|
||||||
|
assert_eq!(retained.lines().count(), 201);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn completed_command_output_can_be_read_in_bounded_unicode_pages() {
|
async fn completed_command_output_can_be_read_in_bounded_unicode_pages() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -2001,6 +2224,7 @@ mod tests {
|
|||||||
command: "printf 'aéz'".into(),
|
command: "printf 'aéz'".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2120,6 +2344,7 @@ mod tests {
|
|||||||
content: "done".into(),
|
content: "done".into(),
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
workdir.inner.commands.lock().await.insert(
|
workdir.inner.commands.lock().await.insert(
|
||||||
@@ -2224,6 +2449,7 @@ mod tests {
|
|||||||
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-7".into()),
|
tool_call_id: Some("tool-7".into()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2327,6 +2553,7 @@ mod tests {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2396,6 +2623,7 @@ mod tests {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -9,6 +11,9 @@ pub struct CommandRequest {
|
|||||||
pub command: String,
|
pub command: String,
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
pub output_limit: usize,
|
pub output_limit: usize,
|
||||||
|
/// Provider-local directory where complete output is retained when the
|
||||||
|
/// inline result exceeds `output_limit`.
|
||||||
|
pub spill_dir: Option<PathBuf>,
|
||||||
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
||||||
/// user-facing command telemetry can update the corresponding Console row
|
/// user-facing command telemetry can update the corresponding Console row
|
||||||
/// without exposing provider/session handles.
|
/// without exposing provider/session handles.
|
||||||
@@ -96,4 +101,6 @@ pub struct CommandOutput {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub next_cursor: Option<usize>,
|
pub next_cursor: Option<usize>,
|
||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
|
/// Complete output retained by the provider when `truncated` is true.
|
||||||
|
pub output_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -703,6 +703,7 @@ async fn run_workdir_session_operation(
|
|||||||
content: String::new(),
|
content: String::new(),
|
||||||
next_cursor: Some(cursor),
|
next_cursor: Some(cursor),
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
WorkdirSessionOperationResult::CommandOutput(output)
|
WorkdirSessionOperationResult::CommandOutput(output)
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ use worker::{
|
|||||||
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
||||||
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||||
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||||
|
bash_output_dir_for_worker_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||||
@@ -886,6 +887,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
let run_dir = worker_aggregate_dir
|
let run_dir = worker_aggregate_dir
|
||||||
.join("runs")
|
.join("runs")
|
||||||
.join(request.run_generation.to_string());
|
.join(request.run_generation.to_string());
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
||||||
let mut prepared = WorkerBootstrap::new(
|
let mut prepared = WorkerBootstrap::new(
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
@@ -894,6 +896,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: run_dir.clone(),
|
run_dir: run_dir.clone(),
|
||||||
|
bash_output_dir,
|
||||||
},
|
},
|
||||||
self.controller_transport,
|
self.controller_transport,
|
||||||
)
|
)
|
||||||
@@ -1131,10 +1134,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
let run_dir = worker_aggregate_dir
|
let run_dir = worker_aggregate_dir
|
||||||
.join("runs")
|
.join("runs")
|
||||||
.join(request.run_generation.to_string());
|
.join(request.run_generation.to_string());
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
||||||
let started = PreparedWorker::new(
|
let started = PreparedWorker::new(
|
||||||
worker,
|
worker,
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: run_dir.clone(),
|
run_dir: run_dir.clone(),
|
||||||
|
bash_output_dir,
|
||||||
},
|
},
|
||||||
self.controller_transport,
|
self.controller_transport,
|
||||||
)
|
)
|
||||||
@@ -2552,10 +2557,14 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let (handle, shutdown_rx) =
|
let bash_output_dir = self.runtime_base.join("bash-output");
|
||||||
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
|
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed(
|
||||||
.await
|
worker,
|
||||||
.map_err(|err| err.to_string())?;
|
&self.runtime_base,
|
||||||
|
&bash_output_dir,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
Ok(RuntimeWorkerController {
|
Ok(RuntimeWorkerController {
|
||||||
handle,
|
handle,
|
||||||
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
|
shutdown: Arc::new(tokio::sync::Mutex::new(Some(shutdown_rx))),
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
||||||
|
|
||||||
let runtime_tmp = tempfile::tempdir()?;
|
let runtime_tmp = tempfile::tempdir()?;
|
||||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
|
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||||
|
let (handle, _shutdown_rx) =
|
||||||
|
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
|
||||||
|
|
||||||
// Check initial status via shared state
|
// Check initial status via shared state
|
||||||
println!("[shared_state] {}", handle.shared_state.status_json());
|
println!("[shared_state] {}", handle.shared_state.status_json());
|
||||||
|
|||||||
@@ -17,9 +17,28 @@ use manifest::WorkerManifest;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WorkerBootstrapLayout {
|
pub enum WorkerBootstrapLayout {
|
||||||
/// A direct Worker rooted below the supplied runtime base directory.
|
/// A direct Worker rooted below the supplied runtime base directory.
|
||||||
Direct { runtime_base: PathBuf },
|
Direct {
|
||||||
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
|
},
|
||||||
/// A runtime-managed Worker with an exact persisted run directory.
|
/// A runtime-managed Worker with an exact persisted run directory.
|
||||||
RuntimeManagedRun { run_dir: PathBuf },
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construction and controller inputs that are stable for one Worker launch.
|
/// Construction and controller inputs that are stable for one Worker launch.
|
||||||
@@ -204,12 +223,29 @@ where
|
|||||||
{
|
{
|
||||||
let cleanup_session = worker.workdir_session().cloned();
|
let cleanup_session = worker.workdir_session().cloned();
|
||||||
let controller = match layout {
|
let controller = match layout {
|
||||||
WorkerBootstrapLayout::Direct { runtime_base } => {
|
WorkerBootstrapLayout::Direct {
|
||||||
WorkerController::spawn_with_transport(worker, &runtime_base, transport).await
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
} => {
|
||||||
|
WorkerController::spawn_with_transport(
|
||||||
|
worker,
|
||||||
|
&runtime_base,
|
||||||
|
&bash_output_dir,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
WorkerController::spawn_runtime_managed_run_with_transport(worker, &run_dir, transport)
|
run_dir,
|
||||||
.await
|
bash_output_dir,
|
||||||
|
} => {
|
||||||
|
WorkerController::spawn_runtime_managed_run_with_transport(
|
||||||
|
worker,
|
||||||
|
&run_dir,
|
||||||
|
&bash_output_dir,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -227,3 +263,22 @@ 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ impl WorkerController {
|
|||||||
pub async fn spawn<C, St>(
|
pub async fn spawn<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -230,6 +231,7 @@ impl WorkerController {
|
|||||||
Self::spawn_inner(
|
Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
@@ -242,24 +244,9 @@ impl WorkerController {
|
|||||||
pub async fn spawn_with_transport<C, St>(
|
pub async fn spawn_with_transport<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
|
||||||
C: LlmClient + Clone + 'static,
|
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
Self::spawn_inner(worker, runtime_base, false, None, transport).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a Worker owned by `worker-runtime`.
|
|
||||||
///
|
|
||||||
/// The controller still uses an ephemeral directory for Unix sockets and
|
|
||||||
/// tool spill artifacts, but does not write legacy pid/status/manifest
|
|
||||||
/// liveness projections.
|
|
||||||
pub async fn spawn_runtime_managed<C, St>(
|
|
||||||
worker: Worker<C, St>,
|
|
||||||
runtime_base: &Path,
|
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
@@ -267,6 +254,33 @@ impl WorkerController {
|
|||||||
Self::spawn_inner(
|
Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
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.
|
||||||
|
/// 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,
|
||||||
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::spawn_inner(
|
||||||
|
worker,
|
||||||
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
@@ -278,6 +292,7 @@ impl WorkerController {
|
|||||||
pub async fn spawn_runtime_managed_run<C, St>(
|
pub async fn spawn_runtime_managed_run<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -286,6 +301,7 @@ impl WorkerController {
|
|||||||
Self::spawn_runtime_managed_run_with_transport(
|
Self::spawn_runtime_managed_run_with_transport(
|
||||||
worker,
|
worker,
|
||||||
run_dir,
|
run_dir,
|
||||||
|
bash_output_dir,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -296,6 +312,7 @@ impl WorkerController {
|
|||||||
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
@@ -305,12 +322,21 @@ impl WorkerController {
|
|||||||
let parent = run_dir
|
let parent = run_dir
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
||||||
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
|
Self::spawn_inner(
|
||||||
|
worker,
|
||||||
|
parent,
|
||||||
|
bash_output_dir,
|
||||||
|
true,
|
||||||
|
Some(run_dir),
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_inner<C, St>(
|
async fn spawn_inner<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
@@ -323,6 +349,7 @@ impl WorkerController {
|
|||||||
let result = Self::spawn_initialized(
|
let result = Self::spawn_initialized(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
runtime_managed,
|
runtime_managed,
|
||||||
runtime_run,
|
runtime_run,
|
||||||
transport,
|
transport,
|
||||||
@@ -340,6 +367,7 @@ impl WorkerController {
|
|||||||
async fn spawn_initialized<C, St>(
|
async fn spawn_initialized<C, St>(
|
||||||
mut worker: Worker<C, St>,
|
mut worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
@@ -397,11 +425,11 @@ impl WorkerController {
|
|||||||
worker.attach_internal_worker_registry(spawned_registry.clone());
|
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||||
worker.attach_working_event_tx(working_event_tx.clone());
|
worker.attach_working_event_tx(working_event_tx.clone());
|
||||||
|
|
||||||
// Bash spills long outputs to a per-worker subdir under the runtime
|
// Bash spill artifacts are owned by the stable Worker identity rather
|
||||||
// dir. Push a recursive `allow(Read)` for that path into the
|
// than a controller session/run generation. Push a recursive
|
||||||
// Worker's runtime scope so the agent can `Read` saved files
|
// `allow(Read)` for the exact tool output path into the Worker's shared
|
||||||
// without polluting the workspace.
|
// runtime scope so the Workdir session and system prompt stay aligned.
|
||||||
let bash_output_dir = runtime_dir.path().join("bash-output");
|
let bash_output_dir = bash_output_dir.to_path_buf();
|
||||||
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
||||||
std::io::Error::other(format!(
|
std::io::Error::other(format!(
|
||||||
"create bash output dir {}: {e}",
|
"create bash output dir {}: {e}",
|
||||||
@@ -880,7 +908,7 @@ where
|
|||||||
.register_tools(tools::core_builtin_tools(
|
.register_tools(tools::core_builtin_tools(
|
||||||
workdir.clone(),
|
workdir.clone(),
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
bash_output_dir,
|
bash_output_dir.clone(),
|
||||||
));
|
));
|
||||||
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model)
|
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model)
|
||||||
{
|
{
|
||||||
@@ -1103,6 +1131,7 @@ where
|
|||||||
spawner_workspace_context,
|
spawner_workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
|
bash_output_dir.clone(),
|
||||||
spawner_workspace_root,
|
spawner_workspace_root,
|
||||||
source_workdir_session,
|
source_workdir_session,
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
|
|||||||
@@ -634,10 +634,12 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
|||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
|
||||||
let started = match start_worker_controller(
|
let started = match start_worker_controller(
|
||||||
worker,
|
worker,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: runtime_base.clone(),
|
runtime_base: runtime_base.clone(),
|
||||||
|
bash_output_dir,
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ mod worker;
|
|||||||
|
|
||||||
pub use bootstrap::{
|
pub use bootstrap::{
|
||||||
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
||||||
WorkerBootstrapLayout, start_worker_controller,
|
WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller,
|
||||||
};
|
};
|
||||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||||
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use manifest::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use workdir::{
|
use workdir::{
|
||||||
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
|
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, WorkdirPath,
|
||||||
WorkdirSessionHandle,
|
WorkdirSessionHandle,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,9 +258,10 @@ pub struct SubWorkerSpawnTool {
|
|||||||
spawner_name: String,
|
spawner_name: String,
|
||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
/// Runtime-owned root used only for bounded Internal Worker tool artifacts such as Bash spill
|
/// Runtime-owned root used for Internal Worker controller state.
|
||||||
/// output. It is not an Internal Worker identity or catalog location.
|
|
||||||
runtime_base: PathBuf,
|
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/
|
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
||||||
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
@@ -292,6 +293,7 @@ impl SubWorkerSpawnTool {
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -304,6 +306,7 @@ impl SubWorkerSpawnTool {
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
source_workdir_session,
|
source_workdir_session,
|
||||||
registry,
|
registry,
|
||||||
@@ -367,7 +370,22 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.reserve_internal_name(input.name.clone())
|
.reserve_internal_name(input.name.clone())
|
||||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||||
|
|
||||||
let workdir_rules = parse_workdir_scope(&input.scope)?;
|
let mut 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()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
workdir_rules.push(WorkdirDelegationRule {
|
||||||
|
target: WorkdirPath::new_scoped(child_bash_output_dir.to_string_lossy())
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
||||||
|
permission: WorkdirDelegationPermission::Read,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
let source_workdir_session =
|
let source_workdir_session =
|
||||||
require_active_workdir_session(self.source_workdir_session.as_ref())?;
|
require_active_workdir_session(self.source_workdir_session.as_ref())?;
|
||||||
let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?;
|
let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?;
|
||||||
@@ -464,14 +482,22 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
||||||
child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone()));
|
child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone()));
|
||||||
|
child
|
||||||
|
.add_scope_rules([ScopeRule {
|
||||||
|
target: child_bash_output_dir.clone(),
|
||||||
|
permission: manifest::Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"grant Internal Worker Bash output scope: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let child_scope = child.scope().clone();
|
let child_scope = child.scope().clone();
|
||||||
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
||||||
register_worker_tools(
|
register_worker_tools(
|
||||||
&mut child,
|
&mut child,
|
||||||
self.runtime_base
|
child_bash_output_dir,
|
||||||
.join("internal-workers")
|
|
||||||
.join(&input.name)
|
|
||||||
.join("bash-output"),
|
|
||||||
self.runtime_base.clone(),
|
self.runtime_base.clone(),
|
||||||
child_registry.clone(),
|
child_registry.clone(),
|
||||||
None,
|
None,
|
||||||
@@ -883,6 +909,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -894,6 +921,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
source_workdir_session,
|
source_workdir_session,
|
||||||
registry,
|
registry,
|
||||||
@@ -907,6 +935,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -938,6 +967,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
workspace_context.clone(),
|
workspace_context.clone(),
|
||||||
parent_notifications.clone(),
|
parent_notifications.clone(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
|
bash_output_dir.clone(),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
source_workdir_session.clone(),
|
source_workdir_session.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@@ -1082,12 +1112,17 @@ extract_threshold = 4000
|
|||||||
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
|
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
|
||||||
let runtime = TempDir::new().unwrap();
|
let runtime = TempDir::new().unwrap();
|
||||||
let workspace_root = runtime.path().join("project");
|
let workspace_root = runtime.path().join("project");
|
||||||
|
let bash_output_dir = runtime.path().join("bash-output");
|
||||||
let available_profiles = write_project_profile_registry(
|
let available_profiles = write_project_profile_registry(
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
Some("reviewer"),
|
Some("reviewer"),
|
||||||
&[("reviewer", "reviewer.toml", INTERNAL_REVIEWER_PROFILE)],
|
&[("reviewer", "reviewer.toml", INTERNAL_REVIEWER_PROFILE)],
|
||||||
);
|
);
|
||||||
let mut manifest = parent_manifest(&workspace_root, None);
|
let mut manifest = parent_manifest(&workspace_root, None);
|
||||||
|
manifest
|
||||||
|
.scope
|
||||||
|
.allow
|
||||||
|
.push(abs_rule(&bash_output_dir, Permission::Read));
|
||||||
manifest.delegation_scope = ScopeConfig {
|
manifest.delegation_scope = ScopeConfig {
|
||||||
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
@@ -1118,6 +1153,7 @@ extract_threshold = 4000
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
|
ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
|
||||||
runtime.path().to_path_buf(),
|
runtime.path().to_path_buf(),
|
||||||
|
bash_output_dir.clone(),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
Some(source_workdir_session),
|
Some(source_workdir_session),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@@ -1167,6 +1203,12 @@ extract_threshold = 4000
|
|||||||
.await
|
.await
|
||||||
.expect("spawn project reviewer as Internal Worker");
|
.expect("spawn project reviewer as Internal Worker");
|
||||||
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
||||||
|
assert!(
|
||||||
|
bash_output_dir
|
||||||
|
.join("sub-workers")
|
||||||
|
.join("reviewer-child")
|
||||||
|
.is_dir()
|
||||||
|
);
|
||||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||||
let record = registry
|
let record = registry
|
||||||
.get_internal("reviewer-child")
|
.get_internal("reviewer-child")
|
||||||
|
|||||||
@@ -861,7 +861,8 @@ async fn controller_compact_method_emits_start_and_done() {
|
|||||||
]);
|
]);
|
||||||
let worker = make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await;
|
let worker = make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await;
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path())
|
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||||
|
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|||||||
@@ -276,12 +276,38 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let runtime_base = tmp.path().to_owned();
|
let runtime_base = tmp.path().to_owned();
|
||||||
std::mem::forget(tmp);
|
std::mem::forget(tmp);
|
||||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
|
let bash_output_dir = runtime_base.join("bash-output");
|
||||||
|
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn controller_grants_read_scope_for_exact_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 (handle, shutdown_rx) =
|
||||||
|
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||||
|
.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!(!handle.runtime_dir.path().join("bash-output").exists());
|
||||||
|
|
||||||
|
handle.send(Method::Shutdown).await.unwrap();
|
||||||
|
shutdown_rx.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn shutdown_closes_bound_workdir_session() {
|
async fn shutdown_closes_bound_workdir_session() {
|
||||||
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
||||||
@@ -297,6 +323,7 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -304,9 +331,11 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
||||||
|
|
||||||
let runtime_base = tempfile::tempdir().unwrap();
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||||
.await
|
let (handle, shutdown_rx) =
|
||||||
.unwrap();
|
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
handle.send(Method::Shutdown).await.unwrap();
|
handle.send(Method::Shutdown).await.unwrap();
|
||||||
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx)
|
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx)
|
||||||
.await
|
.await
|
||||||
@@ -338,6 +367,7 @@ async fn controller_projects_workdir_command_events_and_snapshot_state() {
|
|||||||
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-command-1".into()),
|
tool_call_id: Some("tool-command-1".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -445,6 +475,7 @@ async fn controller_refreshes_command_snapshot_after_high_output_provider_lag()
|
|||||||
.to_owned(),
|
.to_owned(),
|
||||||
timeout_secs: 10,
|
timeout_secs: 10,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-high-output".into()),
|
tool_call_id: Some("tool-high-output".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -508,8 +539,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
||||||
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
||||||
|
|
||||||
|
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||||
assert!(
|
assert!(
|
||||||
WorkerController::spawn(worker, &invalid_runtime_base)
|
WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
@@ -519,6 +551,7 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
command: "printf unreachable".to_owned(),
|
command: "printf unreachable".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -863,7 +896,8 @@ permission = "write"
|
|||||||
let client = MockClient::new(simple_text_events());
|
let client = MockClient::new(simple_text_events());
|
||||||
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let result = WorkerController::spawn(worker, tmp.path()).await;
|
let bash_output_dir = tmp.path().join("bash-output");
|
||||||
|
let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"feature exposure must not imply delegation authority"
|
"feature exposure must not imply delegation authority"
|
||||||
|
|||||||
@@ -15430,6 +15430,7 @@ mod tests {
|
|||||||
command: "printf ready; sleep 30".to_string(),
|
command: "printf ready; sleep 30".to_string(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-call-command-session".to_string()),
|
tool_call_id: Some("tool-call-command-session".to_string()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
Reference in New Issue
Block a user