Merge commit '00ad02d04728a8e32a82a4e72747fefcf8a82d4e' into refactor/T-765-session-feature-boundary
# Conflicts: # crates/worker/src/feature/builtin.rs
This commit is contained in:
Generated
+1
@@ -5266,6 +5266,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"workdir",
|
||||
]
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ fn run_read_with_limits(
|
||||
total_lines,
|
||||
content_hash: content_hasher.finalize().into(),
|
||||
truncated: end < total_lines || byte_truncated,
|
||||
byte_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -885,6 +886,34 @@ mod bounded_read_tests {
|
||||
assert!(error.to_string().contains("exceeds provider limit 4096"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_distinguishes_utf8_byte_cut_from_complete_line_continuation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
fs::write(root.join("text"), "雪雪needle\nnext").unwrap();
|
||||
for (offset, max_bytes, bytes, more, cut) in [
|
||||
(0, 5, "雪", true, true),
|
||||
(0, 64, "雪雪needle\n", true, false),
|
||||
(1, 64, "next", false, false),
|
||||
] {
|
||||
let result = run_read_bounded(
|
||||
&root,
|
||||
ReadRequest {
|
||||
path: FsPath::new("text").unwrap(),
|
||||
offset,
|
||||
limit: 1,
|
||||
max_bytes,
|
||||
},
|
||||
&RootAccess(root.clone()),
|
||||
BoundedReadLimits::new(1024, 64).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.bytes, bytes.as_bytes());
|
||||
assert_eq!(result.truncated, more);
|
||||
assert_eq!(result.byte_truncated, cut);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_caps_retained_response_independently_of_request() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -907,6 +936,7 @@ mod bounded_read_tests {
|
||||
assert_eq!(result.bytes, b"alpha");
|
||||
assert_eq!(result.total_lines, 3);
|
||||
assert!(result.truncated);
|
||||
assert!(result.byte_truncated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,11 @@ pub struct ReadResult {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub total_lines: usize,
|
||||
pub content_hash: ContentHash,
|
||||
/// More source lines remain, or the selected window exceeded the response byte budget.
|
||||
pub truncated: bool,
|
||||
/// The selected line window was cut by its byte budget, possibly within a line.
|
||||
/// Line-based cursors must not treat such a window as completely scanned.
|
||||
pub byte_truncated: bool,
|
||||
}
|
||||
|
||||
/// Binary-safe byte range read with an optional whole-file version fence.
|
||||
|
||||
@@ -134,6 +134,14 @@ pub enum DriveEntryKind {
|
||||
File,
|
||||
}
|
||||
|
||||
/// Current caller's advisory publication ceiling, never a grant or execution credential.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DriveCapabilities {
|
||||
pub write: bool,
|
||||
}
|
||||
|
||||
/// Metadata only: content is returned through bounded text or binary routes.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -491,6 +499,7 @@ impl api_macros::HttpRequestError for DriveApiError {
|
||||
|
||||
impl api_macros::openapi::OpenApiSchema for DriveApiError {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntry {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveCapabilities {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveListQuery {}
|
||||
impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {}
|
||||
@@ -518,6 +527,7 @@ pub fn drive_api_typescript() -> String {
|
||||
DriveEntryRef::decl(&config),
|
||||
DriveEntryKind::decl(&config),
|
||||
DriveEntry::decl(&config),
|
||||
DriveCapabilities::decl(&config),
|
||||
DriveEntryQuery::decl(&config),
|
||||
DriveListQuery::decl(&config),
|
||||
DriveSearchQuery::decl(&config),
|
||||
|
||||
@@ -2762,6 +2762,12 @@ pub trait ServerApi {
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<DriveEntry, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/capabilities", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_capabilities(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<DriveCapabilities, DriveApiError>;
|
||||
#[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||
async fn drive_metadata(
|
||||
&self,
|
||||
|
||||
@@ -23,9 +23,7 @@ use session_store::{
|
||||
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
||||
};
|
||||
use thiserror::Error;
|
||||
use worker::bootstrap::{
|
||||
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
|
||||
};
|
||||
use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
|
||||
use worker::controller::WorkerControllerTransport;
|
||||
use worker::ipc::protocol_session::{
|
||||
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
||||
@@ -216,7 +214,6 @@ impl StandaloneHost {
|
||||
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||
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(
|
||||
bootstrap_manifest,
|
||||
@@ -224,10 +221,7 @@ impl StandaloneHost {
|
||||
launch.prompt_catalog,
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
},
|
||||
WorkerBootstrapLayout::Direct { runtime_base },
|
||||
WorkerControllerTransport::InProcess,
|
||||
);
|
||||
if let Some(subject) = &subject {
|
||||
@@ -428,7 +422,6 @@ impl StandaloneHost {
|
||||
);
|
||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||
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(
|
||||
manifest,
|
||||
@@ -436,10 +429,7 @@ impl StandaloneHost {
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
workspace_context,
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
},
|
||||
WorkerBootstrapLayout::Direct { runtime_base },
|
||||
WorkerControllerTransport::InProcess,
|
||||
);
|
||||
if let Some(subject) = &subject {
|
||||
|
||||
@@ -126,7 +126,7 @@ async fn local_host_compaction_freezes_pending_journal_through_request_and_durab
|
||||
worker::PromptCatalogSource::builtins_only(),
|
||||
worker::WorkerWorkspaceContext::no_workspace(),
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime"), bash_output_dir: fixture._temp.path().join("bash") },
|
||||
worker::WorkerBootstrapLayout::Direct { runtime_base: fixture._temp.path().join("runtime") },
|
||||
worker::WorkerControllerTransport::InProcess,
|
||||
).with_model_client(model.clone()).with_subjektiv_host(fixture.host().clone()).prepare_restored("journal-test").await.unwrap();
|
||||
assert_eq!(prepared.worker().session_id().to_string(), fixture.context.session_id);
|
||||
|
||||
@@ -23,7 +23,8 @@ thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
workdir = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v7"] }
|
||||
|
||||
[dev-dependencies]
|
||||
filetime = "0.2.27"
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] }
|
||||
|
||||
+261
-200
@@ -1,19 +1,19 @@
|
||||
//! Bash Tool using WorkdirSession command capability.
|
||||
//!
|
||||
//! The selected session owns cwd, process launch, bounded output, timeout, and cancellation.
|
||||
//! Workdir owns launch and byte transport; Bash owns bounded preview and retained evidence.
|
||||
//! Bash is not a sandbox; the Runtime process and OS user define the execution boundary.
|
||||
//! Per-execution handles and nonces prevent cancellation of another command. Dropping a future
|
||||
//! does not confirm termination; active registrations remain until cleanup requests cancellation
|
||||
//! and observes terminal output.
|
||||
//! does not confirm termination. Registrations remain during bounded cancellation/drain cleanup;
|
||||
//! an unconfirmed provider outcome leaves partial evidence, never a fabricated terminal fence.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::bash_output::{BashOutputMetadata, BashOutputView, BashOutputWriter};
|
||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use async_trait::async_trait;
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use workdir::{
|
||||
CommandHandle, CommandOutputRequest, CommandRequest, WorkdirPath, WorkdirSessionHandle,
|
||||
WorkdirSessionRouter,
|
||||
@@ -35,9 +35,9 @@ struct BashParams {
|
||||
timeout: Option<u64>,
|
||||
}
|
||||
|
||||
pub(crate) struct BashTool {
|
||||
pub struct BashTool {
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
output_dir: PathBuf,
|
||||
output: BashOutputView,
|
||||
state: Arc<Mutex<BashExecutionState>>,
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ struct CommandGuard {
|
||||
execution_id: String,
|
||||
execution_nonce: u64,
|
||||
handle: Option<CommandHandle>,
|
||||
cursor: u64,
|
||||
retained: Option<BashOutputWriter>,
|
||||
}
|
||||
|
||||
impl Drop for CommandGuard {
|
||||
@@ -74,20 +76,66 @@ impl Drop for CommandGuard {
|
||||
let state = Arc::clone(&self.state);
|
||||
let execution_id = self.execution_id.clone();
|
||||
let execution_nonce = self.execution_nonce;
|
||||
let mut retained = self.retained.take();
|
||||
let cursor = self.cursor;
|
||||
// A dropped provider future is not terminal confirmation. Keep the live
|
||||
// execution registered until cleanup has both requested cancellation and
|
||||
// observed terminal command output, so cancellation/session teardown
|
||||
// cannot race with an apparently empty registry.
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
let _ = workdir.cancel_command(handle.clone()).await;
|
||||
let _ = workdir
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: INLINE_BYTE_BUDGET,
|
||||
let mut cursor = cursor;
|
||||
loop {
|
||||
let page = command_page(
|
||||
&workdir,
|
||||
CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||
wait: true,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(writer) = &mut retained {
|
||||
let mut next = cursor;
|
||||
for chunk in &page.chunks {
|
||||
if chunk.sequence != next {
|
||||
return Err(workdir::WorkdirError::Unavailable(
|
||||
"command cleanup sequence gap".into(),
|
||||
));
|
||||
}
|
||||
writer
|
||||
.append_chunk(chunk)
|
||||
.map_err(workdir::WorkdirError::Unavailable)?;
|
||||
next = next.checked_add(chunk.bytes.len() as u64).ok_or_else(|| {
|
||||
workdir::WorkdirError::Unavailable(
|
||||
"command cleanup cursor overflow".into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if page.next_cursor != next {
|
||||
return Err(workdir::WorkdirError::Unavailable(
|
||||
"command cleanup cursor gap".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
if let Some(terminal) = page.terminal {
|
||||
if let Some(writer) = &mut retained {
|
||||
writer
|
||||
.finish_transport(&terminal)
|
||||
.map_err(workdir::WorkdirError::Unavailable)?;
|
||||
}
|
||||
return Ok::<(), workdir::WorkdirError>(());
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
// Bounded cleanup can end with an unknown provider outcome. The writer then
|
||||
// terminalizes partial evidence on drop; retaining a dead collector in `active`
|
||||
// would leak registrations and falsely suggest it could still collect output.
|
||||
let _ = result;
|
||||
let mut state = state.lock().unwrap();
|
||||
if state
|
||||
.active
|
||||
@@ -101,15 +149,49 @@ impl Drop for CommandGuard {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BashTool {
|
||||
async fn execute(
|
||||
// Output pages are replayable only with the exact same command/cursor/limit.
|
||||
// Retry read transport failures, never command start, and cap both attempts and
|
||||
// backoff. The caller's deadline bounds a disconnected or unresponsive provider.
|
||||
async fn command_page(
|
||||
session: &workdir::WorkdirSessionHandle,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
for attempt in 0..3 {
|
||||
match session.command_output(request.clone()).await {
|
||||
Err(
|
||||
workdir::WorkdirError::Transport(_)
|
||||
| workdir::WorkdirError::Unavailable(_)
|
||||
| workdir::WorkdirError::OutcomeUnknown(_),
|
||||
) if attempt < 2 => {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100 << attempt)).await;
|
||||
}
|
||||
result => return result,
|
||||
}
|
||||
}
|
||||
unreachable!("bounded retry loop returns its last result")
|
||||
}
|
||||
|
||||
impl BashTool {
|
||||
pub fn input_schema() -> serde_json::Value {
|
||||
serde_json::to_value(schemars::schema_for!(BashParams)).expect("Bash schema serialization")
|
||||
}
|
||||
|
||||
pub fn new(router: Arc<WorkdirSessionRouter>, output: BashOutputView) -> Self {
|
||||
Self {
|
||||
router,
|
||||
output,
|
||||
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_result(
|
||||
&self,
|
||||
input_json: &str,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
) -> Result<BashResult, ToolError> {
|
||||
let params: BashParams = serde_json::from_str(input_json)
|
||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?;
|
||||
let retained = self.output.begin().map_err(ToolError::ExecutionFailed)?;
|
||||
let selected = crate::routing::resolve_session(
|
||||
&self.router,
|
||||
params.target_workdir.as_deref(),
|
||||
@@ -134,14 +216,15 @@ impl Tool for BashTool {
|
||||
execution_id: execution_id.clone(),
|
||||
execution_nonce,
|
||||
handle: None,
|
||||
cursor: 0,
|
||||
retained: Some(retained),
|
||||
};
|
||||
let handle = session
|
||||
.start_command(CommandRequest {
|
||||
command: params.command,
|
||||
timeout_secs,
|
||||
output_limit: INLINE_BYTE_BUDGET,
|
||||
transport_byte_budget: self.output.byte_budget(),
|
||||
cwd: params.cwd,
|
||||
spill_dir: Some(self.output_dir.clone()),
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
})
|
||||
.await
|
||||
@@ -167,15 +250,101 @@ impl Tool for BashTool {
|
||||
.await
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
}
|
||||
let output = session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: INLINE_BYTE_BUDGET,
|
||||
let mut cursor = 0;
|
||||
let mut stdout_preview = Vec::new();
|
||||
let mut stderr_preview = Vec::new();
|
||||
let mut seen = [0_u64; 2];
|
||||
let mut empty_polls = 0;
|
||||
let deadline =
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs + 30);
|
||||
let terminal = loop {
|
||||
let page = tokio::time::timeout_at(
|
||||
deadline,
|
||||
command_page(
|
||||
&session,
|
||||
CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||
wait: true,
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ToolError::ExecutionFailed(
|
||||
"command byte transport deadline exceeded; terminal outcome unknown".into(),
|
||||
)
|
||||
})?
|
||||
.map_err(crate::ToolsError::from)?;
|
||||
let mut next = cursor;
|
||||
for chunk in &page.chunks {
|
||||
if chunk.sequence != next || chunk.bytes.is_empty() {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"command byte transport sequence gap".into(),
|
||||
));
|
||||
}
|
||||
next = next
|
||||
.checked_add(chunk.bytes.len() as u64)
|
||||
.filter(|n| *n <= self.output.byte_budget())
|
||||
.ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(
|
||||
"command byte transport exceeds delivery budget".into(),
|
||||
)
|
||||
})?;
|
||||
let index = if chunk.stream == workdir::CommandStream::Stdout {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
if chunk.offset != seen[index] {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"command byte transport offset gap".into(),
|
||||
));
|
||||
}
|
||||
seen[index] += chunk.bytes.len() as u64;
|
||||
guard
|
||||
.retained
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.append_chunk(chunk)
|
||||
.map_err(ToolError::ExecutionFailed)?;
|
||||
let available =
|
||||
INLINE_BYTE_BUDGET.saturating_sub(stdout_preview.len() + stderr_preview.len());
|
||||
let preview = if index == 0 {
|
||||
&mut stdout_preview
|
||||
} else {
|
||||
&mut stderr_preview
|
||||
};
|
||||
preview.extend_from_slice(&chunk.bytes[..available.min(chunk.bytes.len())]);
|
||||
}
|
||||
if page.next_cursor != next {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"command byte transport cursor does not fence the page".into(),
|
||||
));
|
||||
}
|
||||
if page.chunks.is_empty() && page.terminal.is_none() {
|
||||
empty_polls += 1;
|
||||
if empty_polls > 32 {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"command byte transport made no progress after bounded polls".into(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
empty_polls = 0;
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
guard.cursor = cursor;
|
||||
if let Some(terminal) = page.terminal {
|
||||
break terminal;
|
||||
}
|
||||
};
|
||||
let metadata = guard
|
||||
.retained
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.finish_transport(&terminal)
|
||||
.map_err(ToolError::ExecutionFailed)?;
|
||||
let cancellation_requested = {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let owns_registration = state
|
||||
@@ -193,41 +362,78 @@ impl Tool for BashTool {
|
||||
};
|
||||
guard.handle = None;
|
||||
|
||||
let timed_out = output.timed_out;
|
||||
let summary = if cancellation_requested {
|
||||
let timed_out = terminal.status == workdir::CommandStatus::TimedOut;
|
||||
let summary =
|
||||
if cancellation_requested || terminal.status == workdir::CommandStatus::Cancelled {
|
||||
format!("$ {cmd_summary} (cancelled)")
|
||||
} else if output.timed_out {
|
||||
} else if timed_out {
|
||||
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
|
||||
} else {
|
||||
match output.exit_code {
|
||||
match terminal.exit_code {
|
||||
Some(0) => format!("$ {cmd_summary}"),
|
||||
Some(code) => format!("$ {cmd_summary} (exit {code})"),
|
||||
None => format!("$ {cmd_summary} (terminated)"),
|
||||
}
|
||||
};
|
||||
let content = if output.content.is_empty() {
|
||||
None
|
||||
} else if output.truncated {
|
||||
let notice = match output.output_path {
|
||||
Some(path) => format!(
|
||||
"[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 {
|
||||
Some(output.content)
|
||||
};
|
||||
let output = ToolOutput {
|
||||
Ok(BashResult {
|
||||
summary,
|
||||
content,
|
||||
attachments: Vec::new(),
|
||||
};
|
||||
if cancellation_requested {
|
||||
stdout_preview: String::from_utf8_lossy(&stdout_preview).into_owned(),
|
||||
stderr_preview: String::from_utf8_lossy(&stderr_preview).into_owned(),
|
||||
preview_omitted: terminal.stdout_bytes.saturating_add(terminal.stderr_bytes)
|
||||
> (stdout_preview.len() + stderr_preview.len()) as u64,
|
||||
metadata,
|
||||
cancelled: cancellation_requested
|
||||
|| terminal.status == workdir::CommandStatus::Cancelled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BashResult {
|
||||
pub summary: String,
|
||||
pub stdout_preview: String,
|
||||
pub stderr_preview: String,
|
||||
pub preview_omitted: bool,
|
||||
pub metadata: BashOutputMetadata,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
impl BashResult {
|
||||
pub fn stdout_entry(&self) -> String {
|
||||
format!("/bash-output/{}/stdout", self.metadata.id)
|
||||
}
|
||||
pub fn stderr_entry(&self) -> String {
|
||||
format!("/bash-output/{}/stderr", self.metadata.id)
|
||||
}
|
||||
pub fn order_entry(&self) -> String {
|
||||
format!("/bash-output/{}/order", self.metadata.id)
|
||||
}
|
||||
pub fn tool_output(&self) -> ToolOutput {
|
||||
let content = serde_json::json!({
|
||||
"stdout_preview": self.stdout_preview, "stderr_preview": self.stderr_preview,
|
||||
"preview_omitted": self.preview_omitted, "output": self.metadata,
|
||||
"stdout_entry": self.stdout_entry(), "stderr_entry": self.stderr_entry(), "order_entry": self.order_entry(),
|
||||
});
|
||||
ToolOutput {
|
||||
summary: self.summary.clone(),
|
||||
content: Some(content.to_string()),
|
||||
attachments: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for BashTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input: &str,
|
||||
ctx: agen::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let result = self.execute_result(input, ctx).await?;
|
||||
let output = result.tool_output();
|
||||
if result.cancelled {
|
||||
Err(ToolError::Cancelled(output))
|
||||
} else if timed_out {
|
||||
} else if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) {
|
||||
Err(ToolError::Interrupted(output))
|
||||
} else {
|
||||
Ok(output)
|
||||
@@ -288,164 +494,19 @@ fn truncate_for_summary(command: &str) -> String {
|
||||
summary
|
||||
}
|
||||
|
||||
pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition {
|
||||
routed_bash_tool(crate::routing::singleton_router(session), output_dir)
|
||||
pub fn bash_tool(session: WorkdirSessionHandle, output: BashOutputView) -> ToolDefinition {
|
||||
routed_bash_tool(crate::routing::singleton_router(session), output)
|
||||
}
|
||||
|
||||
pub(crate) fn routed_bash_tool(
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
output_dir: PathBuf,
|
||||
output: BashOutputView,
|
||||
) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(BashParams);
|
||||
let meta = ToolMeta::new("Bash")
|
||||
.description("Execute a shell command in one selected Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by that WorkdirSession provider. This is not a sandbox.")
|
||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||
router: router.clone(),
|
||||
output_dir: output_dir.clone(),
|
||||
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||
});
|
||||
.description("Execute a shell command in one selected Workdir. Workdir owns launch, lossless bounded byte transport, timeout and cancel; Bash owns preview, quota-limited ephemeral output and read-only virtual references. This is not a sandbox.")
|
||||
.input_schema(BashTool::input_schema());
|
||||
let tool: Arc<dyn Tool> = Arc::new(BashTool::new(router.clone(), output.clone()));
|
||||
(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,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
ScopeRule {
|
||||
target: output.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
},
|
||||
],
|
||||
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, "cwd": "." }).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 missing_cwd_is_rejected_before_command_start() {
|
||||
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 error = bash
|
||||
.execute(
|
||||
&serde_json::json!({ "command": "printf hidden" }).to_string(),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("missing field `cwd`"));
|
||||
}
|
||||
|
||||
#[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", "cwd": "." }).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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
//! Bash-owned bounded output, independent of Workdir transport and Console subscribers.
|
||||
//!
|
||||
//! Output is ephemeral evidence, not a filesystem grant. Every lookup checks the live view;
|
||||
//! children can see their own subtree and ancestors can retain evidence after child revocation.
|
||||
//! Root teardown clears all bytes. A new root (including restore) has a new incarnation and
|
||||
//! never reconstructs grants from history. Expiry is measured from admission, enforced on
|
||||
//! lookup and by a weak reaper once commands run in Tokio. Parent and all descendants share
|
||||
//! one pool: admission reserves a whole command budget, evicts the oldest terminal evidence
|
||||
//! first, and refuses rather than evicting a live collector. Revoking a child denies access
|
||||
//! but leaves ancestor evidence until pool eviction/expiry; root teardown clears the pool.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Serialize;
|
||||
use workdir::{CommandStatus, CommandStream};
|
||||
|
||||
const COMMAND_BYTES: usize = 4 * 1024 * 1024;
|
||||
const STORE_BYTES: usize = 32 * 1024 * 1024;
|
||||
const STORE_COUNT: usize = 64;
|
||||
const ORDER_COUNT: usize = 4096;
|
||||
const RETENTION: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BashOutputView {
|
||||
store: Arc<Mutex<Store>>,
|
||||
lineage: Vec<String>,
|
||||
active: Arc<AtomicBool>,
|
||||
authority: Arc<dyn Fn() -> bool + Send + Sync>,
|
||||
}
|
||||
|
||||
struct Store {
|
||||
records: VecDeque<Arc<Mutex<Record>>>,
|
||||
reserved: usize,
|
||||
retention: Duration,
|
||||
reaper_started: bool,
|
||||
_shutdown: tokio::sync::watch::Sender<()>,
|
||||
}
|
||||
|
||||
struct Record {
|
||||
id: String,
|
||||
owner: Vec<String>,
|
||||
created: Instant,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
order: Vec<OutputRange>,
|
||||
stdout_seen: u64,
|
||||
stderr_seen: u64,
|
||||
truncated: bool,
|
||||
limit: Option<RetentionLimit>,
|
||||
transport_counts: Option<(u64, u64)>,
|
||||
transport_truncation: Option<workdir::CommandOutputTruncation>,
|
||||
terminal: Option<OutputTerminal>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OutputRange {
|
||||
pub stream: CommandStream,
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct OutputTerminal {
|
||||
pub status: Option<CommandStatus>,
|
||||
pub exit_code: Option<i32>,
|
||||
/// False on a forcibly dropped collector or failed transport: received bytes are retained,
|
||||
/// but they are not a claim about the unobserved process tail.
|
||||
pub transport_complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RetentionLimit {
|
||||
ByteQuota,
|
||||
OrderQuota,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct BashOutputMetadata {
|
||||
pub id: String,
|
||||
pub stdout_saved: usize,
|
||||
pub stderr_saved: usize,
|
||||
pub stdout_seen: u64,
|
||||
pub stderr_seen: u64,
|
||||
pub stdout_delivered: u64,
|
||||
pub stderr_delivered: u64,
|
||||
pub retention_limit: Option<RetentionLimit>,
|
||||
pub transport_truncation: Option<workdir::CommandOutputTruncation>,
|
||||
pub storage_truncated: bool,
|
||||
pub terminal: OutputTerminal,
|
||||
pub text_encoding: &'static str,
|
||||
pub ordering: &'static str,
|
||||
}
|
||||
|
||||
/// A bounded reservation acquired before command start. Drop terminalizes partial evidence.
|
||||
pub struct BashOutputWriter {
|
||||
record: Arc<Mutex<Record>>,
|
||||
}
|
||||
|
||||
impl Default for BashOutputView {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BashOutputView {
|
||||
pub fn new() -> Self {
|
||||
let (shutdown, _) = tokio::sync::watch::channel(());
|
||||
let store = Arc::new(Mutex::new(Store {
|
||||
records: VecDeque::new(),
|
||||
reserved: 0,
|
||||
retention: RETENTION,
|
||||
reaper_started: false,
|
||||
_shutdown: shutdown,
|
||||
}));
|
||||
let view = Self {
|
||||
store,
|
||||
lineage: vec![uuid::Uuid::now_v7().to_string()],
|
||||
active: Arc::new(AtomicBool::new(true)),
|
||||
authority: Arc::new(|| true),
|
||||
};
|
||||
view.start_reaper();
|
||||
view
|
||||
}
|
||||
|
||||
fn start_reaper(&self) {
|
||||
let Ok(runtime) = tokio::runtime::Handle::try_current() else {
|
||||
return;
|
||||
};
|
||||
let mut store = self.store.lock().unwrap();
|
||||
if store.reaper_started {
|
||||
return;
|
||||
}
|
||||
let receiver = store._shutdown.subscribe();
|
||||
store.reaper_started = true;
|
||||
let weak = Arc::downgrade(&self.store);
|
||||
runtime.spawn(reap(weak, receiver));
|
||||
}
|
||||
|
||||
pub fn byte_budget(&self) -> u64 {
|
||||
COMMAND_BYTES as u64
|
||||
}
|
||||
|
||||
pub fn child(&self, authority: Arc<dyn Fn() -> bool + Send + Sync>) -> Self {
|
||||
let parent = self.clone();
|
||||
let mut lineage = self.lineage.clone();
|
||||
lineage.push(uuid::Uuid::now_v7().to_string());
|
||||
Self {
|
||||
store: self.store.clone(),
|
||||
lineage,
|
||||
active: Arc::new(AtomicBool::new(true)),
|
||||
authority: Arc::new(move || parent.is_active() && authority()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active.load(Ordering::Acquire) && (self.authority)()
|
||||
}
|
||||
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.lineage.len() == 1
|
||||
}
|
||||
|
||||
pub fn revoke(&self) {
|
||||
self.active.store(false, Ordering::Release);
|
||||
if self.lineage.len() == 1 {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
store.records.clear();
|
||||
store.reserved = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin(&self) -> Result<BashOutputWriter, String> {
|
||||
if !self.is_active() {
|
||||
return Err("Bash output authority revoked".into());
|
||||
}
|
||||
// A host may construct the Feature before entering Tokio. Command admission
|
||||
// happens inside the runtime, so idle expiry must start here as well.
|
||||
self.start_reaper();
|
||||
let mut store = self.store.lock().unwrap();
|
||||
if !self.is_active() {
|
||||
return Err("Bash output authority revoked".into());
|
||||
}
|
||||
store.expire(Instant::now());
|
||||
while store.records.len() >= STORE_COUNT || store.reserved + COMMAND_BYTES > STORE_BYTES {
|
||||
let Some(index) = store
|
||||
.records
|
||||
.iter()
|
||||
.position(|r| r.lock().unwrap().terminal.is_some())
|
||||
else {
|
||||
return Err("Bash output capacity is occupied by live commands".into());
|
||||
};
|
||||
store.records.remove(index);
|
||||
store.reserved -= COMMAND_BYTES;
|
||||
}
|
||||
let record = Arc::new(Mutex::new(Record {
|
||||
id: uuid::Uuid::now_v7().to_string(),
|
||||
owner: self.lineage.clone(),
|
||||
created: Instant::now(),
|
||||
stdout: Vec::new(),
|
||||
stderr: Vec::new(),
|
||||
order: Vec::new(),
|
||||
stdout_seen: 0,
|
||||
stderr_seen: 0,
|
||||
truncated: false,
|
||||
limit: None,
|
||||
transport_counts: None,
|
||||
transport_truncation: None,
|
||||
terminal: None,
|
||||
}));
|
||||
store.records.push_back(record.clone());
|
||||
store.reserved += COMMAND_BYTES;
|
||||
Ok(BashOutputWriter { record })
|
||||
}
|
||||
|
||||
fn record(&self, id: &str) -> Option<Arc<Mutex<Record>>> {
|
||||
if !self.is_active() {
|
||||
return None;
|
||||
}
|
||||
let mut store = self.store.lock().unwrap();
|
||||
if !self.is_active() {
|
||||
return None;
|
||||
}
|
||||
store.expire(Instant::now());
|
||||
store
|
||||
.records
|
||||
.iter()
|
||||
.find(|r| {
|
||||
let r = r.lock().unwrap();
|
||||
r.id == id && r.owner.starts_with(&self.lineage) && r.terminal.is_some()
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn metadata(&self, id: &str) -> Option<BashOutputMetadata> {
|
||||
let record = self.record(id)?;
|
||||
let r = record.lock().unwrap();
|
||||
Some(r.metadata())
|
||||
}
|
||||
|
||||
/// Copies at most the caller's bounded range; never publishes output bodies in descriptors.
|
||||
pub fn read(
|
||||
&self,
|
||||
id: &str,
|
||||
stream: CommandStream,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Option<Vec<u8>> {
|
||||
let record = self.record(id)?;
|
||||
let r = record.lock().unwrap();
|
||||
let bytes = match stream {
|
||||
CommandStream::Stdout => &r.stdout,
|
||||
CommandStream::Stderr => &r.stderr,
|
||||
};
|
||||
let start = offset.min(bytes.len());
|
||||
Some(bytes[start..start.saturating_add(limit.min(64 * 1024)).min(bytes.len())].to_vec())
|
||||
}
|
||||
|
||||
/// Scan the bounded raw source without cloning it, decoding only the requested line window.
|
||||
/// A long single line uses the common byte reader instead of allocating an oversized text page.
|
||||
pub fn line_window(
|
||||
&self,
|
||||
id: &str,
|
||||
stream: CommandStream,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Option<Result<(String, bool), String>> {
|
||||
let record = self.record(id)?;
|
||||
let r = record.lock().unwrap();
|
||||
let bytes = match stream {
|
||||
CommandStream::Stdout => &r.stdout,
|
||||
CommandStream::Stderr => &r.stderr,
|
||||
};
|
||||
let mut lines = bytes.split_inclusive(|b| *b == b'\n').skip(offset);
|
||||
let mut window = Vec::new();
|
||||
for line in lines.by_ref().take(limit.min(2000)) {
|
||||
if window.len() + line.len() > 64 * 1024 {
|
||||
return Some(Err(
|
||||
"text source window exceeds 64 KiB; use the common byte reader".into(),
|
||||
));
|
||||
}
|
||||
window.extend_from_slice(line);
|
||||
}
|
||||
let more = lines.next().is_some();
|
||||
Some(Ok((String::from_utf8_lossy(&window).into_owned(), more)))
|
||||
}
|
||||
|
||||
pub fn order(&self, id: &str) -> Option<Vec<OutputRange>> {
|
||||
let record = self.record(id)?;
|
||||
let r = record.lock().unwrap();
|
||||
Some(r.order.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Record {
|
||||
fn metadata(&self) -> BashOutputMetadata {
|
||||
BashOutputMetadata {
|
||||
id: self.id.clone(),
|
||||
stdout_saved: self.stdout.len(),
|
||||
stderr_saved: self.stderr.len(),
|
||||
stdout_seen: self.transport_counts.map_or(self.stdout_seen, |c| c.0),
|
||||
stderr_seen: self.transport_counts.map_or(self.stderr_seen, |c| c.1),
|
||||
stdout_delivered: self.stdout_seen,
|
||||
stderr_delivered: self.stderr_seen,
|
||||
retention_limit: self.limit,
|
||||
transport_truncation: self.transport_truncation.clone(),
|
||||
storage_truncated: self.truncated || self.transport_truncation.is_some(),
|
||||
terminal: self.terminal.clone().expect("finalized record"),
|
||||
text_encoding: "utf8_lossy",
|
||||
ordering: "provider_read_observation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
fn expire(&mut self, now: Instant) {
|
||||
let before = self.records.len();
|
||||
self.records
|
||||
.retain(|r| now.duration_since(r.lock().unwrap().created) < self.retention);
|
||||
self.reserved -= (before - self.records.len()) * COMMAND_BYTES;
|
||||
}
|
||||
}
|
||||
|
||||
async fn reap(weak: Weak<Mutex<Store>>, mut shutdown: tokio::sync::watch::Receiver<()>) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {},
|
||||
_ = shutdown.changed() => return,
|
||||
}
|
||||
let Some(store) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
store.lock().unwrap().expire(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
impl BashOutputWriter {
|
||||
pub fn append_chunk(&mut self, chunk: &workdir::CommandByteChunk) -> Result<(), String> {
|
||||
let expected = {
|
||||
let r = self.record.lock().unwrap();
|
||||
match chunk.stream {
|
||||
CommandStream::Stdout => r.stdout_seen,
|
||||
CommandStream::Stderr => r.stderr_seen,
|
||||
}
|
||||
};
|
||||
if chunk.offset != expected || chunk.bytes.is_empty() {
|
||||
return Err("command byte transport stream gap or empty chunk".into());
|
||||
}
|
||||
self.append(chunk.stream, &chunk.bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn append(&mut self, stream: CommandStream, bytes: &[u8]) {
|
||||
let mut r = self.record.lock().unwrap();
|
||||
let seen = match stream {
|
||||
CommandStream::Stdout => &mut r.stdout_seen,
|
||||
CommandStream::Stderr => &mut r.stderr_seen,
|
||||
};
|
||||
let start = *seen;
|
||||
*seen = seen.saturating_add(bytes.len() as u64);
|
||||
let available = COMMAND_BYTES.saturating_sub(r.stdout.len() + r.stderr.len());
|
||||
let n = if r.truncated || r.order.len() >= ORDER_COUNT {
|
||||
0
|
||||
} else {
|
||||
available.min(bytes.len())
|
||||
};
|
||||
if n < bytes.len() {
|
||||
r.truncated = true;
|
||||
if r.limit.is_none() {
|
||||
r.limit = Some(if r.order.len() >= ORDER_COUNT {
|
||||
RetentionLimit::OrderQuota
|
||||
} else {
|
||||
RetentionLimit::ByteQuota
|
||||
});
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
match stream {
|
||||
CommandStream::Stdout => r.stdout.extend_from_slice(&bytes[..n]),
|
||||
CommandStream::Stderr => r.stderr.extend_from_slice(&bytes[..n]),
|
||||
}
|
||||
if let Some(last) = r
|
||||
.order
|
||||
.last_mut()
|
||||
.filter(|last| last.stream == stream && last.end == start)
|
||||
{
|
||||
last.end += n as u64;
|
||||
} else {
|
||||
r.order.push(OutputRange {
|
||||
stream,
|
||||
start,
|
||||
end: start + n as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish_transport(
|
||||
&mut self,
|
||||
terminal: &workdir::CommandTerminal,
|
||||
) -> Result<BashOutputMetadata, String> {
|
||||
let mut r = self.record.lock().unwrap();
|
||||
if terminal.stdout_delivered_bytes != r.stdout_seen
|
||||
|| terminal.stderr_delivered_bytes != r.stderr_seen
|
||||
|| terminal.stdout_bytes < r.stdout_seen
|
||||
|| terminal.stderr_bytes < r.stderr_seen
|
||||
|| ((terminal.stdout_bytes != r.stdout_seen || terminal.stderr_bytes != r.stderr_seen)
|
||||
!= terminal.truncation_reason.is_some())
|
||||
{
|
||||
return Err("command terminal counts do not fence delivered bytes".into());
|
||||
}
|
||||
r.transport_counts = Some((terminal.stdout_bytes, terminal.stderr_bytes));
|
||||
r.transport_truncation = terminal.truncation_reason.clone();
|
||||
r.terminal = Some(OutputTerminal {
|
||||
status: Some(terminal.status),
|
||||
exit_code: terminal.exit_code,
|
||||
transport_complete: true,
|
||||
});
|
||||
Ok(r.metadata())
|
||||
}
|
||||
|
||||
pub fn finish(&mut self, terminal: OutputTerminal) -> BashOutputMetadata {
|
||||
let mut r = self.record.lock().unwrap();
|
||||
r.terminal = Some(terminal);
|
||||
r.metadata()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BashOutputWriter {
|
||||
fn drop(&mut self) {
|
||||
let mut r = self.record.lock().unwrap();
|
||||
if r.terminal.is_none() {
|
||||
r.terminal = Some(OutputTerminal {
|
||||
status: None,
|
||||
exit_code: None,
|
||||
transport_complete: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn finish(view: &BashOutputView, text: &[u8]) -> String {
|
||||
let mut writer = view.begin().unwrap();
|
||||
writer.append(CommandStream::Stdout, text);
|
||||
writer
|
||||
.finish(OutputTerminal {
|
||||
status: Some(CommandStatus::Completed),
|
||||
exit_code: Some(0),
|
||||
transport_complete: true,
|
||||
})
|
||||
.id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoked_child_is_denied_while_parent_keeps_evidence_and_sibling_is_denied() {
|
||||
let root = BashOutputView::new();
|
||||
let lease = Arc::new(AtomicBool::new(true));
|
||||
let child = root.child({
|
||||
let lease = lease.clone();
|
||||
Arc::new(move || lease.load(Ordering::Acquire))
|
||||
});
|
||||
let sibling = root.child(Arc::new(|| true));
|
||||
let id = finish(&child, b"child");
|
||||
assert_eq!(
|
||||
child.read(&id, CommandStream::Stdout, 0, 20).unwrap(),
|
||||
b"child"
|
||||
);
|
||||
assert!(sibling.metadata(&id).is_none());
|
||||
assert!(BashOutputView::new().metadata(&id).is_none());
|
||||
lease.store(false, Ordering::Release);
|
||||
assert!(child.metadata(&id).is_none());
|
||||
assert_eq!(
|
||||
root.read(&id, CommandStream::Stdout, 0, 20).unwrap(),
|
||||
b"child"
|
||||
);
|
||||
root.revoke();
|
||||
let restored = BashOutputView::new();
|
||||
assert!(root.metadata(&id).is_none());
|
||||
assert!(restored.metadata(&id).is_none());
|
||||
let new_id = finish(&restored, b"new incarnation");
|
||||
assert_ne!(new_id, id);
|
||||
assert!(child.metadata(&new_id).is_none());
|
||||
assert!(root.begin().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_and_descendants_share_admission_without_evicting_live_evidence() {
|
||||
let root = BashOutputView::new();
|
||||
let child = root.child(Arc::new(|| true));
|
||||
let sibling = root.child(Arc::new(|| true));
|
||||
let mut writers = (0..STORE_BYTES / COMMAND_BYTES)
|
||||
.map(|index| {
|
||||
if index % 2 == 0 {
|
||||
root.begin()
|
||||
} else {
|
||||
child.begin()
|
||||
}
|
||||
.unwrap()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(sibling.begin().is_err());
|
||||
let mut first = writers.remove(0);
|
||||
first.append(CommandStream::Stdout, b"finished");
|
||||
let metadata = first.finish(OutputTerminal {
|
||||
status: Some(CommandStatus::Completed),
|
||||
exit_code: Some(0),
|
||||
transport_complete: true,
|
||||
});
|
||||
assert!(root.metadata(&metadata.id).is_some());
|
||||
let _replacement = sibling.begin().unwrap();
|
||||
assert!(root.metadata(&metadata.id).is_none());
|
||||
assert!(root.begin().is_err());
|
||||
assert_eq!(writers.len(), STORE_BYTES / COMMAND_BYTES - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_and_forced_drop_are_distinct_from_preview_omission() {
|
||||
let root = BashOutputView::new();
|
||||
let mut writer = root.begin().unwrap();
|
||||
writer.append(CommandStream::Stdout, &vec![b'x'; COMMAND_BYTES + 1]);
|
||||
writer.append(CommandStream::Stderr, b"tail");
|
||||
let id = writer.record.lock().unwrap().id.clone();
|
||||
drop(writer);
|
||||
let m = root.metadata(&id).unwrap();
|
||||
assert_eq!(m.stdout_saved, COMMAND_BYTES);
|
||||
assert_eq!(m.stdout_seen, COMMAND_BYTES as u64 + 1);
|
||||
assert_eq!(m.stderr_seen, 4);
|
||||
assert!(m.storage_truncated);
|
||||
assert!(!m.terminal.transport_complete);
|
||||
assert_eq!(
|
||||
root.read(&id, CommandStream::Stdout, 0, usize::MAX)
|
||||
.unwrap()
|
||||
.len(),
|
||||
64 * 1024
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_and_count_eviction_never_reuse_old_references() {
|
||||
let root = BashOutputView::new();
|
||||
let id = finish(&root, b"old");
|
||||
root.store.lock().unwrap().retention = Duration::ZERO;
|
||||
assert!(root.metadata(&id).is_none());
|
||||
root.store.lock().unwrap().retention = RETENTION;
|
||||
let first = finish(&root, b"first");
|
||||
for _ in 0..STORE_COUNT {
|
||||
finish(&root, b"later");
|
||||
}
|
||||
assert!(root.metadata(&first).is_none());
|
||||
assert!(root.store.lock().unwrap().reserved <= STORE_BYTES);
|
||||
}
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn expiry_reclaims_bytes_without_another_read_and_reaper_ends_with_owner() {
|
||||
// Exercise host construction outside a runtime, followed by real async admission.
|
||||
let root = std::thread::spawn(BashOutputView::new).join().unwrap();
|
||||
finish(&root, b"expires without lookup");
|
||||
let mut closed = root.store.lock().unwrap()._shutdown.subscribe();
|
||||
tokio::task::yield_now().await;
|
||||
// The store uses monotonic wall time for publication and Tokio time only for scheduling.
|
||||
// Age the record deterministically rather than delaying this test in real time.
|
||||
root.store.lock().unwrap().records[0]
|
||||
.lock()
|
||||
.unwrap()
|
||||
.created = Instant::now() - RETENTION;
|
||||
tokio::time::advance(Duration::from_secs(31)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(root.store.lock().unwrap().reserved, 0);
|
||||
drop(root);
|
||||
assert!(closed.changed().await.is_err());
|
||||
}
|
||||
#[test]
|
||||
fn terminal_counts_must_fence_the_received_prefix_before_marking_evidence_complete() {
|
||||
let root = BashOutputView::new();
|
||||
let mut writer = root.begin().unwrap();
|
||||
writer.append(CommandStream::Stdout, b"abc");
|
||||
let mut terminal = serde_json::json!({"status":"completed","exit_code":0,"stdout_bytes":9,"stderr_bytes":0,"stdout_delivered_bytes":4,"stderr_delivered_bytes":0,"truncation_reason":"byte_budget"});
|
||||
assert!(
|
||||
writer
|
||||
.finish_transport(&serde_json::from_value(terminal.clone()).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
terminal["stdout_delivered_bytes"] = serde_json::json!(3);
|
||||
let metadata = writer
|
||||
.finish_transport(&serde_json::from_value(terminal).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(metadata.stdout_saved, 3);
|
||||
assert_eq!(metadata.stdout_delivered, 3);
|
||||
assert_eq!(metadata.stdout_seen, 9);
|
||||
assert!(metadata.storage_truncated);
|
||||
assert!(metadata.retention_limit.is_none());
|
||||
assert_eq!(
|
||||
metadata.transport_truncation,
|
||||
Some(workdir::CommandOutputTruncation::ByteBudget)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -403,3 +403,71 @@ fn check_pattern(pattern: &str) -> Result<(), ToolError> {
|
||||
#[cfg(test)]
|
||||
#[path = "checkout_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
/// Read a bounded native text window through the same checked target.
|
||||
/// The returned one-shot action records prior-Read only after the caller's presentation succeeds.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn read_checkout_text(
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
path: WorkdirPath,
|
||||
validator: Vec<u8>,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
max_bytes: usize,
|
||||
) -> Result<(workdir::ReadResult, Option<Vec<u8>>, impl FnOnce()), ToolError> {
|
||||
let selected =
|
||||
crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?;
|
||||
if selected.generation != generation {
|
||||
return Err(stale_checkout("checkout connection changed"));
|
||||
}
|
||||
let tracker = tracker.scoped_attachment(&selected.alias, selected.generation);
|
||||
let target = FileTarget {
|
||||
session: selected.session.clone(),
|
||||
path: path.clone(),
|
||||
validator: Some(validator),
|
||||
};
|
||||
let (result, validator) = target.read(offset, limit, max_bytes).await?;
|
||||
let hash = result.content_hash.clone();
|
||||
let total_lines = result.total_lines;
|
||||
Ok((result, validator, move || {
|
||||
tracker.record_workdir_observation(&path, hash, total_lines);
|
||||
}))
|
||||
}
|
||||
|
||||
/// Binary-safe checked byte window. This does not establish a text prior-Read observation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn read_checkout_bytes(
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
path: WorkdirPath,
|
||||
validator: Vec<u8>,
|
||||
offset: u64,
|
||||
max_bytes: usize,
|
||||
) -> Result<(workdir::ReadBytesResult, Vec<u8>), ToolError> {
|
||||
let selected =
|
||||
crate::routing::resolve_session(&router, Some(alias), WorkdirSessionCapability::Read)?;
|
||||
if selected.generation != generation {
|
||||
return Err(stale_checkout("checkout connection changed"));
|
||||
}
|
||||
let result = selected
|
||||
.session
|
||||
.checkout_execute(workdir::CheckoutRequest {
|
||||
target: path.clone(),
|
||||
validator,
|
||||
operation: workdir::CheckoutOperation::ReadBytes { offset, max_bytes },
|
||||
})
|
||||
.await
|
||||
.map_err(crate::file_target::checked_error)?;
|
||||
match result.output {
|
||||
workdir::CheckoutOutput::ReadBytes(read)
|
||||
if read.path == path && read.offset == offset && read.bytes.len() <= max_bytes =>
|
||||
{
|
||||
Ok((read, result.observation.validator))
|
||||
}
|
||||
_ => Err(crate::file_target::readonly_result_error("ReadBytes")),
|
||||
}
|
||||
}
|
||||
|
||||
+10
-9
@@ -5,6 +5,7 @@ pub mod error;
|
||||
pub mod tracker;
|
||||
|
||||
mod bash;
|
||||
pub mod bash_output;
|
||||
mod edit;
|
||||
mod file_target;
|
||||
mod glob;
|
||||
@@ -16,7 +17,7 @@ pub mod view_image;
|
||||
mod web;
|
||||
mod write;
|
||||
|
||||
pub use bash::bash_tool;
|
||||
pub use bash::{BashResult, BashTool, bash_tool};
|
||||
pub use checkout::{CheckoutToolOutput, execute_checkout_tool};
|
||||
pub use edit::edit_tool;
|
||||
pub use error::ToolsError;
|
||||
@@ -35,7 +36,7 @@ pub use write::write_tool;
|
||||
pub fn core_builtin_tools(
|
||||
session: workdir::WorkdirSessionHandle,
|
||||
tracker: Tracker,
|
||||
bash_output_dir: std::path::PathBuf,
|
||||
bash_output: bash_output::BashOutputView,
|
||||
) -> Vec<agen::tool::ToolDefinition> {
|
||||
use workdir::WorkdirSessionCapability;
|
||||
|
||||
@@ -58,7 +59,7 @@ pub fn core_builtin_tools(
|
||||
tools.push(grep::routed_grep_tool(router.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirSessionCapability::Command) {
|
||||
tools.push(bash::routed_bash_tool(router, bash_output_dir));
|
||||
tools.push(bash::routed_bash_tool(router, bash_output));
|
||||
}
|
||||
tools
|
||||
}
|
||||
@@ -71,7 +72,7 @@ pub fn core_builtin_tools(
|
||||
pub fn routed_builtin_tools(
|
||||
router: std::sync::Arc<workdir::WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
bash_output_dir: std::path::PathBuf,
|
||||
bash_output: bash_output::BashOutputView,
|
||||
) -> Vec<agen::tool::ToolDefinition> {
|
||||
vec![
|
||||
read::routed_read_tool(router.clone(), tracker.clone()),
|
||||
@@ -79,14 +80,14 @@ pub fn routed_builtin_tools(
|
||||
edit::routed_edit_tool(router.clone(), tracker),
|
||||
glob::routed_glob_tool(router.clone()),
|
||||
grep::routed_grep_tool(router.clone()),
|
||||
bash::routed_bash_tool(router, bash_output_dir),
|
||||
bash::routed_bash_tool(router, bash_output),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn routed_builtin_tools_for_current_capabilities(
|
||||
router: std::sync::Arc<workdir::WorkdirSessionRouter>,
|
||||
tracker: Tracker,
|
||||
bash_output_dir: std::path::PathBuf,
|
||||
bash_output: bash_output::BashOutputView,
|
||||
) -> Vec<agen::tool::ToolDefinition> {
|
||||
use workdir::WorkdirSessionCapability;
|
||||
|
||||
@@ -108,7 +109,7 @@ pub fn routed_builtin_tools_for_current_capabilities(
|
||||
tools.push(grep::routed_grep_tool(router.clone()));
|
||||
}
|
||||
if capabilities.supports(WorkdirSessionCapability::Command) {
|
||||
tools.push(bash::routed_bash_tool(router, bash_output_dir));
|
||||
tools.push(bash::routed_bash_tool(router, bash_output));
|
||||
}
|
||||
tools
|
||||
}
|
||||
@@ -121,7 +122,7 @@ pub fn read_only_builtin_tools(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
"read-only tool projection requires a read-only Workdir session"
|
||||
);
|
||||
core_builtin_tools(session, Tracker::new(), std::path::PathBuf::new())
|
||||
core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new())
|
||||
}
|
||||
|
||||
pub fn read_only_routed_builtin_tools(
|
||||
@@ -173,7 +174,7 @@ mod workdir_tool_tests {
|
||||
SharedScope::new(Scope::writable(dir.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
));
|
||||
let names = core_builtin_tools(session, Tracker::new(), dir.path().join("output"))
|
||||
let names = core_builtin_tools(session, Tracker::new(), bash_output::BashOutputView::new())
|
||||
.into_iter()
|
||||
.map(|definition| definition().0.name)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use agen::tool::{Tool, ToolDefinition};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use manifest::Scope;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{Tracker, core_builtin_tools};
|
||||
@@ -28,31 +28,23 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, TempDir, Registry) {
|
||||
fn setup() -> (TempDir, Registry) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let base = Scope::writable(dir.path()).unwrap();
|
||||
let mut config = ScopeConfig {
|
||||
allow: base.allow_rules(),
|
||||
deny: base.deny_rules(),
|
||||
};
|
||||
config.allow.push(ScopeRule {
|
||||
target: spill.path().to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
});
|
||||
let scope = Scope::from_config(&config).unwrap();
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let fs: WorkdirSessionHandle =
|
||||
std::sync::Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
||||
(dir, spill, reg)
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
tracker,
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
));
|
||||
(dir, reg)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unicode_path_and_content() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("日本語ファイル.txt");
|
||||
let content = "こんにちは 🦀 世界\nabc\n";
|
||||
|
||||
@@ -87,7 +79,7 @@ async fn unicode_path_and_content() {
|
||||
async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_target = outside.path().join("secret.txt");
|
||||
std::fs::write(&outside_target, "secret").unwrap();
|
||||
@@ -151,7 +143,7 @@ async fn symlink_to_outside_scope_is_rejected_for_write() {
|
||||
async fn broken_symlink_reports_target_and_repair_hint() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let link = dir.path().join("external-project");
|
||||
let target = dir.path().join("missing-target");
|
||||
symlink(&target, &link).unwrap();
|
||||
@@ -173,7 +165,7 @@ async fn broken_symlink_reports_target_and_repair_hint() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_file_read_and_edit() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("empty.txt");
|
||||
std::fs::write(&file, "").unwrap();
|
||||
|
||||
@@ -207,7 +199,7 @@ async fn empty_file_read_and_edit() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn very_long_single_line() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("long.txt");
|
||||
let big: String = "x".repeat(1024 * 1024); // 1 MiB, no newlines
|
||||
std::fs::write(&file, &big).unwrap();
|
||||
@@ -226,7 +218,7 @@ async fn very_long_single_line() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn absolute_path_requires_matching_read_scope() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
let outside_file = outside.path().join("outside.txt");
|
||||
std::fs::write(&outside_file, "secret").unwrap();
|
||||
@@ -247,7 +239,7 @@ async fn absolute_path_requires_matching_read_scope() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn directory_target_is_rejected_for_read() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
let read = reg.get("Read");
|
||||
let err = read
|
||||
.execute(&json!({ "file_path": "." }).to_string(), Default::default())
|
||||
@@ -258,7 +250,7 @@ async fn directory_target_is_rejected_for_read() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn deeply_nested_new_file_is_created() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let deep = dir.path().join("a/b/c/d/e/deep.txt");
|
||||
let write = reg.get("Write");
|
||||
write
|
||||
@@ -277,7 +269,7 @@ async fn deeply_nested_new_file_is_created() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_preserves_unicode() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("u.txt");
|
||||
std::fs::write(&file, "🦀 rust 🦀\n").unwrap();
|
||||
|
||||
@@ -306,7 +298,7 @@ async fn replace_preserves_unicode() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn grep_handles_unicode_pattern() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("u.txt");
|
||||
std::fs::write(&file, "English\n日本語\nрусский\n").unwrap();
|
||||
|
||||
|
||||
+347
-151
@@ -11,7 +11,7 @@ use agen::tool::{
|
||||
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
|
||||
ToolExecutionTerminal, ToolMeta,
|
||||
};
|
||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||
use manifest::Scope;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tools::{
|
||||
@@ -23,21 +23,6 @@ use workdir::{
|
||||
WorkdirToolScopeRule,
|
||||
};
|
||||
|
||||
fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
||||
let base = Scope::writable(workspace).unwrap();
|
||||
let mut config = ScopeConfig {
|
||||
allow: base.allow_rules(),
|
||||
deny: base.deny_rules(),
|
||||
};
|
||||
config.allow.push(ScopeRule {
|
||||
target: spill.to_path_buf(),
|
||||
permission: Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
});
|
||||
Scope::from_config(&config).unwrap()
|
||||
}
|
||||
|
||||
struct Registry {
|
||||
entries: Vec<(ToolMeta, Arc<dyn Tool>)>,
|
||||
}
|
||||
@@ -61,39 +46,38 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, TempDir, Registry) {
|
||||
fn setup() -> (TempDir, Registry) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let fs: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(fs, tracker, spill.path().to_path_buf()));
|
||||
(dir, spill, reg)
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
tracker,
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
));
|
||||
(dir, reg)
|
||||
}
|
||||
|
||||
fn setup_routed() -> (
|
||||
TempDir,
|
||||
TempDir,
|
||||
TempDir,
|
||||
Arc<WorkdirSessionRouter>,
|
||||
Registry,
|
||||
) {
|
||||
fn setup_routed() -> (TempDir, TempDir, Arc<WorkdirSessionRouter>, Registry) {
|
||||
let left = TempDir::new().unwrap();
|
||||
let right = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
for (alias, dir) in [("left", &left), ("right", &right)] {
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let session: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new(alias).unwrap(), session)
|
||||
.unwrap();
|
||||
}
|
||||
let definitions =
|
||||
routed_builtin_tools(router.clone(), Tracker::new(), spill.path().to_path_buf());
|
||||
(left, right, spill, router, Registry::new(definitions))
|
||||
let definitions = routed_builtin_tools(
|
||||
router.clone(),
|
||||
Tracker::new(),
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
);
|
||||
(left, right, router, Registry::new(definitions))
|
||||
}
|
||||
|
||||
fn valid_gif_with_len(target_len: usize) -> Vec<u8> {
|
||||
@@ -137,7 +121,7 @@ async fn call_err(tool: &Arc<dyn Tool>, input: serde_json::Value) -> agen::tool:
|
||||
|
||||
#[test]
|
||||
fn core_builtin_tools_registers_full_set() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
let mut names = reg.names();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["Bash", "Edit", "Glob", "Grep", "Read", "Write"]);
|
||||
@@ -145,7 +129,7 @@ fn core_builtin_tools_registers_full_set() {
|
||||
|
||||
#[test]
|
||||
fn meta_has_description_and_schema() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
for (meta, _) in ®.entries {
|
||||
assert!(
|
||||
!meta.description.is_empty(),
|
||||
@@ -163,7 +147,7 @@ fn meta_has_description_and_schema() {
|
||||
|
||||
#[test]
|
||||
fn routed_tool_schemas_share_optional_target_workdir() {
|
||||
let (_left, _right, _spill, router, mut reg) = setup_routed();
|
||||
let (_left, _right, router, mut reg) = setup_routed();
|
||||
reg.entries.push(routed_view_image_tool(router)());
|
||||
for (meta, _) in ®.entries {
|
||||
let target = &meta.input_schema["properties"]["target_workdir"];
|
||||
@@ -174,7 +158,7 @@ fn routed_tool_schemas_share_optional_target_workdir() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn routed_tools_require_alias_only_when_attachment_set_is_ambiguous() {
|
||||
let (_left, _right, _spill, router, reg) = setup_routed();
|
||||
let (_left, _right, router, reg) = setup_routed();
|
||||
let error = call_err(®.get("Read"), json!({ "file_path": "same.txt" })).await;
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("target_workdir_required"), "{message}");
|
||||
@@ -199,7 +183,7 @@ async fn empty_routed_surface_returns_no_attachment_error() {
|
||||
let reg = Registry::new(routed_builtin_tools(
|
||||
router,
|
||||
Tracker::new(),
|
||||
Path::new("unused").to_path_buf(),
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
));
|
||||
let message = call_err(®.get("Read"), json!({ "file_path": "file.txt" }))
|
||||
.await
|
||||
@@ -209,7 +193,7 @@ async fn empty_routed_surface_returns_no_attachment_error() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
||||
let (left, right, _spill, router, mut reg) = setup_routed();
|
||||
let (left, right, router, mut reg) = setup_routed();
|
||||
std::fs::write(left.path().join("same.txt"), "left value\n").unwrap();
|
||||
std::fs::write(right.path().join("same.txt"), "right value\n").unwrap();
|
||||
std::fs::write(left.path().join("only-left.log"), "LEFT-NEEDLE\n").unwrap();
|
||||
@@ -282,7 +266,7 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(bash.content.unwrap().trim()).unwrap(),
|
||||
std::fs::canonicalize(serde_json::from_str::<serde_json::Value>(&bash.content.unwrap()).unwrap()["stdout_preview"].as_str().unwrap().trim()).unwrap(),
|
||||
std::fs::canonicalize(right.path()).unwrap()
|
||||
);
|
||||
|
||||
@@ -304,13 +288,12 @@ async fn target_alias_routes_each_tool_and_isolates_read_before_edit() {
|
||||
async fn selected_attachment_capability_is_checked_without_fallback() {
|
||||
let left = TempDir::new().unwrap();
|
||||
let right = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("writable").unwrap(),
|
||||
Arc::new(LocalWorkdirSession::new(
|
||||
scope_with_spill(left.path(), spill.path()),
|
||||
Scope::writable(left.path()).unwrap(),
|
||||
left.path().to_path_buf(),
|
||||
)),
|
||||
)
|
||||
@@ -322,7 +305,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
|
||||
workdir::Workdir::new("readonly"),
|
||||
right.path().to_path_buf(),
|
||||
right.path().to_path_buf(),
|
||||
manifest::SharedScope::new(scope_with_spill(right.path(), spill.path())),
|
||||
manifest::SharedScope::new(Scope::writable(right.path()).unwrap()),
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
)),
|
||||
)
|
||||
@@ -330,7 +313,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
|
||||
let reg = Registry::new(routed_builtin_tools(
|
||||
router.clone(),
|
||||
Tracker::new(),
|
||||
spill.path().to_path_buf(),
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
));
|
||||
|
||||
let message = call_err(
|
||||
@@ -352,7 +335,7 @@ async fn selected_attachment_capability_is_checked_without_fallback() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn routed_tools_observe_detach_after_registration() {
|
||||
let (_left, _right, _spill, router, reg) = setup_routed();
|
||||
let (_left, _right, router, reg) = setup_routed();
|
||||
let alias = WorkdirAttachmentAlias::new("left").unwrap();
|
||||
router.detach(&alias).await.unwrap();
|
||||
|
||||
@@ -372,7 +355,6 @@ async fn routed_tools_observe_detach_after_registration() {
|
||||
async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||
let first = TempDir::new().unwrap();
|
||||
let second = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
std::fs::write(first.path().join("same.txt"), "same bytes\n").unwrap();
|
||||
std::fs::write(second.path().join("same.txt"), "same bytes\n").unwrap();
|
||||
|
||||
@@ -382,7 +364,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||
.attach(
|
||||
alias.clone(),
|
||||
Arc::new(LocalWorkdirSession::new(
|
||||
scope_with_spill(first.path(), spill.path()),
|
||||
Scope::writable(first.path()).unwrap(),
|
||||
first.path().to_path_buf(),
|
||||
)),
|
||||
)
|
||||
@@ -390,7 +372,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||
let reg = Registry::new(routed_builtin_tools(
|
||||
router.clone(),
|
||||
Tracker::new(),
|
||||
spill.path().to_path_buf(),
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
));
|
||||
call(
|
||||
®.get("Read"),
|
||||
@@ -403,7 +385,7 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||
.attach(
|
||||
alias.clone(),
|
||||
Arc::new(LocalWorkdirSession::new(
|
||||
scope_with_spill(second.path(), spill.path()),
|
||||
Scope::writable(second.path()).unwrap(),
|
||||
second.path().to_path_buf(),
|
||||
)),
|
||||
)
|
||||
@@ -432,6 +414,8 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() {
|
||||
struct ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession,
|
||||
truncate_first_binary_response: bool,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize,
|
||||
lose_terminal_only: bool,
|
||||
mutate_after_first_binary_response: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
@@ -550,7 +534,23 @@ impl workdir::WorkdirSession for ExternalValidatingSession {
|
||||
&self,
|
||||
request: workdir::CommandOutputRequest,
|
||||
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
self.inner.command_output(request).await
|
||||
let output = self.inner.command_output(request).await?;
|
||||
if (!self.lose_terminal_only || output.terminal.is_some())
|
||||
&& self
|
||||
.command_response_losses
|
||||
.fetch_update(
|
||||
std::sync::atomic::Ordering::AcqRel,
|
||||
std::sync::atomic::Ordering::Acquire,
|
||||
|remaining| remaining.checked_sub(1),
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
// The production provider has committed a page, but its response is lost.
|
||||
return Err(workdir::WorkdirError::Transport(
|
||||
"injected lost response".into(),
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn cancel_command(
|
||||
@@ -568,11 +568,12 @@ impl workdir::WorkdirSession for ExternalValidatingSession {
|
||||
#[tokio::test]
|
||||
async fn external_view_image_uses_a_protocol_bounded_request() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
||||
truncate_first_binary_response: false,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||
lose_terminal_only: false,
|
||||
mutate_after_first_binary_response: None,
|
||||
});
|
||||
let png = include_bytes!("fixtures/view-image/valid.png");
|
||||
@@ -590,13 +591,14 @@ async fn external_view_image_uses_a_protocol_bounded_request() {
|
||||
#[tokio::test]
|
||||
async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let image_path = dir.path().join("image.png");
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap();
|
||||
let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()),
|
||||
truncate_first_binary_response: true,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||
lose_terminal_only: false,
|
||||
mutate_after_first_binary_response: None,
|
||||
});
|
||||
let (_meta, tool) = view_image_tool(truncated)();
|
||||
@@ -613,6 +615,8 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
||||
let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()),
|
||||
truncate_first_binary_response: false,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(0),
|
||||
lose_terminal_only: false,
|
||||
mutate_after_first_binary_response: Some(image_path),
|
||||
});
|
||||
let (_meta, tool) = view_image_tool(changing)();
|
||||
@@ -626,8 +630,7 @@ async fn external_view_image_rejects_truncated_and_cross_version_chunks() {
|
||||
async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() {
|
||||
const LIMIT: usize = 10 * 1024 * 1024;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let session: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let (_meta, tool) = view_image_tool(session)();
|
||||
@@ -650,8 +653,7 @@ async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments()
|
||||
#[tokio::test]
|
||||
async fn view_image_attaches_valid_supported_image_fixtures() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let session: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let (_meta, tool) = view_image_tool(session)();
|
||||
@@ -726,8 +728,7 @@ async fn delegated_view_image_preserves_caller_relative_path_identity() {
|
||||
#[tokio::test]
|
||||
async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let session: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let png = include_bytes!("fixtures/view-image/valid.png");
|
||||
@@ -752,7 +753,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_then_edit_then_read_roundtrip() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "hello world\n").unwrap();
|
||||
let p = "a.txt";
|
||||
@@ -784,7 +785,7 @@ async fn read_then_edit_then_read_roundtrip() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_then_grep_finds_content() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let write = reg.get("Write");
|
||||
let grep = reg.get("Grep");
|
||||
|
||||
@@ -813,7 +814,7 @@ async fn write_then_grep_finds_content() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn glob_finds_written_files() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
let write = reg.get("Write");
|
||||
let glob = reg.get("Glob");
|
||||
|
||||
@@ -837,7 +838,7 @@ async fn glob_finds_written_files() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn absolute_path_is_rejected() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let write = reg.get("Write");
|
||||
|
||||
@@ -859,7 +860,7 @@ async fn absolute_path_is_rejected() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_to_existing_without_read_fails() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("exists.txt");
|
||||
std::fs::write(&file, "preexisting").unwrap();
|
||||
|
||||
@@ -880,7 +881,7 @@ async fn write_to_existing_without_read_fails() {
|
||||
async fn shared_workdir_across_tools() {
|
||||
// The key invariant: all builtin tools share the same WorkdirSession instance,
|
||||
// so read-history set by Read is visible to Edit and Write.
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("shared.txt");
|
||||
std::fs::write(&file, "one\n").unwrap();
|
||||
|
||||
@@ -907,7 +908,7 @@ async fn shared_workdir_across_tools() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_requires_read_across_tools() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let file = dir.path().join("a.txt");
|
||||
std::fs::write(&file, "foo\n").unwrap();
|
||||
|
||||
@@ -928,7 +929,7 @@ async fn edit_requires_read_across_tools() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn deterministic_tool_order_is_registration_order() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
// Registration order from core_builtin_tools(): Read, Write, Edit, Glob, Grep, Bash
|
||||
let names: Vec<&str> = reg.entries.iter().map(|(m, _)| m.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
@@ -940,7 +941,7 @@ async fn deterministic_tool_order_is_registration_order() {
|
||||
// Regression: tool name capitalization matches Claude Code reference
|
||||
#[test]
|
||||
fn tool_names_match_reference_spec() {
|
||||
let (_dir, _spill, reg) = setup();
|
||||
let (_dir, reg) = setup();
|
||||
for expected in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
||||
assert!(
|
||||
reg.entries.iter().any(|(m, _)| m.name == expected),
|
||||
@@ -953,15 +954,14 @@ fn tool_names_match_reference_spec() {
|
||||
async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
// Build a fresh registry that shares a tracker we can query afterwards.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let spill = TempDir::new().unwrap();
|
||||
let scope = scope_with_spill(dir.path(), spill.path());
|
||||
let scope = Scope::writable(dir.path()).unwrap();
|
||||
let fs: WorkdirSessionHandle =
|
||||
Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf()));
|
||||
let tracker = Tracker::new();
|
||||
let reg = Registry::new(core_builtin_tools(
|
||||
fs,
|
||||
tracker.clone(),
|
||||
spill.path().to_path_buf(),
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
));
|
||||
|
||||
let a = dir.path().join("a.txt");
|
||||
@@ -1003,118 +1003,314 @@ async fn tracker_recent_files_tracks_read_write_edit() {
|
||||
async fn bash_inherits_workdir_cwd() {
|
||||
// The Bash tool starts at the WorkdirSession's pwd. Without any `cd`, its
|
||||
// `pwd` should canonicalize to the workspace root we set up.
|
||||
let (dir, _spill, reg) = setup();
|
||||
let (dir, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
let out = call(&bash, json!({ "command": "pwd", "cwd": "." })).await;
|
||||
let body = out.content.unwrap();
|
||||
let actual = std::fs::canonicalize(body.trim()).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let actual = std::fs::canonicalize(parsed["stdout_preview"].as_str().unwrap().trim()).unwrap();
|
||||
let expected = std::fs::canonicalize(dir.path()).unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
fn output_fixture() -> (
|
||||
TempDir,
|
||||
WorkdirSessionHandle,
|
||||
tools::bash_output::BashOutputView,
|
||||
Arc<dyn Tool>,
|
||||
) {
|
||||
let root = TempDir::new().unwrap();
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||
Scope::writable(root.path()).unwrap(),
|
||||
root.path().into(),
|
||||
));
|
||||
let view = tools::bash_output::BashOutputView::new();
|
||||
let (_, bash) = tools::bash_tool(session.clone(), view.clone())();
|
||||
(root, session, view, bash)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_provider_output_exposes_readable_retained_path() {
|
||||
let (_dir, spill, reg) = setup();
|
||||
let bash = reg.get("Bash");
|
||||
async fn bash_output_is_retained_without_a_filesystem_grant_or_telemetry_subscriber() {
|
||||
let (root, _session, view, bash) = output_fixture();
|
||||
let out = call(
|
||||
&bash,
|
||||
json!({ "command": "printf 'x%.0s' {1..20480}", "cwd": "." }),
|
||||
json!({ "command": "printf 'x%.0s' {1..20480}; printf '\\377€' >&2; exit 7", "cwd": "." }),
|
||||
)
|
||||
.await;
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains("bounded WorkdirSession command output"));
|
||||
assert!(body.contains("full output saved to"));
|
||||
assert!(body.contains(spill.path().to_str().unwrap()));
|
||||
let artifact = std::fs::read_dir(spill.path())
|
||||
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||
assert_eq!(body["output"]["terminal"]["exit_code"], 7);
|
||||
assert_eq!(body["preview_omitted"], true);
|
||||
assert_eq!(body["output"]["storage_truncated"], false);
|
||||
let id = body["output"]["id"].as_str().unwrap();
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stdout, 0, 65536)
|
||||
.unwrap(),
|
||||
vec![b'x'; 20480]
|
||||
);
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stderr, 0, 65536)
|
||||
.unwrap(),
|
||||
[vec![255], "€".as_bytes().to_vec()].concat()
|
||||
);
|
||||
assert!(
|
||||
body["stdout_entry"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.next()
|
||||
.expect("retained output")
|
||||
.unwrap()
|
||||
.path();
|
||||
assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480);
|
||||
.starts_with("/bash-output/")
|
||||
);
|
||||
assert!(!body.to_string().contains(root.path().to_str().unwrap()));
|
||||
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
async fn await_command_output(
|
||||
events: &mut tokio::sync::broadcast::Receiver<workdir::CommandEvent>,
|
||||
) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if matches!(
|
||||
events.recv().await.unwrap(),
|
||||
workdir::CommandEvent::Output { .. }
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("command output readiness");
|
||||
}
|
||||
async fn await_command_terminal(
|
||||
events: &mut tokio::sync::broadcast::Receiver<workdir::CommandEvent>,
|
||||
) {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let workdir::CommandEvent::Terminal { status, .. } = events.recv().await.unwrap() {
|
||||
assert_eq!(status, workdir::CommandStatus::Cancelled);
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("command terminal fence");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let marker = dir.path().join("must-not-run-after-cancel");
|
||||
let command = format!(
|
||||
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
|
||||
marker.display()
|
||||
);
|
||||
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
|
||||
let (_root, session, _view, bash) = output_fixture();
|
||||
let mut events = session.subscribe_command_events().unwrap();
|
||||
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
|
||||
let bash = reg.get("Bash");
|
||||
let executing = bash.clone();
|
||||
let execution_context = context.clone();
|
||||
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await });
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
bash.cancel_execution(&context)
|
||||
let execution = tokio::spawn(async move {
|
||||
executing.execute(r#"{"command":"printf 'before'; printf 'err-before' >&2; while :; do :; done","cwd":"."}"#, execution_context).await
|
||||
});
|
||||
await_command_output(&mut events).await;
|
||||
bash.cancel_execution(&context).await.unwrap();
|
||||
let ToolError::Cancelled(output) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), execution)
|
||||
.await
|
||||
.expect("signal exact execution cancellation");
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution)
|
||||
.await
|
||||
.expect("cancelled Bash should terminate inside the Engine grace budget")
|
||||
.expect("Bash task join");
|
||||
|
||||
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
|
||||
panic!("expected typed cancellation result");
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap_err()
|
||||
else {
|
||||
panic!("expected cancellation");
|
||||
};
|
||||
let content = output.content.expect("bounded progress output");
|
||||
assert!(
|
||||
content.contains("before"),
|
||||
"missing pre-cancel stdout: {content}"
|
||||
);
|
||||
assert!(
|
||||
content.contains("err-before"),
|
||||
"missing pre-cancel stderr: {content}"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("after"),
|
||||
"post-cancel output leaked: {content}"
|
||||
);
|
||||
assert!(content.len() <= 16 * 1024, "output must remain bounded");
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"the cancelled command continued executing after terminal confirmation"
|
||||
);
|
||||
let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(body["stdout_preview"], "before");
|
||||
assert_eq!(body["stderr_preview"], "err-before");
|
||||
assert_eq!(body["output"]["terminal"]["status"], "cancelled");
|
||||
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
|
||||
let (dir, _spill, reg) = setup();
|
||||
let marker = dir.path().join("must-not-survive-force-close");
|
||||
let command = format!("sleep 1; touch {}", marker.display());
|
||||
let input = serde_json::to_string(&json!({ "command": command, "cwd": "." })).unwrap();
|
||||
let bash = reg.get("Bash");
|
||||
let (_root, session, _view, bash) = output_fixture();
|
||||
let mut events = session.subscribe_command_events().unwrap();
|
||||
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
|
||||
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
let (handle, terminal) = ToolExecutionHandle::start(
|
||||
bash.clone(),
|
||||
r#"{"command":"printf ready; while :; do :; done","cwd":"."}"#.into(),
|
||||
context,
|
||||
);
|
||||
await_command_output(&mut events).await;
|
||||
handle.force_close();
|
||||
assert!(matches!(
|
||||
terminal.await,
|
||||
ToolExecutionTerminal::OutcomeUnknown
|
||||
));
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
|
||||
assert!(
|
||||
!marker.exists(),
|
||||
"CommandGuard cleanup allowed a force-closed command to continue"
|
||||
);
|
||||
|
||||
let output = bash
|
||||
.execute(
|
||||
r#"{"command":"printf 'reused'","cwd":"."}"#,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.expect("workdir session remains reusable after cleanup");
|
||||
assert_eq!(output.content.as_deref(), Some("reused"));
|
||||
await_command_terminal(&mut events).await;
|
||||
let output = call(&bash, json!({"command":"printf reused", "cwd":"."})).await;
|
||||
let body: serde_json::Value = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(body["stdout_preview"], "reused");
|
||||
}
|
||||
|
||||
// Sanity: unused Path import guard
|
||||
const _: fn() -> &'static Path = || Path::new("/");
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_and_sibling_broker_commands_keep_output_ownership_after_revoke() {
|
||||
let (root, source, parent_view, parent_bash) = output_fixture();
|
||||
let broker = WorkdirToolBroker::new(source);
|
||||
let mut leases = Vec::new();
|
||||
let mut views = Vec::new();
|
||||
let mut commands = Vec::new();
|
||||
for name in ["left", "right"] {
|
||||
std::fs::create_dir(root.path().join(name)).unwrap();
|
||||
let lease = Arc::new(
|
||||
broker
|
||||
.scope(WorkdirToolScope {
|
||||
rules: vec![WorkdirToolScopeRule {
|
||||
target: WorkdirPath::new(name).unwrap(),
|
||||
permission: WorkdirToolScopePermission::Write,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}],
|
||||
cwd: WorkdirPath::new(name).unwrap(),
|
||||
command: true,
|
||||
})
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let authority = lease.clone();
|
||||
let view = parent_view.child(Arc::new(move || authority.is_active()));
|
||||
let (_, bash) = tools::bash_tool(lease.tool_session(), view.clone())();
|
||||
leases.push(lease);
|
||||
views.push(view);
|
||||
commands.push(bash);
|
||||
}
|
||||
let left_input =
|
||||
json!({"command":"printf 'left-%.0s' {1..10000}; printf left-err >&2", "cwd":"."});
|
||||
let right_input =
|
||||
json!({"command":"printf 'right-%.0s' {1..10000}; printf right-err >&2", "cwd":"."});
|
||||
let parent_input = json!({"command":"printf parent; printf parent-err >&2", "cwd":"."});
|
||||
let (left, right, parent) = tokio::join!(
|
||||
call(&commands[0], left_input),
|
||||
call(&commands[1], right_input),
|
||||
call(&parent_bash, parent_input)
|
||||
);
|
||||
let outputs = [left, right, parent]
|
||||
.map(|out| serde_json::from_str::<serde_json::Value>(&out.content.unwrap()).unwrap());
|
||||
for (index, label) in ["left", "right", "parent"].iter().enumerate() {
|
||||
let id = outputs[index]["output"]["id"].as_str().unwrap();
|
||||
let bytes = parent_view
|
||||
.read(id, workdir::CommandStream::Stderr, 0, 65536)
|
||||
.unwrap();
|
||||
assert_eq!(bytes, format!("{label}-err").as_bytes());
|
||||
for (child_index, view) in views.iter().enumerate() {
|
||||
assert_eq!(view.metadata(id).is_some(), child_index == index);
|
||||
}
|
||||
}
|
||||
let left_id = outputs[0]["output"]["id"].as_str().unwrap();
|
||||
leases[0].close().await.unwrap();
|
||||
assert!(views[0].metadata(left_id).is_none());
|
||||
assert!(parent_view.metadata(left_id).is_some());
|
||||
assert!(
|
||||
views[1]
|
||||
.metadata(outputs[1]["output"]["id"].as_str().unwrap())
|
||||
.is_some()
|
||||
);
|
||||
leases[1].close().await.unwrap();
|
||||
broker.close().await.unwrap();
|
||||
parent_view.revoke();
|
||||
assert!(parent_view.metadata(left_id).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn utf8_quota_boundary_preserves_raw_prefix_and_reports_saved_vs_seen() {
|
||||
let (_root, _session, view, bash) = output_fixture();
|
||||
let out = call(
|
||||
&bash,
|
||||
json!({"command":"printf '%4194303s' ''; printf '€'", "cwd":"."}),
|
||||
)
|
||||
.await;
|
||||
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||
let id = body["output"]["id"].as_str().unwrap();
|
||||
let saved = body["output"]["stdout_saved"].as_u64().unwrap() as usize;
|
||||
assert_eq!(saved, 4 * 1024 * 1024);
|
||||
assert_eq!(body["output"]["stdout_seen"], 4 * 1024 * 1024 + 2);
|
||||
assert_eq!(body["output"]["stderr_seen"], 0);
|
||||
assert_eq!(body["output"]["storage_truncated"], true);
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stdout, saved - 1, 10)
|
||||
.unwrap(),
|
||||
vec![0xe2]
|
||||
);
|
||||
assert!(
|
||||
view.line_window(id, workdir::CommandStream::Stdout, 0, 1)
|
||||
.unwrap()
|
||||
.is_err()
|
||||
);
|
||||
assert!(out.summary.starts_with("$ "));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_terminal_fences_retained_stdout_and_stderr() {
|
||||
let (_root, _session, view, bash) = output_fixture();
|
||||
let error = tokio::time::timeout(std::time::Duration::from_secs(5), bash.execute(r#"{"command":"printf before; printf err-before >&2; while :; do :; done","cwd":".","timeout":1}"#, Default::default())).await.unwrap().unwrap_err();
|
||||
let ToolError::Interrupted(out) = error else {
|
||||
panic!("expected timeout output");
|
||||
};
|
||||
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||
let id = body["output"]["id"].as_str().unwrap();
|
||||
assert_eq!(body["output"]["terminal"]["status"], "timed_out");
|
||||
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stdout, 0, 65536)
|
||||
.unwrap(),
|
||||
b"before"
|
||||
);
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stderr, 0, 65536)
|
||||
.unwrap(),
|
||||
b"err-before"
|
||||
);
|
||||
}
|
||||
|
||||
// Integration: real process pipes and provider replay into the production Bash collector.
|
||||
// Only response delivery is fault-injected; Runtime's HTTP suite covers actual wire routing.
|
||||
#[tokio::test]
|
||||
async fn lost_page_and_terminal_responses_replay_without_restarting_or_double_appending() {
|
||||
for lose_terminal_only in [false, true] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let session = Arc::new(ExternalValidatingSession {
|
||||
inner: LocalWorkdirSession::new(
|
||||
Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().into(),
|
||||
),
|
||||
truncate_first_binary_response: false,
|
||||
mutate_after_first_binary_response: None,
|
||||
command_response_losses: std::sync::atomic::AtomicUsize::new(1),
|
||||
lose_terminal_only,
|
||||
});
|
||||
let view = tools::bash_output::BashOutputView::new();
|
||||
let definitions = core_builtin_tools(session.clone(), Tracker::new(), view.clone());
|
||||
let bash = Registry::new(definitions).get("Bash");
|
||||
let out = call(&bash, json!({"cwd":".","command":"printf once >> effect.txt; printf 'x%.0s' {1..200000}; printf err >&2; exit 3"})).await;
|
||||
let body: serde_json::Value = serde_json::from_str(&out.content.unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("effect.txt")).unwrap(),
|
||||
b"once"
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.command_response_losses
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
0
|
||||
);
|
||||
assert_eq!(body["output"]["stdout_saved"], 200000);
|
||||
assert_eq!(body["output"]["stdout_delivered"], 200000);
|
||||
assert_eq!(body["output"]["stderr_saved"], 3);
|
||||
assert_eq!(body["output"]["terminal"]["exit_code"], 3);
|
||||
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||
assert_eq!(body["output"]["storage_truncated"], false);
|
||||
let id = body["output"]["id"].as_str().unwrap();
|
||||
for offset in (0..200000).step_by(65536) {
|
||||
assert_eq!(
|
||||
view.read(id, workdir::CommandStream::Stdout, offset, 65536)
|
||||
.unwrap(),
|
||||
vec![b'x'; (200000 - offset).min(65536)]
|
||||
);
|
||||
}
|
||||
use workdir::WorkdirSession as _;
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
tempfile.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["process", "rt", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["io-util", "process", "rt", "sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -48,6 +48,10 @@ pub struct CheckoutRequest {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum CheckoutOperation {
|
||||
ReadBytes {
|
||||
offset: u64,
|
||||
max_bytes: usize,
|
||||
},
|
||||
Read {
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
@@ -71,7 +75,7 @@ pub enum CheckoutOperation {
|
||||
impl CheckoutOperation {
|
||||
pub fn capability(&self) -> WorkdirSessionCapability {
|
||||
match self {
|
||||
Self::Read { .. } => WorkdirSessionCapability::Read,
|
||||
Self::Read { .. } | Self::ReadBytes { .. } => WorkdirSessionCapability::Read,
|
||||
Self::Edit { .. } => WorkdirSessionCapability::Edit,
|
||||
Self::Write { .. } | Self::Create { .. } => WorkdirSessionCapability::Write,
|
||||
}
|
||||
@@ -86,6 +90,7 @@ impl CheckoutOperation {
|
||||
)]
|
||||
pub enum CheckoutOutput {
|
||||
Read(ReadResult),
|
||||
ReadBytes(crate::ReadBytesResult),
|
||||
Write(WriteResult),
|
||||
Edit(EditResult),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Bounded collection for tests whose subject is command routing/lifecycle,
|
||||
//! rather than the byte-page protocol itself.
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CollectedCommand {
|
||||
pub content: String,
|
||||
pub status: CommandStatus,
|
||||
pub exit_code: Option<i32>,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub(crate) trait CollectCommand: WorkdirSession {
|
||||
async fn collect_command_output(
|
||||
&self,
|
||||
mut request: CommandOutputRequest,
|
||||
) -> Result<CollectedCommand, WorkdirError> {
|
||||
let mut bytes = Vec::new();
|
||||
request.wait = true;
|
||||
loop {
|
||||
let page = self.command_output(request.clone()).await?;
|
||||
for chunk in page.chunks {
|
||||
bytes.extend(chunk.bytes);
|
||||
assert!(
|
||||
bytes.len() <= 16 * 1024 * 1024,
|
||||
"fixture output exceeded bounded test collection"
|
||||
);
|
||||
}
|
||||
request.cursor = page.next_cursor;
|
||||
if let Some(terminal) = page.terminal {
|
||||
return Ok(CollectedCommand {
|
||||
content: String::from_utf8_lossy(&bytes).into_owned(),
|
||||
status: terminal.status,
|
||||
exit_code: terminal.exit_code,
|
||||
timed_out: terminal.status == CommandStatus::TimedOut,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: WorkdirSession + ?Sized> CollectCommand for T {}
|
||||
+145
-35
@@ -9,7 +9,8 @@
|
||||
//! COMMAND remains separate explicit authority. General session capability intersections may form
|
||||
//! subsets independently of this grant hierarchy; the codec never implicitly expands nonhierarchical
|
||||
//! grants. Commands run with the shared host's OS permissions, not a sandbox confining processes
|
||||
//! to a directory. Output is bounded and inline; provider-local spill paths are not exposed.
|
||||
//! to a directory. Output uses bounded byte pages with cursor acknowledgements;
|
||||
//! providers never receive caller-selected output paths.
|
||||
|
||||
use std::path::{Component, Path};
|
||||
|
||||
@@ -18,7 +19,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError};
|
||||
use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities};
|
||||
|
||||
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2;
|
||||
pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 3;
|
||||
/// Maximum serialized Backend-to-provider command frame size.
|
||||
pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024;
|
||||
/// Maximum serialized provider-to-Backend result frame size. Read bytes are
|
||||
@@ -33,9 +34,9 @@ const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
||||
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = crate::MAX_COMMAND_READ_BYTES;
|
||||
pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000;
|
||||
const MAX_EXTERNAL_COMMAND_CURSOR: u64 = crate::MAX_COMMAND_TRANSPORT_BYTES;
|
||||
|
||||
fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
|
||||
!handle.0.is_empty()
|
||||
@@ -185,6 +186,11 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
&& !request.validator.is_empty()
|
||||
&& request.validator.len() <= 256
|
||||
&& match &request.operation {
|
||||
crate::CheckoutOperation::ReadBytes { offset, max_bytes } => {
|
||||
*offset <= 9_007_199_254_740_991
|
||||
&& *max_bytes > 0
|
||||
&& *max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes
|
||||
}
|
||||
crate::CheckoutOperation::Read {
|
||||
limit, max_bytes, ..
|
||||
} => {
|
||||
@@ -267,11 +273,10 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<()
|
||||
}
|
||||
WorkdirSessionOperation::CommandStart(request) => {
|
||||
!request.command.is_empty()
|
||||
&& request.validate().is_ok()
|
||||
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs)
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit)
|
||||
&& is_root_relative(&request.cwd)
|
||||
&& request.spill_dir.is_none()
|
||||
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
|
||||
!tool_call_id.is_empty()
|
||||
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
|
||||
@@ -375,6 +380,13 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
||||
WorkdirSessionOperationResult::CheckoutExecute(result) => {
|
||||
valid_checkout_observation(&result.observation)
|
||||
&& match &result.output {
|
||||
crate::CheckoutOutput::ReadBytes(read) => {
|
||||
read.path == result.observation.path
|
||||
&& validate_external_result(&WorkdirSessionOperationResult::ReadBytes(
|
||||
read.clone(),
|
||||
))
|
||||
.is_ok()
|
||||
}
|
||||
crate::CheckoutOutput::Read(read) => {
|
||||
read.path == result.observation.path
|
||||
&& validate_external_result(&WorkdirSessionOperationResult::Read(
|
||||
@@ -450,11 +462,19 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<()
|
||||
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
|
||||
WorkdirSessionOperationResult::CommandStatus(_) => true,
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||
output.transport_bounds_valid()
|
||||
&& output.chunks.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||
&& output
|
||||
.next_cursor
|
||||
.is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR)
|
||||
&& output.output_path.is_none()
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.bytes.len())
|
||||
.sum::<usize>()
|
||||
<= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||
&& output.next_cursor <= MAX_EXTERNAL_COMMAND_CURSOR
|
||||
&& output
|
||||
.terminal
|
||||
.as_ref()
|
||||
.is_none_or(|terminal| terminal.status != crate::CommandStatus::Running)
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandCancel => true,
|
||||
};
|
||||
@@ -801,11 +821,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
||||
let future = r#"{
|
||||
"version": 3,
|
||||
for version in [
|
||||
EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION - 1,
|
||||
EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + 1,
|
||||
] {
|
||||
let unsupported = serde_json::json!({
|
||||
"version": version,
|
||||
"message": {"kind": "heartbeat", "generation": 1, "sequence": 1}
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<ExternalWorkdirServerFrame>(future).is_err());
|
||||
});
|
||||
assert!(serde_json::from_value::<ExternalWorkdirServerFrame>(unsupported).is_err());
|
||||
}
|
||||
|
||||
let invalid_limits = r#"{
|
||||
"max_source_bytes": 1024,
|
||||
@@ -825,32 +850,35 @@ mod tests {
|
||||
assert!(ExternalWorkdirOperation::try_from(absolute).is_err());
|
||||
|
||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
command: "cat /home/operator/private/session.log".to_string(),
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf unsafe".to_string(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: Some("/home/operator/private/output".into()),
|
||||
|
||||
cwd: serde_json::from_value(serde_json::json!("/absolute")).unwrap(),
|
||||
|
||||
tool_call_id: None,
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||
|
||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf bounded".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("tool-call-1".to_string()),
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_ok());
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
|
||||
CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
}
|
||||
))
|
||||
@@ -887,13 +915,17 @@ mod tests {
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
chunks: vec![],
|
||||
next_cursor: 0,
|
||||
terminal: Some(crate::CommandTerminal {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
stdout_bytes: 0,
|
||||
stderr_bytes: 0,
|
||||
stdout_delivered_bytes: 0,
|
||||
stderr_delivered_bytes: 0,
|
||||
truncation_reason: None,
|
||||
})
|
||||
}
|
||||
))
|
||||
.is_ok()
|
||||
@@ -901,13 +933,14 @@ mod tests {
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: true,
|
||||
output_path: Some("/provider/private/output".into()),
|
||||
chunks: vec![crate::CommandByteChunk {
|
||||
sequence: 0,
|
||||
stream: crate::CommandStream::Stdout,
|
||||
offset: 0,
|
||||
bytes: vec![0; MAX_EXTERNAL_COMMAND_OUTPUT_BYTES + 1]
|
||||
}],
|
||||
next_cursor: 0,
|
||||
terminal: None
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
@@ -958,7 +991,7 @@ mod tests {
|
||||
#[test]
|
||||
fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() {
|
||||
let unknown = r#"{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"message": {
|
||||
"kind": "heartbeat",
|
||||
"generation": 1,
|
||||
@@ -1012,4 +1045,81 @@ mod tests {
|
||||
assert!(ExternalWorkdirOperationId::new("x".repeat(129)).is_err());
|
||||
assert!(ExternalWorkdirOperationId::new("operation-1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_budget_and_page_metadata_obey_external_hard_bounds() {
|
||||
fn request(budget: u64) -> WorkdirSessionOperation {
|
||||
WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
command: "true".into(),
|
||||
timeout_secs: 1,
|
||||
cwd: WorkdirPath::root(),
|
||||
transport_byte_budget: budget,
|
||||
tool_call_id: None,
|
||||
})
|
||||
}
|
||||
for budget in [0, crate::MAX_COMMAND_TRANSPORT_BYTES] {
|
||||
let operation = ExternalWorkdirOperation::try_from(request(budget)).unwrap();
|
||||
let wire = serde_json::to_value(operation).unwrap();
|
||||
assert!(serde_json::from_value::<ExternalWorkdirOperation>(wire).is_ok());
|
||||
}
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(request(crate::MAX_COMMAND_TRANSPORT_BYTES + 1))
|
||||
.is_err()
|
||||
);
|
||||
fn page() -> crate::CommandOutput {
|
||||
crate::CommandOutput {
|
||||
chunks: vec![crate::CommandByteChunk {
|
||||
sequence: 0,
|
||||
stream: crate::CommandStream::Stdout,
|
||||
offset: 0,
|
||||
bytes: b"abc".to_vec(),
|
||||
}],
|
||||
next_cursor: 3,
|
||||
terminal: Some(crate::CommandTerminal {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
stdout_bytes: 100 * 1024 * 1024,
|
||||
stderr_bytes: 7,
|
||||
stdout_delivered_bytes: 3,
|
||||
stderr_delivered_bytes: 0,
|
||||
truncation_reason: Some(crate::CommandOutputTruncation::ByteBudget),
|
||||
}),
|
||||
}
|
||||
}
|
||||
let accepted = ExternalWorkdirOperationResult::try_from(
|
||||
WorkdirSessionOperationResult::CommandOutput(page()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
serde_json::from_value::<ExternalWorkdirOperationResult>(
|
||||
serde_json::to_value(accepted).unwrap()
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
for mutate in [
|
||||
|p: &mut crate::CommandOutput| p.next_cursor = crate::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||
|p: &mut crate::CommandOutput| p.chunks[0].offset = crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
|p: &mut crate::CommandOutput| p.chunks[0].sequence = u64::MAX,
|
||||
|p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_delivered_bytes = 4,
|
||||
|p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().stdout_bytes = 2,
|
||||
|p: &mut crate::CommandOutput| p.terminal.as_mut().unwrap().truncation_reason = None,
|
||||
] {
|
||||
let mut invalid = page();
|
||||
mutate(&mut invalid);
|
||||
let wire = serde_json::to_value(WorkdirSessionOperationResult::CommandOutput(invalid))
|
||||
.unwrap();
|
||||
assert!(serde_json::from_value::<ExternalWorkdirOperationResult>(wire).is_err());
|
||||
}
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandOutput(
|
||||
crate::CommandOutputRequest {
|
||||
handle: crate::CommandHandle("command-1".into()),
|
||||
cursor: crate::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||
limit: 1,
|
||||
wait: false,
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#[cfg(test)]
|
||||
use crate::command_test_support::CollectCommand as _;
|
||||
use crate::http::{
|
||||
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
|
||||
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
||||
@@ -59,11 +62,12 @@ async fn external_local_provider_is_strictly_read_only() {
|
||||
let command = WorkdirSession::start_command(
|
||||
&session,
|
||||
CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "echo changed > item.txt".to_string(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
},
|
||||
)
|
||||
@@ -145,11 +149,12 @@ async fn external_local_provider_read_write_is_filesystem_only() {
|
||||
let command = WorkdirSession::start_command(
|
||||
&session,
|
||||
CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "touch command-ran".to_string(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
},
|
||||
)
|
||||
@@ -193,17 +198,18 @@ async fn external_command_permission_is_explicit_and_independent_from_file_acces
|
||||
|
||||
let handle = session
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf command-ok".to_string(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let output = session
|
||||
.command_output(CommandOutputRequest {
|
||||
.collect_command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
|
||||
+87
-12
@@ -693,6 +693,12 @@ mod client {
|
||||
let mutation = request.operation.capability() != crate::WorkdirSessionCapability::Read;
|
||||
let expected_path = request.target.clone();
|
||||
let expected_output = request.operation.capability();
|
||||
let byte_range = match &request.operation {
|
||||
crate::CheckoutOperation::ReadBytes { offset, max_bytes } => {
|
||||
Some((*offset, *max_bytes))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CheckoutExecute(request))
|
||||
.await
|
||||
@@ -702,11 +708,30 @@ mod client {
|
||||
&& !result.observation.validator.is_empty()
|
||||
&& result.observation.validator.len() <= 256
|
||||
&& match (&result.output, expected_output) {
|
||||
(
|
||||
crate::CheckoutOutput::ReadBytes(read),
|
||||
crate::WorkdirSessionCapability::Read,
|
||||
) => byte_range.is_some_and(|(offset, max)| {
|
||||
read.path == expected_path
|
||||
&& read.offset == offset
|
||||
&& read.bytes.len() <= max
|
||||
&& read
|
||||
.offset
|
||||
.checked_add(read.bytes.len() as u64)
|
||||
.is_some_and(|end| {
|
||||
end <= read.total_bytes
|
||||
|| (read.bytes.is_empty() && read.eof)
|
||||
})
|
||||
&& read.eof
|
||||
== (read.offset.saturating_add(read.bytes.len() as u64)
|
||||
>= read.total_bytes)
|
||||
}),
|
||||
(
|
||||
crate::CheckoutOutput::Read(read),
|
||||
crate::WorkdirSessionCapability::Read,
|
||||
) => {
|
||||
read.path == expected_path
|
||||
byte_range.is_none()
|
||||
&& read.path == expected_path
|
||||
&& read.bytes.len()
|
||||
<= crate::BoundedReadLimits::EXTERNAL_DEFAULT
|
||||
.max_response_bytes
|
||||
@@ -822,6 +847,7 @@ mod client {
|
||||
&self,
|
||||
request: CommandRequest,
|
||||
) -> Result<CommandHandle, WorkdirError> {
|
||||
request.validate()?;
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStart(request))
|
||||
.await?
|
||||
@@ -848,17 +874,39 @@ mod client {
|
||||
&self,
|
||||
request: CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) {
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"command byte limit is outside transport bounds".into(),
|
||||
));
|
||||
}
|
||||
let wait = request.wait;
|
||||
loop {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(request.clone()))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandOutput(result)
|
||||
if wait && result.status == CommandStatus::Running => {}
|
||||
WorkdirSessionOperationResult::CommandOutput(result) => return Ok(result),
|
||||
_ => return Err(Self::mismatch("command_output")),
|
||||
WorkdirSessionOperationResult::CommandOutput(result) => {
|
||||
let size = result
|
||||
.chunks
|
||||
.iter()
|
||||
.try_fold(0usize, |size, chunk| size.checked_add(chunk.bytes.len()));
|
||||
if !result.transport_bounds_valid()
|
||||
|| size.is_none_or(|size| {
|
||||
size > request.limit
|
||||
|| request.cursor.checked_add(size as u64)
|
||||
!= Some(result.next_cursor)
|
||||
})
|
||||
{
|
||||
return Err(Self::mismatch("bounded command byte page"));
|
||||
}
|
||||
if wait && result.chunks.is_empty() && result.terminal.is_none() {
|
||||
// A waiting request must block at the provider. Never spin HTTP
|
||||
// requests against an immediate empty response. A caller may retry
|
||||
// this exact cursor with its own bounded transport retry policy.
|
||||
return Err(Self::mismatch("waiting command byte page"));
|
||||
}
|
||||
return Ok(result);
|
||||
}
|
||||
_ => return Err(Self::mismatch("command_output")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,20 +965,47 @@ mod client {
|
||||
response: reqwest::Response,
|
||||
) -> Result<T, WorkdirError> {
|
||||
if response.status().is_success() {
|
||||
response.json().await.map_err(http_unavailable)
|
||||
let bytes = bounded_response_bytes(response).await?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|_| WorkdirError::Transport("Runtime Workdir response was invalid".into()))
|
||||
} else {
|
||||
Err(decode_error(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
async fn bounded_response_bytes(
|
||||
mut response: reqwest::Response,
|
||||
) -> Result<Vec<u8>, WorkdirError> {
|
||||
const MAX_RESPONSE_BYTES: usize = crate::external::MAX_EXTERNAL_PROVIDER_FRAME_BYTES;
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_RESPONSE_BYTES as u64)
|
||||
{
|
||||
return Err(WorkdirError::Transport(
|
||||
"Runtime Workdir response exceeded transport bound".into(),
|
||||
));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_unavailable)? {
|
||||
if chunk.len() > MAX_RESPONSE_BYTES.saturating_sub(bytes.len()) {
|
||||
return Err(WorkdirError::Transport(
|
||||
"Runtime Workdir response exceeded transport bound".into(),
|
||||
));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn decode_error(response: reqwest::Response) -> WorkdirError {
|
||||
response
|
||||
.json::<WorkdirTransportError>()
|
||||
.await
|
||||
match bounded_response_bytes(response).await {
|
||||
Ok(bytes) => serde_json::from_slice::<WorkdirTransportError>(&bytes)
|
||||
.map(WorkdirTransportError::into_workdir_error)
|
||||
.unwrap_or_else(|_| {
|
||||
WorkdirError::Transport("Runtime Workdir error response was invalid".to_string())
|
||||
})
|
||||
WorkdirError::Transport("Runtime Workdir error response was invalid".into())
|
||||
}),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_unavailable(_error: reqwest::Error) -> WorkdirError {
|
||||
|
||||
@@ -618,3 +618,6 @@ mod capability_tests {
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_test_support;
|
||||
|
||||
+111
-872
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,377 @@
|
||||
//! Captured process bytes use a bounded queue, never the lossy Console channel.
|
||||
//! Pipe backpressure bounds unread process output without temporary-file growth.
|
||||
//! A replay page survives a lost HTTP response until its next cursor is sent.
|
||||
|
||||
use super::*;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
const QUEUE_CHUNKS: usize = 8;
|
||||
pub(super) const MAX_COMMANDS: usize = 64;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Message {
|
||||
Bytes(crate::CommandByteChunk),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct LocalCommand {
|
||||
pub task: JoinHandle<()>,
|
||||
pub cancel: watch::Sender<bool>,
|
||||
pub status: watch::Receiver<Option<crate::CommandTerminal>>,
|
||||
pub transport: Arc<Mutex<Transport>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Transport {
|
||||
receiver: mpsc::Receiver<Message>,
|
||||
pending: Option<crate::CommandByteChunk>,
|
||||
cursor: u64,
|
||||
replay: Option<(u64, CommandOutput)>,
|
||||
terminal: Option<crate::CommandTerminal>,
|
||||
terminal_status: watch::Receiver<Option<crate::CommandTerminal>>,
|
||||
}
|
||||
|
||||
impl Transport {
|
||||
pub async fn read(
|
||||
&mut self,
|
||||
request: &CommandOutputRequest,
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
if !(1..=crate::MAX_COMMAND_READ_BYTES).contains(&request.limit) {
|
||||
return Err(WorkdirError::Conflict(
|
||||
"command byte limit is outside transport bounds".into(),
|
||||
));
|
||||
}
|
||||
if let Some((cursor, page)) = &self.replay {
|
||||
if request.cursor == *cursor {
|
||||
if page
|
||||
.chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.bytes.len())
|
||||
.sum::<usize>()
|
||||
> request.limit
|
||||
{
|
||||
return Err(WorkdirError::Conflict(
|
||||
"retry byte limit is smaller than retained page".into(),
|
||||
));
|
||||
}
|
||||
return Ok(page.clone());
|
||||
}
|
||||
if request.cursor != page.next_cursor {
|
||||
return Err(WorkdirError::Conflict(
|
||||
"command cursor is not the current page acknowledgement".into(),
|
||||
));
|
||||
}
|
||||
self.cursor = page.next_cursor;
|
||||
self.replay = None;
|
||||
}
|
||||
if request.cursor != self.cursor {
|
||||
return Err(WorkdirError::Conflict(
|
||||
"command cursor was acknowledged or is ahead of output".into(),
|
||||
));
|
||||
}
|
||||
let mut page = CommandOutput {
|
||||
chunks: Vec::new(),
|
||||
next_cursor: self.cursor,
|
||||
terminal: self.terminal.clone(),
|
||||
};
|
||||
let mut remaining = request.limit;
|
||||
loop {
|
||||
let message = if let Some(chunk) = self.pending.take() {
|
||||
Some(Message::Bytes(chunk))
|
||||
} else if page.chunks.is_empty() && page.terminal.is_none() && request.wait {
|
||||
self.receiver.recv().await
|
||||
} else {
|
||||
match self.receiver.try_recv() {
|
||||
Ok(message) => Some(message),
|
||||
Err(mpsc::error::TryRecvError::Empty) => break,
|
||||
Err(mpsc::error::TryRecvError::Disconnected) => None,
|
||||
}
|
||||
};
|
||||
match message {
|
||||
Some(Message::Bytes(mut chunk)) => {
|
||||
let count = remaining.min(chunk.bytes.len());
|
||||
if count < chunk.bytes.len() {
|
||||
let tail = chunk.bytes.split_off(count);
|
||||
self.pending = Some(crate::CommandByteChunk {
|
||||
sequence: chunk.sequence + count as u64,
|
||||
stream: chunk.stream,
|
||||
offset: chunk.offset + count as u64,
|
||||
bytes: tail,
|
||||
});
|
||||
}
|
||||
page.next_cursor += count as u64;
|
||||
remaining -= count;
|
||||
page.chunks.push(chunk);
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None if page.terminal.is_some() => break,
|
||||
None => {
|
||||
let terminal = self.terminal_status.borrow().clone().ok_or_else(|| {
|
||||
WorkdirError::Unavailable(
|
||||
"command transport ended before terminal fence".into(),
|
||||
)
|
||||
})?;
|
||||
self.terminal = Some(terminal.clone());
|
||||
page.terminal = Some(terminal);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// An empty poll must not become a permanently replayed empty page.
|
||||
if !page.chunks.is_empty() || page.terminal.is_some() {
|
||||
self.replay = Some((self.cursor, page.clone()));
|
||||
}
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
pub fn terminal_delivered(&self) -> bool {
|
||||
self.replay
|
||||
.as_ref()
|
||||
.is_some_and(|(_, page)| page.terminal.is_some())
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.receiver.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn start(
|
||||
cwd: PathBuf,
|
||||
request: CommandRequest,
|
||||
command_id: String,
|
||||
telemetry: CommandTelemetry,
|
||||
environment: BTreeMap<String, String>,
|
||||
resources: Vec<Arc<dyn WorkdirSessionResource>>,
|
||||
) -> LocalCommand {
|
||||
let (sender, receiver) = mpsc::channel(QUEUE_CHUNKS);
|
||||
let (cancel, cancel_rx) = watch::channel(false);
|
||||
let (status_tx, status) = watch::channel(None);
|
||||
let transport = Arc::new(Mutex::new(Transport {
|
||||
receiver,
|
||||
pending: None,
|
||||
cursor: 0,
|
||||
replay: None,
|
||||
terminal: None,
|
||||
terminal_status: status.clone(),
|
||||
}));
|
||||
let task = tokio::spawn(async move {
|
||||
let _resources = resources;
|
||||
let terminal = execute(
|
||||
cwd,
|
||||
request,
|
||||
&command_id,
|
||||
&telemetry,
|
||||
environment,
|
||||
cancel_rx,
|
||||
&sender,
|
||||
)
|
||||
.await;
|
||||
telemetry.terminal(&command_id, terminal.status, terminal.exit_code);
|
||||
// Closing the byte queue fences this already-published terminal, without
|
||||
// requiring spare queue capacity or a receiver to release task resources.
|
||||
let _ = status_tx.send(Some(terminal));
|
||||
});
|
||||
LocalCommand {
|
||||
task,
|
||||
cancel,
|
||||
status,
|
||||
transport,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ByteAccounting {
|
||||
stdout: u64,
|
||||
stderr: u64,
|
||||
stdout_delivered: u64,
|
||||
stderr_delivered: u64,
|
||||
}
|
||||
|
||||
impl ByteAccounting {
|
||||
fn terminal(&self, status: CommandStatus, exit_code: Option<i32>) -> crate::CommandTerminal {
|
||||
crate::CommandTerminal {
|
||||
status,
|
||||
exit_code,
|
||||
stdout_bytes: self.stdout,
|
||||
stderr_bytes: self.stderr,
|
||||
stdout_delivered_bytes: self.stdout_delivered,
|
||||
stderr_delivered_bytes: self.stderr_delivered,
|
||||
truncation_reason: (self.stdout > self.stdout_delivered
|
||||
|| self.stderr > self.stderr_delivered)
|
||||
.then_some(crate::CommandOutputTruncation::ByteBudget),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn pump<R: AsyncRead + Unpin>(
|
||||
mut stdout: R,
|
||||
mut stderr: impl AsyncRead + Unpin,
|
||||
sender: &mpsc::Sender<Message>,
|
||||
telemetry: &CommandTelemetry,
|
||||
command_id: &str,
|
||||
mut remaining: u64,
|
||||
accounting: &mut ByteAccounting,
|
||||
) -> std::io::Result<()> {
|
||||
let mut stdout_done = false;
|
||||
let mut stderr_done = false;
|
||||
let mut stdout_decoder = CommandOutputDecoder::default();
|
||||
let mut stderr_decoder = CommandOutputDecoder::default();
|
||||
let mut sequence = 0;
|
||||
let mut stdout_buffer = [0; COMMAND_EVENT_CHUNK_BYTES];
|
||||
let mut stderr_buffer = [0; COMMAND_EVENT_CHUNK_BYTES];
|
||||
while !stdout_done || !stderr_done {
|
||||
// After quota, drain locally even if nobody acknowledges the queue.
|
||||
// Before quota, reserve first to retain bounded pipe backpressure.
|
||||
let permit = if remaining > 0 {
|
||||
let Ok(permit) = sender.reserve().await else {
|
||||
return Ok(());
|
||||
};
|
||||
Some(permit)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (stream, result) = tokio::select! {
|
||||
result = stdout.read(&mut stdout_buffer), if !stdout_done => (CommandStream::Stdout, result),
|
||||
result = stderr.read(&mut stderr_buffer), if !stderr_done => (CommandStream::Stderr, result),
|
||||
};
|
||||
let buffer = match stream {
|
||||
CommandStream::Stdout => &stdout_buffer,
|
||||
CommandStream::Stderr => &stderr_buffer,
|
||||
};
|
||||
let count = result?;
|
||||
let decoder = match stream {
|
||||
CommandStream::Stdout => &mut stdout_decoder,
|
||||
CommandStream::Stderr => &mut stderr_decoder,
|
||||
};
|
||||
if count == 0 {
|
||||
match stream {
|
||||
CommandStream::Stdout => stdout_done = true,
|
||||
CommandStream::Stderr => stderr_done = true,
|
||||
}
|
||||
publish_decoded_output(decoder, telemetry, command_id, stream, true);
|
||||
continue;
|
||||
}
|
||||
let (seen, delivered) = match stream {
|
||||
CommandStream::Stdout => (&mut accounting.stdout, &mut accounting.stdout_delivered),
|
||||
CommandStream::Stderr => (&mut accounting.stderr, &mut accounting.stderr_delivered),
|
||||
};
|
||||
*seen = seen.saturating_add(count as u64);
|
||||
let captured = remaining.min(count as u64) as usize;
|
||||
if captured > 0 {
|
||||
let offset = *delivered;
|
||||
decoder.read_offset += captured as u64;
|
||||
decoder.pending.extend_from_slice(&buffer[..captured]);
|
||||
publish_decoded_output(decoder, telemetry, command_id, stream, false);
|
||||
permit
|
||||
.expect("reserved while budget remains")
|
||||
.send(Message::Bytes(crate::CommandByteChunk {
|
||||
sequence,
|
||||
stream,
|
||||
offset,
|
||||
bytes: buffer[..captured].to_vec(),
|
||||
}));
|
||||
*delivered += captured as u64;
|
||||
sequence += captured as u64;
|
||||
remaining -= captured as u64;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
cwd: PathBuf,
|
||||
request: CommandRequest,
|
||||
command_id: &str,
|
||||
telemetry: &CommandTelemetry,
|
||||
environment: BTreeMap<String, String>,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
sender: &mpsc::Sender<Message>,
|
||||
) -> crate::CommandTerminal {
|
||||
telemetry.started(command_id, request.tool_call_id);
|
||||
let mut command = Command::new("bash");
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(request.command)
|
||||
.current_dir(cwd)
|
||||
.envs(environment)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
#[cfg(unix)]
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut accounting = ByteAccounting::default();
|
||||
let Ok(mut child) = command.spawn() else {
|
||||
return accounting.terminal(CommandStatus::Failed, None);
|
||||
};
|
||||
let mut process_group = CommandProcessGroupGuard::new(child.id());
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let (status, exit_code) = {
|
||||
let output = pump(
|
||||
stdout,
|
||||
stderr,
|
||||
sender,
|
||||
telemetry,
|
||||
command_id,
|
||||
request.transport_byte_budget,
|
||||
&mut accounting,
|
||||
);
|
||||
tokio::pin!(output);
|
||||
let mut drained = false;
|
||||
let timeout = tokio::time::sleep(Duration::from_secs(request.timeout_secs.max(1)));
|
||||
tokio::pin!(timeout);
|
||||
let terminal = loop {
|
||||
tokio::select! {
|
||||
result = &mut output, if !drained => {
|
||||
drained = true;
|
||||
if result.is_err() || sender.is_closed() {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
break (CommandStatus::Failed, None);
|
||||
}
|
||||
}
|
||||
exit = child.wait() => {
|
||||
process_group.terminate();
|
||||
break match exit {
|
||||
Ok(exit) => (if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, exit.code()),
|
||||
Err(_) => (CommandStatus::Failed, None),
|
||||
};
|
||||
}
|
||||
_ = &mut timeout => {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let code = child.wait().await.ok().and_then(|exit| exit.code());
|
||||
break (CommandStatus::TimedOut, code);
|
||||
}
|
||||
_ = sender.closed() => {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
break (CommandStatus::Cancelled, None);
|
||||
}
|
||||
changed = cancel.changed() => {
|
||||
if changed.is_err() || *cancel.borrow() {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let code = child.wait().await.ok().and_then(|exit| exit.code());
|
||||
break (CommandStatus::Cancelled, code);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Even after kill, preserve the bounded pipe tail before publishing terminal.
|
||||
if !drained {
|
||||
let _ = output.await;
|
||||
}
|
||||
terminal
|
||||
};
|
||||
accounting.terminal(status, exit_code)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,490 @@
|
||||
//! Process/pipe integration through the production local provider. Temporary roots
|
||||
//! and provider tasks are RAII-owned; byte pulls and bounded timeouts synchronize tests.
|
||||
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct Harness {
|
||||
session: LocalWorkdirSession,
|
||||
_root: TempDir,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let root = TempDir::new().unwrap();
|
||||
let session = LocalWorkdirSession::new(
|
||||
Scope::writable(root.path()).unwrap(),
|
||||
root.path().to_path_buf(),
|
||||
);
|
||||
Self {
|
||||
session,
|
||||
_root: root,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(&self, command: &str, timeout_secs: u64) -> CommandHandle {
|
||||
self.start_budget(command, timeout_secs, crate::MAX_COMMAND_TRANSPORT_BYTES)
|
||||
.await
|
||||
}
|
||||
|
||||
fn request(command: &str, timeout_secs: u64, budget: u64) -> CommandRequest {
|
||||
CommandRequest {
|
||||
transport_byte_budget: budget,
|
||||
command: command.into(),
|
||||
timeout_secs,
|
||||
cwd: WorkdirPath::root(),
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_budget(&self, command: &str, timeout_secs: u64, budget: u64) -> CommandHandle {
|
||||
self.session
|
||||
.start_command(Self::request(command, timeout_secs, budget))
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn page(&self, handle: &CommandHandle, cursor: u64, limit: usize) -> CommandOutput {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
self.session.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit,
|
||||
wait: true,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("bounded command pull")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn collect(
|
||||
&self,
|
||||
handle: &CommandHandle,
|
||||
mut cursor: u64,
|
||||
) -> (Vec<crate::CommandByteChunk>, crate::CommandTerminal) {
|
||||
let mut chunks = Vec::new();
|
||||
loop {
|
||||
let page = self
|
||||
.page(handle, cursor, crate::MAX_COMMAND_READ_BYTES)
|
||||
.await;
|
||||
assert!(
|
||||
page.chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.bytes.len())
|
||||
.sum::<usize>()
|
||||
<= crate::MAX_COMMAND_READ_BYTES
|
||||
);
|
||||
cursor = page.next_cursor;
|
||||
chunks.extend(page.chunks);
|
||||
assert!(chunks.iter().map(|chunk| chunk.bytes.len()).sum::<usize>() <= 8 * 1024 * 1024);
|
||||
if let Some(terminal) = page.terminal {
|
||||
return (chunks, terminal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_bytes(chunks: &[crate::CommandByteChunk], stream: CommandStream) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
for chunk in chunks.iter().filter(|chunk| chunk.stream == stream) {
|
||||
assert_eq!(chunk.offset, bytes.len() as u64);
|
||||
bytes.extend_from_slice(&chunk.bytes);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn high_single_line_output_survives_absent_and_lagged_console_subscribers() {
|
||||
for lagged in [false, true] {
|
||||
let harness = Harness::new();
|
||||
let _lagged_receiver = lagged.then(|| harness.session.subscribe_command_events().unwrap());
|
||||
let handle = harness.start("head -c 3145728 /dev/zero | tr '\\0' x; head -c 65536 /dev/zero | tr '\\0' e >&2; exit 7", 20).await;
|
||||
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||
assert_eq!(terminal.status, CommandStatus::Failed);
|
||||
assert_eq!(terminal.exit_code, Some(7));
|
||||
assert_eq!(
|
||||
stream_bytes(&chunks, CommandStream::Stdout),
|
||||
vec![b'x'; 3145728]
|
||||
);
|
||||
assert_eq!(
|
||||
stream_bytes(&chunks, CommandStream::Stderr),
|
||||
vec![b'e'; 65536]
|
||||
);
|
||||
let mut cursor = 0;
|
||||
for chunk in chunks {
|
||||
assert_eq!(chunk.sequence, cursor);
|
||||
cursor += chunk.bytes.len() as u64;
|
||||
}
|
||||
harness.session.close().await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_byte_pages_preserve_raw_utf8_invalid_bytes_and_replay_until_acknowledged() {
|
||||
let harness = Harness::new();
|
||||
let handle = harness
|
||||
.start(
|
||||
"printf '\\342\\202\\254\\377\\000'; printf '\\346\\227\\245' >&2",
|
||||
5,
|
||||
)
|
||||
.await;
|
||||
let mut chunks = Vec::new();
|
||||
let mut cursor = 0;
|
||||
loop {
|
||||
let page = harness.page(&handle, cursor, 1).await;
|
||||
assert_eq!(harness.page(&handle, cursor, 1).await, page);
|
||||
cursor = page.next_cursor;
|
||||
chunks.extend(page.chunks);
|
||||
if let Some(terminal) = page.terminal {
|
||||
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
stream_bytes(&chunks, CommandStream::Stdout),
|
||||
b"\xe2\x82\xac\xff\0"
|
||||
);
|
||||
assert_eq!(
|
||||
stream_bytes(&chunks, CommandStream::Stderr),
|
||||
b"\xe6\x97\xa5"
|
||||
);
|
||||
assert!(
|
||||
harness
|
||||
.session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1,
|
||||
wait: false
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_while_backpressured_drains_bytes_before_terminal_fence() {
|
||||
let harness = Harness::new();
|
||||
let handle = harness.start("printf ready; exec yes x", 30).await;
|
||||
let first = harness.page(&handle, 0, 1).await;
|
||||
harness
|
||||
.session
|
||||
.cancel_command(handle.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let (mut chunks, terminal) = harness.collect(&handle, first.next_cursor).await;
|
||||
let mut all = first.chunks;
|
||||
all.append(&mut chunks);
|
||||
assert!(stream_bytes(&all, CommandStream::Stdout).starts_with(b"ready"));
|
||||
assert_eq!(terminal.status, CommandStatus::Cancelled);
|
||||
let final_page = harness
|
||||
.page(
|
||||
&handle,
|
||||
all.iter().map(|chunk| chunk.bytes.len() as u64).sum(),
|
||||
1,
|
||||
)
|
||||
.await;
|
||||
assert!(final_page.chunks.is_empty());
|
||||
assert_eq!(final_page.terminal, Some(terminal));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn timeout_and_session_close_finalize_without_an_output_subscriber() {
|
||||
let harness = Harness::new();
|
||||
let handle = harness
|
||||
.start("printf ready; exec tail -f /dev/null", 1)
|
||||
.await;
|
||||
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||
assert_eq!(stream_bytes(&chunks, CommandStream::Stdout), b"ready");
|
||||
assert_eq!(terminal.status, CommandStatus::TimedOut);
|
||||
let handle = harness.start("exec yes x", 30).await;
|
||||
// Receiving one byte establishes actual process output, without a timing sleep.
|
||||
harness.page(&handle, 0, 1).await;
|
||||
tokio::time::timeout(Duration::from_secs(5), harness.session.close())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(harness.session.command_status(handle).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interleaved_streams_keep_independent_offsets_and_bounded_read_validation() {
|
||||
let harness = Harness::new();
|
||||
let handle = harness
|
||||
.start(
|
||||
"for ((i=0;i<100;i++)); do printf 'out%s;' \"$i\"; printf 'err%s;' \"$i\" >&2; done",
|
||||
5,
|
||||
)
|
||||
.await;
|
||||
for limit in [0, crate::MAX_COMMAND_READ_BYTES + 1] {
|
||||
assert!(
|
||||
harness
|
||||
.session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor: 0,
|
||||
limit,
|
||||
wait: false
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||
let expected = |prefix| {
|
||||
(0..100)
|
||||
.map(|i| format!("{prefix}{i};"))
|
||||
.collect::<String>()
|
||||
.into_bytes()
|
||||
};
|
||||
assert_eq!(
|
||||
stream_bytes(&chunks, CommandStream::Stdout),
|
||||
expected("out")
|
||||
);
|
||||
assert_eq!(
|
||||
stream_bytes(&chunks, CommandStream::Stderr),
|
||||
expected("err")
|
||||
);
|
||||
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn byte_budget_zero_exact_partial_and_shared_keep_seen_and_delivered_distinct() {
|
||||
let harness = Harness::new();
|
||||
for (command, budget, stdout_seen, stderr_seen, exit_code) in [
|
||||
("printf abc; printf defg >&2", 0, 3, 4, 0),
|
||||
("printf abc", 3, 3, 0, 0),
|
||||
("printf abcdefg", 3, 7, 0, 0),
|
||||
("printf abcdefg; exit 7", 3, 7, 0, 7),
|
||||
(
|
||||
"head -c 32768 /dev/zero; head -c 32768 /dev/zero >&2",
|
||||
12345,
|
||||
32768,
|
||||
32768,
|
||||
0,
|
||||
),
|
||||
] {
|
||||
let handle = harness.start_budget(command, 5, budget).await;
|
||||
let (chunks, terminal) = harness.collect(&handle, 0).await;
|
||||
let stdout = stream_bytes(&chunks, CommandStream::Stdout);
|
||||
let stderr = stream_bytes(&chunks, CommandStream::Stderr);
|
||||
let mut cursor = 0;
|
||||
for chunk in &chunks {
|
||||
assert_eq!(chunk.sequence, cursor);
|
||||
cursor += chunk.bytes.len() as u64;
|
||||
}
|
||||
assert_eq!(cursor, budget.min(stdout_seen + stderr_seen));
|
||||
assert_eq!(terminal.stdout_bytes, stdout_seen);
|
||||
assert_eq!(terminal.stderr_bytes, stderr_seen);
|
||||
assert_eq!(terminal.stdout_delivered_bytes, stdout.len() as u64);
|
||||
assert_eq!(terminal.stderr_delivered_bytes, stderr.len() as u64);
|
||||
assert_eq!(terminal.exit_code, Some(exit_code));
|
||||
assert_eq!(
|
||||
terminal.status,
|
||||
if exit_code == 0 {
|
||||
CommandStatus::Completed
|
||||
} else {
|
||||
CommandStatus::Failed
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
terminal.truncation_reason,
|
||||
(cursor < stdout_seen + stderr_seen)
|
||||
.then_some(crate::CommandOutputTruncation::ByteBudget)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_budget_finishes_without_receiver_and_keeps_timeout_cancel_distinct() {
|
||||
let harness = Harness::new();
|
||||
for budget in [0, 1, (QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64] {
|
||||
let handle = harness
|
||||
.start_budget("head -c 1048576 /dev/zero; printf error >&2", 5, budget)
|
||||
.await;
|
||||
let mut status = harness.session.inner.commands.lock().await[&handle.0]
|
||||
.status
|
||||
.clone();
|
||||
let terminal = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
status.wait_for(|value| value.is_some()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||
assert_eq!(terminal.stdout_bytes, 1048576);
|
||||
assert_eq!(terminal.stderr_bytes, 5);
|
||||
assert_eq!(
|
||||
terminal.stdout_delivered_bytes + terminal.stderr_delivered_bytes,
|
||||
budget
|
||||
);
|
||||
// Full capture queue cannot retain the process task or its resources.
|
||||
{
|
||||
let mut commands = harness.session.inner.commands.lock().await;
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
&mut commands.get_mut(&handle.0).unwrap().task,
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
// Only now acknowledge the captured queue and terminal fence.
|
||||
let (_, delivered_terminal) = harness.collect(&handle, 0).await;
|
||||
assert_eq!(delivered_terminal, terminal);
|
||||
}
|
||||
for (cancel, expected) in [
|
||||
(false, CommandStatus::TimedOut),
|
||||
(true, CommandStatus::Cancelled),
|
||||
] {
|
||||
let handle = harness
|
||||
.start_budget(
|
||||
"printf ready-extra; head -c 32768 /dev/zero; exec tail -f /dev/null",
|
||||
1,
|
||||
5,
|
||||
)
|
||||
.await;
|
||||
let first = harness.page(&handle, 0, 5).await;
|
||||
assert_eq!(first.next_cursor, 5);
|
||||
if cancel {
|
||||
harness
|
||||
.session
|
||||
.cancel_command(handle.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let (rest, terminal) = harness.collect(&handle, first.next_cursor).await;
|
||||
assert!(rest.is_empty());
|
||||
assert_eq!(terminal.status, expected);
|
||||
assert_eq!(terminal.stdout_delivered_bytes, 5);
|
||||
assert!(terminal.stdout_bytes >= 11);
|
||||
assert_eq!(
|
||||
terminal.truncation_reason,
|
||||
Some(crate::CommandOutputTruncation::ByteBudget)
|
||||
);
|
||||
if !cancel {
|
||||
assert_eq!(terminal.stdout_bytes, 32779);
|
||||
assert_eq!(
|
||||
terminal.truncation_reason,
|
||||
Some(crate::CommandOutputTruncation::ByteBudget)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn budget_replay_preserves_unique_bytes_and_rejects_future_and_old_cursors() {
|
||||
let harness = Harness::new();
|
||||
let handle = harness.start_budget("printf abcdefghij", 5, 7).await;
|
||||
let first = harness.page(&handle, 0, 2).await;
|
||||
for _ in 0..3 {
|
||||
assert_eq!(harness.page(&handle, 0, 2).await, first);
|
||||
}
|
||||
assert!(
|
||||
harness
|
||||
.session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor: 8,
|
||||
limit: 2,
|
||||
wait: false,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let (rest, terminal) = harness.collect(&handle, first.next_cursor).await;
|
||||
let mut all = first.chunks;
|
||||
all.extend(rest);
|
||||
assert_eq!(stream_bytes(&all, CommandStream::Stdout), b"abcdefg");
|
||||
assert_eq!(terminal.stdout_bytes, 10);
|
||||
assert_eq!(terminal.stdout_delivered_bytes, 7);
|
||||
assert!(
|
||||
harness
|
||||
.session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 2,
|
||||
wait: false,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_budget_is_rejected_before_process_start() {
|
||||
let harness = Harness::new();
|
||||
assert!(matches!(
|
||||
harness
|
||||
.session
|
||||
.start_command(Harness::request(
|
||||
"touch must-not-exist",
|
||||
5,
|
||||
crate::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||
))
|
||||
.await,
|
||||
Err(WorkdirError::InvalidArgument(_))
|
||||
));
|
||||
assert!(!harness._root.path().join("must-not-exist").exists());
|
||||
assert!(harness.session.inner.commands.lock().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slow_receiver_backpressures_before_quota_but_full_queue_does_not_block_discard() {
|
||||
use std::future::Future;
|
||||
use std::task::Poll;
|
||||
let telemetry = CommandTelemetry::new();
|
||||
let input = vec![b'x'; 1024 * 1024];
|
||||
for budget in [
|
||||
crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
(QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64,
|
||||
] {
|
||||
let (sender, mut receiver) = mpsc::channel(QUEUE_CHUNKS);
|
||||
let mut accounting = ByteAccounting::default();
|
||||
{
|
||||
let output = pump(
|
||||
input.as_slice(),
|
||||
&b""[..],
|
||||
&sender,
|
||||
&telemetry,
|
||||
"test",
|
||||
budget,
|
||||
&mut accounting,
|
||||
);
|
||||
tokio::pin!(output);
|
||||
let polled = std::future::poll_fn(|cx| Poll::Ready(output.as_mut().poll(cx))).await;
|
||||
if budget == crate::MAX_COMMAND_TRANSPORT_BYTES {
|
||||
assert!(polled.is_pending(), "a slow receiver must stop pipe reads");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(polled, Poll::Ready(Ok(()))),
|
||||
"discard must not reserve queue capacity"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(receiver.len(), QUEUE_CHUNKS);
|
||||
assert_eq!(
|
||||
accounting.stdout_delivered,
|
||||
(QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES) as u64
|
||||
);
|
||||
assert_eq!(
|
||||
accounting.stdout,
|
||||
if budget == crate::MAX_COMMAND_TRANSPORT_BYTES {
|
||||
accounting.stdout_delivered
|
||||
} else {
|
||||
input.len() as u64
|
||||
}
|
||||
);
|
||||
let mut queued_bytes = 0;
|
||||
while let Ok(Message::Bytes(chunk)) = receiver.try_recv() {
|
||||
queued_bytes += chunk.bytes.len();
|
||||
}
|
||||
assert_eq!(queued_bytes, QUEUE_CHUNKS * COMMAND_EVENT_CHUNK_BYTES);
|
||||
}
|
||||
}
|
||||
+162
-19
@@ -1,5 +1,3 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -8,18 +6,18 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct CommandHandle(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CommandRequest {
|
||||
pub command: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub timeout_secs: u64,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub output_limit: usize,
|
||||
/// Aggregate unique-byte capture allowance, independent of consumer storage.
|
||||
/// Providers drain and count excess bytes without delivering their payload.
|
||||
#[schemars(range(min = 0, max = 67_108_864_u64))]
|
||||
pub transport_byte_budget: u64,
|
||||
/// Workdir-relative command directory. Providers validate it against the
|
||||
/// active session before process start.
|
||||
pub cwd: fs_operation::FsPath,
|
||||
/// 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
|
||||
/// user-facing command telemetry can update the corresponding Console row
|
||||
/// without exposing provider/session handles.
|
||||
@@ -27,12 +25,23 @@ pub struct CommandRequest {
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandRequest {
|
||||
pub fn validate(&self) -> Result<(), crate::WorkdirError> {
|
||||
if self.transport_byte_budget > MAX_COMMAND_TRANSPORT_BYTES {
|
||||
return Err(crate::WorkdirError::InvalidArgument(
|
||||
"command transport byte budget exceeds provider maximum".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CommandOutputRequest {
|
||||
pub handle: CommandHandle,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub cursor: usize,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
#[schemars(range(min = 0, max = 67_108_864_u64))]
|
||||
pub cursor: u64,
|
||||
#[schemars(range(min = 1, max = 65_536_usize))]
|
||||
pub limit: usize,
|
||||
pub wait: bool,
|
||||
}
|
||||
@@ -107,16 +116,150 @@ pub enum CommandEvent {
|
||||
},
|
||||
}
|
||||
|
||||
/// Sequence is the merged transport byte position. Offset addresses raw bytes
|
||||
/// within the named stream; neither ordering nor offsets depend on UTF-8 decoding.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CommandOutput {
|
||||
pub struct CommandByteChunk {
|
||||
pub sequence: u64,
|
||||
pub stream: CommandStream,
|
||||
pub offset: u64,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CommandOutputTruncation {
|
||||
ByteBudget,
|
||||
}
|
||||
|
||||
/// Raw totals include discarded pipe bytes. Delivered totals count only prefixes
|
||||
/// admitted to the transport queue, not page reads or retries by a consumer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CommandTerminal {
|
||||
pub status: CommandStatus,
|
||||
#[schemars(range(min = -2147483648_i32, max = 2147483647_i32))]
|
||||
pub exit_code: Option<i32>,
|
||||
pub timed_out: bool,
|
||||
pub content: String,
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub next_cursor: Option<usize>,
|
||||
pub truncated: bool,
|
||||
/// Complete output retained by the provider when `truncated` is true.
|
||||
pub output_path: Option<PathBuf>,
|
||||
pub stdout_bytes: u64,
|
||||
pub stderr_bytes: u64,
|
||||
pub stdout_delivered_bytes: u64,
|
||||
pub stderr_delivered_bytes: u64,
|
||||
pub truncation_reason: Option<CommandOutputTruncation>,
|
||||
}
|
||||
|
||||
/// Sending next_cursor acknowledges this page. Only the current page may be
|
||||
/// retried; acknowledged bytes expire. Terminal fences all captured bytes,
|
||||
/// including on timeout/cancel. Consumers must pull while the process runs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CommandOutput {
|
||||
pub chunks: Vec<CommandByteChunk>,
|
||||
pub next_cursor: u64,
|
||||
pub terminal: Option<CommandTerminal>,
|
||||
}
|
||||
|
||||
impl CommandOutput {
|
||||
pub(crate) fn transport_bounds_valid(&self) -> bool {
|
||||
let mut end = None;
|
||||
let mut stream_ends = [None, None];
|
||||
for chunk in &self.chunks {
|
||||
let stream_index = match chunk.stream {
|
||||
CommandStream::Stdout => 0,
|
||||
CommandStream::Stderr => 1,
|
||||
};
|
||||
if chunk.bytes.is_empty()
|
||||
|| chunk.offset > chunk.sequence
|
||||
|| stream_ends[stream_index].is_some_and(|end| chunk.offset != end)
|
||||
|| end.is_some_and(|end| chunk.sequence != end)
|
||||
|| chunk
|
||||
.sequence
|
||||
.checked_add(chunk.bytes.len() as u64)
|
||||
.is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES)
|
||||
|| chunk
|
||||
.offset
|
||||
.checked_add(chunk.bytes.len() as u64)
|
||||
.is_none_or(|end| end > MAX_COMMAND_TRANSPORT_BYTES)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
end = chunk.sequence.checked_add(chunk.bytes.len() as u64);
|
||||
stream_ends[stream_index] = chunk.offset.checked_add(chunk.bytes.len() as u64);
|
||||
}
|
||||
self.next_cursor <= MAX_COMMAND_TRANSPORT_BYTES
|
||||
&& end.is_none_or(|end| end == self.next_cursor)
|
||||
&& self.terminal.as_ref().is_none_or(|terminal| {
|
||||
let truncated = terminal.stdout_bytes > terminal.stdout_delivered_bytes
|
||||
|| terminal.stderr_bytes > terminal.stderr_delivered_bytes;
|
||||
stream_ends[0].is_none_or(|end| end == terminal.stdout_delivered_bytes)
|
||||
&& stream_ends[1].is_none_or(|end| end == terminal.stderr_delivered_bytes)
|
||||
&& terminal.status != CommandStatus::Running
|
||||
&& terminal.stdout_delivered_bytes <= terminal.stdout_bytes
|
||||
&& terminal.stderr_delivered_bytes <= terminal.stderr_bytes
|
||||
&& terminal
|
||||
.stdout_delivered_bytes
|
||||
.checked_add(terminal.stderr_delivered_bytes)
|
||||
== Some(self.next_cursor)
|
||||
&& terminal.truncation_reason
|
||||
== truncated.then_some(CommandOutputTruncation::ByteBudget)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub const MAX_COMMAND_TRANSPORT_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
pub const MAX_COMMAND_READ_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_wire_rejects_legacy_output_host_paths() {
|
||||
let command = serde_json::json!({
|
||||
"command": "printf bytes", "timeout_secs": 1, "cwd": "", "transport_byte_budget": 0
|
||||
});
|
||||
assert!(serde_json::from_value::<CommandRequest>(command.clone()).is_ok());
|
||||
for field in ["spill_dir", "output_limit"] {
|
||||
let mut legacy = command.clone();
|
||||
legacy[field] = serde_json::json!("/host/private");
|
||||
assert!(serde_json::from_value::<CommandRequest>(legacy).is_err());
|
||||
}
|
||||
let output = serde_json::json!({"chunks": [], "next_cursor": 0, "terminal": null, "output_path": "/host/private"});
|
||||
assert!(serde_json::from_value::<CommandOutput>(output).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_budget_wire_requires_field_and_checks_provider_hard_bound() {
|
||||
let wire = serde_json::json!({"command": "true", "timeout_secs": 1, "cwd": ""});
|
||||
assert!(serde_json::from_value::<CommandRequest>(wire.clone()).is_err());
|
||||
for budget in [
|
||||
0,
|
||||
MAX_COMMAND_TRANSPORT_BYTES,
|
||||
MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||
] {
|
||||
let mut value = wire.clone();
|
||||
value["transport_byte_budget"] = serde_json::json!(budget);
|
||||
let request: CommandRequest = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(
|
||||
request.validate().is_ok(),
|
||||
budget <= MAX_COMMAND_TRANSPORT_BYTES
|
||||
);
|
||||
}
|
||||
let mut negative = wire;
|
||||
negative["transport_byte_budget"] = serde_json::json!(-1);
|
||||
assert!(serde_json::from_value::<CommandRequest>(negative).is_err());
|
||||
assert_eq!(
|
||||
serde_json::to_value(CommandOutputTruncation::ByteBudget).unwrap(),
|
||||
"byte_budget"
|
||||
);
|
||||
let schema = serde_json::to_value(schemars::schema_for!(CommandRequest)).unwrap();
|
||||
assert_eq!(
|
||||
schema["properties"]["transport_byte_budget"]["maximum"],
|
||||
MAX_COMMAND_TRANSPORT_BYTES
|
||||
);
|
||||
assert!(
|
||||
schema["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&serde_json::json!("transport_byte_budget"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,9 @@ impl WorkdirSession for RoutedWorkdirSession {
|
||||
let _active = self.state.enter()?;
|
||||
let handle = request.handle.clone();
|
||||
let output = self.state.session.command_output(request).await?;
|
||||
self.state.mark_terminal(&handle, output.status);
|
||||
if let Some(terminal) = &output.terminal {
|
||||
self.state.mark_terminal(&handle, terminal.status);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
@@ -789,11 +791,12 @@ mod tests {
|
||||
.unwrap();
|
||||
let handle = routed
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf done".to_string(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: crate::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
@@ -823,11 +826,12 @@ mod tests {
|
||||
let routed = router.attach(alias, Arc::clone(&underlying)).unwrap();
|
||||
let handle = routed
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "sleep 30".to_string(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: crate::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
@@ -850,11 +854,12 @@ mod tests {
|
||||
let routed = router.attach(alias.clone(), session("wd", &dir)).unwrap();
|
||||
let handle = routed
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "sleep 30".to_string(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: crate::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
|
||||
+289
-37
@@ -10,6 +10,9 @@
|
||||
//! provider-side before name enumeration, descent, or content reads. Returned coordinates are not
|
||||
//! prefixed with cwd again.
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::command_test_support::CollectCommand as _;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -124,6 +127,7 @@ impl WorkdirToolBroker {
|
||||
next_lease_id: AtomicU64::new(1),
|
||||
close_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||
owned_commands: Arc::new(Mutex::new(HashSet::new())),
|
||||
command_reads: Mutex::new(HashMap::new()),
|
||||
pending_command_events: Arc::new(Mutex::new(HashMap::new())),
|
||||
starting_tool_calls: Arc::new(Mutex::new(HashSet::new())),
|
||||
forwarded_starts: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -230,25 +234,48 @@ impl WorkdirScopeLease {
|
||||
.source
|
||||
.cancel_command(handle.clone())
|
||||
.await;
|
||||
let terminal = self
|
||||
let read_cursor = self
|
||||
.broker
|
||||
.authority
|
||||
.command_reads
|
||||
.lock()
|
||||
.expect("command cursor mutex poisoned")
|
||||
.get(&command_id)
|
||||
.cloned()
|
||||
.expect("owned command cursor");
|
||||
let mut read_cursor = read_cursor.lock().await;
|
||||
let mut cursor = *read_cursor;
|
||||
let terminal = loop {
|
||||
let output = self
|
||||
.broker
|
||||
.authority
|
||||
.source
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1,
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: crate::MAX_COMMAND_READ_BYTES,
|
||||
wait: true,
|
||||
})
|
||||
.await;
|
||||
match output {
|
||||
Ok(output) => {
|
||||
cursor = output.next_cursor;
|
||||
*read_cursor = cursor;
|
||||
if let Some(terminal) = output.terminal {
|
||||
break Ok(terminal);
|
||||
}
|
||||
}
|
||||
Err(error) => break Err(error),
|
||||
}
|
||||
};
|
||||
match (cancel, terminal) {
|
||||
(_, Ok(output)) => {
|
||||
self.broker.authority.publish_terminal_if_missing(
|
||||
&command_id,
|
||||
output.status,
|
||||
output.exit_code,
|
||||
output.next_cursor.unwrap_or(output.content.len()) as u64,
|
||||
&output.content,
|
||||
cursor,
|
||||
"",
|
||||
);
|
||||
self.broker
|
||||
.authority
|
||||
@@ -582,7 +609,12 @@ struct ScopedWorkdirSession {
|
||||
scope_lock: tokio::sync::Mutex<()>,
|
||||
next_lease_id: AtomicU64,
|
||||
close_lock: Arc<tokio::sync::Mutex<()>>,
|
||||
// Live cleanup responsibility ends at terminal delivery. Read ownership is
|
||||
// retained separately so a lost terminal response can still be replayed.
|
||||
owned_commands: Arc<Mutex<HashSet<String>>>,
|
||||
// Starting commands prunes completed entries at the bounded replay ceiling;
|
||||
// current scope validity is still checked before every read or retry.
|
||||
command_reads: Mutex<HashMap<String, Arc<tokio::sync::Mutex<u64>>>>,
|
||||
pending_command_events: Arc<Mutex<HashMap<String, Vec<CommandEvent>>>>,
|
||||
starting_tool_calls: Arc<Mutex<HashSet<String>>>,
|
||||
forwarded_starts: Arc<Mutex<HashSet<String>>>,
|
||||
@@ -700,6 +732,11 @@ impl ScopedWorkdirSession {
|
||||
.lock()
|
||||
.expect("scoped command set mutex poisoned")
|
||||
.contains(&handle.0)
|
||||
|| self
|
||||
.command_reads
|
||||
.lock()
|
||||
.expect("command cursor mutex poisoned")
|
||||
.contains_key(&handle.0)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
@@ -1013,6 +1050,7 @@ impl ScopedWorkdirSession {
|
||||
next_lease_id: AtomicU64::new(1),
|
||||
close_lock: close_lock.clone(),
|
||||
owned_commands,
|
||||
command_reads: Mutex::new(HashMap::new()),
|
||||
pending_command_events,
|
||||
starting_tool_calls,
|
||||
forwarded_starts,
|
||||
@@ -1222,6 +1260,9 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
.intersection(WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if let crate::CheckoutOutput::Read(read) = &mut result.output {
|
||||
read.path = result_path.clone();
|
||||
}
|
||||
if let crate::CheckoutOutput::ReadBytes(read) = &mut result.output {
|
||||
read.path = result_path;
|
||||
}
|
||||
Ok(result)
|
||||
@@ -1407,6 +1448,16 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
.lock()
|
||||
.expect("scoped command set mutex poisoned");
|
||||
owned.insert(handle.0.clone());
|
||||
{
|
||||
let mut reads = self
|
||||
.command_reads
|
||||
.lock()
|
||||
.expect("command cursor mutex poisoned");
|
||||
if reads.len() >= 64 {
|
||||
reads.retain(|id, _| owned.contains(id));
|
||||
}
|
||||
reads.insert(handle.0.clone(), Arc::new(tokio::sync::Mutex::new(0)));
|
||||
}
|
||||
if let Some(tool_call_id) = &tool_call_id {
|
||||
self.starting_tool_calls
|
||||
.lock()
|
||||
@@ -1457,14 +1508,28 @@ impl WorkdirSession for ScopedWorkdirSession {
|
||||
) -> Result<CommandOutput, WorkdirError> {
|
||||
self.ensure_owned_command(&request.handle)?;
|
||||
let command_id = request.handle.0.clone();
|
||||
let read_cursor = self
|
||||
.command_reads
|
||||
.lock()
|
||||
.expect("command cursor mutex poisoned")
|
||||
.get(&command_id)
|
||||
.cloned();
|
||||
let mut read_cursor = match &read_cursor {
|
||||
Some(cursor) => Some(cursor.lock().await),
|
||||
None => None,
|
||||
};
|
||||
self.ensure_owned_command(&request.handle)?;
|
||||
let output = self.source.command_output(request).await?;
|
||||
if !matches!(output.status, CommandStatus::Running) {
|
||||
if let Some(cursor) = &mut read_cursor {
|
||||
**cursor = output.next_cursor;
|
||||
}
|
||||
if let Some(terminal) = &output.terminal {
|
||||
self.publish_terminal_if_missing(
|
||||
&command_id,
|
||||
output.status,
|
||||
output.exit_code,
|
||||
output.next_cursor.unwrap_or(output.content.len()) as u64,
|
||||
&output.content,
|
||||
terminal.status,
|
||||
terminal.exit_code,
|
||||
output.next_cursor,
|
||||
"",
|
||||
);
|
||||
self.owned_commands
|
||||
.lock()
|
||||
@@ -2080,20 +2145,21 @@ mod tests {
|
||||
session: &WorkdirSessionHandle,
|
||||
command: impl Into<String>,
|
||||
tool_call_id: impl Into<String>,
|
||||
) -> CommandOutput {
|
||||
) -> crate::command_test_support::CollectedCommand {
|
||||
let handle = session
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: command.into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some(tool_call_id.into()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
session
|
||||
.command_output(CommandOutputRequest {
|
||||
.collect_command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
@@ -2112,11 +2178,12 @@ mod tests {
|
||||
.expect("delegation wrapper must preserve command observation");
|
||||
let handle = parent
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf ready; sleep 0.2; printf done".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("tool-delegated".into()),
|
||||
})
|
||||
.await
|
||||
@@ -2139,7 +2206,7 @@ mod tests {
|
||||
assert_eq!(snapshots[0].stdout.content, "ready");
|
||||
|
||||
let output = parent
|
||||
.command_output(CommandOutputRequest {
|
||||
.collect_command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
@@ -2179,11 +2246,12 @@ mod tests {
|
||||
);
|
||||
let error = child
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "pwd".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
@@ -2211,11 +2279,12 @@ mod tests {
|
||||
|
||||
let handle = first
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "pwd; sleep 0.2".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 4096,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("first-command".into()),
|
||||
})
|
||||
.await
|
||||
@@ -2239,7 +2308,7 @@ mod tests {
|
||||
));
|
||||
|
||||
let output = first
|
||||
.command_output(CommandOutputRequest {
|
||||
.collect_command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 4096,
|
||||
@@ -2316,11 +2385,12 @@ mod tests {
|
||||
assert!(matches!(
|
||||
child
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf denied".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("read-only-command".into()),
|
||||
})
|
||||
.await,
|
||||
@@ -2622,11 +2692,12 @@ mod tests {
|
||||
assert!(matches!(
|
||||
child
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf revoked".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("revoked-child-command".into()),
|
||||
})
|
||||
.await,
|
||||
@@ -2724,11 +2795,12 @@ mod tests {
|
||||
for index in 0..MAX_SCOPED_COMMANDS {
|
||||
child
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "sleep 30".into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some(format!("command-{index}")),
|
||||
})
|
||||
.await
|
||||
@@ -2737,11 +2809,12 @@ mod tests {
|
||||
|
||||
let error = child
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "sleep 30".into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("command-over-limit".into()),
|
||||
})
|
||||
.await
|
||||
@@ -2771,11 +2844,12 @@ mod tests {
|
||||
let command = tokio::spawn(async move {
|
||||
command_child
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "sleep 30".into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("racing-command".into()),
|
||||
})
|
||||
.await
|
||||
@@ -2817,11 +2891,12 @@ mod tests {
|
||||
let mut events = child.subscribe_command_events().unwrap();
|
||||
let handle = child
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "sleep 30; printf leaked > marker".into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("owned-command".into()),
|
||||
})
|
||||
.await
|
||||
@@ -3012,11 +3087,12 @@ mod tests {
|
||||
assert!(matches!(
|
||||
parent
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf closed".into(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
cwd: FsPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: Some("closed-parent-command".into()),
|
||||
})
|
||||
.await,
|
||||
@@ -3028,4 +3104,180 @@ mod tests {
|
||||
"child result after parent close: {child_result:?}"
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn scoped_byte_cursor_survives_partial_ack_then_revocation_and_terminal_retry() {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir(root.path().join("child")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let child = parent
|
||||
.scope(request("child", WorkdirToolScopePermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
let tool = child.tool_session();
|
||||
let handle = tool
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf ready; exec yes x".into(),
|
||||
timeout_secs: 10,
|
||||
cwd: FsPath::root(),
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cursor = 0;
|
||||
for _ in 0..2 {
|
||||
let page = tool
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: 1,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cursor = page.next_cursor;
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), child.close())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
tool.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor,
|
||||
limit: 1,
|
||||
wait: false
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let handle = parent
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf parent".into(),
|
||||
timeout_secs: 5,
|
||||
cwd: FsPath::root(),
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cursor = 0;
|
||||
loop {
|
||||
let request = CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
};
|
||||
let page = parent.command_output(request.clone()).await.unwrap();
|
||||
assert_eq!(parent.command_output(request).await.unwrap(), page);
|
||||
cursor = page.next_cursor;
|
||||
if page.terminal.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn scoped_terminal_response_loss_replays_without_duplicate_capture_and_rejects_invalid_cursors()
|
||||
{
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
let root = TempDir::new().unwrap();
|
||||
fs::create_dir(root.path().join("child")).unwrap();
|
||||
let parent = session(root.path());
|
||||
let child = parent
|
||||
.scope(request("child", WorkdirToolScopePermission::Write))
|
||||
.await
|
||||
.unwrap();
|
||||
let tool = child.tool_session();
|
||||
let handle = tool
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: crate::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "printf abcdef".into(),
|
||||
timeout_secs: 5,
|
||||
cwd: FsPath::root(),
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cursor = 0;
|
||||
let mut captured = Vec::new();
|
||||
let terminal_request = loop {
|
||||
let request = CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: 2,
|
||||
wait: true,
|
||||
};
|
||||
// Simulate losing each produced HTTP response, including terminal:
|
||||
// append only the retried page, never both copies.
|
||||
let lost = tool.command_output(request.clone()).await.unwrap();
|
||||
let replay = tool.command_output(request.clone()).await.unwrap();
|
||||
assert_eq!(replay, lost);
|
||||
for chunk in &replay.chunks {
|
||||
captured.extend_from_slice(&chunk.bytes);
|
||||
}
|
||||
cursor = replay.next_cursor;
|
||||
if let Some(terminal) = replay.terminal {
|
||||
assert_eq!(terminal.status, CommandStatus::Completed);
|
||||
break request;
|
||||
}
|
||||
};
|
||||
assert_eq!(captured, b"abcdef");
|
||||
assert!(
|
||||
!child
|
||||
.broker
|
||||
.authority
|
||||
.owned_commands
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&handle.0)
|
||||
);
|
||||
assert!(
|
||||
child
|
||||
.broker
|
||||
.authority
|
||||
.command_reads
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains_key(&handle.0)
|
||||
);
|
||||
assert_eq!(
|
||||
tool.command_status(handle.clone()).await.unwrap(),
|
||||
CommandStatus::Completed
|
||||
);
|
||||
for invalid in [0, cursor + 1] {
|
||||
assert!(
|
||||
tool.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor: invalid,
|
||||
limit: 2,
|
||||
wait: false
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
// Invalid cursor requests do not destroy the retained terminal page.
|
||||
assert!(
|
||||
tool.command_output(terminal_request)
|
||||
.await
|
||||
.unwrap()
|
||||
.terminal
|
||||
.is_some()
|
||||
);
|
||||
child.close().await.unwrap();
|
||||
assert!(
|
||||
tool.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor,
|
||||
limit: 2,
|
||||
wait: false
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
})
|
||||
.await
|
||||
.expect("bounded scoped terminal replay");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +103,12 @@ impl WorkdirSession for CommandFailureSession {
|
||||
}
|
||||
fn command() -> CommandRequest {
|
||||
CommandRequest {
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "fake command, never executed".into(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 100,
|
||||
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,11 +167,12 @@ async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
|
||||
assert_reason(
|
||||
child
|
||||
.start_command(workdir::CommandRequest {
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "must-not-run".into(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1,
|
||||
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -56,6 +56,7 @@ server-api = { path = "../server-api" }
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
workdir = { workspace = true, features = ["http-client"] }
|
||||
futures.workspace = true
|
||||
agen.workspace = true
|
||||
serial_test = "3.4.0"
|
||||
|
||||
@@ -67,7 +67,7 @@ use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use workdir::{
|
||||
CommandOutput, CommandStatus, WorkdirSessionHandle, dispatch_workdir_session_operation,
|
||||
CommandOutput, WorkdirSessionHandle, dispatch_workdir_session_operation,
|
||||
http::{
|
||||
OpenWorkdirSessionRequest, OpenWorkdirSessionResponse, WorkdirSessionId,
|
||||
WorkdirSessionOperation, WorkdirSessionOperationRequest, WorkdirSessionOperationResult,
|
||||
@@ -914,13 +914,9 @@ async fn run_workdir_session_operation(
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Err(_) => CommandOutput {
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
status: CommandStatus::Running,
|
||||
content: String::new(),
|
||||
next_cursor: Some(cursor),
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
chunks: Vec::new(),
|
||||
next_cursor: cursor,
|
||||
terminal: None,
|
||||
},
|
||||
};
|
||||
WorkdirSessionOperationResult::CommandOutput(output)
|
||||
@@ -2426,6 +2422,7 @@ pub enum RuntimeHttpServerError {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod command_transport_tests;
|
||||
#[cfg(feature = "fs-store")]
|
||||
mod session_material_tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Loopback integration: production RemoteWorkdirSession, signed HTTP middleware,
|
||||
//! command operation handler, local process pipes, and JSON DTOs. Only opening the
|
||||
//! pre-seeded session is a fixture; no Runtime Worker/materializer is claimed here.
|
||||
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
use workdir::http::{
|
||||
RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture,
|
||||
};
|
||||
use workdir::{CommandOutputRequest, CommandRequest, CommandStream, WorkdirSession};
|
||||
|
||||
struct SignedAuthorization {
|
||||
identity: RuntimeIdentityMaterial,
|
||||
trust: WorkspaceIssuerTrustRecord,
|
||||
}
|
||||
impl std::fmt::Debug for SignedAuthorization {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("SignedAuthorization")
|
||||
}
|
||||
}
|
||||
impl WorkdirHttpAuthorization for SignedAuthorization {
|
||||
fn bearer_token(
|
||||
&self,
|
||||
method: String,
|
||||
path: String,
|
||||
body: Vec<u8>,
|
||||
) -> WorkdirHttpAuthorizationFuture {
|
||||
let claims = signed_claims(&self.trust, &method, &path, &body);
|
||||
let token =
|
||||
issue_workspace_capability_token(&self.identity.signing_key().unwrap(), &claims)
|
||||
.unwrap();
|
||||
Box::pin(async move { Ok(token) })
|
||||
}
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
server: tokio::task::JoinHandle<()>,
|
||||
session: WorkdirSessionHandle,
|
||||
_root: tempfile::TempDir,
|
||||
remote: RemoteWorkdirSession,
|
||||
client: reqwest::Client,
|
||||
operation_url: url::Url,
|
||||
authorization: Arc<SignedAuthorization>,
|
||||
}
|
||||
impl Drop for Harness {
|
||||
fn drop(&mut self) {
|
||||
self.server.abort();
|
||||
}
|
||||
}
|
||||
impl Harness {
|
||||
async fn raw_operation(&self, operation: serde_json::Value) -> reqwest::Response {
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation": operation})).unwrap();
|
||||
let token = self
|
||||
.authorization
|
||||
.bearer_token(
|
||||
"POST".into(),
|
||||
self.operation_url.path().into(),
|
||||
body.clone(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
self.client
|
||||
.post(self.operation_url.clone())
|
||||
.bearer_auth(token)
|
||||
.header("content-type", "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn new() -> Self {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||
Workdir::new("wd-transport"),
|
||||
root.path().to_path_buf(),
|
||||
root.path().to_path_buf(),
|
||||
SharedScope::new(Scope::writable(root.path()).unwrap()),
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
));
|
||||
let identity = RuntimeIdentityMaterial::generate("transport-key").unwrap();
|
||||
let trust = workspace_trust(&identity);
|
||||
let state = RuntimeHttpState {
|
||||
runtime: Runtime::new_memory(),
|
||||
local_token: None,
|
||||
workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))),
|
||||
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
|
||||
"session-transport".into(),
|
||||
RuntimeHttpWorkdirSession {
|
||||
owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"),
|
||||
session: session.clone(),
|
||||
},
|
||||
)]))),
|
||||
ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")),
|
||||
};
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/v1/working-directories/{id}/sessions",
|
||||
post(|| async {
|
||||
Json(OpenWorkdirSessionResponse {
|
||||
session_id: WorkdirSessionId::new("session-transport").unwrap(),
|
||||
workdir_id: Workdir::new("wd-transport").id().clone(),
|
||||
capabilities: WorkdirSessionCapabilities::ALL,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/v1/workdir-sessions/{session_id}/operations",
|
||||
post(run_workdir_session_operation),
|
||||
)
|
||||
.route(
|
||||
"/v1/workdir-sessions/{session_id}",
|
||||
delete(close_workdir_session),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
.layer(middleware::from_fn_with_state(state, require_runtime_auth));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base: url::Url = format!("http://{}", listener.local_addr().unwrap())
|
||||
.parse()
|
||||
.unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
RUNTIME_WORKSPACE_SCOPE_HEADER,
|
||||
"workspace-a".parse().unwrap(),
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap();
|
||||
let authorization = Arc::new(SignedAuthorization { identity, trust });
|
||||
let operation_url = base
|
||||
.join("/v1/workdir-sessions/session-transport/operations")
|
||||
.unwrap();
|
||||
let remote = RemoteWorkdirSession::open_with_authorization(
|
||||
client.clone(),
|
||||
base,
|
||||
authorization.clone(),
|
||||
Workdir::new("wd-transport").id().clone(),
|
||||
OpenWorkdirSessionRequest::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
server,
|
||||
session,
|
||||
_root: root,
|
||||
remote,
|
||||
client,
|
||||
operation_url,
|
||||
authorization,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_http_command_pages_preserve_binary_streams_replay_and_nonzero_exit() {
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
let harness = Harness::new().await;
|
||||
let handle = harness.remote.start_command(CommandRequest {
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: "head -c 2097152 /dev/zero | tr '\\0' x; printf '\\342\\202\\254\\377\\000'; printf 'stderr\\346\\227\\245' >&2; exit 9".into(),
|
||||
timeout_secs: 15, cwd: WorkdirPath::root(), tool_call_id: Some("http-tool-call".into()),
|
||||
}).await.unwrap();
|
||||
let mut cursor = 0;
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let terminal = loop {
|
||||
let request = CommandOutputRequest { handle: handle.clone(), cursor, limit: 16384, wait: true };
|
||||
let page = harness.remote.command_output(request.clone()).await.unwrap();
|
||||
assert_eq!(harness.remote.command_output(request).await.unwrap(), page);
|
||||
assert!(page.chunks.iter().map(|chunk| chunk.bytes.len()).sum::<usize>() <= 16384);
|
||||
for chunk in page.chunks {
|
||||
assert_eq!(chunk.sequence, cursor);
|
||||
cursor += chunk.bytes.len() as u64;
|
||||
let target = match chunk.stream { CommandStream::Stdout => &mut stdout, CommandStream::Stderr => &mut stderr };
|
||||
assert_eq!(chunk.offset, target.len() as u64);
|
||||
target.extend(chunk.bytes);
|
||||
}
|
||||
assert_eq!(cursor, page.next_cursor);
|
||||
if let Some(terminal) = page.terminal { break terminal; }
|
||||
};
|
||||
let mut expected = vec![b'x'; 2097152];
|
||||
expected.extend_from_slice(b"\xe2\x82\xac\xff\0");
|
||||
assert_eq!(stdout, expected);
|
||||
assert_eq!(stderr, b"stderr\xe6\x97\xa5");
|
||||
assert_eq!(terminal.status, workdir::CommandStatus::Failed);
|
||||
assert_eq!(terminal.exit_code, Some(9));
|
||||
assert!(harness.remote.command_output(CommandOutputRequest { handle, cursor: 0, limit: 1, wait: false }).await.is_err());
|
||||
harness.remote.close().await.unwrap();
|
||||
assert!(harness.session.command_snapshot().is_empty());
|
||||
}).await.expect("bounded HTTP command transport");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_http_cancel_drains_tail_before_terminal_and_timeout_is_distinct() {
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
let harness = Harness::new().await;
|
||||
for (command, timeout, cancel, expected) in [
|
||||
(
|
||||
"printf ready; exec yes x",
|
||||
10,
|
||||
true,
|
||||
workdir::CommandStatus::Cancelled,
|
||||
),
|
||||
(
|
||||
"printf ready; exec tail -f /dev/null",
|
||||
1,
|
||||
false,
|
||||
workdir::CommandStatus::TimedOut,
|
||||
),
|
||||
] {
|
||||
let handle = harness
|
||||
.remote
|
||||
.start_command(CommandRequest {
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
command: command.into(),
|
||||
timeout_secs: timeout,
|
||||
cwd: WorkdirPath::root(),
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cursor = 0;
|
||||
let first = harness
|
||||
.remote
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: 5,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
first
|
||||
.chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.bytes.iter().copied())
|
||||
.collect::<Vec<_>>(),
|
||||
b"ready"
|
||||
);
|
||||
cursor = first.next_cursor;
|
||||
if cancel {
|
||||
harness.remote.cancel_command(handle.clone()).await.unwrap();
|
||||
}
|
||||
loop {
|
||||
let page = harness
|
||||
.remote
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
cursor = page.next_cursor;
|
||||
if let Some(terminal) = page.terminal {
|
||||
assert_eq!(terminal.status, expected);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
harness.remote.close().await.unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("bounded cancellation and timeout over HTTP");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_http_command_budget_bounds_unique_payload_and_reports_larger_seen_totals() {
|
||||
tokio::time::timeout(Duration::from_secs(20), async {
|
||||
let harness = Harness::new().await;
|
||||
for budget in [0, 12345] {
|
||||
let handle = harness
|
||||
.remote
|
||||
.start_command(CommandRequest {
|
||||
command: "head -c 1048576 /dev/zero; head -c 65536 /dev/zero >&2; exit 9"
|
||||
.into(),
|
||||
timeout_secs: 10,
|
||||
transport_byte_budget: budget,
|
||||
cwd: WorkdirPath::root(),
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut cursor = 0;
|
||||
let mut delivered = [0_u64; 2];
|
||||
let terminal = loop {
|
||||
let request = CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor,
|
||||
limit: 997,
|
||||
wait: true,
|
||||
};
|
||||
let page = harness
|
||||
.remote
|
||||
.command_output(request.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(harness.remote.command_output(request).await.unwrap(), page);
|
||||
for chunk in page.chunks {
|
||||
assert_eq!(chunk.sequence, cursor);
|
||||
cursor += chunk.bytes.len() as u64;
|
||||
let index = match chunk.stream {
|
||||
CommandStream::Stdout => 0,
|
||||
CommandStream::Stderr => 1,
|
||||
};
|
||||
assert_eq!(chunk.offset, delivered[index]);
|
||||
delivered[index] += chunk.bytes.len() as u64;
|
||||
}
|
||||
assert_eq!(cursor, page.next_cursor);
|
||||
assert!(
|
||||
cursor <= budget,
|
||||
"RemoteWorkdirSession must not receive bytes beyond budget"
|
||||
);
|
||||
if let Some(terminal) = page.terminal {
|
||||
break terminal;
|
||||
}
|
||||
};
|
||||
assert_eq!(cursor, budget);
|
||||
assert_eq!(terminal.stdout_bytes, 1048576);
|
||||
assert_eq!(terminal.stderr_bytes, 65536);
|
||||
assert_eq!(terminal.stdout_delivered_bytes, delivered[0]);
|
||||
assert_eq!(terminal.stderr_delivered_bytes, delivered[1]);
|
||||
assert_eq!(
|
||||
terminal.truncation_reason,
|
||||
Some(workdir::CommandOutputTruncation::ByteBudget)
|
||||
);
|
||||
assert_eq!(terminal.status, workdir::CommandStatus::Failed);
|
||||
assert_eq!(terminal.exit_code, Some(9));
|
||||
assert!(
|
||||
harness
|
||||
.remote
|
||||
.command_output(CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor: budget + 1,
|
||||
limit: 1,
|
||||
wait: false,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
if budget > 0 {
|
||||
assert!(
|
||||
harness
|
||||
.remote
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1,
|
||||
wait: false,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
harness.remote.close().await.unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("bounded HTTP byte-budget transport");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn real_http_rejects_invalid_or_legacy_budget_requests_before_process_start() {
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
let harness = Harness::new().await;
|
||||
let request = CommandRequest {
|
||||
command: "touch must-not-exist".into(),
|
||||
timeout_secs: 1,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES + 1,
|
||||
cwd: WorkdirPath::root(),
|
||||
tool_call_id: None,
|
||||
};
|
||||
let oversize =
|
||||
serde_json::to_value(WorkdirSessionOperation::CommandStart(request)).unwrap();
|
||||
assert_eq!(
|
||||
harness.raw_operation(oversize.clone()).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
);
|
||||
let mut missing = oversize.clone();
|
||||
// CommandStart is externally tagged by operation / request.
|
||||
let object = missing.as_object_mut().unwrap();
|
||||
let request = object.get_mut("request").unwrap().as_object_mut().unwrap();
|
||||
request.remove("transport_byte_budget");
|
||||
assert_eq!(
|
||||
harness.raw_operation(missing).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
);
|
||||
let mut legacy = oversize;
|
||||
let request = legacy["request"].as_object_mut().unwrap();
|
||||
request.insert("transport_byte_budget".into(), serde_json::json!(0));
|
||||
request.insert("spill_dir".into(), serde_json::json!("/private/host"));
|
||||
assert_eq!(
|
||||
harness.raw_operation(legacy).await.status(),
|
||||
reqwest::StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert!(!harness._root.path().join("must-not-exist").exists());
|
||||
harness.remote.close().await.unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("bounded HTTP budget admission");
|
||||
}
|
||||
@@ -147,7 +147,6 @@ use worker::{
|
||||
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
||||
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||
bash_output_dir_for_worker_id,
|
||||
};
|
||||
|
||||
#[cfg(feature = "ws-server")]
|
||||
@@ -1090,29 +1089,24 @@ fn runtime_local_workdir_session(
|
||||
root: &Path,
|
||||
cwd: &Path,
|
||||
scope: manifest::SharedScope,
|
||||
command_output_scope: manifest::SharedScope,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
command_environment: std::collections::BTreeMap<String, String>,
|
||||
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
|
||||
) -> WorkdirSessionHandle {
|
||||
Arc::new(
|
||||
LocalWorkdirSession::materialized_bound_with_environment_and_command_output_scope(
|
||||
Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
|
||||
Workdir::new(workdir_id),
|
||||
root.to_path_buf(),
|
||||
cwd.to_path_buf(),
|
||||
scope,
|
||||
command_output_scope,
|
||||
capabilities,
|
||||
command_environment,
|
||||
resources,
|
||||
),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_local_workdir_router(
|
||||
attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
||||
capabilities: &BTreeMap<WorkdirAttachmentAlias, WorkdirSessionCapabilities>,
|
||||
command_output_scope: manifest::SharedScope,
|
||||
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
for (alias, binding) in attachments {
|
||||
@@ -1129,7 +1123,6 @@ fn runtime_local_workdir_router(
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
scope,
|
||||
command_output_scope.clone(),
|
||||
capabilities,
|
||||
binding.command_environment(),
|
||||
binding.session_resources().map_err(|error| {
|
||||
@@ -1146,7 +1139,6 @@ fn restored_workdir_router(
|
||||
local_attachments: &BTreeMap<WorkdirAttachmentAlias, WorkingDirectoryBinding>,
|
||||
logical_attachments: &[crate::catalog::LogicalWorkdirAttachment],
|
||||
claims: &[crate::catalog::WorkingDirectoryAttachmentClaim],
|
||||
scope: manifest::SharedScope,
|
||||
workspace_client: Arc<dyn WorkspaceClient>,
|
||||
) -> Result<Arc<WorkdirSessionRouter>, String> {
|
||||
let mut local_capabilities = local_attachments
|
||||
@@ -1163,7 +1155,7 @@ fn restored_workdir_router(
|
||||
local_capabilities.insert(attachment.alias.clone(), attachment.capabilities);
|
||||
}
|
||||
}
|
||||
let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?;
|
||||
let router = runtime_local_workdir_router(local_attachments, &local_capabilities)?;
|
||||
for attachment in logical_attachments {
|
||||
if let Some(local) = local_attachments.get(&attachment.alias) {
|
||||
if local.working_directory.id != attachment.working_directory_id {
|
||||
@@ -1517,7 +1509,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
let store = CombinedStore::new(session_store, worker_metadata_store);
|
||||
|
||||
let run_dir = self.worker_run_dir(&request.worker_ref)?;
|
||||
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
||||
let bootstrap = WorkerBootstrap::new(
|
||||
manifest,
|
||||
store,
|
||||
@@ -1526,7 +1517,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
filesystem_authority,
|
||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: run_dir.clone(),
|
||||
bash_output_dir,
|
||||
},
|
||||
self.controller_transport,
|
||||
);
|
||||
@@ -1571,7 +1561,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
worker.bind_workdir_sessions(runtime_local_workdir_router(
|
||||
&request.workdir_attachments,
|
||||
&workdir_capabilities,
|
||||
worker.scope().clone(),
|
||||
)?);
|
||||
if let (Some(runtime_id), Some(workspace_id)) =
|
||||
(observation_runtime_id, observation_workspace_id.clone())
|
||||
@@ -1838,7 +1827,6 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
&request.workdir_attachments,
|
||||
&request.logical_workdir_attachments,
|
||||
&request.request.workdir_attachments,
|
||||
worker.scope().clone(),
|
||||
worker.workspace_client_handle(),
|
||||
)?;
|
||||
worker.bind_workdir_sessions(workdir_sessions);
|
||||
@@ -1875,13 +1863,11 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
||||
|
||||
let workspace_client = worker.workspace_client_handle();
|
||||
let run_dir = self.worker_restore_run_dir(&request.worker_ref, request.operation_id)?;
|
||||
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
||||
let cleanup_sessions = worker.workdir_sessions();
|
||||
let started = PreparedWorker::new(
|
||||
worker,
|
||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: run_dir.clone(),
|
||||
bash_output_dir,
|
||||
},
|
||||
self.controller_transport,
|
||||
)
|
||||
@@ -3755,7 +3741,6 @@ where
|
||||
working_directory_id,
|
||||
binding.root(),
|
||||
binding.cwd(),
|
||||
scope.clone(),
|
||||
scope,
|
||||
WorkdirSessionCapabilities::ALL,
|
||||
binding.command_environment(),
|
||||
@@ -6805,8 +6790,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn restored_router_uses_workspace_proxy_for_logical_only_attachment() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let scope = manifest::SharedScope::new(manifest::Scope::writable(root.path()).unwrap());
|
||||
let client = WorkerWorkspaceContext::local_filesystem(Some(
|
||||
WorkspaceId::new("workspace-a").unwrap(),
|
||||
))
|
||||
@@ -6821,7 +6804,6 @@ mod tests {
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
}],
|
||||
&[],
|
||||
scope,
|
||||
client,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -6866,7 +6848,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let alias = WorkdirAttachmentAlias::new("docs").unwrap();
|
||||
let workdir_id = binding.working_directory.id.clone();
|
||||
let command_output_scope = manifest::SharedScope::new(manifest::Scope::empty());
|
||||
let attachments = BTreeMap::from([(alias.clone(), binding)]);
|
||||
let logical = [LogicalWorkdirAttachment {
|
||||
alias: alias.clone(),
|
||||
@@ -6878,9 +6859,7 @@ mod tests {
|
||||
))
|
||||
.client_handle();
|
||||
|
||||
let router =
|
||||
restored_workdir_router(&attachments, &logical, &[], command_output_scope, client)
|
||||
.unwrap();
|
||||
let router = restored_workdir_router(&attachments, &logical, &[], client).unwrap();
|
||||
|
||||
let session = router.session(&alias).unwrap();
|
||||
assert_eq!(
|
||||
@@ -7567,12 +7546,8 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
let bash_output_dir = self.runtime_base.join("bash-output");
|
||||
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed(
|
||||
worker,
|
||||
&self.runtime_base,
|
||||
&bash_output_dir,
|
||||
)
|
||||
let (handle, shutdown_rx) =
|
||||
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(RuntimeWorkerController {
|
||||
@@ -8698,7 +8673,6 @@ mod tests {
|
||||
"working-directory-42",
|
||||
root.path(),
|
||||
root.path(),
|
||||
spawned_scope.clone(),
|
||||
spawned_scope,
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
Default::default(),
|
||||
@@ -8709,7 +8683,6 @@ mod tests {
|
||||
"working-directory-42",
|
||||
root.path(),
|
||||
root.path(),
|
||||
restored_scope.clone(),
|
||||
restored_scope,
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
Default::default(),
|
||||
@@ -10234,8 +10207,7 @@ mod tests {
|
||||
|
||||
let alias = WorkdirAttachmentAlias::new("checkout").unwrap();
|
||||
let attachments = BTreeMap::from([(alias, binding.clone())]);
|
||||
let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap());
|
||||
let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope).unwrap();
|
||||
let router = runtime_local_workdir_router(&attachments, &BTreeMap::new()).unwrap();
|
||||
session_clone.close().await.unwrap();
|
||||
assert_eq!(
|
||||
materializer.cleanup_working_directory(id).unwrap_err().code,
|
||||
|
||||
@@ -47,9 +47,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
||||
|
||||
let runtime_tmp = tempfile::tempdir()?;
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, _shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
|
||||
|
||||
// Check initial status via shared state
|
||||
println!("[shared_state] {}", handle.shared_state.status_json());
|
||||
|
||||
@@ -17,28 +17,9 @@ use manifest::WorkerManifest;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkerBootstrapLayout {
|
||||
/// A direct Worker rooted below the supplied runtime base directory.
|
||||
Direct {
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
},
|
||||
Direct { runtime_base: PathBuf },
|
||||
/// A runtime-managed Worker with an exact persisted run directory.
|
||||
RuntimeManagedRun {
|
||||
run_dir: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
/// Return the temporary Bash spill directory owned by a stable Worker identity.
|
||||
///
|
||||
/// The directory deliberately lives outside session/run-generation storage so a
|
||||
/// restarted controller for the same Worker keeps the same readable artifact
|
||||
/// boundary.
|
||||
pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf {
|
||||
std::env::temp_dir()
|
||||
.join("yoi")
|
||||
.join("workers")
|
||||
.join(worker_id.to_string())
|
||||
.join("bash-output")
|
||||
RuntimeManagedRun { run_dir: PathBuf },
|
||||
}
|
||||
|
||||
/// Construction and controller inputs that are stable for one Worker launch.
|
||||
@@ -272,27 +253,12 @@ where
|
||||
{
|
||||
let cleanup_sessions = worker.workdir_sessions();
|
||||
let controller = match layout {
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
} => {
|
||||
WorkerController::spawn_with_transport_owned(
|
||||
worker,
|
||||
&runtime_base,
|
||||
&bash_output_dir,
|
||||
transport,
|
||||
)
|
||||
.await
|
||||
WorkerBootstrapLayout::Direct { runtime_base } => {
|
||||
WorkerController::spawn_with_transport_owned(worker, &runtime_base, transport).await
|
||||
}
|
||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir,
|
||||
bash_output_dir,
|
||||
} => {
|
||||
WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
|
||||
WorkerController::spawn_runtime_managed_run_with_transport_owned(
|
||||
worker,
|
||||
&run_dir,
|
||||
&bash_output_dir,
|
||||
transport,
|
||||
worker, &run_dir, transport,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -313,22 +279,3 @@ where
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bash_output_dir_for_worker_id;
|
||||
|
||||
#[test]
|
||||
fn bash_output_directory_is_stable_per_worker_below_system_temp() {
|
||||
let path = bash_output_dir_for_worker_id("019c1234-worker");
|
||||
|
||||
assert_eq!(
|
||||
path,
|
||||
std::env::temp_dir()
|
||||
.join("yoi")
|
||||
.join("workers")
|
||||
.join("019c1234-worker")
|
||||
.join("bash-output")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+186
-183
@@ -7,7 +7,7 @@
|
||||
//! empty children mean "no indexable children", not "empty directory". List/Glob/Grep discover
|
||||
//! content; Inspect checks selected coordinates.
|
||||
//!
|
||||
//! Interfaces/validators bind the projection incarnation, attachment generation, and provider state
|
||||
//! Object validators bind the projection incarnation, attachment generation, and provider state
|
||||
//! so alias reuse after restore or detach cannot transfer old observations to a new connection.
|
||||
//! Publication also checks the generation recorded at acquisition. Possessing an Interface grants
|
||||
//! no authority: route-reconstructed permission inputs and current provider capability/scope are
|
||||
@@ -28,9 +28,8 @@ use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::{Value as Json, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use wip_protocol::{
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ReturnDeclaration, TypeExpr, Value,
|
||||
Documentation, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration,
|
||||
ParameterDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{
|
||||
CheckoutObservation, EntryKind, WorkdirPath, WorkdirSessionCapability as Cap,
|
||||
@@ -117,13 +116,23 @@ impl Provider {
|
||||
}
|
||||
Some((decode_identity(slug)?, WorkdirPath::new(tail).ok()?))
|
||||
}
|
||||
fn wrap_validator(&self, alias: &str, generation: u64, workdir: &str, raw: &[u8]) -> Vec<u8> {
|
||||
fn wrap_validator(
|
||||
&self,
|
||||
alias: &str,
|
||||
generation: u64,
|
||||
workdir: &str,
|
||||
target: &WorkdirPath,
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
raw: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut hash = Sha256::new();
|
||||
for value in [
|
||||
self.incarnation.as_bytes(),
|
||||
alias.as_bytes(),
|
||||
&generation.to_be_bytes(),
|
||||
workdir.as_bytes(),
|
||||
target.as_str().as_bytes(),
|
||||
format!("{capabilities:?}").as_bytes(),
|
||||
raw,
|
||||
] {
|
||||
hash.update(value.len().to_be_bytes());
|
||||
@@ -236,45 +245,17 @@ impl Provider {
|
||||
return Ok(None);
|
||||
}
|
||||
let workdir = selected.session.workdir().id().as_str().to_owned();
|
||||
let root = checkout_root(&alias);
|
||||
// The router guard pins this connection across both observations. Root
|
||||
// lifetime is attachment lifetime, not directory-content validator state.
|
||||
let scope = if target.is_root() {
|
||||
None
|
||||
} else {
|
||||
let root_observation =
|
||||
match selected.session.checkout_observe(WorkdirPath::root()).await {
|
||||
Ok(o) => o,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
self.entry_projection(
|
||||
&root,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
WorkdirPath::root(),
|
||||
root_observation,
|
||||
)
|
||||
};
|
||||
let Some(projection) = self.entry_projection(
|
||||
let projection = self.entry_projection(
|
||||
path,
|
||||
&alias,
|
||||
selected.generation,
|
||||
&workdir,
|
||||
target,
|
||||
observation,
|
||||
) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path == root {
|
||||
return WipPublication::self_scoped(projection).map(Some);
|
||||
}
|
||||
let Some(scope) = scope else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(WipPublication {
|
||||
);
|
||||
Ok(projection.map(|projection| WipPublication {
|
||||
projection,
|
||||
scope: Some(scope.object),
|
||||
scope: None,
|
||||
}))
|
||||
}
|
||||
fn entry_projection(
|
||||
@@ -286,16 +267,44 @@ impl Provider {
|
||||
target: WorkdirPath,
|
||||
observation: CheckoutObservation,
|
||||
) -> Option<WipProjection> {
|
||||
let ops = self.operations(&observation);
|
||||
if ops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
use crate::file_operation::Contract as C;
|
||||
let has = |cap, name| observation.capabilities.supports(cap) && self.may_allow(name);
|
||||
let mut contracts = Vec::new();
|
||||
let kind = if observation.kind == EntryKind::File {
|
||||
if has(Cap::Read, "Read") {
|
||||
contracts.extend([C::ReadLines, C::SearchText, C::ReadBytes]);
|
||||
}
|
||||
if has(Cap::Edit, "Edit") {
|
||||
contracts.push(C::Edit);
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
contracts.push(C::Write);
|
||||
}
|
||||
"file"
|
||||
} else {
|
||||
if has(Cap::Read, "List") {
|
||||
contracts.push(C::List);
|
||||
}
|
||||
if has(Cap::Glob, "Glob") {
|
||||
contracts.push(C::Glob);
|
||||
}
|
||||
if has(Cap::Grep, "Grep") {
|
||||
contracts.push(C::Grep);
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
contracts.push(C::CreateFile);
|
||||
}
|
||||
"directory"
|
||||
};
|
||||
let validator = self.wrap_validator(alias, generation, workdir, &observation.validator);
|
||||
let primary = *contracts.first()?;
|
||||
let validator = self.wrap_validator(
|
||||
alias,
|
||||
generation,
|
||||
workdir,
|
||||
&target,
|
||||
observation.capabilities,
|
||||
&observation.validator,
|
||||
);
|
||||
let handler = Arc::new(FileHandler {
|
||||
provider: self.clone(),
|
||||
alias: alias.into(),
|
||||
@@ -304,86 +313,17 @@ impl Provider {
|
||||
target,
|
||||
observation,
|
||||
});
|
||||
let family = format!("{kind}-g{generation}");
|
||||
let mut projection = self.project(path, descriptor(ops), validator, &family, handler);
|
||||
// Keep path-qualified local names (permissions/capabilities may differ
|
||||
// per target) while binding every content Interface to its live entrance.
|
||||
projection.interface.scope = checkout_root(alias);
|
||||
projection.interface.name.push_str("#target/");
|
||||
projection.interface.name.push_str(&encode_identity(path));
|
||||
projection.object.interfaces = vec![projection.interface.clone()];
|
||||
let mut projection = self.project(path, primary.descriptor(), validator, kind, handler);
|
||||
projection.interface = primary.reference();
|
||||
projection.interface_validator = Some(primary.validator());
|
||||
projection.object.interfaces = Vec::new();
|
||||
crate::file_operation::expose(&mut projection, contracts);
|
||||
Some(projection)
|
||||
}
|
||||
|
||||
fn operations(&self, o: &CheckoutObservation) -> Vec<OperationDeclaration> {
|
||||
let mut ops = Vec::new();
|
||||
let has = |cap, name| o.capabilities.supports(cap) && self.may_allow(name);
|
||||
if o.kind == EntryKind::File {
|
||||
if has(Cap::Read, "Read") {
|
||||
ops.push(operation(
|
||||
"read",
|
||||
"Read text with line offsets and numbered output",
|
||||
crate::file_operation::parameters("read", true).unwrap(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Edit, "Edit") {
|
||||
ops.push(operation(
|
||||
"edit",
|
||||
"Replace a unique string (requires prior Read)",
|
||||
crate::file_operation::parameters("edit", true).unwrap(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
ops.push(operation(
|
||||
"write",
|
||||
"Overwrite this existing file (requires prior Read)",
|
||||
crate::file_operation::parameters("write", true).unwrap(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if has(Cap::Read, "List") {
|
||||
ops.push(operation(
|
||||
"list",
|
||||
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; pass returned after to continue. Limit defaults to 100, maximum 1000. List permission and provider Read/enumeration authority apply. Includes hidden/ignored names per provider List policy, unlike Glob/Grep.",
|
||||
vec![parameter("limit", false, TypeExpr::Integer), parameter("after", false, cursor_type())],
|
||||
));
|
||||
}
|
||||
if has(Cap::Glob, "Glob") {
|
||||
ops.push(operation(
|
||||
"glob",
|
||||
"Glob within this directory",
|
||||
vec![
|
||||
parameter("pattern", true, TypeExpr::String),
|
||||
parameter("path", false, TypeExpr::String),
|
||||
],
|
||||
));
|
||||
}
|
||||
if has(Cap::Grep, "Grep") {
|
||||
ops.push(operation(
|
||||
"grep",
|
||||
"Search within this directory with the existing Grep contract",
|
||||
grep_parameters(),
|
||||
));
|
||||
}
|
||||
if has(Cap::Write, "Write") {
|
||||
ops.push(operation("create_file", "Create a new relative file, creating permitted missing parents; never overwrite", vec![parameter("path",true,TypeExpr::String),parameter("content",true,TypeExpr::String)]));
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
fn interface_target(&self, reference: &wip_protocol::InterfaceReference) -> Option<String> {
|
||||
if reference.scope == ROOT {
|
||||
return Some(ROOT.into());
|
||||
}
|
||||
let (_, encoded) = reference.name.rsplit_once("#target/")?;
|
||||
let path = decode_identity(encoded)?;
|
||||
let (alias, _) = self.parse(&path)?;
|
||||
(reference.scope == checkout_root(&alias)).then_some(path)
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let _permit = tokio::time::timeout(self.deadline, self.permits.acquire())
|
||||
.await
|
||||
@@ -588,8 +528,14 @@ impl FileHandler {
|
||||
if let Some(raw) = result.validator {
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider
|
||||
.wrap_validator(&self.alias, self.generation, &self.workdir, &raw),
|
||||
self.provider.wrap_validator(
|
||||
&self.alias,
|
||||
self.generation,
|
||||
&self.workdir,
|
||||
&self.target,
|
||||
self.observation.capabilities,
|
||||
&raw,
|
||||
),
|
||||
))
|
||||
} else {
|
||||
Ok(WipOperationOutput::native(value))
|
||||
@@ -604,6 +550,127 @@ impl WipOperationHandler for FileHandler {
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if operation == "read_bytes" {
|
||||
let (offset, limit) = crate::file_operation::byte_range(arguments)?;
|
||||
if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {return Err(protocol(ProtocolErrorCode::PermissionDenied,"byte read denied"));}
|
||||
let _permit =
|
||||
tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"byte read capacity deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| protocol(ProtocolErrorCode::Internal, "byte read unavailable"))?;
|
||||
let (read, raw) = tokio::time::timeout(
|
||||
self.provider.deadline,
|
||||
tools::checkout::read_checkout_bytes(
|
||||
self.provider.router.clone(),
|
||||
&self.alias,
|
||||
self.generation,
|
||||
self.target.clone(),
|
||||
self.observation.validator.clone(),
|
||||
offset,
|
||||
limit,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"byte read deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
self.check_connection()?;
|
||||
let value = crate::file_operation::bytes_result(arguments, &read.bytes, read.eof)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider.wrap_validator(
|
||||
&self.alias,
|
||||
self.generation,
|
||||
&self.workdir,
|
||||
&self.target,
|
||||
self.observation.capabilities,
|
||||
&raw,
|
||||
),
|
||||
));
|
||||
}
|
||||
if matches!(operation, "read_lines" | "search_text") {
|
||||
let (offset, limit) =
|
||||
crate::file_operation::window(arguments, operation == "search_text")?;
|
||||
if !self.provider.allowed("Read",&json!({"target_workdir":self.alias,"file_path":self.target.as_str(),"offset":offset,"limit":limit})) {
|
||||
return Err(protocol(ProtocolErrorCode::PermissionDenied,"text read denied"));
|
||||
}
|
||||
let _permit =
|
||||
tokio::time::timeout(self.provider.deadline, self.provider.permits.acquire())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text read capacity deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(|_| protocol(ProtocolErrorCode::Internal, "text read unavailable"))?;
|
||||
let result = tokio::time::timeout(
|
||||
self.provider.deadline,
|
||||
tools::checkout::read_checkout_text(
|
||||
self.provider.router.clone(),
|
||||
self.provider.tracker.clone(),
|
||||
&self.alias,
|
||||
self.generation,
|
||||
self.target.clone(),
|
||||
self.observation.validator.clone(),
|
||||
offset,
|
||||
limit,
|
||||
crate::file_operation::MAX_BYTES,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text read deadline",
|
||||
)
|
||||
})?
|
||||
.map_err(map_tool_error)?;
|
||||
let (read, validator, record_read) = result;
|
||||
self.check_connection()?;
|
||||
if read.byte_truncated {
|
||||
return Err(protocol(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text line window exceeds the provider byte budget; use byte ranges",
|
||||
));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&read.bytes);
|
||||
let value = crate::file_operation::page_result(
|
||||
operation,
|
||||
arguments,
|
||||
&text,
|
||||
offset,
|
||||
read.truncated,
|
||||
)?;
|
||||
// Only a successfully rendered Read can establish mutation authority.
|
||||
// Search and failed result budgets must leave prior-Read history unchanged.
|
||||
if operation == "read_lines" {
|
||||
record_read();
|
||||
}
|
||||
return Ok(match validator {
|
||||
Some(raw) => WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.provider.wrap_validator(
|
||||
&self.alias,
|
||||
self.generation,
|
||||
&self.workdir,
|
||||
&self.target,
|
||||
self.observation.capabilities,
|
||||
&raw,
|
||||
),
|
||||
),
|
||||
None => WipOperationOutput::native(value),
|
||||
});
|
||||
}
|
||||
let tool = match operation {
|
||||
"list" => "List",
|
||||
"read" => "Read",
|
||||
@@ -752,14 +819,6 @@ fn join_under(parent: &WorkdirPath, relative: &str) -> Result<String, WipOperati
|
||||
format!("{}/{}", parent.as_str(), child.as_str())
|
||||
})
|
||||
}
|
||||
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn operation(
|
||||
name: &str,
|
||||
summary: &str,
|
||||
@@ -774,53 +833,10 @@ fn operation(
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: result_type(name == "list"),
|
||||
r#type: TypeExpr::Json,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn field(name: &str, required: bool, r#type: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: name.into(),
|
||||
required,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
fn cursor_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("entry", true, TypeExpr::Entry),
|
||||
],
|
||||
}
|
||||
}
|
||||
fn result_type(list: bool) -> TypeExpr {
|
||||
let mut item = vec![field("entry", true, TypeExpr::Entry)];
|
||||
if list {
|
||||
item.extend([
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("size", true, TypeExpr::Integer),
|
||||
]);
|
||||
}
|
||||
let mut fields = vec![
|
||||
field("summary", true, TypeExpr::String),
|
||||
field("content", false, TypeExpr::String),
|
||||
field(
|
||||
"items",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record { fields: item }),
|
||||
},
|
||||
),
|
||||
];
|
||||
if list {
|
||||
fields.extend([
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
field("after", false, cursor_type()),
|
||||
]);
|
||||
}
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn kind_name(kind: EntryKind) -> &'static str {
|
||||
match kind {
|
||||
EntryKind::Directory => "directory",
|
||||
@@ -841,19 +857,6 @@ fn cursor_value(alias: &str, cursor: &workdir::ListCursor) -> Value {
|
||||
fn descriptor(operations: Vec<OperationDeclaration>) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor{format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation{summary:"AI-oriented attached Workdir operations".into(),details:Some("The route binds attachment and target. Line offsets are zero-based; text output numbers lines from one. Read-before-write and provider validators are both required. Paths are Worldspace references, never host paths.".into())}),types:Vec::new(),operations}
|
||||
}
|
||||
fn grep_parameters() -> Vec<ParameterDeclaration> {
|
||||
let mut p = vec![parameter("pattern", true, TypeExpr::String)];
|
||||
for name in ["path", "glob", "type", "output_mode"] {
|
||||
p.push(parameter(name, false, TypeExpr::String));
|
||||
}
|
||||
for name in ["-A", "-B", "-C", "head_limit", "offset"] {
|
||||
p.push(parameter(name, false, TypeExpr::Integer));
|
||||
}
|
||||
for name in ["case_insensitive", "multiline"] {
|
||||
p.push(parameter(name, false, TypeExpr::Boolean));
|
||||
}
|
||||
p
|
||||
}
|
||||
fn error(code: ProtocolErrorCode, message: &str) -> ProtocolError {
|
||||
ProtocolError {
|
||||
code,
|
||||
|
||||
@@ -31,11 +31,18 @@ enum WriteResponseFault {
|
||||
LostBody,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum CommandResponseFault {
|
||||
LostTerminal,
|
||||
EmptyWaiting,
|
||||
}
|
||||
|
||||
struct ProviderState {
|
||||
session: WorkdirSessionHandle,
|
||||
requests: Mutex<Vec<(String, String)>>,
|
||||
operations: Mutex<Vec<WorkdirSessionOperation>>,
|
||||
write_fault: Mutex<Option<WriteResponseFault>>,
|
||||
command_fault: Mutex<Option<CommandResponseFault>>,
|
||||
observe_refusal: Mutex<bool>,
|
||||
}
|
||||
|
||||
@@ -152,6 +159,21 @@ async fn operate(
|
||||
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())),
|
||||
});
|
||||
}
|
||||
if let WorkdirSessionOperation::CommandOutput(output) = &request.operation {
|
||||
if matches!(
|
||||
*state.command_fault.lock().unwrap(),
|
||||
Some(CommandResponseFault::EmptyWaiting)
|
||||
) {
|
||||
return AxumJson(workdir::http::WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
chunks: vec![],
|
||||
next_cursor: output.cursor,
|
||||
terminal: None,
|
||||
},
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let result =
|
||||
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
|
||||
match result {
|
||||
@@ -159,6 +181,15 @@ async fn operate(
|
||||
Ok(result) => {
|
||||
let fault = if is_write {
|
||||
state.write_fault.lock().unwrap().take()
|
||||
} else if matches!(&result, workdir::http::WorkdirSessionOperationResult::CommandOutput(output) if output.terminal.is_some())
|
||||
{
|
||||
let mut command_fault = state.command_fault.lock().unwrap();
|
||||
if matches!(*command_fault, Some(CommandResponseFault::LostTerminal)) {
|
||||
command_fault.take();
|
||||
Some(WriteResponseFault::LostBody)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -237,6 +268,7 @@ impl HttpFixture {
|
||||
requests: Mutex::new(Vec::new()),
|
||||
operations: Mutex::new(Vec::new()),
|
||||
write_fault: Mutex::new(None),
|
||||
command_fault: Mutex::new(None),
|
||||
observe_refusal: Mutex::new(false),
|
||||
});
|
||||
let app = Router::new()
|
||||
@@ -358,7 +390,8 @@ async fn observe_interface(runtime: &WipRuntime, path: &str) -> wip_protocol::In
|
||||
}
|
||||
|
||||
async fn native_call(runtime: &WipRuntime, path: &str, operation: &str, args: Json) -> Value {
|
||||
let interface = observe_interface(runtime, path).await;
|
||||
observe_interface(runtime, path).await;
|
||||
let interface = super::checkout_wip_tests::contract(operation).reference();
|
||||
runtime
|
||||
.call(
|
||||
path.into(),
|
||||
@@ -620,7 +653,8 @@ async fn committed_write_unknown(fault: WriteResponseFault) {
|
||||
let (runtime, router) = native_runtime(remote);
|
||||
let file = "/checkouts/main/src/deep/a.txt";
|
||||
native_call(&runtime, file, "read", json!({})).await;
|
||||
let interface = observe_interface(&runtime, file).await;
|
||||
observe_interface(&runtime, file).await;
|
||||
let interface = super::checkout_wip_tests::contract("write").reference();
|
||||
*fixture.state.write_fault.lock().unwrap() = Some(fault);
|
||||
let error = runtime
|
||||
.call(
|
||||
@@ -798,3 +832,175 @@ async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_wit
|
||||
"refusal was retried"
|
||||
);
|
||||
}
|
||||
|
||||
// Full collection/projection integration: production HTTP client, local process pipes,
|
||||
// Bash collector/store, WIP codec and common file dispatch. Only HTTP response loss is injected.
|
||||
#[tokio::test]
|
||||
async fn remote_native_bash_retains_streams_quota_and_replays_lost_terminal_through_common_files() {
|
||||
watchdog(async {
|
||||
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
|
||||
let remote = Arc::new(f.open(TOKEN).await.unwrap());
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router.attach(WorkdirAttachmentAlias::new("main").unwrap(), remote.clone()).unwrap();
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None).unwrap();
|
||||
let view = tools::bash_output::BashOutputView::new();
|
||||
crate::feature::builtin::bash::mount(&mut registry, router.clone(), view, None).unwrap();
|
||||
let runtime = WipRuntime::new(WipHost::new(registry), SecurityContext::new("remote-bash"), 0).unwrap();
|
||||
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::LostTerminal);
|
||||
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":r"printf 'first\nneedle\n'; printf '%200000s' ''; printf '\377€' >&2; exit 7","cwd":"."}}), Default::default()).await.unwrap();
|
||||
f.public_output(run.content.as_ref().unwrap());
|
||||
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
|
||||
assert_eq!(result["metadata"]["stdout_saved"], 200013);
|
||||
assert_eq!(result["metadata"]["stderr_saved"], 4);
|
||||
assert_eq!(result["metadata"]["terminal"]["exit_code"], 7);
|
||||
assert_eq!(result["metadata"]["terminal"]["transport_complete"], true);
|
||||
assert_eq!(result["metadata"]["storage_truncated"], false);
|
||||
assert_eq!(result["preview_omitted"], true);
|
||||
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
|
||||
runtime.inspect(result[name].as_str().unwrap().into(), false).await.unwrap();
|
||||
}
|
||||
let stdout = result["stdout_entry"].as_str().unwrap();
|
||||
let search = runtime.invoke(stdout.into(), crate::file_operation::Contract::SearchText.reference(), "search".into(), json!({"pattern":"needle","limit":2}), Default::default()).await.unwrap();
|
||||
assert!(search.content.unwrap().contains("needle"));
|
||||
let stderr = runtime.invoke(result["stderr_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"limit":4}), Default::default()).await.unwrap();
|
||||
let stderr: Json = serde_json::from_str(&stderr.content.unwrap()).unwrap();
|
||||
use base64::Engine as _;
|
||||
assert_eq!(base64::engine::general_purpose::STANDARD.decode(stderr["data"].as_str().unwrap()).unwrap(), [vec![255], "€".as_bytes().to_vec()].concat());
|
||||
let run = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf '%4194303s' ''; printf '€'","cwd":"."}}), Default::default()).await.unwrap();
|
||||
let result: Json = serde_json::from_str(&run.content.unwrap()).unwrap();
|
||||
assert_eq!(result["metadata"]["stdout_saved"], 4194304);
|
||||
assert_eq!(result["metadata"]["stdout_seen"], 4194306);
|
||||
assert_eq!(result["metadata"]["transport_truncation"], "byte_budget");
|
||||
assert_eq!(result["metadata"]["storage_truncated"], true);
|
||||
let tail = runtime.invoke(result["stdout_entry"].as_str().unwrap().into(), crate::file_operation::Contract::ReadBytes.reference(), "read".into(), json!({"offset":4194303,"limit":4}), Default::default()).await.unwrap();
|
||||
let tail: Json = serde_json::from_str(&tail.content.unwrap()).unwrap();
|
||||
assert_eq!(base64::engine::general_purpose::STANDARD.decode(tail["data"].as_str().unwrap()).unwrap(), vec![0xe2]);
|
||||
assert_eq!(tail["eof"], true);
|
||||
assert_eq!(f.state.operations.lock().unwrap().iter().filter(|op| matches!(op, WorkdirSessionOperation::CommandStart(_))).count(), 2);
|
||||
assert!(f.state.command_fault.lock().unwrap().is_none());
|
||||
router.close_all().await.unwrap();
|
||||
f.assert_endpoints();
|
||||
}).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_remote_command_rejects_empty_response_without_http_busy_loop() {
|
||||
watchdog(async {
|
||||
let f = HttpFixture::new(WorkdirSessionCapabilities::ALL).await;
|
||||
let remote = f.open(TOKEN).await.unwrap();
|
||||
*f.state.command_fault.lock().unwrap() = Some(CommandResponseFault::EmptyWaiting);
|
||||
let error = remote
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: workdir::CommandHandle("unused".into()),
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, WorkdirError::Unavailable(_)));
|
||||
assert_eq!(
|
||||
f.state
|
||||
.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|op| matches!(op, WorkdirSessionOperation::CommandOutput(_)))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
remote.close().await.unwrap();
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_checked_byte_read_uses_common_contract_and_keeps_binary_boundaries() {
|
||||
use base64::Engine as _;
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
|
||||
let remote = Arc::new(fixture.open(TOKEN).await.unwrap());
|
||||
let (runtime, _) = native_runtime(remote);
|
||||
let path = "/checkouts/main/src/deep/a.txt";
|
||||
observe_interface(&runtime, path).await;
|
||||
let output = runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
crate::file_operation::Contract::ReadBytes.reference(),
|
||||
"read".into(),
|
||||
json!({"offset":1,"limit":3}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let json: Json = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(json["encoding"], "base64");
|
||||
assert_eq!(json["offset"], 1);
|
||||
assert_eq!(json["next_offset"], 4);
|
||||
let source = fixture
|
||||
.state
|
||||
.session
|
||||
.read_bytes(workdir::ReadBytesRequest {
|
||||
path: WorkdirPath::new("src/deep/a.txt").unwrap(),
|
||||
offset: 1,
|
||||
max_bytes: 3,
|
||||
expected_hash: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(json["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
source.bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_failed_text_read_does_not_authorize_mutations() {
|
||||
for operation in ["write", "edit"] {
|
||||
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
checkout_wip_tests::failed_text_read_cannot_authorize_mutation(
|
||||
Arc::new(remote),
|
||||
&fixture.dir.path().join("text"),
|
||||
&oversized,
|
||||
operation,
|
||||
)
|
||||
.await;
|
||||
let mutations = fixture
|
||||
.state
|
||||
.operations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|op| {
|
||||
matches!(
|
||||
op,
|
||||
WorkdirSessionOperation::CheckoutExecute(CheckoutRequest {
|
||||
operation: CheckoutOperation::Write { .. }
|
||||
| CheckoutOperation::Edit { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
mutations, 1,
|
||||
"only the mutation after a successful Read may reach the provider"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() {
|
||||
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_WRITE).await;
|
||||
let remote = fixture.open(TOKEN).await.unwrap();
|
||||
checkout_wip_tests::assert_long_utf8_search_refuses_incomplete_line(
|
||||
Arc::new(remote),
|
||||
fixture.dir.path(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -683,7 +683,12 @@ async fn committed_checkout_mutation_losing_connection_is_unknown_not_rejected_o
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let route = object_path("main", &file.target);
|
||||
let mut projection = p.node(&route).await.unwrap().unwrap();
|
||||
let interface = projection.interface.clone();
|
||||
let interface = match operation {
|
||||
"write" => crate::file_operation::Contract::Write,
|
||||
"edit" => crate::file_operation::Contract::Edit,
|
||||
_ => crate::file_operation::Contract::CreateFile,
|
||||
}
|
||||
.reference();
|
||||
projection.handler = Arc::new(DetachBeforePublication {
|
||||
file,
|
||||
calls: calls.clone(),
|
||||
|
||||
@@ -41,13 +41,28 @@ async fn interface(r: &WipRuntime, path: &str) -> wip_protocol::InterfaceReferen
|
||||
.unwrap();
|
||||
p.interface
|
||||
}
|
||||
pub(super) fn contract(operation: &str) -> crate::file_operation::Contract {
|
||||
use crate::file_operation::Contract as C;
|
||||
match operation {
|
||||
"read" => C::ReadLines,
|
||||
"search" => C::SearchText,
|
||||
"edit" => C::Edit,
|
||||
"write" => C::Write,
|
||||
"list" => C::List,
|
||||
"glob" => C::Glob,
|
||||
"grep" => C::Grep,
|
||||
"create_file" => C::CreateFile,
|
||||
_ => panic!("unknown file operation {operation}"),
|
||||
}
|
||||
}
|
||||
async fn call(
|
||||
r: &WipRuntime,
|
||||
path: &str,
|
||||
operation: &str,
|
||||
args: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let i = interface(r, path).await;
|
||||
interface(r, path).await;
|
||||
let i = contract(operation).reference();
|
||||
r.call(path.into(), i, operation.into(), args, Default::default())
|
||||
.await
|
||||
}
|
||||
@@ -336,12 +351,26 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
|
||||
let old = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
router.detach(&alias).await.unwrap();
|
||||
assert!(r.host.projection_live(path).await.unwrap().is_none());
|
||||
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&old.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
old.interface_validator
|
||||
);
|
||||
router.attach(alias, session(&b, false)).unwrap();
|
||||
let new = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
assert_ne!(old.interface, new.interface);
|
||||
assert_eq!(old.interface, new.interface);
|
||||
assert_ne!(old.object.validator, new.object.validator);
|
||||
assert!(r.host.fetch_interface_live(&old.interface).await.is_err());
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&old.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
old.interface_validator
|
||||
);
|
||||
assert!(
|
||||
call(
|
||||
&r,
|
||||
@@ -354,14 +383,16 @@ async fn checkout_wip_detach_alias_reuse_and_restore_fence_old_references() {
|
||||
);
|
||||
let restored = runtime(router, None);
|
||||
let next = restored.host.projection_live(path).await.unwrap().unwrap();
|
||||
assert_ne!(new.interface, next.interface);
|
||||
assert_eq!(new.interface, next.interface);
|
||||
assert_ne!(new.object.validator, next.object.validator);
|
||||
assert!(
|
||||
assert_eq!(
|
||||
restored
|
||||
.host
|
||||
.fetch_interface_live(&new.interface)
|
||||
.await
|
||||
.is_err()
|
||||
.unwrap()
|
||||
.validator,
|
||||
new.interface_validator
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(b.path().join("file.txt")).unwrap(),
|
||||
@@ -446,7 +477,11 @@ async fn checkout_wip_policy_is_not_feature_authority_and_compatibility_is_repla
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||
.unwrap();
|
||||
for def in tools::routed_builtin_tools(router, tools::Tracker::new(), dir.path().join("bash")) {
|
||||
for def in tools::routed_builtin_tools(
|
||||
router,
|
||||
tools::Tracker::new(),
|
||||
tools::bash_output::BashOutputView::new(),
|
||||
) {
|
||||
let (meta, tool) = def();
|
||||
let p = compatibility_projection(meta, tool, None).unwrap();
|
||||
registry.mount(p).unwrap();
|
||||
@@ -497,7 +532,7 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
|
||||
.unwrap();
|
||||
let r = runtime(router, None);
|
||||
let path = "/checkouts/main/file.txt";
|
||||
let i = interface(&r, path).await;
|
||||
interface(&r, path).await;
|
||||
// No rediscovery or Interface refresh between any of these operations.
|
||||
for (op, args) in [
|
||||
("read", json!({})),
|
||||
@@ -505,7 +540,13 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
|
||||
("write", json!({"content":"saved\n"})),
|
||||
("read", json!({})),
|
||||
] {
|
||||
r.call(path.into(), i.clone(), op.into(), args, Default::default())
|
||||
r.call(
|
||||
path.into(),
|
||||
contract(op).reference(),
|
||||
op.into(),
|
||||
args,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -514,7 +555,8 @@ async fn checkout_wip_post_operation_validators_support_cached_chains() {
|
||||
"saved\n"
|
||||
);
|
||||
let parent = "/checkouts/main";
|
||||
let i = interface(&r, parent).await;
|
||||
interface(&r, parent).await;
|
||||
let i = contract("create_file").reference();
|
||||
for name in ["one.txt", "nested/two.txt"] {
|
||||
r.call(
|
||||
parent.into(),
|
||||
@@ -617,11 +659,12 @@ async fn checkout_wip_scoped_child_search_and_revoke_cannot_expose_siblings() {
|
||||
.is_none()
|
||||
);
|
||||
let file = "/checkouts/child/a.txt";
|
||||
let i = interface(&child, file).await;
|
||||
interface(&child, file).await;
|
||||
let i = contract("write").reference();
|
||||
child
|
||||
.call(
|
||||
file.into(),
|
||||
i.clone(),
|
||||
contract("read").reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
Default::default(),
|
||||
@@ -686,7 +729,21 @@ pub(super) async fn model_invoke(
|
||||
operation: &str,
|
||||
arguments: Json,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":inspected["interfaces"][0]["reference"], "operation":operation, "arguments":arguments})).await
|
||||
let reference = inspected["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|item| &item["reference"])
|
||||
.find(|r| {
|
||||
Some(wip_protocol::InterfaceReference {
|
||||
scope: r["scope"].as_str().unwrap().into(),
|
||||
name: r["name"].as_str().unwrap().into(),
|
||||
})
|
||||
.and_then(|r| crate::file_operation::Contract::from_reference(&r))
|
||||
.is_some_and(|c| c.operation() == operation)
|
||||
})
|
||||
.unwrap_or(&inspected["interfaces"][0]["reference"]);
|
||||
model_operation(r, "Invoke", json!({"path":inspected["path"], "interface":reference, "operation":operation, "arguments":arguments})).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -752,10 +809,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let entrance = model_inspect(r.clone(), "/checkouts/main").await;
|
||||
assert_eq!(entrance["path"], "/checkouts/main");
|
||||
assert_eq!(
|
||||
entrance["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
assert_eq!(entrance["interfaces"][0]["reference"]["scope"], "/");
|
||||
let projection = r
|
||||
.host
|
||||
.projection_live("/checkouts/main")
|
||||
@@ -829,10 +883,7 @@ async fn checkout_model_list_glob_grep_entries_are_lazy_and_never_become_tree_ed
|
||||
json!([])
|
||||
);
|
||||
let deep = model_inspect(r.clone(), "/checkouts/main/nested/deep").await;
|
||||
assert_eq!(
|
||||
deep["interfaces"][0]["reference"]["scope"],
|
||||
"/checkouts/main"
|
||||
);
|
||||
assert_eq!(deep["interfaces"][0]["reference"]["scope"], "/");
|
||||
for (operation, args) in [
|
||||
("list", json!({})),
|
||||
("glob", json!({"pattern":"*.txt"})),
|
||||
@@ -926,7 +977,7 @@ async fn checkout_list_permission_cannot_be_bypassed_with_glob_or_command_identi
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connection_before_read() {
|
||||
async fn common_scope_rejects_wrong_namespace_scope_ref_and_old_target_authority() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "same\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
@@ -943,8 +994,8 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let fetched = r.host.fetch_interface_live(&p.interface).await.unwrap();
|
||||
assert!(
|
||||
fetched.scope_ref.is_none(),
|
||||
"ref-less publication is legitimate"
|
||||
fetched.scope_ref.is_some(),
|
||||
"common namespace uses the Host root scope"
|
||||
);
|
||||
// Ordinary content changes do not terminate the entrance's Interface lifetime.
|
||||
std::fs::write(dir.path().join("new.txt"), "fixture").unwrap();
|
||||
@@ -959,12 +1010,12 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
|
||||
let request = |reference, scope_ref| CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: path.into(),
|
||||
validator: None,
|
||||
validator: p.object.validator.clone(),
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference,
|
||||
scope_ref,
|
||||
validator: None,
|
||||
validator: fetched.validator.clone(),
|
||||
},
|
||||
operation: "read".into(),
|
||||
arguments: BTreeMap::new(),
|
||||
@@ -996,6 +1047,654 @@ async fn checkout_ancestor_scope_rejects_siblings_prefixes_refs_and_old_connecti
|
||||
router.attach(main, session(&dir, true)).unwrap();
|
||||
let forged = request(p.interface.clone(), None);
|
||||
assert!(
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::InterfaceMismatch)
|
||||
matches!(r.host.call(forged, WipCallContext { execution:Default::default(),security_context:"fixture".into() }).await, Err(WipOperationError::Protocol(e)) if e.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
}
|
||||
|
||||
fn observed_common_request(
|
||||
path: &str,
|
||||
validator: Option<Vec<u8>>,
|
||||
fetched: &FetchInterfaceResponse,
|
||||
operation: &str,
|
||||
) -> CallOperationRequest {
|
||||
CallOperationRequest {
|
||||
target: wip_protocol::Target {
|
||||
path: path.into(),
|
||||
validator,
|
||||
},
|
||||
interface: wip_protocol::InterfaceTarget {
|
||||
reference: fetched.interface.clone(),
|
||||
scope_ref: fetched.scope_ref.clone(),
|
||||
validator: fetched.validator.clone(),
|
||||
},
|
||||
operation: operation.into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
fn file_context() -> WipCallContext {
|
||||
WipCallContext {
|
||||
execution: Default::default(),
|
||||
security_context: "file-contract-test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_contract_survives_one_attachment_update_but_old_object_authority_never_transfers() {
|
||||
let left = TempDir::new().unwrap();
|
||||
let right = TempDir::new().unwrap();
|
||||
for root in [&left, &right] {
|
||||
std::fs::write(root.path().join("a.txt"), "same\n").unwrap();
|
||||
}
|
||||
std::fs::write(left.path().join("b.txt"), "same\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
let alias = WorkdirAttachmentAlias::new("left").unwrap();
|
||||
router.attach(alias.clone(), session(&left, false)).unwrap();
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("right").unwrap(),
|
||||
session(&right, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = runtime(router.clone(), None);
|
||||
let path = "/checkouts/left/a.txt";
|
||||
let other = "/checkouts/right/a.txt";
|
||||
let first = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let same_bytes = r
|
||||
.host
|
||||
.projection_live("/checkouts/left/b.txt")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let independent = r.host.projection_live(other).await.unwrap().unwrap();
|
||||
assert_eq!(first.interface, independent.interface);
|
||||
assert_eq!(first.descriptor, independent.descriptor);
|
||||
assert_ne!(first.object.validator, same_bytes.object.validator);
|
||||
let definition = r.host.fetch_interface_live(&first.interface).await.unwrap();
|
||||
let request =
|
||||
observed_common_request(path, first.object.validator.clone(), &definition, "read");
|
||||
assert!(r.host.call(request.clone(), file_context()).await.is_ok());
|
||||
// Replace the same path with a different inode containing exactly the same bytes.
|
||||
std::fs::write(left.path().join("replacement"), "same\n").unwrap();
|
||||
std::fs::rename(left.path().join("replacement"), left.path().join("a.txt")).unwrap();
|
||||
assert!(
|
||||
matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&first.interface)
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
definition.validator
|
||||
);
|
||||
assert!(
|
||||
r.host
|
||||
.call(
|
||||
observed_common_request(
|
||||
other,
|
||||
independent.object.validator.clone(),
|
||||
&definition,
|
||||
"read"
|
||||
),
|
||||
file_context()
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
call(&r, path, "read", json!({})).await.unwrap();
|
||||
router.detach(&alias).await.unwrap();
|
||||
router.attach(alias, session(&left, false)).unwrap();
|
||||
assert!(
|
||||
matches!(r.host.call(request,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
assert!(
|
||||
r.host
|
||||
.call(
|
||||
observed_common_request(other, independent.object.validator, &definition, "read"),
|
||||
file_context()
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
call(&r, path, "read", json!({})).await.unwrap();
|
||||
let before_restore = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let restored = runtime(router, None);
|
||||
let restored_definition = restored
|
||||
.host
|
||||
.fetch_interface_live(&first.interface)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(definition.descriptor, restored_definition.descriptor);
|
||||
assert_eq!(definition.validator, restored_definition.validator);
|
||||
assert!(
|
||||
matches!(restored.host.call(observed_common_request(path,before_restore.object.validator,&restored_definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
call(&restored, path, "read", json!({})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_readonly_contract_rejects_wrong_membership_operation_and_interface_validator() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a.txt"), "雪.*\r\nother\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let path = "/checkouts/main/a.txt";
|
||||
let observed = model_inspect(r.clone(), path).await;
|
||||
let read = json_content(
|
||||
model_invoke(r.clone(), &observed, "read", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
read,
|
||||
json!({"content":" 1\t雪.*\n","next_offset":1,"truncated":true})
|
||||
);
|
||||
let searched = json_content(
|
||||
model_invoke(
|
||||
r.clone(),
|
||||
&observed,
|
||||
"search",
|
||||
json!({"pattern":".*","limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
searched,
|
||||
json!({"matches":[{"line":1,"text":"雪.*"}],"next_offset":1,"truncated":true})
|
||||
);
|
||||
let p = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let read = r
|
||||
.host
|
||||
.fetch_interface_live(&contract("read").reference())
|
||||
.await
|
||||
.unwrap();
|
||||
let write = r
|
||||
.host
|
||||
.fetch_interface_live(&contract("write").reference())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&write,"write"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
assert!(
|
||||
matches!(r.host.call(observed_common_request(path,p.object.validator.clone(),&read,"missing"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::OperationNotFound)
|
||||
);
|
||||
let mut stale = observed_common_request(path, p.object.validator.clone(), &read, "read");
|
||||
stale.interface.validator = Some(vec![0]);
|
||||
assert!(
|
||||
matches!(r.host.call(stale,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceValidatorMismatch)
|
||||
);
|
||||
let mut legacy = observed_common_request(path, p.object.validator, &read, "read");
|
||||
legacy.interface.reference = InterfaceReference {
|
||||
scope: "/checkouts/main".into(),
|
||||
name: "yoi.checkout/old/file/v1#target/old".into(),
|
||||
};
|
||||
assert!(
|
||||
matches!(r.host.call(legacy,file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InterfaceMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
|
||||
"雪.*\r\nother\n"
|
||||
);
|
||||
}
|
||||
|
||||
// The fixture substitutes only a binary source. Real Host/Client/model tools, fixed common
|
||||
// descriptors, membership, validators and output decoding stay on their production path.
|
||||
struct ReadonlyBytes {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
version: Arc<std::sync::atomic::AtomicU64>,
|
||||
captured: u64,
|
||||
}
|
||||
impl ReadonlyBytes {
|
||||
fn projection(&self) -> WipProjection {
|
||||
let contract = crate::file_operation::Contract::ReadBytes;
|
||||
WipProjection {
|
||||
route: "/virtual".into(),
|
||||
capability: "virtual:bytes".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "virtual".into(),
|
||||
description: None,
|
||||
interfaces: vec![contract.reference()],
|
||||
r#ref: None,
|
||||
validator: Some(self.captured.to_be_bytes().to_vec()),
|
||||
},
|
||||
interface: contract.reference(),
|
||||
descriptor: contract.descriptor(),
|
||||
interface_validator: Some(contract.validator()),
|
||||
handler: Arc::new(Self {
|
||||
bytes: self.bytes.clone(),
|
||||
version: self.version.clone(),
|
||||
captured: self.captured,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for ReadonlyBytes {
|
||||
async fn call(
|
||||
&self,
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if op != "read_bytes" {
|
||||
return Err(WipOperationError::Protocol(protocol_error(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"readonly bytes",
|
||||
)));
|
||||
}
|
||||
if self.captured != self.version.load(Ordering::SeqCst) {
|
||||
return Err(WipOperationError::Protocol(protocol_error(
|
||||
ProtocolErrorCode::ValidatorMismatch,
|
||||
"virtual source replaced",
|
||||
)));
|
||||
}
|
||||
let (offset, limit) = crate::file_operation::byte_range(args)?;
|
||||
if offset > self.bytes.len() as u64 {
|
||||
return Err(WipOperationError::Protocol(protocol_error(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"past EOF",
|
||||
)));
|
||||
}
|
||||
let end = (offset as usize + limit).min(self.bytes.len());
|
||||
Ok(WipOperationOutput::native(
|
||||
crate::file_operation::bytes_result(
|
||||
args,
|
||||
&self.bytes[offset as usize..end],
|
||||
end == self.bytes.len(),
|
||||
)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for ReadonlyBytes {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
Ok((path == "/virtual").then(|| {
|
||||
let current = Self {
|
||||
bytes: self.bytes.clone(),
|
||||
version: self.version.clone(),
|
||||
captured: self.version.load(Ordering::SeqCst),
|
||||
};
|
||||
WipPublication {
|
||||
projection: current.projection(),
|
||||
scope: None,
|
||||
}
|
||||
}))
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn common_bytes_read_long_lines_invalid_utf8_arbitrary_boundaries_and_eof_without_write_authority()
|
||||
{
|
||||
use base64::Engine as _;
|
||||
let mut bytes = "雪".as_bytes().to_vec();
|
||||
bytes.extend_from_slice(&[0xff, 0, 0xc0]);
|
||||
bytes.extend(vec![b'x'; 100_000]);
|
||||
let bytes = Arc::new(bytes);
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("raw"), bytes.as_ref()).unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
session(&dir, true),
|
||||
)
|
||||
.unwrap();
|
||||
let source = Arc::new(ReadonlyBytes {
|
||||
bytes: bytes.clone(),
|
||||
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
||||
captured: 1,
|
||||
});
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router, tools::Tracker::new(), None).unwrap();
|
||||
registry.allocate_namespace("virtual", "virtual").unwrap();
|
||||
registry.mount(source.projection()).unwrap();
|
||||
registry
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: "/virtual".into(),
|
||||
provider: source.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
let r = Arc::new(WipRuntime::from_mounts(registry, "bytes-test".into()).unwrap());
|
||||
let virtual_inspect = model_inspect(r.clone(), "/virtual").await;
|
||||
let reference = virtual_inspect["interfaces"][0]["reference"].clone();
|
||||
let checkout = model_inspect(r.clone(), "/checkouts/main/raw").await;
|
||||
assert!(
|
||||
checkout["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"] == reference)
|
||||
);
|
||||
for path in ["/virtual", "/checkouts/main/raw"] {
|
||||
for offset in [0, 1, 2, 3, 5, 80_000, bytes.len()] {
|
||||
let output=json_content(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":{"offset":offset,"limit":17}})).await.unwrap());
|
||||
let end = (offset + 17).min(bytes.len());
|
||||
assert_eq!(output["encoding"], "base64");
|
||||
assert_eq!(output["offset"], offset);
|
||||
assert_eq!(output["next_offset"], end);
|
||||
assert_eq!(output["eof"], end == bytes.len());
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(output["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
bytes[offset..end]
|
||||
);
|
||||
}
|
||||
for arguments in [
|
||||
json!({"limit":0}),
|
||||
json!({"limit":32769}),
|
||||
json!({"offset":-1}),
|
||||
json!({"offset":9_007_199_254_740_991i64}),
|
||||
json!({"offset":bytes.len()+1}),
|
||||
json!({"file_path":"other"}),
|
||||
] {
|
||||
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":reference,"operation":"read","arguments":arguments})).await.is_err());
|
||||
}
|
||||
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":{"scope":"/","name":"yoi.file/write-text/v1"},"operation":"write","arguments":{"content":"lost"}})).await.is_err());
|
||||
}
|
||||
let fetched = r
|
||||
.host
|
||||
.fetch_interface_live(&crate::file_operation::Contract::ReadBytes.reference())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut request = observed_common_request(
|
||||
"/virtual",
|
||||
Some(1u64.to_be_bytes().to_vec()),
|
||||
&fetched,
|
||||
"read",
|
||||
);
|
||||
source.version.store(2, Ordering::SeqCst);
|
||||
assert!(
|
||||
matches!(r.host.call(request.clone(),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
request.target.validator = Some(2u64.to_be_bytes().to_vec());
|
||||
assert!(r.host.call(request, file_context()).await.is_ok());
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("raw")).unwrap(),
|
||||
bytes.as_ref().clone()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scope_downgrade_changes_object_membership_not_common_contract_and_read_remains_available()
|
||||
{
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("a"), "same").unwrap();
|
||||
let scope = manifest::SharedScope::new(manifest::Scope::writable(dir.path()).unwrap());
|
||||
let session = Arc::new(LocalWorkdirSession::materialized(
|
||||
dir.path().into(),
|
||||
dir.path().into(),
|
||||
scope.clone(),
|
||||
WorkdirSessionCapabilities::READ_WRITE,
|
||||
));
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let path = "/checkouts/main/a";
|
||||
let old = model_inspect(r.clone(), path).await;
|
||||
let current = r.host.projection_live(path).await.unwrap().unwrap();
|
||||
let definition = r
|
||||
.host
|
||||
.fetch_interface_live(&contract("read").reference())
|
||||
.await
|
||||
.unwrap();
|
||||
scope
|
||||
.update(|_| {
|
||||
manifest::Scope::from_config(&manifest::ScopeConfig {
|
||||
allow: vec![manifest::ScopeRule {
|
||||
target: dir.path().into(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: manifest::SymlinkPolicy::Resolved,
|
||||
}],
|
||||
deny: vec![],
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
assert!(model_operation(r.clone(),"Invoke",json!({"path":path,"interface":contract_json("write"),"operation":"write","arguments":{"content":"lost"}})).await.is_err());
|
||||
let new = json_content(
|
||||
model_operation(r.clone(), "Inspect", json!({"path":path,"refresh":true}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_ne!(old["object_signature"], new["object_signature"]);
|
||||
assert!(
|
||||
!new["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
r.host
|
||||
.fetch_interface_live(&contract("read").reference())
|
||||
.await
|
||||
.unwrap()
|
||||
.validator,
|
||||
definition.validator
|
||||
);
|
||||
assert!(
|
||||
matches!(r.host.call(observed_common_request(path,current.object.validator,&definition,"read"),file_context()).await,Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
assert_eq!(
|
||||
json_content(
|
||||
model_invoke(r.clone(), &new, "read", json!({}))
|
||||
.await
|
||||
.unwrap()
|
||||
)["content"],
|
||||
" 1\tsame\n"
|
||||
);
|
||||
}
|
||||
fn contract_json(operation: &str) -> Json {
|
||||
let r = contract(operation).reference();
|
||||
json!({"scope":r.scope,"name":r.name})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn common_contract_registration_rejects_descriptor_redefinition_and_domain_operation_injection() {
|
||||
let source = ReadonlyBytes {
|
||||
bytes: Arc::new(vec![]),
|
||||
version: Arc::new(std::sync::atomic::AtomicU64::new(1)),
|
||||
captured: 1,
|
||||
};
|
||||
let mut registry = WipMountRegistry::new();
|
||||
registry.allocate_namespace("virtual", "virtual").unwrap();
|
||||
let mut invalid = source.projection();
|
||||
invalid.descriptor.operations[0]
|
||||
.parameters
|
||||
.push(ParameterDeclaration {
|
||||
name: "target_override".into(),
|
||||
required: false,
|
||||
documentation: None,
|
||||
r#type: TypeExpr::String,
|
||||
});
|
||||
assert!(matches!(
|
||||
registry.mount(invalid),
|
||||
Err(WipMountError::InvalidProjection { .. })
|
||||
));
|
||||
let projection = source.projection();
|
||||
registry.mount(projection.clone()).unwrap();
|
||||
let mut extension = projection.descriptor.clone();
|
||||
extension.operations[0].name = "erase".into();
|
||||
assert!(matches!(
|
||||
registry.contribute_operations(WipOperationContribution {
|
||||
route: "/virtual".into(),
|
||||
contributor: "other".into(),
|
||||
interface: projection.interface,
|
||||
descriptor: extension,
|
||||
handler: projection.handler
|
||||
}),
|
||||
Err(WipMountError::InterfaceCollision { .. })
|
||||
));
|
||||
}
|
||||
|
||||
pub(super) async fn failed_text_read_cannot_authorize_mutation(
|
||||
source: WorkdirSessionHandle,
|
||||
disk_file: &std::path::Path,
|
||||
oversized: &str,
|
||||
operation: &str,
|
||||
) {
|
||||
let original = format!("{oversized}\nknown\n");
|
||||
std::fs::write(disk_file, &original).unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let inspected = model_inspect(r.clone(), "/checkouts/main/text").await;
|
||||
let arguments = if operation == "write" {
|
||||
json!({"content":"changed"})
|
||||
} else {
|
||||
json!({"old_string":"known","new_string":"changed"})
|
||||
};
|
||||
let failure = model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
failure.to_string().contains("ResourceLimitExceeded"),
|
||||
"{failure}"
|
||||
);
|
||||
let denied = model_invoke(r.clone(), &inspected, operation, arguments.clone()).await;
|
||||
assert!(denied.is_err(), "failed read authorized {operation}");
|
||||
let denied = denied.unwrap_err();
|
||||
assert!(denied.to_string().contains("InvalidArguments"), "{denied}");
|
||||
assert_eq!(std::fs::read_to_string(disk_file).unwrap(), original);
|
||||
model_invoke(r.clone(), &inspected, "read", json!({"offset":1,"limit":1}))
|
||||
.await
|
||||
.unwrap();
|
||||
// A failed read must neither grant new authority nor erase an earlier successful Read.
|
||||
assert!(
|
||||
model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
model_invoke(r, &inspected, operation, arguments)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if operation == "write" {
|
||||
"changed".to_string()
|
||||
} else {
|
||||
original.replace("known", "changed")
|
||||
};
|
||||
assert_eq!(std::fs::read_to_string(disk_file).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_text_read_does_not_authorize_write() {
|
||||
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
failed_text_read_cannot_authorize_mutation(
|
||||
session(&dir, false),
|
||||
&dir.path().join("text"),
|
||||
&oversized,
|
||||
"write",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_text_read_does_not_authorize_edit() {
|
||||
for oversized in ["\"".repeat(50_000), "x".repeat(65_530)] {
|
||||
let dir = TempDir::new().unwrap();
|
||||
failed_text_read_cannot_authorize_mutation(
|
||||
session(&dir, false),
|
||||
&dir.path().join("text"),
|
||||
&oversized,
|
||||
"edit",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn assert_long_utf8_search_refuses_incomplete_line(
|
||||
source: WorkdirSessionHandle,
|
||||
directory: &std::path::Path,
|
||||
) {
|
||||
let original = format!("{}needle\nnext\n", "雪".repeat(22_000));
|
||||
std::fs::write(directory.join("text"), &original).unwrap();
|
||||
for (name, text) in [
|
||||
("normal", "雪\nneedle\nnext\n"),
|
||||
("unterminated", "雪\nneedle\nnext"),
|
||||
] {
|
||||
std::fs::write(directory.join(name), text).unwrap();
|
||||
}
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), source)
|
||||
.unwrap();
|
||||
let r = Arc::new(runtime(router, None));
|
||||
let inspected = model_inspect(r.clone(), "/checkouts/main/text").await;
|
||||
let result = model_invoke(
|
||||
r.clone(),
|
||||
&inspected,
|
||||
"search",
|
||||
json!({"pattern":"needle","limit":1}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"search advanced over a byte-truncated UTF-8 line: {result:?}"
|
||||
);
|
||||
let failure = result.unwrap_err();
|
||||
assert!(
|
||||
failure.to_string().contains("ResourceLimitExceeded"),
|
||||
"{failure}"
|
||||
);
|
||||
let read = model_invoke(r.clone(), &inspected, "read", json!({"limit":1}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(read.to_string().contains("ResourceLimitExceeded"), "{read}");
|
||||
let write = model_invoke(r.clone(), &inspected, "write", json!({"content":"lost"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(write.to_string().contains("InvalidArguments"), "{write}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(directory.join("text")).unwrap(),
|
||||
original
|
||||
);
|
||||
for name in ["normal", "unterminated"] {
|
||||
let inspected = model_inspect(r.clone(), &format!("/checkouts/main/{name}")).await;
|
||||
for offset in 0..=3 {
|
||||
let page = json_content(
|
||||
model_invoke(
|
||||
r.clone(),
|
||||
&inspected,
|
||||
"search",
|
||||
json!({"pattern":"needle","offset":offset,"limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(page["next_offset"], (offset + 1).min(3));
|
||||
assert_eq!(page["truncated"], offset < 2);
|
||||
assert_eq!(
|
||||
page["matches"],
|
||||
if offset == 1 {
|
||||
json!([{"line":2,"text":"needle"}])
|
||||
} else {
|
||||
json!([])
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_utf8_search_refuses_incomplete_line_and_preserves_line_pagination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert_long_utf8_search_refuses_incomplete_line(session(&dir, false), dir.path()).await;
|
||||
}
|
||||
|
||||
@@ -692,7 +692,6 @@ impl WorkerController {
|
||||
pub async fn spawn<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
@@ -701,7 +700,6 @@ impl WorkerController {
|
||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
false,
|
||||
None,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
@@ -715,7 +713,7 @@ impl WorkerController {
|
||||
pub async fn spawn_with_transport<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
@@ -723,42 +721,32 @@ impl WorkerController {
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let (handle, shutdown, _task) =
|
||||
Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport)
|
||||
.await?;
|
||||
Self::spawn_with_transport_owned(worker, runtime_base, transport).await?;
|
||||
Ok((handle, shutdown))
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_with_transport_owned<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
false,
|
||||
None,
|
||||
transport,
|
||||
)
|
||||
.await
|
||||
Self::spawn_inner(worker, runtime_base, false, None, transport).await
|
||||
}
|
||||
|
||||
/// Spawn a Worker owned by `worker-runtime`.
|
||||
///
|
||||
/// The controller uses an ephemeral directory for Unix sockets while tool
|
||||
/// spill artifacts use the separately supplied Worker-owned temporary path.
|
||||
/// The controller uses an ephemeral directory for Unix sockets. Bash output is
|
||||
/// owned by its live Feature instance, independently of filesystem layout.
|
||||
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness
|
||||
/// projections.
|
||||
pub async fn spawn_runtime_managed<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
@@ -767,7 +755,6 @@ impl WorkerController {
|
||||
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
true,
|
||||
None,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
@@ -780,7 +767,6 @@ impl WorkerController {
|
||||
pub async fn spawn_runtime_managed_run<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
run_dir: &Path,
|
||||
bash_output_dir: &Path,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
@@ -789,7 +775,6 @@ impl WorkerController {
|
||||
Self::spawn_runtime_managed_run_with_transport(
|
||||
worker,
|
||||
run_dir,
|
||||
bash_output_dir,
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
)
|
||||
.await
|
||||
@@ -800,19 +785,15 @@ impl WorkerController {
|
||||
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
run_dir: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||
where
|
||||
C: LlmClient + Clone + 'static,
|
||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||
{
|
||||
let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned(
|
||||
worker,
|
||||
run_dir,
|
||||
bash_output_dir,
|
||||
transport,
|
||||
)
|
||||
let (handle, shutdown, _task) =
|
||||
Self::spawn_runtime_managed_run_with_transport_owned(worker, run_dir, transport)
|
||||
.await?;
|
||||
Ok((handle, shutdown))
|
||||
}
|
||||
@@ -820,7 +801,7 @@ impl WorkerController {
|
||||
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
run_dir: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
transport: WorkerControllerTransport,
|
||||
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||
where
|
||||
@@ -830,21 +811,13 @@ impl WorkerController {
|
||||
let parent = run_dir
|
||||
.parent()
|
||||
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
||||
Self::spawn_inner(
|
||||
worker,
|
||||
parent,
|
||||
bash_output_dir,
|
||||
true,
|
||||
Some(run_dir),
|
||||
transport,
|
||||
)
|
||||
.await
|
||||
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
|
||||
}
|
||||
|
||||
async fn spawn_inner<C, St>(
|
||||
worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
runtime_managed: bool,
|
||||
runtime_run: Option<&Path>,
|
||||
transport: WorkerControllerTransport,
|
||||
@@ -857,7 +830,6 @@ impl WorkerController {
|
||||
let result = Self::spawn_initialized(
|
||||
worker,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
runtime_managed,
|
||||
runtime_run,
|
||||
transport,
|
||||
@@ -874,7 +846,7 @@ impl WorkerController {
|
||||
async fn spawn_initialized<C, St>(
|
||||
mut worker: Worker<C, St>,
|
||||
runtime_base: &Path,
|
||||
bash_output_dir: &Path,
|
||||
|
||||
runtime_managed: bool,
|
||||
runtime_run: Option<&Path>,
|
||||
transport: WorkerControllerTransport,
|
||||
@@ -888,8 +860,7 @@ impl WorkerController {
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// === 1. Initialization (channels / RuntimeDir / worker-immutable
|
||||
// snapshots / SpawnedWorkerRegistry / alerter attach /
|
||||
// bash-output scope) ===
|
||||
// snapshots / SpawnedWorkerRegistry / alerter attach) ===
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
||||
let (working_event_tx, _) = broadcast::channel::<Event>(256);
|
||||
@@ -897,8 +868,8 @@ impl WorkerController {
|
||||
let in_flight = InFlightEvents::new(working_event_tx.clone());
|
||||
worker.attach_in_flight_events(in_flight.clone());
|
||||
|
||||
// Runtime directory is created before tool registration because it owns
|
||||
// bounded tool artifacts, and before initial status/history writes consume
|
||||
// Runtime directory is created before tool registration and before
|
||||
// initial status/history writes consume
|
||||
// the greeting we build after registration is complete.
|
||||
let runtime_dir = Arc::new(if let Some(run_dir) = runtime_run {
|
||||
RuntimeDir::create_worker_run(run_dir).await?
|
||||
@@ -935,26 +906,6 @@ impl WorkerController {
|
||||
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||
worker.attach_working_event_tx(working_event_tx.clone());
|
||||
|
||||
// Bash spill artifacts are owned by the stable Worker identity rather
|
||||
// than a controller session/run generation. Push a recursive
|
||||
// `allow(Read)` for the exact tool output path into the Worker's shared
|
||||
// runtime scope so the Workdir session and system prompt stay aligned.
|
||||
let bash_output_dir = bash_output_dir.to_path_buf();
|
||||
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
"create bash output dir {}: {e}",
|
||||
bash_output_dir.display()
|
||||
))
|
||||
})?;
|
||||
worker
|
||||
.add_scope_rules([manifest::ScopeRule {
|
||||
target: bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.map_err(std::io::Error::other)?;
|
||||
|
||||
// === 1.5. Direct writer wiring ===
|
||||
//
|
||||
// Engine callbacks fire `on_history_append` for each assistant
|
||||
@@ -974,7 +925,6 @@ impl WorkerController {
|
||||
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
||||
let fs_for_view = register_worker_tools(
|
||||
&mut worker,
|
||||
bash_output_dir,
|
||||
runtime_base.to_path_buf(),
|
||||
spawned_registry.clone(),
|
||||
Some(method_tx.downgrade()),
|
||||
@@ -1440,7 +1390,7 @@ fn validate_memory_lifecycle_targets(
|
||||
/// the shared state.
|
||||
pub(crate) async fn register_worker_tools<C, St>(
|
||||
worker: &mut Worker<C, St>,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
runtime_base: PathBuf,
|
||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||
parent_method_tx: Option<mpsc::WeakSender<Method>>,
|
||||
@@ -1485,6 +1435,7 @@ where
|
||||
let lifecycle_enabled = worker.manifest_lifecycle_features_enabled() && backend_job.is_none();
|
||||
let prompts = worker.prompts().clone();
|
||||
let tracker = tools::Tracker::new();
|
||||
let bash_output = spawned_registry.bash_output();
|
||||
{
|
||||
let paste_store = worker.store().clone();
|
||||
let paste_session_id = worker.session_id();
|
||||
@@ -1507,13 +1458,13 @@ where
|
||||
tools::routed_builtin_tools_for_current_capabilities(
|
||||
workdir_sessions.clone(),
|
||||
tracker.clone(),
|
||||
bash_output_dir.clone(),
|
||||
bash_output.clone(),
|
||||
)
|
||||
} else {
|
||||
tools::routed_builtin_tools(
|
||||
workdir_sessions.clone(),
|
||||
tracker.clone(),
|
||||
bash_output_dir.clone(),
|
||||
bash_output.clone(),
|
||||
)
|
||||
};
|
||||
worker.engine_mut().register_tools(workdir_tools);
|
||||
@@ -1910,6 +1861,20 @@ where
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if wip_mode {
|
||||
crate::feature::builtin::bash::mount(
|
||||
&mut wip_mount_registry,
|
||||
worker.workdir_sessions(),
|
||||
bash_output.clone(),
|
||||
wip_permissions.clone(),
|
||||
)
|
||||
.map_err(|error| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("mount Bash output WIP projection: {error}"),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
if wip_mode && let Some(feature) = &workdir_wip_feature {
|
||||
crate::feature::builtin::manage_workdir::wip::mount_workspace_workdir_wip(
|
||||
&mut wip_mount_registry,
|
||||
@@ -2040,7 +2005,6 @@ where
|
||||
spawner_workspace_context,
|
||||
parent_notifications,
|
||||
runtime_base.clone(),
|
||||
bash_output_dir.clone(),
|
||||
spawner_workspace_root,
|
||||
workdir_tool_broker,
|
||||
spawned_registry.clone(),
|
||||
@@ -3326,6 +3290,8 @@ async fn controller_loop<C, St>(
|
||||
}
|
||||
}
|
||||
|
||||
spawned_registry.bash_output().revoke();
|
||||
|
||||
if let Some(observer) = command_observer.take() {
|
||||
observer.abort();
|
||||
let _ = observer.await;
|
||||
@@ -4139,7 +4105,14 @@ mod tests {
|
||||
request: crate::worker::WorkspaceRequest,
|
||||
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
|
||||
{
|
||||
// Native discovery must not enumerate or perform any mutation.
|
||||
// Native discovery reads metadata and the current caller's ceiling, never inventory or mutations.
|
||||
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get);
|
||||
if request.path == "/api/w/workspace-1/drive/capabilities" {
|
||||
return Ok(crate::worker::WorkspaceResponse {
|
||||
status: 200,
|
||||
body: serde_json::json!({"write":true}).to_string(),
|
||||
});
|
||||
}
|
||||
assert_eq!(request.path, "/api/w/workspace-1/drive/root");
|
||||
Ok(crate::worker::WorkspaceResponse { status: 200, body: serde_json::json!({
|
||||
"entry": {"workspace_id": "workspace-1", "node_id": "1"},
|
||||
@@ -4212,7 +4185,6 @@ mod tests {
|
||||
.descriptor();
|
||||
register_worker_tools(
|
||||
&mut worker,
|
||||
root.path().join("bash-output"),
|
||||
root.path().join("runtime"),
|
||||
SpawnedWorkerRegistry::new_for_internal_services(),
|
||||
None,
|
||||
|
||||
@@ -64,7 +64,6 @@ async fn prepared(
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -635,12 +635,11 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
|
||||
|
||||
let started = match start_worker_controller(
|
||||
worker,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: runtime_base.clone(),
|
||||
bash_output_dir,
|
||||
},
|
||||
WorkerControllerTransport::UnixSocket,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod backend_job_result;
|
||||
pub(crate) mod bash;
|
||||
|
||||
/// Host metadata participants are independent of model-facing Tool enablement.
|
||||
/// Disabling a Feature must not preserve stale Session-bound authority evidence.
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
//! Bash Feature projection. The view is instance-owned and shared only by explicit parent
|
||||
//! routing. No output is indexable; committed result entries directly address immutable files.
|
||||
//! Root namespace knowledge grants nothing: every publication and read checks the current view.
|
||||
|
||||
use crate::{
|
||||
file_operation::{self, Contract},
|
||||
permission::permission_action_for,
|
||||
wip::{
|
||||
WipCallContext, WipMountError, WipMountRegistry, WipOperationError, WipOperationHandler,
|
||||
WipOperationOutput, WipProjection, WipProjectionKind, WipPublication, WipSubtreeMount,
|
||||
WipSubtreeProvider, contextual_reference, json_to_wip, wip_to_json,
|
||||
},
|
||||
};
|
||||
use agen::tool::{Tool, ToolError};
|
||||
use async_trait::async_trait;
|
||||
use manifest::{ToolPermissionAction, ToolPermissionConfig};
|
||||
use serde_json::{Value as Json, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use tools::{BashTool, bash_output::BashOutputView};
|
||||
use wip_protocol::{
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, Object,
|
||||
OperationDeclaration, ParameterDeclaration, ProtocolError, ProtocolErrorCode,
|
||||
ReturnDeclaration, TypeExpr, Value,
|
||||
};
|
||||
use workdir::{CommandStream, WorkdirSessionRouter};
|
||||
|
||||
const ROOT: &str = "/bash-output";
|
||||
|
||||
pub(crate) fn mount(
|
||||
registry: &mut WipMountRegistry,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
view: BashOutputView,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
) -> Result<(), WipMountError> {
|
||||
registry.allocate_namespace("bash", "bash-output")?;
|
||||
let provider = Arc::new(Provider {
|
||||
service: BashTool::new(router, view.clone()),
|
||||
view,
|
||||
permissions,
|
||||
incarnation: uuid::Uuid::now_v7().to_string(),
|
||||
});
|
||||
registry.mount(provider.root())?;
|
||||
registry.replace_compatibility_tools(ROOT, ["Bash"])?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(provider),
|
||||
})
|
||||
}
|
||||
|
||||
struct Provider {
|
||||
service: BashTool,
|
||||
view: BashOutputView,
|
||||
permissions: Option<ToolPermissionConfig>,
|
||||
incarnation: String,
|
||||
}
|
||||
impl Provider {
|
||||
fn allowed(&self, tool: &str, args: &Json) -> bool {
|
||||
self.view.is_active()
|
||||
&& self
|
||||
.permissions
|
||||
.as_ref()
|
||||
.is_none_or(|p| permission_action_for(p, tool, args) == ToolPermissionAction::Allow)
|
||||
}
|
||||
fn may_allow(&self, tool: &str) -> bool {
|
||||
if !self.view.is_active() {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = &self.permissions else {
|
||||
return true;
|
||||
};
|
||||
for rule in p.rules.iter().filter(|r| r.tool.eq_ignore_ascii_case(tool)) {
|
||||
if rule.action == ToolPermissionAction::Allow {
|
||||
return true;
|
||||
}
|
||||
if rule.pattern == "*" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
p.default_action == ToolPermissionAction::Allow
|
||||
}
|
||||
fn root(self: &Arc<Self>) -> WipProjection {
|
||||
let interface = contextual_reference("yoi.bash/command/v1", ROOT);
|
||||
let descriptor = InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
documentation: Some(Documentation {
|
||||
summary: "Run Bash with bounded ephemeral output; retained bytes are accessed via result entries, not listed".into(),
|
||||
details: None,
|
||||
}),
|
||||
types: vec![],
|
||||
operations: vec![OperationDeclaration {
|
||||
name: "run".into(),
|
||||
documentation: None,
|
||||
parameters: vec![ParameterDeclaration {
|
||||
name: "input".into(),
|
||||
required: true,
|
||||
documentation: Some(Documentation {
|
||||
summary: "Bash input, validated by the existing Tool argument contract".into(),
|
||||
details: Some(BashTool::input_schema().to_string()),
|
||||
}),
|
||||
r#type: TypeExpr::Json,
|
||||
}],
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("summary", TypeExpr::String),
|
||||
field("stdout_preview", TypeExpr::String),
|
||||
field("stderr_preview", TypeExpr::String),
|
||||
field("preview_omitted", TypeExpr::Boolean),
|
||||
field("metadata", TypeExpr::Json),
|
||||
field("stdout_entry", TypeExpr::Entry),
|
||||
field("stderr_entry", TypeExpr::Entry),
|
||||
field("order_entry", TypeExpr::Entry),
|
||||
],
|
||||
},
|
||||
},
|
||||
}],
|
||||
};
|
||||
WipProjection {
|
||||
route: ROOT.into(),
|
||||
capability: "bash:command".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: "bash-output".into(),
|
||||
description: Some("Bash command and nonindexable retained output".into()),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(format!("bash:{}", self.incarnation)),
|
||||
validator: Some(self.incarnation.as_bytes().to_vec()),
|
||||
},
|
||||
interface,
|
||||
interface_validator: Some(Sha256::digest(format!("{descriptor:?}")).to_vec()),
|
||||
descriptor,
|
||||
handler: Arc::new(Run {
|
||||
provider: self.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn parse(&self, path: &str) -> Option<(String, Option<CommandStream>)> {
|
||||
let rest = path.strip_prefix("/bash-output/")?;
|
||||
let (id, stream) = rest.split_once('/')?;
|
||||
if uuid::Uuid::parse_str(id).ok()?.to_string() != id {
|
||||
return None;
|
||||
}
|
||||
let stream = match stream {
|
||||
"stdout" => Some(CommandStream::Stdout),
|
||||
"stderr" => Some(CommandStream::Stderr),
|
||||
"order" => None,
|
||||
_ => return None,
|
||||
};
|
||||
Some((id.into(), stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for Arc<Provider> {
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
if !self.view.is_active() {
|
||||
return Ok(None);
|
||||
}
|
||||
if path == ROOT {
|
||||
let projection = self.root();
|
||||
return Ok(Some(WipPublication {
|
||||
scope: Some(projection.object.clone()),
|
||||
projection,
|
||||
}));
|
||||
}
|
||||
let Some((id, stream)) = self.parse(path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(metadata) = self.view.metadata(&id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !self.may_allow("Read") && !self.may_allow("Grep") {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut contracts = vec![];
|
||||
if self.may_allow("Read") {
|
||||
contracts.extend([Contract::ReadLines, Contract::ReadBytes]);
|
||||
}
|
||||
if self.may_allow("Grep") {
|
||||
contracts.push(Contract::SearchText);
|
||||
}
|
||||
let primary = contracts[0];
|
||||
let interface = primary.reference();
|
||||
let descriptor = primary.descriptor();
|
||||
let validator = Sha256::digest(format!("{}:{id}:{stream:?}", self.incarnation)).to_vec();
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "bash:output".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
object: Object {
|
||||
name: path.rsplit('/').next().unwrap().into(),
|
||||
description: Some(format!(
|
||||
"Read-only output component (stdout={} bytes, stderr={} bytes); text is UTF-8 lossy; storage_truncated={}",
|
||||
metadata.stdout_saved, metadata.stderr_saved, metadata.storage_truncated
|
||||
)),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: Some(format!("bash:{}:{id}:{stream:?}", self.incarnation)),
|
||||
validator: Some(validator.clone()),
|
||||
},
|
||||
interface,
|
||||
descriptor: descriptor.clone(),
|
||||
interface_validator: Some(primary.validator()),
|
||||
handler: Arc::new(File {
|
||||
provider: self.clone(),
|
||||
id,
|
||||
stream,
|
||||
path: path.into(),
|
||||
validator,
|
||||
}),
|
||||
};
|
||||
file_operation::expose(&mut projection, contracts);
|
||||
Ok(Some(WipPublication {
|
||||
projection,
|
||||
scope: None,
|
||||
}))
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
struct Run {
|
||||
provider: Arc<Provider>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Run {
|
||||
fn is_visible(&self) -> bool {
|
||||
self.provider.view.is_active()
|
||||
}
|
||||
fn operation_available(&self, op: &str) -> bool {
|
||||
op == "run" && self.provider.may_allow("Bash")
|
||||
}
|
||||
async fn call(
|
||||
&self,
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
ctx: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if op != "run" {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"unknown Bash operation",
|
||||
));
|
||||
}
|
||||
let input = args
|
||||
.get("input")
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::InvalidArguments, "input is required"))?;
|
||||
let input =
|
||||
wip_to_json(input).map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?;
|
||||
if !self.provider.allowed("Bash", &input) {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"Bash not permitted",
|
||||
));
|
||||
}
|
||||
let result = self
|
||||
.provider
|
||||
.service
|
||||
.execute_result(&input.to_string(), ctx.execution)
|
||||
.await
|
||||
.map_err(tool_error)?;
|
||||
if result.cancelled {
|
||||
return Err(WipOperationError::Cancelled(result.tool_output()));
|
||||
}
|
||||
if result.metadata.terminal.status == Some(workdir::CommandStatus::TimedOut) {
|
||||
return Err(WipOperationError::Interrupted(result.tool_output()));
|
||||
}
|
||||
// Entry fields are typed by the native Descriptor. No display-string parsing is involved.
|
||||
Ok(WipOperationOutput::native(Value::Record(BTreeMap::from([
|
||||
("summary".into(), Value::String(result.summary.clone())),
|
||||
(
|
||||
"stdout_preview".into(),
|
||||
Value::String(result.stdout_preview.clone()),
|
||||
),
|
||||
(
|
||||
"stderr_preview".into(),
|
||||
Value::String(result.stderr_preview.clone()),
|
||||
),
|
||||
(
|
||||
"preview_omitted".into(),
|
||||
Value::Boolean(result.preview_omitted),
|
||||
),
|
||||
("stdout_entry".into(), Value::String(result.stdout_entry())),
|
||||
("stderr_entry".into(), Value::String(result.stderr_entry())),
|
||||
("order_entry".into(), Value::String(result.order_entry())),
|
||||
(
|
||||
"metadata".into(),
|
||||
json_to_wip(&serde_json::to_value(result.metadata).unwrap())
|
||||
.map_err(|e| fail(ProtocolErrorCode::Internal, &e))?,
|
||||
),
|
||||
]))))
|
||||
}
|
||||
async fn cancel(&self, ctx: &agen::tool::ToolExecutionContext) -> Result<(), ToolError> {
|
||||
self.provider.service.cancel_execution(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
struct File {
|
||||
provider: Arc<Provider>,
|
||||
id: String,
|
||||
stream: Option<CommandStream>,
|
||||
path: String,
|
||||
validator: Vec<u8>,
|
||||
}
|
||||
impl File {
|
||||
fn order_text(&self) -> Result<String, WipOperationError> {
|
||||
use std::fmt::Write;
|
||||
let ranges = self
|
||||
.provider
|
||||
.view
|
||||
.order(&self.id)
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?;
|
||||
let mut text = String::new();
|
||||
for r in ranges {
|
||||
writeln!(&mut text, "{:?} {} {}", r.stream, r.start, r.end).unwrap();
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for File {
|
||||
fn is_visible(&self) -> bool {
|
||||
self.provider.view.metadata(&self.id).is_some()
|
||||
}
|
||||
fn object_validator(&self) -> Option<Vec<u8>> {
|
||||
self.is_visible().then(|| self.validator.clone())
|
||||
}
|
||||
fn operation_available(&self, op: &str) -> bool {
|
||||
match op {
|
||||
"read_lines" | "read_bytes" | "read" => self.provider.may_allow("Read"),
|
||||
"search_text" | "search" => self.provider.may_allow("Grep"),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
async fn call(
|
||||
&self,
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
_: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
if !self.operation_available(op) {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"output operation not permitted",
|
||||
));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(args.clone()))
|
||||
.map_err(|e| fail(ProtocolErrorCode::InvalidArguments, &e))?;
|
||||
let tool = if op == "search_text" {
|
||||
input["path"] = json!(self.path);
|
||||
"Grep"
|
||||
} else {
|
||||
input["file_path"] = json!(self.path);
|
||||
"Read"
|
||||
};
|
||||
if !self.provider.allowed(tool, &input) {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::PermissionDenied,
|
||||
"output operation not permitted",
|
||||
));
|
||||
}
|
||||
let metadata = self
|
||||
.provider
|
||||
.view
|
||||
.metadata(&self.id)
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?;
|
||||
if op == "read_bytes" {
|
||||
let (offset, limit) = file_operation::byte_range(args)?;
|
||||
let (bytes, total) = if let Some(stream) = self.stream {
|
||||
let total = if stream == CommandStream::Stdout {
|
||||
metadata.stdout_saved
|
||||
} else {
|
||||
metadata.stderr_saved
|
||||
};
|
||||
if offset > total as u64 {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"byte offset exceeds output length",
|
||||
));
|
||||
}
|
||||
let bytes = self
|
||||
.provider
|
||||
.view
|
||||
.read(&self.id, stream, offset as usize, limit)
|
||||
.ok_or_else(|| {
|
||||
fail(ProtocolErrorCode::NotFound, "output expired or revoked")
|
||||
})?;
|
||||
(bytes, total)
|
||||
} else {
|
||||
let text = self.order_text()?;
|
||||
if offset > text.len() as u64 {
|
||||
return Err(fail(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"byte offset exceeds output length",
|
||||
));
|
||||
}
|
||||
(
|
||||
text.as_bytes()[offset as usize..(offset as usize + limit).min(text.len())]
|
||||
.to_vec(),
|
||||
text.len(),
|
||||
)
|
||||
};
|
||||
let eof = offset + bytes.len() as u64 == total as u64;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
file_operation::bytes_result(args, &bytes, eof)?,
|
||||
self.validator.clone(),
|
||||
));
|
||||
}
|
||||
let (offset, limit) = file_operation::window(args, op == "search_text")?;
|
||||
let value = if let Some(stream) = self.stream {
|
||||
let (text, more) = self
|
||||
.provider
|
||||
.view
|
||||
.line_window(&self.id, stream, offset, limit)
|
||||
.ok_or_else(|| fail(ProtocolErrorCode::NotFound, "output expired or revoked"))?
|
||||
.map_err(|e| fail(ProtocolErrorCode::ResourceLimitExceeded, &e))?;
|
||||
file_operation::page_result(op, args, &text, offset, more)?
|
||||
} else {
|
||||
file_operation::text_result(op, args, &self.order_text()?)?
|
||||
};
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.validator.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn fail(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
fn tool_error(error: ToolError) -> WipOperationError {
|
||||
match error {
|
||||
ToolError::InvalidArgument(_) => fail(ProtocolErrorCode::InvalidArguments, "invalid Bash arguments"),
|
||||
ToolError::Cancelled(output) => WipOperationError::Cancelled(output),
|
||||
ToolError::Interrupted(output) => WipOperationError::Interrupted(output),
|
||||
_ => WipOperationError::OutcomeUnknown("Bash execution failed; effects or process termination may be unconfirmed; do not automatically restart the command".into()),
|
||||
}
|
||||
}
|
||||
fn field(name: &str, r#type: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: name.into(),
|
||||
required: true,
|
||||
documentation: None,
|
||||
r#type,
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,9 @@ impl DriveBackend {
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn capabilities(&self) -> Result<DriveCapabilities, DriveError> {
|
||||
self.get("/capabilities", &serde_json::json!({})).await
|
||||
}
|
||||
pub async fn root(&self) -> Result<DriveEntry, DriveError> {
|
||||
let entry: DriveEntry = self
|
||||
.json(
|
||||
|
||||
@@ -7,12 +7,14 @@ use std::collections::VecDeque;
|
||||
struct Client {
|
||||
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
|
||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||
capabilities: Mutex<WorkspaceResponse>,
|
||||
}
|
||||
impl Client {
|
||||
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
capabilities: Mutex::new(response(DriveCapabilities { write: false })),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,7 +32,15 @@ impl WorkspaceClient for Client {
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let capabilities = request.path == "/api/w/workspace/drive/capabilities";
|
||||
self.requests.lock().unwrap().push(request);
|
||||
if capabilities {
|
||||
let response = self.capabilities.lock().unwrap();
|
||||
return Ok(WorkspaceResponse {
|
||||
status: response.status,
|
||||
body: response.body.clone(),
|
||||
});
|
||||
}
|
||||
self.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -479,7 +489,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
let feature = feature(client.clone());
|
||||
let mut mounts = crate::wip::WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let provider = wip::DriveProvider(feature);
|
||||
let provider = wip::DriveProvider::new(feature);
|
||||
let path = entry_path(&entry.entry);
|
||||
let publication = provider.publication(&path).await.unwrap().unwrap();
|
||||
assert_eq!(publication.projection.route, path);
|
||||
@@ -487,7 +497,8 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
publication.scope.as_ref().unwrap().r#ref,
|
||||
publication.projection.object.r#ref
|
||||
);
|
||||
assert_eq!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(publication.projection.object.validator.is_some());
|
||||
assert_ne!(publication.projection.object.validator, Some(b"3".to_vec()));
|
||||
assert!(provider.children("/drive").await.unwrap().is_empty());
|
||||
assert!(
|
||||
provider
|
||||
@@ -504,7 +515,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
|
||||
.flat_map(|op| &op.parameters)
|
||||
.any(|parameter| parameter.name == "expected_mutation_id")
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 1);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -656,3 +667,662 @@ async fn oversized_images_and_unknown_arguments_fail_before_binary_transfer() {
|
||||
);
|
||||
assert_eq!(client.json.requests.lock().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
fn native_context() -> crate::wip::WipCallContext {
|
||||
crate::wip::WipCallContext {
|
||||
execution: ToolExecutionContext::direct(),
|
||||
security_context: "drive-test".into(),
|
||||
}
|
||||
}
|
||||
fn native_args(value: Value) -> std::collections::BTreeMap<String, wip_protocol::Value> {
|
||||
let wip_protocol::Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else {
|
||||
panic!("named arguments required")
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_reads_use_bounded_complete_source_and_literal_window_search() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let text = "α\r\nneedle.*\nNeedle.*\nlast\n";
|
||||
let mut entry = entry("2", "3");
|
||||
entry.size = Some(text.len() as u32);
|
||||
let read = DriveReadTextResponse {
|
||||
entry: entry.clone(),
|
||||
text: text.into(),
|
||||
truncated: false,
|
||||
};
|
||||
let client = Client::new(vec![
|
||||
response(entry.clone()),
|
||||
response(&read),
|
||||
response(&read),
|
||||
]);
|
||||
let feature = feature(client.clone());
|
||||
let publication = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut projection = publication.projection;
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::ReadLines.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::SearchText.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.any(|op| op.name == "read" && op.parameters.iter().any(|p| p.name == "max_bytes"))
|
||||
);
|
||||
let original_handler = projection.handler.clone();
|
||||
assert!(crate::file_operation::select(
|
||||
&mut projection,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
let read = projection
|
||||
.handler
|
||||
.call(
|
||||
"read",
|
||||
&native_args(json!({"offset":1,"limit":2})),
|
||||
native_context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("common read failed"));
|
||||
let value = crate::wip::wip_to_json(&read.value).unwrap();
|
||||
assert_eq!(value["next_offset"], 3);
|
||||
assert_eq!(value["truncated"], true);
|
||||
assert!(value["content"].as_str().unwrap().contains("needle.*"));
|
||||
// The common selector wraps a provider handler once; reuse the original
|
||||
// publication for the independent SearchText contract.
|
||||
projection.handler = original_handler;
|
||||
let request = client.requests.lock().unwrap().last().unwrap().clone();
|
||||
assert!(request.path.contains("max_bytes=65536"));
|
||||
assert!(crate::file_operation::select(
|
||||
&mut projection,
|
||||
&Contract::SearchText.reference()
|
||||
));
|
||||
let search = projection
|
||||
.handler
|
||||
.call(
|
||||
"search",
|
||||
&native_args(json!({"pattern":"needle.*","limit":3})),
|
||||
native_context(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("common search failed"));
|
||||
let value = crate::wip::wip_to_json(&search.value).unwrap();
|
||||
assert_eq!(value["matches"], json!([{"line":2,"text":"needle.*"}]));
|
||||
assert_eq!(value["next_offset"], 3);
|
||||
assert_eq!(value["truncated"], true);
|
||||
}
|
||||
|
||||
fn read_response(entry: &DriveEntry, text: &str) -> DriveReadTextResponse {
|
||||
DriveReadTextResponse {
|
||||
entry: entry.clone(),
|
||||
text: text.into(),
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_refuses_truncated_mismatched_stale_and_denied_reads() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
use wip_protocol::ProtocolErrorCode;
|
||||
for (case, expected) in [
|
||||
("truncated", ProtocolErrorCode::ResourceLimitExceeded),
|
||||
("size", ProtocolErrorCode::ResourceLimitExceeded),
|
||||
("stale", ProtocolErrorCode::ValidatorMismatch),
|
||||
("denied", ProtocolErrorCode::PermissionDenied),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let mut read = read_response(&entry, "hello");
|
||||
match case {
|
||||
"truncated" => read.truncated = true,
|
||||
"size" => read.text = "hell".into(),
|
||||
"stale" => read.entry.last_mutation_id = "4".into(),
|
||||
_ => {}
|
||||
}
|
||||
let read = if case == "denied" {
|
||||
WorkspaceResponse {
|
||||
status: 403,
|
||||
body: "denied".into(),
|
||||
}
|
||||
} else {
|
||||
response(read)
|
||||
};
|
||||
let client = Client::new(vec![response(entry.clone()), read]);
|
||||
let projection = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let result = projection
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(ref error)) if error.code == expected),
|
||||
"{case}"
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 3, "{case}: no retries");
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.all(|request| request.method == crate::worker::WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_rejects_oversized_source_before_read_and_remount_changes_object_validator()
|
||||
{
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let mut entry = entry("2", "3");
|
||||
entry.size = Some(server_api::DRIVE_CHUNK_MAX_BYTES + 1);
|
||||
let client = Client::new(vec![response(entry.clone()), response(entry.clone())]);
|
||||
let first = feature(client.clone());
|
||||
let second = first.clone();
|
||||
let path = entry_path(&entry.entry);
|
||||
let first = wip::DriveProvider::new(first)
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let second = wip::DriveProvider::new(second)
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_ne!(first.object.validator, second.object.validator);
|
||||
assert_eq!(first.object.interfaces, second.object.interfaces);
|
||||
let result = first
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::ResourceLimitExceeded)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_text_reads_keep_tool_permission_denial_before_backend_read() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let feature = feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action: manifest::ToolPermissionAction::Deny,
|
||||
rules: Vec::new(),
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
let result = projection
|
||||
.handler
|
||||
.call("read_lines", &native_args(json!({})), native_context())
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::PermissionDenied)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_read_only_capability_omits_file_and_folder_mutation_contracts() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (kind, root) in [
|
||||
(DriveEntryKind::File, false),
|
||||
(DriveEntryKind::Folder, false),
|
||||
(DriveEntryKind::Folder, true),
|
||||
] {
|
||||
let mut entry = entry("2", "3");
|
||||
entry.kind = kind;
|
||||
if root {
|
||||
entry.parent = None;
|
||||
}
|
||||
let path = if root {
|
||||
"/drive".into()
|
||||
} else {
|
||||
entry_path(&entry.entry)
|
||||
};
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
let projection = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(!projection.descriptor.operations.iter().any(|op| matches!(
|
||||
op.name.as_str(),
|
||||
"write"
|
||||
| "edit"
|
||||
| "delete"
|
||||
| "relocate"
|
||||
| "create_folder"
|
||||
| "create_text"
|
||||
| "save_workdir"
|
||||
)));
|
||||
let result = projection
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&native_args(json!({"content":"denied"})),
|
||||
native_context(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == wip_protocol::ProtocolErrorCode::OperationNotFound)
|
||||
);
|
||||
let requests = client.requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(requests[1].path, "/api/w/workspace/drive/capabilities");
|
||||
assert_eq!(
|
||||
requests[1].method,
|
||||
crate::worker::WorkspaceRequestMethod::Get
|
||||
);
|
||||
assert!(requests[1].body.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_capability_failure_never_publishes_an_assumed_write_ceiling() {
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (status, body) in [
|
||||
(403, "denied"),
|
||||
(500, "unavailable"),
|
||||
(200, "{}"),
|
||||
(200, "{\"write\":true,\"grant_id\":\"spoof\"}"),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = WorkspaceResponse {
|
||||
status,
|
||||
body: body.into(),
|
||||
};
|
||||
let result = wip::DriveProvider::new(feature(client.clone()))
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await;
|
||||
assert!(!matches!(result, Ok(Some(_))), "{status}: {body}");
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_mutation_publication_intersects_tool_permission_ceiling() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true });
|
||||
let feature = feature(client).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action: manifest::ToolPermissionAction::Allow,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "DriveWrite".into(),
|
||||
pattern: "*".into(),
|
||||
action: manifest::ToolPermissionAction::Deny,
|
||||
}],
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert!(
|
||||
!projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference())
|
||||
);
|
||||
assert!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Edit.reference())
|
||||
);
|
||||
assert!(
|
||||
!projection
|
||||
.descriptor
|
||||
.operations
|
||||
.iter()
|
||||
.any(|op| matches!(op.name.as_str(), "write" | "edit"))
|
||||
);
|
||||
}
|
||||
|
||||
/// Narrow transport fixture for native mutation chaining. The real provider,
|
||||
/// Client/Host, permission checks and Backend CAS adapter remain in the path;
|
||||
/// only canonical Drive persistence is replaced by an entry and text value.
|
||||
#[derive(Debug)]
|
||||
struct NativeMutations(Mutex<(DriveEntry, String, Vec<DriveMutationRequest>)>);
|
||||
impl WorkspaceClient for NativeMutations {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some("workspace")
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"drive-native-mutations"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
request: WorkspaceRequest,
|
||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||
let mut state = self.0.lock().unwrap();
|
||||
let suffix = request
|
||||
.path
|
||||
.strip_prefix("/api/w/workspace/drive/")
|
||||
.unwrap();
|
||||
Ok(if suffix == "capabilities" {
|
||||
response(DriveCapabilities { write: true })
|
||||
} else if suffix.starts_with("metadata?") {
|
||||
response(&state.0)
|
||||
} else if suffix.starts_with("read-text?") {
|
||||
response(read_response(&state.0, &state.1))
|
||||
} else if suffix == "mutate" {
|
||||
let request: DriveMutationRequest =
|
||||
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||
let DriveMutation::UpdateText {
|
||||
id,
|
||||
expected_mutation_id,
|
||||
text,
|
||||
..
|
||||
} = &request.mutation
|
||||
else {
|
||||
panic!("unexpected mutation")
|
||||
};
|
||||
assert_eq!(id, &state.0.entry);
|
||||
assert_eq!(expected_mutation_id, &state.0.last_mutation_id);
|
||||
state.1 = text.clone();
|
||||
state.0.size = Some(text.len() as u32);
|
||||
state.0.last_mutation_id = request.request_id.clone();
|
||||
let output = response(DriveMutationResponse {
|
||||
request_id: request.request_id.clone(),
|
||||
entry: Some(state.0.clone()),
|
||||
});
|
||||
state.2.push(request);
|
||||
output
|
||||
} else {
|
||||
panic!("unexpected request: {}", request.path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_common_edit_then_write_reuses_exact_post_operation_object_validator() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::{WipMountRegistry, WipRuntime};
|
||||
let entry = entry("2", "3");
|
||||
let path = entry_path(&entry.entry);
|
||||
let client = Arc::new(NativeMutations(Mutex::new((
|
||||
entry,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
))));
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
wip::mount_drive_wip(&mut mounts, &feature).unwrap();
|
||||
let runtime = WipRuntime::from_mounts(mounts, "drive-test".into()).unwrap();
|
||||
runtime
|
||||
.prepare_call_observations(path.clone(), true)
|
||||
.await
|
||||
.unwrap();
|
||||
let edit = runtime
|
||||
.call(
|
||||
path.clone(),
|
||||
Contract::Edit.reference(),
|
||||
"edit".into(),
|
||||
json!({"old_string":"hello","new_string":"updated"}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let edit: Value = serde_json::from_str(edit.content.as_deref().unwrap()).unwrap();
|
||||
assert!(edit["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string());
|
||||
assert_eq!(client.0.lock().unwrap().1, "updated");
|
||||
// No intervening Inspect/prepare_call_observations: success depends on the
|
||||
// exact nonce-bound post-commit validator returned by the first operation.
|
||||
let write = runtime
|
||||
.call(
|
||||
path,
|
||||
Contract::Write.reference(),
|
||||
"write".into(),
|
||||
json!({"content":"final"}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let write: Value = serde_json::from_str(write.content.as_deref().unwrap()).unwrap();
|
||||
assert!(write["metadata"]["entry"]["metadata"]["last_mutation_id"].is_string());
|
||||
let state = client.0.lock().unwrap();
|
||||
assert_eq!(state.1, "final");
|
||||
assert_eq!(state.2.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_argument_dependent_write_policy_is_rechecked_and_ask_is_not_published() {
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::WipSubtreeProvider;
|
||||
for (default_action, pattern, action, published, code) in [
|
||||
(
|
||||
manifest::ToolPermissionAction::Deny,
|
||||
"*permitted*",
|
||||
manifest::ToolPermissionAction::Allow,
|
||||
true,
|
||||
wip_protocol::ProtocolErrorCode::PermissionDenied,
|
||||
),
|
||||
(
|
||||
manifest::ToolPermissionAction::Allow,
|
||||
"*",
|
||||
manifest::ToolPermissionAction::Ask,
|
||||
false,
|
||||
wip_protocol::ProtocolErrorCode::OperationNotFound,
|
||||
),
|
||||
] {
|
||||
let entry = entry("2", "3");
|
||||
let client = Client::new(vec![response(entry.clone())]);
|
||||
*client.capabilities.lock().unwrap() = response(DriveCapabilities { write: true });
|
||||
let feature =
|
||||
feature(client.clone()).with_permissions(Some(manifest::ToolPermissionConfig {
|
||||
default_action,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "DriveWrite".into(),
|
||||
pattern: pattern.into(),
|
||||
action,
|
||||
}],
|
||||
}));
|
||||
let projection = wip::DriveProvider::new(feature)
|
||||
.publication(&entry_path(&entry.entry))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_eq!(
|
||||
projection
|
||||
.object
|
||||
.interfaces
|
||||
.contains(&Contract::Write.reference()),
|
||||
published
|
||||
);
|
||||
let result = projection
|
||||
.handler
|
||||
.call(
|
||||
"write",
|
||||
&native_args(json!({"content":"denied"})),
|
||||
native_context(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(crate::wip::WipOperationError::Protocol(error)) if error.code == code)
|
||||
);
|
||||
assert_eq!(client.requests.lock().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
// A single retained Object observation models a restored Client cache. All later
|
||||
// publication and execution use the real new-mount provider; the new Host supplies
|
||||
// the common contract's root scope independently, without replaying its old ref.
|
||||
struct RetainedObjectOnce {
|
||||
current: wip::DriveProvider,
|
||||
retained: Mutex<Option<wip_protocol::Object>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl crate::wip::WipSubtreeProvider for RetainedObjectOnce {
|
||||
async fn publication(
|
||||
&self,
|
||||
path: &str,
|
||||
) -> Result<Option<crate::wip::WipPublication>, wip_protocol::ProtocolError> {
|
||||
let Some(mut publication) = self.current.publication(path).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(retained) = self.retained.lock().unwrap().take() {
|
||||
publication.projection.object = retained;
|
||||
publication = crate::wip::WipPublication::self_scoped(publication.projection)?;
|
||||
}
|
||||
Ok(Some(publication))
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, wip_protocol::ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_same_feature_remount_rejects_retained_object_with_fresh_common_scope_then_reobservation_succeeds()
|
||||
{
|
||||
use crate::file_operation::Contract;
|
||||
use crate::wip::{WipMountRegistry, WipRuntime, WipSubtreeMount, WipSubtreeProvider};
|
||||
let entry = entry("2", "3");
|
||||
let path = entry_path(&entry.entry);
|
||||
let client = Arc::new(NativeMutations(Mutex::new((
|
||||
entry,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
))));
|
||||
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
|
||||
// mount_drive_wip constructs exactly one of these providers per mount. Reuse
|
||||
// the same Feature, whose mutation request identity deliberately does not rotate.
|
||||
let first = wip::DriveProvider::new(feature.clone());
|
||||
let old = first.publication(&path).await.unwrap().unwrap().projection;
|
||||
let cloned = first
|
||||
.clone()
|
||||
.publication(&path)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.projection;
|
||||
assert_eq!(old.object.validator, cloned.object.validator);
|
||||
let second = wip::DriveProvider::new(feature.clone());
|
||||
let current = second.publication(&path).await.unwrap().unwrap().projection;
|
||||
assert_ne!(old.object.validator, current.object.validator);
|
||||
let mut old_contract = old.clone();
|
||||
let mut current_contract = current.clone();
|
||||
assert!(crate::file_operation::select(
|
||||
&mut old_contract,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
assert!(crate::file_operation::select(
|
||||
&mut current_contract,
|
||||
&Contract::ReadLines.reference()
|
||||
));
|
||||
assert_eq!(old_contract.interface, current_contract.interface);
|
||||
assert_eq!(old_contract.descriptor, current_contract.descriptor);
|
||||
assert_eq!(
|
||||
old_contract.interface_validator,
|
||||
current_contract.interface_validator
|
||||
);
|
||||
|
||||
// Mount only the selected entry for this cache-restoration fixture. The real
|
||||
// Host still selects the root common contract and validates target membership,
|
||||
// scope_ref and Object/Interface validators before running the Drive handler.
|
||||
let mut mounts = WipMountRegistry::new();
|
||||
mounts.allocate_namespace("drive", "drive").unwrap();
|
||||
mounts.mount(current).unwrap();
|
||||
mounts
|
||||
.mount_subtree(WipSubtreeMount {
|
||||
root: path.clone(),
|
||||
provider: Arc::new(RetainedObjectOnce {
|
||||
current: second,
|
||||
retained: Mutex::new(Some(old.object)),
|
||||
}),
|
||||
})
|
||||
.unwrap();
|
||||
let runtime = WipRuntime::from_mounts(mounts, "drive-remount".into()).unwrap();
|
||||
runtime.inspect(path.clone(), true).await.unwrap();
|
||||
let interface_path = format!(
|
||||
"{}::{}",
|
||||
serde_json::to_string("/").unwrap(),
|
||||
serde_json::to_string(&Contract::ReadLines.reference().name).unwrap()
|
||||
);
|
||||
// This fetch obtains the *new* Host's root scope_ref and the unchanged common
|
||||
// Interface validator; only the retained target observation remains stale.
|
||||
runtime.inspect(interface_path, true).await.unwrap();
|
||||
let error = runtime
|
||||
.call(
|
||||
path.clone(),
|
||||
Contract::ReadLines.reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ValidatorMismatch"), "{error}");
|
||||
assert!(!error.contains("InterfaceMismatch"), "{error}");
|
||||
runtime.inspect(path.clone(), true).await.unwrap();
|
||||
let result = runtime
|
||||
.call(
|
||||
path,
|
||||
Contract::ReadLines.reference(),
|
||||
"read".into(),
|
||||
json!({}),
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result: Value = serde_json::from_str(result.content.as_deref().unwrap()).unwrap();
|
||||
assert!(result["content"].as_str().unwrap().contains("hello"));
|
||||
assert!(client.0.lock().unwrap().2.is_empty());
|
||||
}
|
||||
|
||||
@@ -40,7 +40,11 @@ pub fn mount_drive_wip(
|
||||
registry.allocate_namespace("drive", "drive")?;
|
||||
// Registration is only a namespace entrance. The subtree provides the
|
||||
// current target/scope/descriptor/handler in a single self-scoped snapshot.
|
||||
let names = root_specs();
|
||||
// This unresolved entrance has no live capability evidence.
|
||||
let names: Vec<_> = root_specs()
|
||||
.into_iter()
|
||||
.filter(|spec| !mutation(spec))
|
||||
.collect();
|
||||
let descriptor = descriptor(&names, true);
|
||||
registry.mount(WipProjection {
|
||||
route: ROOT.into(),
|
||||
@@ -63,7 +67,7 @@ pub fn mount_drive_wip(
|
||||
})?;
|
||||
registry.mount_subtree(WipSubtreeMount {
|
||||
root: ROOT.into(),
|
||||
provider: Arc::new(DriveProvider(feature.clone())),
|
||||
provider: Arc::new(DriveProvider::new(feature.clone())),
|
||||
})?;
|
||||
registry.replace_compatibility_tools(ROOT, SPECS.iter().map(|spec| spec.name))?;
|
||||
Ok(())
|
||||
@@ -80,7 +84,19 @@ impl WipOperationHandler for Unresolved {
|
||||
Err(map_error(DriveError::Denied))
|
||||
}
|
||||
}
|
||||
pub(super) struct DriveProvider(pub(super) DriveFeature);
|
||||
#[derive(Clone)]
|
||||
pub(super) struct DriveProvider {
|
||||
feature: DriveFeature,
|
||||
observation_nonce: Arc<str>,
|
||||
}
|
||||
impl DriveProvider {
|
||||
pub(super) fn new(feature: DriveFeature) -> Self {
|
||||
Self {
|
||||
feature,
|
||||
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipSubtreeProvider for DriveProvider {
|
||||
// Arbitrary tree depth is safe: there are deliberately no indexable entries.
|
||||
@@ -92,27 +108,50 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
}
|
||||
async fn publication(&self, path: &str) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let result = if path == ROOT {
|
||||
self.0.backend.root().await
|
||||
self.feature.backend.root().await
|
||||
} else {
|
||||
let Some(entry) = parse_entry(&self.0, path).map_err(protocol)? else {
|
||||
let Some(entry) = parse_entry(&self.feature, path).map_err(protocol)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.0.backend.metadata(&entry).await
|
||||
self.feature.backend.metadata(&entry).await
|
||||
};
|
||||
let entry = match result {
|
||||
Ok(entry) => entry,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let capabilities = match self.feature.backend.capabilities().await {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(DriveError::Denied | DriveError::NotFound) => return Ok(None),
|
||||
Err(error) => return Err(protocol(error)),
|
||||
};
|
||||
let root = path == ROOT;
|
||||
let specs = if root {
|
||||
let specs: Vec<_> = if root {
|
||||
root_specs()
|
||||
} else {
|
||||
entry_specs(&entry)
|
||||
};
|
||||
}
|
||||
.into_iter()
|
||||
.filter(|spec| {
|
||||
!mutation(spec) || (capabilities.write && publication_may_allow(&self.feature, spec))
|
||||
})
|
||||
.collect();
|
||||
let common_mutations: Vec<_> = specs
|
||||
.iter()
|
||||
.filter_map(|spec| match spec.operation {
|
||||
"edit" => Some(crate::file_operation::Contract::Edit),
|
||||
"write" => Some(crate::file_operation::Contract::Write),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let descriptor = descriptor(&specs, root);
|
||||
let reference = contextual_reference(INTERFACE, path);
|
||||
let validator = entry.last_mutation_id.as_bytes().to_vec();
|
||||
let validator = object_validator(
|
||||
&self.observation_nonce,
|
||||
&entry,
|
||||
&entry.last_mutation_id,
|
||||
&specs,
|
||||
);
|
||||
let object = Object {
|
||||
name: if root {
|
||||
"drive".into()
|
||||
@@ -130,7 +169,8 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
)),
|
||||
validator: Some(validator),
|
||||
};
|
||||
let projection = WipProjection {
|
||||
let common_read = entry.kind == DriveEntryKind::File;
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "drive:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
@@ -139,18 +179,80 @@ impl WipSubtreeProvider for DriveProvider {
|
||||
interface_validator: Some(digest(format!("{descriptor:?}").as_bytes()).into_bytes()),
|
||||
descriptor,
|
||||
handler: Arc::new(DriveHandler {
|
||||
feature: self.0.clone(),
|
||||
feature: self.feature.clone(),
|
||||
observation_nonce: self.observation_nonce.clone(),
|
||||
entry,
|
||||
specs,
|
||||
root,
|
||||
}),
|
||||
};
|
||||
// This is publication evidence only. Backend execution rechecks current
|
||||
// authority and the captured commit precondition on every mutation.
|
||||
crate::file_operation::expose(&mut projection, common_mutations);
|
||||
if common_read {
|
||||
crate::file_operation::expose(
|
||||
&mut projection,
|
||||
[
|
||||
crate::file_operation::Contract::ReadLines,
|
||||
crate::file_operation::Contract::SearchText,
|
||||
],
|
||||
);
|
||||
}
|
||||
WipPublication::self_scoped(projection).map(Some)
|
||||
}
|
||||
async fn children(&self, _: &str) -> Result<Vec<String>, ProtocolError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
fn object_validator(
|
||||
observation_nonce: &str,
|
||||
entry: &DriveEntry,
|
||||
mutation: &str,
|
||||
specs: &[Spec],
|
||||
) -> Vec<u8> {
|
||||
// Object observations belong to one mount, even when a restored Host reuses
|
||||
// the same Feature instance. Feature.nonce separately identifies mutations.
|
||||
// Current membership is part of Object representation, not the shared Interface lifetime.
|
||||
let operations: Vec<_> = specs.iter().map(|spec| spec.operation).collect();
|
||||
digest(
|
||||
format!(
|
||||
"{observation_nonce:?}:{:?}:{:?}:{mutation:?}:{operations:?}",
|
||||
entry.entry.workspace_id, entry.entry.node_id
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn mutation(spec: &Spec) -> bool {
|
||||
matches!(
|
||||
spec.operation,
|
||||
"write" | "edit" | "delete" | "relocate" | "create_folder" | "create_text" | "save_workdir"
|
||||
)
|
||||
}
|
||||
|
||||
fn publication_may_allow(feature: &DriveFeature, spec: &Spec) -> bool {
|
||||
let Some(permissions) = &feature.permissions else {
|
||||
return true;
|
||||
};
|
||||
// Like checkout publication, retain potentially allowed argument-dependent
|
||||
// operations but hide an unconditional Deny/Ask ceiling. This is not an
|
||||
// authorization decision: exact arguments are still checked at execution.
|
||||
for rule in permissions
|
||||
.rules
|
||||
.iter()
|
||||
.filter(|rule| rule.tool.eq_ignore_ascii_case(spec.name))
|
||||
{
|
||||
if rule.action == manifest::ToolPermissionAction::Allow {
|
||||
return true;
|
||||
}
|
||||
if rule.pattern == "*" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
permissions.default_action == manifest::ToolPermissionAction::Allow
|
||||
}
|
||||
|
||||
fn root_specs() -> Vec<Spec> {
|
||||
SPECS
|
||||
.iter()
|
||||
@@ -194,11 +296,10 @@ fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclarati
|
||||
}
|
||||
}
|
||||
fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
InterfaceDescriptor {format:INTERFACE_FORMAT_V1.into(),documentation:Some(Documentation {summary:"Backend-authorized Workspace Drive operations".into(),details:Some("Entry IDs are Workspace-bound. The Host/Client retain the last committed request observed for an entry. Never supply validators or silently retry a conflict against a newer observation. On outcome-unknown, query the original request receipt. Read-only grants reject mutations at Backend. List/search return paths for direct Inspect, never tree inventories. Image bytes attach to durable ToolOutput; URLs are not image viewing. Workdir source read and Drive destination write are independent.".into())}),types:Vec::new(),operations:specs.iter().filter(|spec| !matches!(spec.operation,"edit"|"write")).map(|spec|OperationDeclaration {name:spec.operation.into(),documentation:Some(Documentation {summary:spec.description.into(),details:None}),parameters:match spec.operation {
|
||||
"list"=>vec![parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"search"=>vec![parameter("query",true,TypeExpr::String),parameter("include_text",false,TypeExpr::Boolean),parameter("limit",false,TypeExpr::Integer),parameter("after",false,TypeExpr::String)],
|
||||
"read"=>vec![parameter("max_bytes",false,TypeExpr::Integer)],
|
||||
"write"|"edit"=>crate::file_operation::parameters(spec.operation,false).expect("shared text arguments"),
|
||||
"create_text"=>vec![parameter("name",true,TypeExpr::String),parameter("content",true,TypeExpr::String),parameter("content_type",false,TypeExpr::String)],
|
||||
"create_folder"=>vec![parameter("name",true,TypeExpr::String)],
|
||||
"relocate"=>vec![parameter("parent",true,TypeExpr::Entry),parameter("name",true,TypeExpr::String)],
|
||||
@@ -209,6 +310,7 @@ fn descriptor(specs: &[Spec], _root: bool) -> InterfaceDescriptor {
|
||||
}
|
||||
struct DriveHandler {
|
||||
feature: DriveFeature,
|
||||
observation_nonce: Arc<str>,
|
||||
entry: DriveEntry,
|
||||
specs: Vec<Spec>,
|
||||
root: bool,
|
||||
@@ -221,21 +323,34 @@ impl WipOperationHandler for DriveHandler {
|
||||
arguments: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let common_text = matches!(operation, "read_lines" | "search_text");
|
||||
let backend_operation = if common_text { "read" } else { operation };
|
||||
let spec = self
|
||||
.specs
|
||||
.iter()
|
||||
.find(|spec| spec.operation == operation)
|
||||
.find(|spec| spec.operation == backend_operation)
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::OperationNotFound,
|
||||
"Drive operation is not published",
|
||||
)
|
||||
})?;
|
||||
let declaration = descriptor(&self.specs, self.root)
|
||||
let contract = match operation {
|
||||
"read_lines" => Some(crate::file_operation::Contract::ReadLines),
|
||||
"search_text" => Some(crate::file_operation::Contract::SearchText),
|
||||
"edit" => Some(crate::file_operation::Contract::Edit),
|
||||
"write" => Some(crate::file_operation::Contract::Write),
|
||||
_ => None,
|
||||
};
|
||||
let declaration = if let Some(contract) = contract {
|
||||
contract.descriptor().operations.into_iter().next().unwrap()
|
||||
} else {
|
||||
descriptor(&self.specs, self.root)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.unwrap();
|
||||
.unwrap()
|
||||
};
|
||||
if arguments
|
||||
.keys()
|
||||
.any(|key| !declaration.parameters.iter().any(|p| p.name == *key))
|
||||
@@ -244,8 +359,22 @@ impl WipOperationHandler for DriveHandler {
|
||||
"unrecognized arguments; committed-request observations are Client-managed".into(),
|
||||
)));
|
||||
}
|
||||
let mut input = wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?;
|
||||
let mut input = if common_text {
|
||||
// Validate the common input before performing any source read. Its
|
||||
// line window is evaluated only after the complete bounded read.
|
||||
crate::file_operation::text_result(operation, arguments, "")?;
|
||||
if self
|
||||
.entry
|
||||
.size
|
||||
.is_some_and(|size| size > server_api::DRIVE_CHUNK_MAX_BYTES)
|
||||
{
|
||||
return Err(map_error(DriveError::Limit));
|
||||
}
|
||||
json!({"entry": self.entry.entry, "max_bytes": server_api::DRIVE_CHUNK_MAX_BYTES})
|
||||
} else {
|
||||
wip_to_json(&Value::Record(arguments.clone()))
|
||||
.map_err(|_| map_error(DriveError::Invalid("invalid WIP arguments".into())))?
|
||||
};
|
||||
let fields = input
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| map_error(DriveError::Invalid("named arguments required".into())))?;
|
||||
@@ -298,6 +427,19 @@ impl WipOperationHandler for DriveHandler {
|
||||
)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
if common_text {
|
||||
let content = complete_text(&output.value)?;
|
||||
let value = crate::file_operation::text_result(operation, arguments, content)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.entry,
|
||||
&self.entry.last_mutation_id,
|
||||
&self.specs,
|
||||
),
|
||||
));
|
||||
}
|
||||
let value = json_to_wip(&output.value).map_err(|_| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"Completed Drive result unavailable; query request_status before another mutation"
|
||||
@@ -314,13 +456,39 @@ impl WipOperationHandler for DriveHandler {
|
||||
"Committed Drive request identity unavailable".into(),
|
||||
)
|
||||
})?;
|
||||
WipOperationOutput::native_with_validator(value, last_mutation_id.as_bytes().to_vec())
|
||||
WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.entry,
|
||||
last_mutation_id,
|
||||
&self.specs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
WipOperationOutput::native(value)
|
||||
};
|
||||
Ok(result.with_attachments(output.attachments))
|
||||
}
|
||||
}
|
||||
/// A bounded line/search contract cannot claim that a partial blob is the full
|
||||
/// source. Retain Drive's separate whole-text read with its truncation flag.
|
||||
fn complete_text(value: &serde_json::Value) -> Result<&str, WipOperationError> {
|
||||
let content = value["text"]
|
||||
.as_str()
|
||||
.ok_or_else(|| map_error(DriveError::Unavailable))?;
|
||||
let size = value["entry"]["metadata"]["size"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| map_error(DriveError::Unavailable))?;
|
||||
if value["truncated"].as_bool() != Some(false)
|
||||
|| size != content.len() as u64
|
||||
|| content.len() > server_api::DRIVE_CHUNK_MAX_BYTES as usize
|
||||
{
|
||||
return Err(map_error(DriveError::Limit));
|
||||
}
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn parse_entry(feature: &DriveFeature, path: &str) -> Result<Option<DriveEntryRef>, DriveError> {
|
||||
let workspace = feature.backend.workspace()?;
|
||||
let prefix = format!(
|
||||
|
||||
@@ -505,13 +505,7 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_command(
|
||||
&self,
|
||||
mut request: CommandRequest,
|
||||
) -> Result<CommandHandle, WorkdirError> {
|
||||
// `spill_dir` belongs to the Worker host running BashTool. This remote
|
||||
// WorkdirSession provider cannot safely resolve or write that host path.
|
||||
request.spill_dir = None;
|
||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||
match self.operate(WorkdirSessionOperation::CommandStart(request))? {
|
||||
WorkdirSessionOperationResult::CommandStart(result) => Ok(result),
|
||||
_ => Err(Self::mismatch("command_start")),
|
||||
@@ -1660,9 +1654,9 @@ mod tests {
|
||||
.start_command(CommandRequest {
|
||||
command: "true".to_string(),
|
||||
timeout_secs: 120,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker-local/bash-output".into()),
|
||||
tool_call_id: Some("call-1".to_string()),
|
||||
})
|
||||
.await
|
||||
@@ -1675,7 +1669,7 @@ mod tests {
|
||||
assert_eq!(body["operation"]["operation"], "command_start");
|
||||
assert_eq!(body["operation"]["request"]["command"], "true");
|
||||
assert_eq!(body["operation"]["request"]["cwd"], "");
|
||||
assert!(body["operation"]["request"]["spill_dir"].is_null());
|
||||
assert!(body["operation"]["request"].get("spill_dir").is_none());
|
||||
assert_eq!(body["operation"]["request"]["tool_call_id"], "call-1");
|
||||
assert!(
|
||||
!requests[0]
|
||||
@@ -1942,9 +1936,9 @@ mod tests {
|
||||
.start_command(CommandRequest {
|
||||
command: "printf done".to_string(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -47,6 +47,12 @@ use crate::wip::{
|
||||
pub(super) struct ConfigProvider {
|
||||
pub feature: WorkspaceConfigFeature,
|
||||
pub backend: Arc<WorkspaceConfigBackend>,
|
||||
// A fresh mount after restore invalidates observations from its predecessor.
|
||||
pub observation_nonce: Arc<str>,
|
||||
}
|
||||
|
||||
fn object_validator(nonce: &str, connection: &str, validator: &str) -> Vec<u8> {
|
||||
Sha256::digest(format!("{nonce}:{connection}:{validator}")).to_vec()
|
||||
}
|
||||
|
||||
/// Backend tree root is empty; source names are canonical relative config paths.
|
||||
@@ -240,7 +246,7 @@ impl ConfigProvider {
|
||||
let descriptor = descriptor(&operations);
|
||||
let interface = contextual_reference("yoi.workspace-config/node/v1", path);
|
||||
let interface_validator = Some(Sha256::digest(format!("{descriptor:?}")).to_vec());
|
||||
Ok(Some(WipProjection {
|
||||
let mut projection = WipProjection {
|
||||
route: path.into(),
|
||||
capability: "workspace-config:content".into(),
|
||||
kind: WipProjectionKind::Native,
|
||||
@@ -252,7 +258,11 @@ impl ConfigProvider {
|
||||
)),
|
||||
interfaces: vec![interface.clone()],
|
||||
r#ref: None,
|
||||
validator: Some(node.validator.as_bytes().to_vec()),
|
||||
validator: Some(object_validator(
|
||||
&self.observation_nonce,
|
||||
&attachment.connection_id,
|
||||
&node.validator,
|
||||
)),
|
||||
},
|
||||
interface,
|
||||
descriptor,
|
||||
@@ -263,9 +273,12 @@ impl ConfigProvider {
|
||||
attachment,
|
||||
snapshot,
|
||||
node,
|
||||
operations,
|
||||
operations: operations.clone(),
|
||||
observation_nonce: self.observation_nonce.clone(),
|
||||
}),
|
||||
}))
|
||||
};
|
||||
crate::file_operation::expose(&mut projection, common_contracts(&operations));
|
||||
Ok(Some(projection))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,12 +348,28 @@ fn operations(
|
||||
names.push(name.into());
|
||||
}
|
||||
}
|
||||
if names.iter().any(|name| name == "read") {
|
||||
names.extend(["read_lines".into(), "search_text".into()]);
|
||||
}
|
||||
if root {
|
||||
names.push("attach".into());
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn common_contracts(names: &[String]) -> Vec<crate::file_operation::Contract> {
|
||||
use crate::file_operation::Contract;
|
||||
[
|
||||
("read_lines", Contract::ReadLines),
|
||||
("search_text", Contract::SearchText),
|
||||
("edit", Contract::Edit),
|
||||
("write", Contract::Write),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(name, contract)| names.iter().any(|n| n == name).then_some(contract))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: name.into(),
|
||||
@@ -356,11 +385,11 @@ fn descriptor(names: &[String]) -> InterfaceDescriptor {
|
||||
summary: "Logical Workspace configuration file operations".into(),
|
||||
details: Some("Backend grants, semantic validation, atomic whole-tree CAS and activation apply. Validators and change digests are managed automatically. apply_changes atomically accepts create/update/delete/rename changes without validators or expected_digest fields. Saving updates configuration for future operation boundaries, not already-running Worker manifests. No OS or command access.".into()),
|
||||
}),
|
||||
operations: names.iter().map(|name| OperationDeclaration {
|
||||
operations: names.iter().filter(|name| !matches!(name.as_str(), "read_lines" | "search_text" | "edit" | "write")).map(|name| OperationDeclaration {
|
||||
name: name.clone(), documentation: None,
|
||||
parameters: match name.as_str() {
|
||||
"attach" => vec![parameter("access", false, TypeExpr::String)],
|
||||
"read" | "write" | "edit" => crate::file_operation::parameters(name, false).unwrap(),
|
||||
"read" => crate::file_operation::parameters(name, false).unwrap(),
|
||||
"delete" => vec![],
|
||||
"create" => vec![parameter("path", false, TypeExpr::String), parameter("content", true, TypeExpr::String), parameter("content_type", false, TypeExpr::String)],
|
||||
"apply_changes" => vec![parameter("changes", true, TypeExpr::Json)],
|
||||
@@ -378,6 +407,7 @@ struct ConfigHandler {
|
||||
snapshot: WorkspaceConfigObserveResponse,
|
||||
node: WorkspaceConfigNode,
|
||||
operations: Vec<String>,
|
||||
observation_nonce: Arc<str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -476,13 +506,18 @@ impl ConfigHandler {
|
||||
|| response.validator != self.node.validator
|
||||
|| Some(&response.digest) != self.node.digest.as_ref()
|
||||
|| Some(&response.content_type) != self.node.content_type.as_ref()
|
||||
|| response.content.len() > MAX_TEXT_BYTES
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"Invalid Workspace config read response",
|
||||
));
|
||||
}
|
||||
if response.content.len() > MAX_TEXT_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"Workspace config source exceeds text limit",
|
||||
));
|
||||
}
|
||||
let mut response = response;
|
||||
response.content = fs_operation::text::read(response.content, text_limits())
|
||||
.map_err(text_error)?
|
||||
@@ -628,10 +663,32 @@ impl ConfigHandler {
|
||||
));
|
||||
}
|
||||
// Direct handler calls also enforce parameter shape; no model validators accepted.
|
||||
let declaration = descriptor(&self.operations)
|
||||
let declaration = match operation {
|
||||
"read_lines" => crate::file_operation::Contract::ReadLines
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation)
|
||||
.next(),
|
||||
"search_text" => crate::file_operation::Contract::SearchText
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"edit" => crate::file_operation::Contract::Edit
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
"write" => crate::file_operation::Contract::Write
|
||||
.descriptor()
|
||||
.operations
|
||||
.into_iter()
|
||||
.next(),
|
||||
_ => descriptor(&self.operations)
|
||||
.operations
|
||||
.into_iter()
|
||||
.find(|op| op.name == operation),
|
||||
}
|
||||
.ok_or_else(invalid)?;
|
||||
if arguments
|
||||
.keys()
|
||||
@@ -646,6 +703,19 @@ impl ConfigHandler {
|
||||
.call(operation, arguments, context)
|
||||
.await;
|
||||
}
|
||||
if matches!(operation, "read_lines" | "search_text") {
|
||||
crate::file_operation::text_result(operation, arguments, "")?;
|
||||
let result = self.read().await?;
|
||||
let value = crate::file_operation::text_result(operation, arguments, &result.content)?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&result.validator,
|
||||
),
|
||||
));
|
||||
}
|
||||
if operation == "read" {
|
||||
let _: fs_operation::text::ReadArgs = decode_text(arguments)?;
|
||||
let result = self.read().await?;
|
||||
@@ -657,7 +727,11 @@ impl ConfigHandler {
|
||||
.map_err(|_| invalid())?;
|
||||
return Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
self.node.validator.as_bytes().to_vec(),
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&self.node.validator,
|
||||
),
|
||||
));
|
||||
}
|
||||
if self.attachment.access != WorkspaceConfigAccess::ReadWrite {
|
||||
@@ -762,7 +836,11 @@ impl ConfigHandler {
|
||||
"activation": "Saved to canonical Workspace configuration; existing Worker manifests are not rewritten"})).map_err(|_| unknown())?;
|
||||
Ok(WipOperationOutput::native_with_validator(
|
||||
value,
|
||||
node.validator.as_bytes().to_vec(),
|
||||
object_validator(
|
||||
&self.observation_nonce,
|
||||
&self.attachment.connection_id,
|
||||
&node.validator,
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,8 +466,19 @@ async fn call(
|
||||
runtime
|
||||
.call(
|
||||
path.into(),
|
||||
interface(path, true),
|
||||
op.into(),
|
||||
match op {
|
||||
"edit" => crate::file_operation::Contract::Edit.reference(),
|
||||
"write" => crate::file_operation::Contract::Write.reference(),
|
||||
"read_lines" => crate::file_operation::Contract::ReadLines.reference(),
|
||||
"search_text" => crate::file_operation::Contract::SearchText.reference(),
|
||||
_ => interface(path, true),
|
||||
},
|
||||
match op {
|
||||
"read_lines" => "read",
|
||||
"search_text" => "search",
|
||||
_ => op,
|
||||
}
|
||||
.into(),
|
||||
args,
|
||||
ToolExecutionContext::direct(),
|
||||
)
|
||||
@@ -825,11 +836,11 @@ async fn workspace_config_production_reattach_new_lifetime_rejects_old_same_byte
|
||||
client.state.lock().unwrap().active = None;
|
||||
let second = feature.attach("second", None).await.unwrap();
|
||||
assert_ne!(first.connection_id, second.connection_id);
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"bad"}))
|
||||
let error = call(&runtime, path, "write", json!({"content":"bad"}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ValidatorMismatch"), "{error}");
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
observe_interface(&runtime, path, true).await;
|
||||
call(&runtime, path, "write", json!({"content":"new-lifetime"}))
|
||||
@@ -958,3 +969,189 @@ async fn workspace_config_production_ungranted_root_does_not_poison_worldspace_d
|
||||
.all(|r| r.method == WorkspaceRequestMethod::Get)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_read_only_text_windows_search_and_rejections_preserve_backend_authority() {
|
||||
let client = Router::new();
|
||||
{
|
||||
let mut state = client.state.lock().unwrap();
|
||||
state.read_only = true;
|
||||
state.entries.get_mut("main.dcdl").unwrap().1 = "α\r\nneedle.*\nNeedle.*\nlast\n".into();
|
||||
}
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("read-only", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let object = runtime
|
||||
.inspect(path.into(), true)
|
||||
.await
|
||||
.unwrap()
|
||||
.content
|
||||
.unwrap();
|
||||
assert!(object.contains(&crate::file_operation::Contract::ReadLines.reference().name));
|
||||
assert!(object.contains(&crate::file_operation::Contract::SearchText.reference().name));
|
||||
assert!(!object.contains(&crate::file_operation::Contract::Write.reference().name));
|
||||
assert!(!object.contains(&crate::file_operation::Contract::Edit.reference().name));
|
||||
let read = call(&runtime, path, "read_lines", json!({"offset":1,"limit":2}))
|
||||
.await
|
||||
.unwrap();
|
||||
let read: Json = serde_json::from_str(read.content.as_deref().unwrap()).unwrap();
|
||||
assert!(read["content"].as_str().unwrap().contains("needle.*"));
|
||||
assert!(read["content"].as_str().unwrap().contains("Needle.*"));
|
||||
assert!(!read["content"].as_str().unwrap().contains('α'));
|
||||
assert_eq!(read["next_offset"], 3);
|
||||
assert_eq!(read["truncated"], true);
|
||||
let search = call(
|
||||
&runtime,
|
||||
path,
|
||||
"search_text",
|
||||
json!({"pattern":"needle.*","limit":3}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let search: Json = serde_json::from_str(search.content.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(search["matches"], json!([{"line":2,"text":"needle.*"}]));
|
||||
assert_eq!(search["next_offset"], 3);
|
||||
assert_eq!(search["truncated"], true);
|
||||
for (op, arguments) in [
|
||||
("read_lines", json!({"limit":0})),
|
||||
("read_lines", json!({"limit":2001})),
|
||||
("search_text", json!({"pattern":""})),
|
||||
(
|
||||
"search_text",
|
||||
json!({"pattern":"x","case_insensitive":true}),
|
||||
),
|
||||
] {
|
||||
let before = client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.path.ends_with("/read"))
|
||||
.count();
|
||||
assert!(call(&runtime, path, op, arguments).await.is_err());
|
||||
let after = client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.requests
|
||||
.iter()
|
||||
.filter(|r| r.path.ends_with("/read"))
|
||||
.count();
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
assert!(
|
||||
call(&runtime, path, "write", json!({"content":"denied"}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
client.state.lock().unwrap().granted = false;
|
||||
assert!(call(&runtime, path, "read_lines", json!({})).await.is_err());
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_text_read_refuses_oversized_rendered_window() {
|
||||
let client = Router::new();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
feature.attach("bounded", None).await.unwrap();
|
||||
let path = "/workspace-config/main.dcdl";
|
||||
client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut("main.dcdl")
|
||||
.unwrap()
|
||||
.1 = "x".repeat(64 * 1024);
|
||||
observe_interface(&runtime, path, true).await;
|
||||
let error = call(&runtime, path, "read_lines", json!({}))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("ResourceLimitExceeded"), "{error}");
|
||||
assert!(client.state.lock().unwrap().commits.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_config_backend_validators_reject_path_and_connection_transplant_even_for_identical_sources()
|
||||
{
|
||||
use crate::wip::WipOperationError;
|
||||
use wip_protocol::ProtocolErrorCode;
|
||||
let client = Router::new();
|
||||
let path = "profiles/team/deep/model.dcdl";
|
||||
client
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.get_mut(path)
|
||||
.unwrap()
|
||||
.1 = "model = old".into();
|
||||
let (feature, runtime) = runtime(client.clone());
|
||||
let first = feature.attach("path-binding", None).await.unwrap();
|
||||
let backend = backend::WorkspaceConfigBackend::new(client.clone());
|
||||
let (main_validator, deep_validator) = {
|
||||
let state = client.state.lock().unwrap();
|
||||
(state.validator("main.dcdl"), state.validator(path))
|
||||
};
|
||||
assert_ne!(main_validator, deep_validator);
|
||||
let read = |connection_id: String, validator: String| WorkspaceConfigReadRequest {
|
||||
connection_id,
|
||||
path: path.into(),
|
||||
validator,
|
||||
};
|
||||
let result = backend
|
||||
.post::<WorkspaceConfigReadResponse, _>(
|
||||
"/read",
|
||||
&read(first.connection_id.clone(), main_validator),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
let result: WorkspaceConfigReadResponse = backend
|
||||
.post(
|
||||
"/read",
|
||||
&read(first.connection_id.clone(), deep_validator.clone()),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("correct path validator rejected"));
|
||||
assert_eq!(result.content, "model = old");
|
||||
assert_eq!(result.validator, deep_validator);
|
||||
client.state.lock().unwrap().active = None;
|
||||
let second = feature.attach("connection-binding", None).await.unwrap();
|
||||
assert_ne!(first.connection_id, second.connection_id);
|
||||
for connection in [first.connection_id, second.connection_id.clone()] {
|
||||
let result = backend
|
||||
.post::<WorkspaceConfigReadResponse, _>(
|
||||
"/read",
|
||||
&read(connection, deep_validator.clone()),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result, Err(WipOperationError::Protocol(error)) if error.code == ProtocolErrorCode::ValidatorMismatch)
|
||||
);
|
||||
}
|
||||
// Fresh path/connection observation restores native common mutation access,
|
||||
// and the returned exact post-operation validator also supports common reads.
|
||||
let route = format!("/workspace-config/{path}");
|
||||
observe_interface(&runtime, &route, true).await;
|
||||
call(
|
||||
&runtime,
|
||||
&route,
|
||||
"write",
|
||||
json!({"content":"model = updated"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = call(&runtime, &route, "read_lines", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.content.unwrap().contains("model = updated"));
|
||||
assert_eq!(client.state.lock().unwrap().commits.len(), 1);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ pub fn mount_workspace_config_wip(
|
||||
provider: Arc::new(super::content::ConfigProvider {
|
||||
feature: feature.clone(),
|
||||
backend: backend.clone(),
|
||||
observation_nonce: uuid::Uuid::now_v7().to_string().into(),
|
||||
}),
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
//! WIP presentation of the provider-neutral file argument contracts. Providers
|
||||
//! choose which read surface to publish; routing and preconditions are not args.
|
||||
//! Stable root file contracts, independent of provider and target lifetimes.
|
||||
//!
|
||||
//! Each capability has one versioned reference and complete descriptor. Providers publish only
|
||||
//! supported combinations. Root scope is a namespace, never a file grant. Current Object
|
||||
//! membership selects the provider handler; target observations, checked operations, prior Read,
|
||||
//! CAS and grants remain provider-owned. Contract validators contain no target or connection state.
|
||||
//!
|
||||
//! Line windows, byte ranges and whole-text domain reads are different meanings, not aliases.
|
||||
//! Settings atomic commits and Drive management/cursor pagination remain domain extensions.
|
||||
//! Common mutations return one metadata envelope while providers retain their commit details.
|
||||
//! Old contextual references in history are neither rewritten nor accepted as aliases; callers
|
||||
//! reobserve Objects and common definitions after migration or restore.
|
||||
use fs_operation::text::TextOperation;
|
||||
use wip_protocol::{ParameterDeclaration, TypeExpr};
|
||||
|
||||
@@ -41,3 +51,651 @@ fn schema_parameters(schema: &schemars::Schema) -> Vec<ParameterDeclaration> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
use crate::wip::{
|
||||
WipCallContext, WipOperationError, WipOperationHandler, WipOperationOutput, WipProjection,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
use wip_protocol::{
|
||||
Documentation, FieldDeclaration, INTERFACE_FORMAT_V1, InterfaceDescriptor, InterfaceReference,
|
||||
OperationDeclaration, ProtocolError, ProtocolErrorCode, ReturnDeclaration, Value,
|
||||
};
|
||||
|
||||
pub(crate) const MAX_LINES: usize = 2000;
|
||||
pub(crate) const MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Contracts have independent capability membership and no target-dependent descriptor fields.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Contract {
|
||||
ReadBytes,
|
||||
ReadLines,
|
||||
SearchText,
|
||||
Edit,
|
||||
Write,
|
||||
List,
|
||||
Glob,
|
||||
Grep,
|
||||
CreateFile,
|
||||
}
|
||||
impl Contract {
|
||||
pub(crate) fn reference(self) -> InterfaceReference {
|
||||
crate::wip::root_reference(match self {
|
||||
Self::ReadLines => "yoi.file/read-lines/v1",
|
||||
Self::ReadBytes => "yoi.file/read-bytes/v1",
|
||||
Self::SearchText => "yoi.file/search-text/v1",
|
||||
Self::Edit => "yoi.file/edit-text/v1",
|
||||
Self::Write => "yoi.file/write-text/v1",
|
||||
Self::List => "yoi.directory/list/v1",
|
||||
Self::Glob => "yoi.directory/glob/v1",
|
||||
Self::Grep => "yoi.directory/grep/v1",
|
||||
Self::CreateFile => "yoi.directory/create-file/v1",
|
||||
})
|
||||
}
|
||||
pub(crate) fn from_reference(r: &InterfaceReference) -> Option<Self> {
|
||||
[
|
||||
Self::ReadLines,
|
||||
Self::ReadBytes,
|
||||
Self::SearchText,
|
||||
Self::Edit,
|
||||
Self::Write,
|
||||
Self::List,
|
||||
Self::Glob,
|
||||
Self::Grep,
|
||||
Self::CreateFile,
|
||||
]
|
||||
.into_iter()
|
||||
.find(|c| c.reference() == *r)
|
||||
}
|
||||
pub(crate) fn operation(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadLines | Self::ReadBytes => "read",
|
||||
Self::SearchText => "search",
|
||||
Self::Edit => "edit",
|
||||
Self::Write => "write",
|
||||
Self::List => "list",
|
||||
Self::Glob => "glob",
|
||||
Self::Grep => "grep",
|
||||
Self::CreateFile => "create_file",
|
||||
}
|
||||
}
|
||||
pub(crate) fn handler_operation(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadLines => "read_lines",
|
||||
Self::ReadBytes => "read_bytes",
|
||||
Self::SearchText => "search_text",
|
||||
_ => self.operation(),
|
||||
}
|
||||
}
|
||||
pub(crate) fn descriptor(self) -> InterfaceDescriptor {
|
||||
let (summary, parameters, result) = match self {
|
||||
Self::ReadBytes => (
|
||||
"Read a bounded binary byte range",
|
||||
vec![
|
||||
parameter("offset", false, TypeExpr::Integer),
|
||||
parameter("limit", false, TypeExpr::Integer),
|
||||
],
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("encoding", true, TypeExpr::String),
|
||||
field("data", true, TypeExpr::String),
|
||||
field("offset", true, TypeExpr::Integer),
|
||||
field("next_offset", true, TypeExpr::Integer),
|
||||
field("eof", true, TypeExpr::Boolean),
|
||||
],
|
||||
},
|
||||
),
|
||||
Self::ReadLines => (
|
||||
"Read a bounded text line window",
|
||||
parameters("read", true).unwrap(),
|
||||
page_type(false),
|
||||
),
|
||||
Self::SearchText => {
|
||||
let mut p = parameters("read", true).unwrap();
|
||||
p.push(parameter("pattern", true, TypeExpr::String));
|
||||
(
|
||||
"Search a bounded text line window for a literal",
|
||||
p,
|
||||
page_type(true),
|
||||
)
|
||||
}
|
||||
Self::Edit | Self::Write => (
|
||||
"Change existing text under provider preconditions",
|
||||
parameters(self.operation(), true).unwrap(),
|
||||
mutation_type(),
|
||||
),
|
||||
Self::List => (
|
||||
"List direct contents, directories first then lexical path. Bounded live pages, not a snapshot; limit defaults to 100, maximum 1000; after continues by kind/entry. Includes hidden/ignored names under provider List policy, unlike Glob/Grep.",
|
||||
vec![
|
||||
parameter("limit", false, TypeExpr::Integer),
|
||||
parameter("after", false, cursor_type()),
|
||||
],
|
||||
result_type(true),
|
||||
),
|
||||
Self::Glob => (
|
||||
"Glob within this directory",
|
||||
vec![
|
||||
parameter("pattern", true, TypeExpr::String),
|
||||
parameter("path", false, TypeExpr::String),
|
||||
],
|
||||
result_type(false),
|
||||
),
|
||||
Self::Grep => (
|
||||
"Regex search within this directory",
|
||||
grep_parameters(),
|
||||
result_type(false),
|
||||
),
|
||||
Self::CreateFile => (
|
||||
"Create a new relative file and permitted missing parents; never overwrite",
|
||||
vec![
|
||||
parameter("path", true, TypeExpr::String),
|
||||
parameter("content", true, TypeExpr::String),
|
||||
],
|
||||
mutation_type(),
|
||||
),
|
||||
};
|
||||
let details = match self {
|
||||
Self::ReadBytes => {
|
||||
"Binary-safe byte range, independent of UTF-8 and line boundaries. Offset defaults to 0; limit defaults to 16384 and must be 1..=32768 bytes. Offset must be <=9007199254708223. Returns encoding=base64 with RFC4648 standard padded data, offset and next_offset in bytes, eof when no later bytes remain. At EOF returns empty data, eof=true and next_offset=offset; offsets beyond EOF are InvalidArguments. Encoded pretty JSON result is bounded to 64 KiB. Does not establish text prior-Read for mutation. InvalidArguments rejects ranges/unknown fields, ResourceLimitExceeded bounds, ValidatorMismatch stale source, PermissionDenied authority. Provider source bounds apply; no automatic retry."
|
||||
}
|
||||
Self::ReadLines | Self::SearchText => {
|
||||
"UTF-8 text (invalid source bytes decoded lossily); Rust lines semantics (CRLF stripped, final newline is not an extra line). Zero-based offset defaults to 0. Limit defaults to 2000, must be 1..=2000. Source/result windows exceeding 64 KiB are refused, never silently skipped. Read returns one-based numbered content. Search is case-sensitive literal matching, pattern nonempty and at most 4096 bytes, returning one-based line/text records. next_offset advances by scanned lines including nonmatches; truncated means more lines remain. Provider source bounds may refuse retrieval. InvalidArguments rejects bad ranges/patterns; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. No automatic retry."
|
||||
}
|
||||
Self::Edit | Self::Write | Self::CreateFile => {
|
||||
"UTF-8 text with provider size bounds. Checked save, prior Read and CAS requirements remain provider-owned. Edit rejects empty or identical old/new strings, missing or ambiguous matches unless replace_all. Success returns metadata with provider commit information. InvalidArguments rejects invalid edits; ResourceLimitExceeded rejects bounds; ValidatorMismatch rejects stale state; PermissionDenied rejects current authority. Post-dispatch failure may be OutcomeUnknown: reconcile, never automatically retry."
|
||||
}
|
||||
_ => {
|
||||
"Paths and cursors are Worldspace entries, not host paths. Provider scope, permission, visibility, bounds and cancellation remain authoritative. Discovery does not acquire every result or add indexable children. InvalidArguments, ResourceLimitExceeded, PermissionDenied and ValidatorMismatch preserve provider failures. No automatic retry."
|
||||
}
|
||||
};
|
||||
InterfaceDescriptor {
|
||||
format: INTERFACE_FORMAT_V1.into(),
|
||||
types: vec![],
|
||||
documentation: Some(Documentation {
|
||||
summary: summary.into(),
|
||||
details: Some(details.into()),
|
||||
}),
|
||||
operations: vec![OperationDeclaration {
|
||||
name: self.operation().into(),
|
||||
documentation: None,
|
||||
parameters,
|
||||
returns: ReturnDeclaration {
|
||||
documentation: None,
|
||||
r#type: result,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
pub(crate) fn validator(self) -> Vec<u8> {
|
||||
Sha256::digest(format!("{:?}", self.descriptor())).to_vec()
|
||||
}
|
||||
}
|
||||
pub(crate) fn expose(p: &mut WipProjection, contracts: impl IntoIterator<Item = Contract>) {
|
||||
for c in contracts {
|
||||
let r = c.reference();
|
||||
if !p.object.interfaces.contains(&r) {
|
||||
p.object.interfaces.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select only from current provider membership; the selected handler still owns all authority.
|
||||
pub(crate) fn select(p: &mut WipProjection, r: &InterfaceReference) -> bool {
|
||||
let Some(c) = Contract::from_reference(r) else {
|
||||
return false;
|
||||
};
|
||||
if !p.object.interfaces.contains(r) {
|
||||
return false;
|
||||
}
|
||||
p.interface = r.clone();
|
||||
p.descriptor = c.descriptor();
|
||||
p.interface_validator = Some(c.validator());
|
||||
p.handler = Arc::new(Selected {
|
||||
contract: c,
|
||||
inner: p.handler.clone(),
|
||||
});
|
||||
true
|
||||
}
|
||||
struct Selected {
|
||||
contract: Contract,
|
||||
inner: Arc<dyn WipOperationHandler>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl WipOperationHandler for Selected {
|
||||
async fn call(
|
||||
&self,
|
||||
_: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
context: WipCallContext,
|
||||
) -> Result<WipOperationOutput, WipOperationError> {
|
||||
let mut out = self
|
||||
.inner
|
||||
.call(self.contract.handler_operation(), args, context)
|
||||
.await?;
|
||||
if matches!(
|
||||
self.contract,
|
||||
Contract::Edit | Contract::Write | Contract::CreateFile
|
||||
) {
|
||||
out.value = Value::Record(BTreeMap::from([("metadata".into(), out.value)]));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
async fn cancel_operation(
|
||||
&self,
|
||||
_: &str,
|
||||
ctx: &agen::tool::ToolExecutionContext,
|
||||
) -> Result<(), agen::tool::ToolError> {
|
||||
self.inner
|
||||
.cancel_operation(self.contract.handler_operation(), ctx)
|
||||
.await
|
||||
}
|
||||
}
|
||||
fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
|
||||
WipOperationError::Protocol(ProtocolError {
|
||||
code,
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
pub(crate) fn window(
|
||||
args: &BTreeMap<String, Value>,
|
||||
search: bool,
|
||||
) -> Result<(usize, usize), WipOperationError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|k| !["offset", "limit"].contains(&k.as_str()) && !(search && k == "pattern"))
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"unknown text argument",
|
||||
));
|
||||
}
|
||||
let integer = |name: &str, default: usize| match args.get(name) {
|
||||
None => Ok(default),
|
||||
Some(Value::Integer(i)) => usize::try_from(*i)
|
||||
.map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative text range")),
|
||||
_ => Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid text range",
|
||||
)),
|
||||
};
|
||||
let offset = integer("offset", 0)?;
|
||||
let limit = integer("limit", MAX_LINES)?;
|
||||
if !(1..=MAX_LINES).contains(&limit)
|
||||
|| (offset as u64) > 9_007_199_254_740_991u64 - MAX_LINES as u64
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"text range exceeds bounds",
|
||||
));
|
||||
}
|
||||
if search {
|
||||
match args.get("pattern") {
|
||||
Some(Value::String(p)) if !p.is_empty() && p.len() <= 4096 => {}
|
||||
_ => {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"search requires a nonempty literal pattern up to 4096 bytes",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((offset, limit))
|
||||
}
|
||||
pub(crate) fn text_result(
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
content: &str,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
let (offset, limit) = window(args, op == "search_text")?;
|
||||
let mut lines = content.lines().skip(offset);
|
||||
let mut text = String::new();
|
||||
for line in lines.by_ref().take(limit) {
|
||||
if text.len().saturating_add(line.len()).saturating_add(1) >= MAX_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text window exceeds source byte bound",
|
||||
));
|
||||
}
|
||||
text.push_str(line);
|
||||
text.push('\n');
|
||||
}
|
||||
page_result(op, args, &text, offset, lines.next().is_some())
|
||||
}
|
||||
/// A checked provider can supply only the selected window, without copying whole source files.
|
||||
pub(crate) fn page_result(
|
||||
op: &str,
|
||||
args: &BTreeMap<String, Value>,
|
||||
text: &str,
|
||||
offset: usize,
|
||||
more: bool,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
let (_, limit) = window(args, op == "search_text")?;
|
||||
if text.len() >= MAX_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"text window exceeds source byte bound",
|
||||
));
|
||||
}
|
||||
let count = text.lines().count();
|
||||
if count > limit {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"provider returned an invalid line window",
|
||||
));
|
||||
}
|
||||
let mut fields = BTreeMap::from([
|
||||
(
|
||||
"next_offset".into(),
|
||||
Value::Integer((offset + count) as i64),
|
||||
),
|
||||
("truncated".into(), Value::Boolean(more)),
|
||||
]);
|
||||
if op == "search_text" {
|
||||
let Some(Value::String(pattern)) = args.get("pattern") else {
|
||||
unreachable!("window checked pattern")
|
||||
};
|
||||
let matches = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| l.contains(pattern))
|
||||
.map(|(i, l)| {
|
||||
Value::Record(BTreeMap::from([
|
||||
("line".into(), Value::Integer((offset + i + 1) as i64)),
|
||||
("text".into(), Value::String(l.into())),
|
||||
]))
|
||||
})
|
||||
.collect();
|
||||
fields.insert("matches".into(), Value::List(matches));
|
||||
} else {
|
||||
let full_len = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, l)| format!("{:>6}\t", offset + i + 1).len() + l.len() + 1)
|
||||
.sum::<usize>();
|
||||
if full_len > MAX_BYTES.saturating_sub(64) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"numbered window exceeds output byte bound",
|
||||
));
|
||||
}
|
||||
let rendered =
|
||||
fs_operation::text::render_numbered(text, offset, offset + count, false, MAX_BYTES);
|
||||
fields.insert("content".into(), Value::String(rendered.body));
|
||||
}
|
||||
let value = Value::Record(fields);
|
||||
let json = crate::wip::wip_to_json(&value)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
|
||||
// The model receives pretty JSON, so budget escapes and record/indentation overhead too.
|
||||
let encoded = serde_json::to_vec_pretty(&json)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "text result unavailable"))?;
|
||||
if encoded.len() > MAX_BYTES {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"serialized text window exceeds output byte bound",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn parameter(n: &str, r: bool, t: TypeExpr) -> ParameterDeclaration {
|
||||
ParameterDeclaration {
|
||||
name: n.into(),
|
||||
required: r,
|
||||
documentation: None,
|
||||
r#type: t,
|
||||
}
|
||||
}
|
||||
fn field(n: &str, r: bool, t: TypeExpr) -> FieldDeclaration {
|
||||
FieldDeclaration {
|
||||
name: n.into(),
|
||||
required: r,
|
||||
documentation: None,
|
||||
r#type: t,
|
||||
}
|
||||
}
|
||||
fn mutation_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![field("metadata", true, TypeExpr::Json)],
|
||||
}
|
||||
}
|
||||
fn page_type(search: bool) -> TypeExpr {
|
||||
let mut fields = vec![
|
||||
field("next_offset", true, TypeExpr::Integer),
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
];
|
||||
fields.push(if search {
|
||||
field(
|
||||
"matches",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("line", true, TypeExpr::Integer),
|
||||
field("text", true, TypeExpr::String),
|
||||
],
|
||||
}),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
field("content", true, TypeExpr::String)
|
||||
});
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn cursor_type() -> TypeExpr {
|
||||
TypeExpr::Record {
|
||||
fields: vec![
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("entry", true, TypeExpr::Entry),
|
||||
],
|
||||
}
|
||||
}
|
||||
fn result_type(list: bool) -> TypeExpr {
|
||||
let mut item = vec![field("entry", true, TypeExpr::Entry)];
|
||||
if list {
|
||||
item.extend([
|
||||
field("kind", true, TypeExpr::String),
|
||||
field("size", true, TypeExpr::Integer),
|
||||
]);
|
||||
}
|
||||
let mut fields = vec![
|
||||
field("summary", true, TypeExpr::String),
|
||||
field("content", false, TypeExpr::String),
|
||||
field(
|
||||
"items",
|
||||
true,
|
||||
TypeExpr::List {
|
||||
items: Box::new(TypeExpr::Record { fields: item }),
|
||||
},
|
||||
),
|
||||
];
|
||||
if list {
|
||||
fields.extend([
|
||||
field("truncated", true, TypeExpr::Boolean),
|
||||
field("after", false, cursor_type()),
|
||||
]);
|
||||
}
|
||||
TypeExpr::Record { fields }
|
||||
}
|
||||
fn grep_parameters() -> Vec<ParameterDeclaration> {
|
||||
let mut p = vec![parameter("pattern", true, TypeExpr::String)];
|
||||
for n in ["path", "glob", "type", "output_mode"] {
|
||||
p.push(parameter(n, false, TypeExpr::String));
|
||||
}
|
||||
for n in ["-A", "-B", "-C", "head_limit", "offset"] {
|
||||
p.push(parameter(n, false, TypeExpr::Integer));
|
||||
}
|
||||
for n in ["case_insensitive", "multiline"] {
|
||||
p.push(parameter(n, false, TypeExpr::Boolean));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn args(value: serde_json::Value) -> BTreeMap<String, Value> {
|
||||
let Value::Record(args) = crate::wip::json_to_wip(&value).unwrap() else {
|
||||
panic!("record")
|
||||
};
|
||||
args
|
||||
}
|
||||
#[test]
|
||||
fn text_window_preserves_unicode_crlf_offsets_and_literal_search_continuation() {
|
||||
let input = "first\r\n雪.*\r\nnot a match\n雪.*\n";
|
||||
let arguments = args(serde_json::json!({"offset":1,"limit":2,"pattern":".*"}));
|
||||
let whole = text_result("search_text", &arguments, input)
|
||||
.unwrap_or_else(|_| panic!("text result refused"));
|
||||
let partial = page_result("search_text", &arguments, "雪.*\r\nnot a match\n", 1, true)
|
||||
.unwrap_or_else(|_| panic!("page refused"));
|
||||
assert_eq!(whole, partial);
|
||||
let result = crate::wip::wip_to_json(&whole).unwrap();
|
||||
assert_eq!(
|
||||
result,
|
||||
serde_json::json!({"matches":[{"line":2,"text":"雪.*"}],"next_offset":3,"truncated":true})
|
||||
);
|
||||
let read = text_result(
|
||||
"read_lines",
|
||||
&args(serde_json::json!({"offset":1,"limit":1})),
|
||||
input,
|
||||
)
|
||||
.unwrap_or_else(|_| panic!("read refused"));
|
||||
assert_eq!(
|
||||
crate::wip::wip_to_json(&read).unwrap(),
|
||||
serde_json::json!({"content":" 2\t雪.*\n","next_offset":2,"truncated":true})
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn text_window_refuses_invalid_ranges_patterns_source_and_encoded_output_bounds() {
|
||||
for value in [
|
||||
serde_json::json!({"limit":0}),
|
||||
serde_json::json!({"limit":2001}),
|
||||
serde_json::json!({"offset":-1}),
|
||||
serde_json::json!({"offset":9_007_199_254_740_991i64}),
|
||||
serde_json::json!({"file_path":"elsewhere"}),
|
||||
] {
|
||||
assert!(
|
||||
matches!(text_result("read_lines",&args(value),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
|
||||
);
|
||||
}
|
||||
for pattern in [String::new(), "x".repeat(4097)] {
|
||||
assert!(
|
||||
matches!(text_result("search_text",&args(serde_json::json!({"pattern":pattern})),"x"),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::InvalidArguments)
|
||||
);
|
||||
}
|
||||
for (operation, arguments, source) in [
|
||||
(
|
||||
"read_lines",
|
||||
serde_json::json!({}),
|
||||
"雪".repeat(MAX_BYTES / 3 + 1),
|
||||
),
|
||||
("read_lines", serde_json::json!({}), "\u{1}".repeat(16000)),
|
||||
(
|
||||
"search_text",
|
||||
serde_json::json!({"pattern":"x"}),
|
||||
"x\n".repeat(MAX_LINES),
|
||||
),
|
||||
(
|
||||
"search_text",
|
||||
serde_json::json!({"pattern":"\""}),
|
||||
format!("{}\n", "\"".repeat(40000)),
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
matches!(text_result(operation,&args(arguments),&source),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::ResourceLimitExceeded),
|
||||
"{operation}: {} source bytes",
|
||||
source.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds reserve room for base64 expansion, JSON encoding, and exact lossless integer offsets.
|
||||
pub(crate) fn byte_range(
|
||||
args: &BTreeMap<String, Value>,
|
||||
) -> Result<(u64, usize), WipOperationError> {
|
||||
if args
|
||||
.keys()
|
||||
.any(|k| !["offset", "limit"].contains(&k.as_str()))
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"unknown byte argument",
|
||||
));
|
||||
}
|
||||
let integer = |name: &str, default: u64| match args.get(name) {
|
||||
None => Ok(default),
|
||||
Some(Value::Integer(i)) => u64::try_from(*i)
|
||||
.map_err(|_| failure(ProtocolErrorCode::InvalidArguments, "negative byte range")),
|
||||
_ => Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid byte range",
|
||||
)),
|
||||
};
|
||||
let offset = integer("offset", 0)?;
|
||||
let limit = integer("limit", 16384)?;
|
||||
if offset > 9_007_199_254_708_223 || !(1..=32768).contains(&limit) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"byte range exceeds bounds",
|
||||
));
|
||||
}
|
||||
Ok((offset, limit as usize))
|
||||
}
|
||||
pub(crate) fn bytes_result(
|
||||
args: &BTreeMap<String, Value>,
|
||||
bytes: &[u8],
|
||||
eof: bool,
|
||||
) -> Result<Value, WipOperationError> {
|
||||
use base64::Engine as _;
|
||||
let (offset, limit) = byte_range(args)?;
|
||||
if bytes.len() > limit || (bytes.is_empty() && !eof) {
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::Internal,
|
||||
"provider returned an invalid byte range",
|
||||
));
|
||||
}
|
||||
let value = Value::Record(BTreeMap::from([
|
||||
("encoding".into(), Value::String("base64".into())),
|
||||
(
|
||||
"data".into(),
|
||||
Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)),
|
||||
),
|
||||
("offset".into(), Value::Integer(offset as i64)),
|
||||
(
|
||||
"next_offset".into(),
|
||||
Value::Integer((offset + bytes.len() as u64) as i64),
|
||||
),
|
||||
("eof".into(), Value::Boolean(eof)),
|
||||
]));
|
||||
let json = crate::wip::wip_to_json(&value)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?;
|
||||
if serde_json::to_vec_pretty(&json)
|
||||
.map_err(|_| failure(ProtocolErrorCode::Internal, "byte result unavailable"))?
|
||||
.len()
|
||||
> MAX_BYTES
|
||||
{
|
||||
return Err(failure(
|
||||
ProtocolErrorCode::ResourceLimitExceeded,
|
||||
"encoded byte result exceeds bound",
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod byte_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn maximum_raw_byte_page_reserves_base64_and_complete_json_budget() {
|
||||
let args = BTreeMap::from([("limit".into(), Value::Integer(32768))]);
|
||||
let result = bytes_result(&args, &vec![0xff; 32768], false)
|
||||
.unwrap_or_else(|_| panic!("valid bounded bytes"));
|
||||
let json = crate::wip::wip_to_json(&result).unwrap();
|
||||
assert!(serde_json::to_vec_pretty(&json).unwrap().len() <= MAX_BYTES);
|
||||
assert_eq!(json["next_offset"], 32768);
|
||||
assert_eq!(json["eof"], false);
|
||||
assert!(
|
||||
matches!(bytes_result(&args,&vec![0;32769],false),Err(WipOperationError::Protocol(e)) if e.code==ProtocolErrorCode::Internal)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ mod worker;
|
||||
|
||||
pub use bootstrap::{
|
||||
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
||||
WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller,
|
||||
WorkerBootstrapLayout, start_worker_controller,
|
||||
};
|
||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
||||
|
||||
@@ -88,7 +88,7 @@ impl InternalSpawnedWorkerRecord {
|
||||
pub(crate) fn new(
|
||||
worker_name: String,
|
||||
scope_delegated: Vec<ScopeRule>,
|
||||
workdir_tool_scope: WorkdirScopeLeaseSet,
|
||||
workdir_tool_scope: Arc<WorkdirScopeLeaseSet>,
|
||||
#[cfg(test)] installed_tools: Vec<String>,
|
||||
session: InternalWorkerSessionHandle,
|
||||
child_registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -97,7 +97,7 @@ impl InternalSpawnedWorkerRecord {
|
||||
Self {
|
||||
worker_name: worker_name.clone(),
|
||||
scope_delegated,
|
||||
workdir_tool_scope: Arc::new(workdir_tool_scope),
|
||||
workdir_tool_scope,
|
||||
#[cfg(test)]
|
||||
installed_tools: installed_tools.into(),
|
||||
published: Arc::new(super::published::PublishedProtocol::new(
|
||||
@@ -329,6 +329,7 @@ pub struct SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify,
|
||||
internal_spawn_cleanup_failed: AtomicBool,
|
||||
parent_scope: Option<SharedScope>,
|
||||
bash_output: Mutex<tools::bash_output::BashOutputView>,
|
||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||
protocol_publish_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
@@ -340,6 +341,14 @@ pub struct SpawnedWorkerRegistryLoad {
|
||||
}
|
||||
|
||||
impl SpawnedWorkerRegistry {
|
||||
pub(crate) fn bash_output(&self) -> tools::bash_output::BashOutputView {
|
||||
self.bash_output.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn inherit_bash_output(&self, view: tools::bash_output::BashOutputView) {
|
||||
*self.bash_output.lock().unwrap() = view;
|
||||
}
|
||||
|
||||
pub(crate) fn new_for_internal_services() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||
@@ -352,6 +361,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope: None,
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
@@ -370,6 +380,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope: None,
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
@@ -387,6 +398,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope: Some(parent_scope),
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
})
|
||||
@@ -473,6 +485,7 @@ impl SpawnedWorkerRegistry {
|
||||
pending_internal_notify: Notify::new(),
|
||||
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||
parent_scope,
|
||||
bash_output: Mutex::new(tools::bash_output::BashOutputView::new()),
|
||||
parent_protocol: Mutex::new(None),
|
||||
protocol_publish_lock: Arc::new(Mutex::new(())),
|
||||
}),
|
||||
@@ -966,6 +979,10 @@ impl SpawnedWorkerRegistry {
|
||||
"an in-flight Internal SubWorker failed cleanup during shutdown",
|
||||
));
|
||||
}
|
||||
let output = self.bash_output();
|
||||
if !output.is_root() {
|
||||
output.revoke();
|
||||
}
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
@@ -1111,6 +1128,7 @@ fn internal_worker_live_snapshot(snapshot: InternalWorkerSnapshot) -> Option<Eve
|
||||
|
||||
impl Drop for SpawnedWorkerRegistry {
|
||||
fn drop(&mut self) {
|
||||
self.bash_output.get_mut().unwrap().revoke();
|
||||
let Some(parent_scope) = &self.parent_scope else {
|
||||
return;
|
||||
};
|
||||
@@ -1297,11 +1315,13 @@ mod tests {
|
||||
InternalSpawnedWorkerRecord::new(
|
||||
name.into(),
|
||||
Vec::new(),
|
||||
Arc::new(
|
||||
workdir::WorkdirScopeLeaseSet::from_single(
|
||||
workdir::WorkdirAttachmentAlias::new("workdir").unwrap(),
|
||||
delegation,
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
Vec::new(),
|
||||
session,
|
||||
registry(),
|
||||
|
||||
@@ -310,7 +310,7 @@ pub struct SubWorkerSpawnTool {
|
||||
/// Runtime-owned root used for Internal Worker controller state.
|
||||
runtime_base: PathBuf,
|
||||
/// Parent Worker-owned temporary root used for bounded Bash spill output.
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
||||
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
||||
workspace_root: PathBuf,
|
||||
@@ -345,7 +345,7 @@ impl SubWorkerSpawnTool {
|
||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||
parent_notifications: ParentNotificationTarget,
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
workspace_root: PathBuf,
|
||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -358,7 +358,7 @@ impl SubWorkerSpawnTool {
|
||||
workspace_context,
|
||||
parent_notifications,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
|
||||
workspace_root,
|
||||
workdir_tool_broker,
|
||||
registry,
|
||||
@@ -430,15 +430,6 @@ impl Tool for SubWorkerSpawnTool {
|
||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||
|
||||
let workdir_rules = parse_workdir_scope(&input.scope)?;
|
||||
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
|
||||
tokio::fs::create_dir_all(&child_bash_output_dir)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"create Internal Worker Bash output directory {}: {error}",
|
||||
child_bash_output_dir.display()
|
||||
))
|
||||
})?;
|
||||
let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?;
|
||||
let tool_scopes = routed_workdir_tool_scopes(
|
||||
workdir_tool_broker,
|
||||
@@ -452,6 +443,7 @@ impl Tool for SubWorkerSpawnTool {
|
||||
.map_err(|error| {
|
||||
ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}"))
|
||||
})?;
|
||||
let workdir_scope = Arc::new(workdir_scope);
|
||||
let child_workdir_tool_broker = workdir_scope.broker_router();
|
||||
|
||||
let spawn_selector =
|
||||
@@ -552,24 +544,17 @@ impl Tool for SubWorkerSpawnTool {
|
||||
)
|
||||
.await
|
||||
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
||||
child
|
||||
.add_scope_rules([ScopeRule {
|
||||
target: child_bash_output_dir.clone(),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: Default::default(),
|
||||
}])
|
||||
.map_err(|error| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"grant Internal Worker Bash output scope: {error}"
|
||||
))
|
||||
})?;
|
||||
let child_scope = child.scope().clone();
|
||||
child.bind_workdir_sessions(workdir_scope.session_router());
|
||||
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
||||
let output_lease = workdir_scope.clone();
|
||||
child_registry.inherit_bash_output(
|
||||
self.registry
|
||||
.bash_output()
|
||||
.child(Arc::new(move || output_lease.is_active())),
|
||||
);
|
||||
register_worker_tools(
|
||||
&mut child,
|
||||
child_bash_output_dir,
|
||||
self.runtime_base.clone(),
|
||||
child_registry.clone(),
|
||||
None,
|
||||
@@ -1052,7 +1037,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||
parent_notifications: ParentNotificationTarget,
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
workspace_root: PathBuf,
|
||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -1064,7 +1049,6 @@ pub(crate) fn sub_worker_spawn_tool(
|
||||
workspace_context,
|
||||
parent_notifications,
|
||||
runtime_base,
|
||||
bash_output_dir,
|
||||
workspace_root,
|
||||
workdir_tool_broker,
|
||||
registry,
|
||||
@@ -1078,7 +1062,7 @@ fn sub_worker_spawn_tool_impl(
|
||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||
parent_notifications: ParentNotificationTarget,
|
||||
runtime_base: PathBuf,
|
||||
bash_output_dir: PathBuf,
|
||||
|
||||
workspace_root: PathBuf,
|
||||
workdir_tool_broker: Option<WorkdirToolBrokerRouter>,
|
||||
registry: Arc<SpawnedWorkerRegistry>,
|
||||
@@ -1110,7 +1094,6 @@ fn sub_worker_spawn_tool_impl(
|
||||
workspace_context.clone(),
|
||||
parent_notifications.clone(),
|
||||
runtime_base.clone(),
|
||||
bash_output_dir.clone(),
|
||||
workspace_root.clone(),
|
||||
workdir_tool_broker.clone(),
|
||||
registry.clone(),
|
||||
@@ -1374,7 +1357,6 @@ enabled = false
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
),
|
||||
runtime.path().to_path_buf(),
|
||||
bash_output_dir.clone(),
|
||||
workspace_root.clone(),
|
||||
Some(workdir_tool_broker),
|
||||
registry.clone(),
|
||||
@@ -1425,12 +1407,7 @@ enabled = false
|
||||
.await
|
||||
.expect("spawn project reviewer as Internal Worker");
|
||||
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
||||
assert!(
|
||||
bash_output_dir
|
||||
.join("sub-workers")
|
||||
.join("reviewer-child")
|
||||
.is_dir()
|
||||
);
|
||||
assert!(!bash_output_dir.join("sub-workers").exists());
|
||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||
let record = registry
|
||||
.get_internal("reviewer-child")
|
||||
@@ -1460,6 +1437,27 @@ enabled = false
|
||||
record.session.wait_until_idle().await,
|
||||
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
||||
);
|
||||
let child_output_view = record.child_registry.bash_output();
|
||||
let child_bash = tools::BashTool::new(
|
||||
record.workdir_tool_scope.session_router(),
|
||||
child_output_view.clone(),
|
||||
);
|
||||
let child_output = child_bash
|
||||
.execute_result(
|
||||
r#"{"command":"printf retained-child-evidence","cwd":"."}"#,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let output_id = child_output.metadata.id.clone();
|
||||
assert!(child_output_view.metadata(&output_id).is_some());
|
||||
assert_eq!(
|
||||
registry
|
||||
.bash_output()
|
||||
.read(&output_id, workdir::CommandStream::Stdout, 0, 65536)
|
||||
.unwrap(),
|
||||
b"retained-child-evidence"
|
||||
);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(!observed_parent_write_revoked.load(Ordering::SeqCst));
|
||||
assert!(observed_instruction_override.load(Ordering::SeqCst));
|
||||
@@ -1563,6 +1561,8 @@ enabled = false
|
||||
!record.workdir_tool_scope.is_active(),
|
||||
"stopped child must revoke cloned scoped sessions"
|
||||
);
|
||||
assert!(child_output_view.metadata(&output_id).is_none());
|
||||
assert!(registry.bash_output().metadata(&output_id).is_some());
|
||||
assert!(registry.get_internal("reviewer-child").is_some());
|
||||
|
||||
assert!(
|
||||
@@ -1573,6 +1573,7 @@ enabled = false
|
||||
.is_some()
|
||||
);
|
||||
assert!(registry.get_internal("reviewer-child").is_none());
|
||||
assert!(registry.bash_output().metadata(&output_id).is_some());
|
||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||
|
||||
fail_requests.store(false, Ordering::SeqCst);
|
||||
@@ -1672,7 +1673,6 @@ enabled = false
|
||||
),
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
runtime.path().to_path_buf(),
|
||||
bash_output_dir,
|
||||
workspace_root.clone(),
|
||||
Some(workdir_tool_broker),
|
||||
registry.clone(),
|
||||
@@ -1805,7 +1805,6 @@ enabled = false
|
||||
ParentNotificationTarget::Durable(Arc::new(|_| {})),
|
||||
),
|
||||
runtime.path().to_path_buf(),
|
||||
bash_output_dir.clone(),
|
||||
workspace_root.clone(),
|
||||
Some(workdir_tool_broker),
|
||||
registry.clone(),
|
||||
|
||||
@@ -943,7 +943,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash-output"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
@@ -1137,7 +1136,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
@@ -1319,7 +1317,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
@@ -1379,7 +1376,6 @@ enabled = true
|
||||
WorkerFilesystemAuthority::None,
|
||||
WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash-output"),
|
||||
},
|
||||
WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -418,13 +418,22 @@ struct MountedProjection {
|
||||
impl MountedProjection {
|
||||
fn resolved(&self) -> WipProjection {
|
||||
let mut projection = self.projection.clone();
|
||||
projection.handler = Arc::new(OperationDispatchHandler {
|
||||
owner: Arc::clone(&self.projection.handler),
|
||||
handlers: self
|
||||
let mut handlers: BTreeMap<_, _> = self
|
||||
.operation_handlers
|
||||
.iter()
|
||||
.map(|(operation, (_, handler))| (operation.clone(), Arc::clone(handler)))
|
||||
.collect(),
|
||||
.map(|(op, (_, h))| (op.clone(), h.clone()))
|
||||
.collect();
|
||||
for reference in &projection.object.interfaces {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
|
||||
handlers.insert(
|
||||
contract.handler_operation().into(),
|
||||
self.projection.handler.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
projection.handler = Arc::new(OperationDispatchHandler {
|
||||
owner: Arc::clone(&self.projection.handler),
|
||||
handlers,
|
||||
});
|
||||
projection
|
||||
}
|
||||
@@ -1017,6 +1026,10 @@ impl WipMountRegistry {
|
||||
descriptor: &InterfaceDescriptor,
|
||||
validator: Option<&[u8]>,
|
||||
) -> bool {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
|
||||
return *descriptor != contract.descriptor()
|
||||
|| validator != Some(contract.validator().as_slice());
|
||||
}
|
||||
self.mounts.values().any(|mounted| {
|
||||
mounted.projection.interface == *interface
|
||||
&& (mounted.projection.descriptor != *descriptor
|
||||
@@ -1082,6 +1095,10 @@ impl WipMountRegistry {
|
||||
validator: Option<&[u8]>,
|
||||
replacing_route: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(interface) {
|
||||
return *descriptor != contract.descriptor()
|
||||
|| validator != Some(contract.validator().as_slice());
|
||||
}
|
||||
self.additional_interfaces.iter().any(|(route, all)| {
|
||||
Some(route.as_str()) != replacing_route
|
||||
&& all.iter().any(|p| {
|
||||
@@ -1188,10 +1205,24 @@ fn validate_projection(projection: &WipProjection) -> Result<(), WipMountError>
|
||||
route: projection.route.clone(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if projection.object.interfaces != [projection.interface.clone()] {
|
||||
if !projection.object.interfaces.contains(&projection.interface)
|
||||
|| projection.object.interfaces.iter().any(|reference| {
|
||||
*reference != projection.interface
|
||||
&& crate::file_operation::Contract::from_reference(reference).is_none()
|
||||
})
|
||||
{
|
||||
return Err(WipMountError::InvalidProjection {
|
||||
route: projection.route.clone(),
|
||||
message: "projected object must expose exactly its declared interface".into(),
|
||||
message: "projected object must expose its declared interface and only known common contracts".into(),
|
||||
});
|
||||
}
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(&projection.interface)
|
||||
&& (projection.descriptor != contract.descriptor()
|
||||
|| projection.interface_validator.as_deref() != Some(contract.validator().as_slice()))
|
||||
{
|
||||
return Err(WipMountError::InvalidProjection {
|
||||
route: projection.route.clone(),
|
||||
message: "common contract descriptor/validator differs from its definition".into(),
|
||||
});
|
||||
}
|
||||
projection
|
||||
@@ -1304,9 +1335,13 @@ impl WipHost {
|
||||
path: &str,
|
||||
reference: &InterfaceReference,
|
||||
) -> Result<Option<WipPublication>, ProtocolError> {
|
||||
let Some(owner) = self.publication_live(path).await? else {
|
||||
let Some(mut owner) = self.publication_live(path).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if crate::file_operation::select(&mut owner.projection, reference) {
|
||||
owner.scope = self.object_at(&reference.scope);
|
||||
return Ok(Some(owner));
|
||||
}
|
||||
// A live provider owns all its publication metadata; static additional
|
||||
// Interface registrations must not survive its scope lifetime changes.
|
||||
if owner.projection.interface == *reference || self.subtree(path).is_some() {
|
||||
@@ -1333,6 +1368,20 @@ impl WipHost {
|
||||
reference
|
||||
.validate()
|
||||
.map_err(|e| protocol_error(ProtocolErrorCode::InvalidRequest, e.to_string()))?;
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
|
||||
let scope = self.object_at("/").ok_or_else(|| {
|
||||
protocol_error(
|
||||
ProtocolErrorCode::InterfaceNotFound,
|
||||
"root scope unavailable",
|
||||
)
|
||||
})?;
|
||||
return Ok(FetchInterfaceResponse {
|
||||
interface: reference.clone(),
|
||||
scope_ref: scope.r#ref,
|
||||
descriptor: contract.descriptor(),
|
||||
validator: Some(contract.validator()),
|
||||
});
|
||||
}
|
||||
let target = if let Some(mount) = self.subtree(&reference.scope) {
|
||||
let target = mount.provider.interface_target(reference).ok_or_else(|| {
|
||||
protocol_error(
|
||||
@@ -1496,6 +1545,9 @@ impl WipHost {
|
||||
&self,
|
||||
reference: &InterfaceReference,
|
||||
) -> Option<(InterfaceDescriptor, Option<Vec<u8>>)> {
|
||||
if let Some(contract) = crate::file_operation::Contract::from_reference(reference) {
|
||||
return Some((contract.descriptor(), Some(contract.validator())));
|
||||
}
|
||||
for (path, all) in &self.registry.additional_interfaces {
|
||||
if self.projection(path).is_none() {
|
||||
continue;
|
||||
@@ -1560,6 +1612,18 @@ impl WipHost {
|
||||
if let Some(validator) = projection.handler.object_validator() {
|
||||
projection.object.validator = Some(validator);
|
||||
}
|
||||
if let Some(contract) =
|
||||
crate::file_operation::Contract::from_reference(&projection.interface)
|
||||
{
|
||||
if !projection
|
||||
.handler
|
||||
.operation_available(contract.handler_operation())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// Common descriptor identity never varies with subject or capability filtering.
|
||||
return Some(projection);
|
||||
}
|
||||
let contextual = contextual_family || projection.handler.contextual_interface();
|
||||
let original_len = projection.descriptor.operations.len();
|
||||
projection
|
||||
@@ -7058,3 +7122,6 @@ mod tests {
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bash_output_tests;
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
//! Native Bash output integration through real WIP Client/Host codec and local process pipes.
|
||||
//! No telemetry/storage fake: only the model client is absent. Runtime HTTP transport is tested
|
||||
//! by worker-runtime's command_transport_tests. Each fixture owns its Workdir and ephemeral view.
|
||||
use super::*;
|
||||
use crate::file_operation::Contract;
|
||||
use tempfile::TempDir;
|
||||
use tools::bash_output::BashOutputView;
|
||||
use workdir::{
|
||||
LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
_dir: TempDir,
|
||||
router: Arc<WorkdirSessionRouter>,
|
||||
view: BashOutputView,
|
||||
runtime: WipRuntime,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
std::fs::write(dir.path().join("real.txt"), "first\n雪 needle\nlast\n").unwrap();
|
||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(
|
||||
manifest::Scope::writable(dir.path()).unwrap(),
|
||||
dir.path().into(),
|
||||
));
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(WorkdirAttachmentAlias::new("main").unwrap(), session)
|
||||
.unwrap();
|
||||
let view = BashOutputView::new();
|
||||
let runtime = runtime(router.clone(), view.clone());
|
||||
Self {
|
||||
_dir: dir,
|
||||
router,
|
||||
view,
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
async fn run(&self, command: &str) -> Json {
|
||||
value(
|
||||
self.runtime
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":command,"cwd":"."}}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}
|
||||
fn runtime(router: Arc<WorkdirSessionRouter>, view: BashOutputView) -> WipRuntime {
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::checkout::mount_checkouts(&mut registry, router.clone(), tools::Tracker::new(), None)
|
||||
.unwrap();
|
||||
crate::feature::builtin::bash::mount(&mut registry, router, view, None).unwrap();
|
||||
WipRuntime::new(WipHost::new(registry), SecurityContext::new("bash-test"), 0).unwrap()
|
||||
}
|
||||
fn value(output: ToolOutput) -> Json {
|
||||
serde_json::from_str(&output.content.unwrap()).unwrap()
|
||||
}
|
||||
async fn invoke(
|
||||
runtime: &WipRuntime,
|
||||
path: &str,
|
||||
contract: Contract,
|
||||
operation: &str,
|
||||
args: Json,
|
||||
) -> Result<Json, ToolError> {
|
||||
runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
contract.reference(),
|
||||
operation.into(),
|
||||
args,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.map(value)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_bash_entries_reuse_checkout_contract_for_direct_inspect_read_search() {
|
||||
let f = Fixture::new();
|
||||
let result = f
|
||||
.run("printf 'first\n雪 needle\nlast\n'; printf error >&2; exit 3")
|
||||
.await;
|
||||
assert_eq!(result["metadata"]["terminal"]["exit_code"], 3);
|
||||
let stdout = result["stdout_entry"].as_str().unwrap();
|
||||
assert!(stdout.starts_with("/bash-output/"));
|
||||
// The native result Descriptor gives these coordinates TypeExpr::Entry; Object inspection
|
||||
// contains capabilities and metadata, not the process output body.
|
||||
let projection = f
|
||||
.runtime
|
||||
.host
|
||||
.projection_live("/bash-output")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let TypeExpr::Record { fields } = &projection.descriptor.operations[0].returns.r#type else {
|
||||
panic!("native record");
|
||||
};
|
||||
assert_eq!(
|
||||
fields
|
||||
.iter()
|
||||
.find(|v| v.name == "stdout_entry")
|
||||
.unwrap()
|
||||
.r#type,
|
||||
TypeExpr::Entry
|
||||
);
|
||||
let inspect = f.runtime.inspect(stdout.into(), false).await.unwrap();
|
||||
assert!(!inspect.content.unwrap().contains("雪 needle"));
|
||||
for path in ["/checkouts/main/real.txt", stdout] {
|
||||
let read = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({"offset":1,"limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read["content"].as_str().unwrap().contains("雪 needle"));
|
||||
assert_eq!(read["next_offset"], 2);
|
||||
let search = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"offset":0,"limit":2,"pattern":"needle"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(search["matches"][0]["line"], 2);
|
||||
assert_eq!(search["next_offset"], 2);
|
||||
assert_eq!(search["truncated"], true);
|
||||
}
|
||||
for name in ["stdout_entry", "stderr_entry", "order_entry"] {
|
||||
assert_eq!(
|
||||
fields.iter().find(|v| v.name == name).unwrap().r#type,
|
||||
TypeExpr::Entry
|
||||
);
|
||||
f.runtime
|
||||
.inspect(result[name].as_str().unwrap().into(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let stderr = invoke(
|
||||
&f.runtime,
|
||||
result["stderr_entry"].as_str().unwrap(),
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stderr["content"].as_str().unwrap().contains("error"));
|
||||
let order = invoke(
|
||||
&f.runtime,
|
||||
result["order_entry"].as_str().unwrap(),
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(order["content"].as_str().unwrap().contains("Stdout"));
|
||||
assert!(order["content"].as_str().unwrap().contains("Stderr"));
|
||||
assert_eq!(result["metadata"]["ordering"], "provider_read_observation");
|
||||
let tree = value(
|
||||
f.runtime
|
||||
.tree("/bash-output".into(), 8, true)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(!tree.to_string().contains(stdout));
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
stdout,
|
||||
Contract::Write,
|
||||
"write",
|
||||
json!({"content":"no"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
stdout,
|
||||
Contract::Edit,
|
||||
"edit",
|
||||
json!({"old_string":"first","new_string":"no"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_byte_reader_handles_long_lines_raw_invalid_utf8_boundaries_and_eof() {
|
||||
let f = Fixture::new();
|
||||
let result = f.run("printf '%100000s' ''; printf '€\\377'").await;
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadLines,
|
||||
"read",
|
||||
json!({"limit":1})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
for (offset, expected) in [
|
||||
(99999, vec![b' ', 0xe2]),
|
||||
(100001, vec![0x82, 0xac]),
|
||||
(100003, vec![0xff]),
|
||||
] {
|
||||
let read = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":offset,"limit":2}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(read["data"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(read["next_offset"], offset + expected.len() as u64);
|
||||
}
|
||||
let eof = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":100004,"limit":2}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(eof["data"], "");
|
||||
assert_eq!(eof["eof"], true);
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":100005})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"limit":32769})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publication_and_invoke_recheck_child_authority_and_root_teardown_invalidates_old_refs() {
|
||||
let f = Fixture::new();
|
||||
let lease = Arc::new(std::sync::atomic::AtomicBool::new(true));
|
||||
let child = f.view.child({
|
||||
let lease = lease.clone();
|
||||
Arc::new(move || lease.load(std::sync::atomic::Ordering::Acquire))
|
||||
});
|
||||
let child_runtime = runtime(f.router.clone(), child.clone());
|
||||
let result = value(
|
||||
child_runtime
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":"printf child-evidence","cwd":"."}}),
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
child_runtime.inspect(path.into(), false).await.unwrap();
|
||||
let sibling = runtime(f.router.clone(), f.view.child(Arc::new(|| true)));
|
||||
let unrelated = runtime(f.router.clone(), BashOutputView::new());
|
||||
assert!(sibling.inspect(path.into(), false).await.is_err());
|
||||
assert!(unrelated.inspect(path.into(), false).await.is_err());
|
||||
lease.store(false, std::sync::atomic::Ordering::Release);
|
||||
assert!(
|
||||
invoke(&child_runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&child_runtime,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"pattern":"child"})
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
f.view.revoke();
|
||||
assert!(
|
||||
invoke(&f.runtime, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let restored = runtime(f.router.clone(), BashOutputView::new());
|
||||
assert!(restored.inspect(path.into(), false).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_timeout_and_cancellation_keep_terminal_evidence_instead_of_success() {
|
||||
let f = Fixture::new();
|
||||
let session = f.router.only_session().unwrap();
|
||||
let mut events = session.subscribe_command_events().unwrap();
|
||||
let runtime = Arc::new(f.runtime);
|
||||
let context = agen::tool::ToolExecutionContext::new("native-cancel", "native-attempt", 0);
|
||||
let running = runtime.clone();
|
||||
let executing_context = context.clone();
|
||||
let execution = tokio::spawn(async move {
|
||||
running
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":"printf before; while :; do :; done","cwd":"."}}),
|
||||
executing_context,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if matches!(
|
||||
events.recv().await.unwrap(),
|
||||
workdir::CommandEvent::Output { .. }
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
runtime.cancel(&context).await.unwrap();
|
||||
let ToolError::Cancelled(output) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), execution)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap_err()
|
||||
else {
|
||||
panic!("expected cancelled terminal output")
|
||||
};
|
||||
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
let id = body["output"]["id"].as_str().unwrap();
|
||||
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||
assert_eq!(body["output"]["terminal"]["status"], "cancelled");
|
||||
assert_eq!(
|
||||
f.view
|
||||
.read(id, workdir::CommandStream::Stdout, 0, 20)
|
||||
.unwrap(),
|
||||
b"before"
|
||||
);
|
||||
assert!(
|
||||
invoke(
|
||||
&runtime,
|
||||
body["stdout_entry"].as_str().unwrap(),
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({})
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
let error = runtime.invoke("/bash-output".into(), contextual_reference("yoi.bash/command/v1", "/bash-output"), "run".into(), json!({"input":{"command":"printf timeout; while :; do :; done","cwd":".","timeout":1}}), Default::default()).await.unwrap_err();
|
||||
let ToolError::Interrupted(output) = error else {
|
||||
panic!("expected timeout evidence")
|
||||
};
|
||||
let body: Json = serde_json::from_str(&output.content.unwrap()).unwrap();
|
||||
assert_eq!(body["output"]["terminal"]["status"], "timed_out");
|
||||
assert_eq!(body["output"]["terminal"]["transport_complete"], true);
|
||||
assert!(
|
||||
f.view
|
||||
.metadata(body["output"]["id"].as_str().unwrap())
|
||||
.is_some()
|
||||
);
|
||||
session.close().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_read_and_search_permissions_are_independent_from_bash_execution() {
|
||||
let f = Fixture::new();
|
||||
let result = f.run("printf needle").await;
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
let mut registry = WipMountRegistry::new();
|
||||
crate::feature::builtin::bash::mount(
|
||||
&mut registry,
|
||||
f.router.clone(),
|
||||
f.view.clone(),
|
||||
Some(ToolPermissionConfig {
|
||||
default_action: ToolPermissionAction::Deny,
|
||||
rules: vec![manifest::ToolPermissionRule {
|
||||
tool: "Grep".into(),
|
||||
pattern: "*".into(),
|
||||
action: ToolPermissionAction::Allow,
|
||||
}],
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let restricted = WipRuntime::new(
|
||||
WipHost::new(registry),
|
||||
SecurityContext::new("restricted"),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
restricted.inspect(path.into(), false).await.unwrap();
|
||||
assert!(
|
||||
invoke(&restricted, path, Contract::ReadBytes, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
invoke(&restricted, path, Contract::ReadLines, "read", json!({}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let found = invoke(
|
||||
&restricted,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"pattern":"needle"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(found["matches"].as_array().unwrap().len() == 1);
|
||||
assert!(
|
||||
restricted
|
||||
.invoke(
|
||||
"/bash-output".into(),
|
||||
contextual_reference("yoi.bash/command/v1", "/bash-output"),
|
||||
"run".into(),
|
||||
json!({"input":{"command":"touch denied","cwd":"."}}),
|
||||
Default::default()
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!f._dir.path().join("denied").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multibyte_long_line_search_refuses_instead_of_skipping_an_unread_suffix() {
|
||||
let f = Fixture::new();
|
||||
let result = f
|
||||
.run("printf '雪%.0s' {1..22000}; printf 'needle\nnext\n'")
|
||||
.await;
|
||||
let path = result["stdout_entry"].as_str().unwrap();
|
||||
let error = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::SearchText,
|
||||
"search",
|
||||
json!({"pattern":"needle","offset":0,"limit":1}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("ResourceLimitExceeded"),
|
||||
"{error}"
|
||||
);
|
||||
// No successful page claims to advance past a partial line. Raw bytes can still
|
||||
// retrieve its unread suffix using the same common file contract.
|
||||
let suffix = invoke(
|
||||
&f.runtime,
|
||||
path,
|
||||
Contract::ReadBytes,
|
||||
"read",
|
||||
json!({"offset":66000,"limit":7}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(suffix["data"].as_str().unwrap())
|
||||
.unwrap(),
|
||||
b"needle\n"
|
||||
);
|
||||
assert_eq!(suffix["next_offset"], 66007);
|
||||
}
|
||||
@@ -17552,12 +17552,10 @@ permission = "write"
|
||||
let layout = if runtime_managed {
|
||||
crate::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: temp.path().join("runtime/runs/1"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
}
|
||||
} else {
|
||||
crate::WorkerBootstrapLayout::Direct {
|
||||
runtime_base: temp.path().join("runtime"),
|
||||
bash_output_dir: temp.path().join("bash"),
|
||||
}
|
||||
};
|
||||
crate::WorkerBootstrap::new(
|
||||
|
||||
@@ -1496,9 +1496,7 @@ async fn post_compact_pause_bills_partial_without_rearming_or_consuming_correlat
|
||||
let session_id = worker.session_id();
|
||||
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, shutdown_receiver) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
@@ -1706,9 +1704,7 @@ async fn controller_does_not_repeat_a_persisted_mid_run_compaction_failure() {
|
||||
let (worker, store) = make_worker_with_manifest_and_store(MID_TURN_MANIFEST_TOML, client).await;
|
||||
let session_id = worker.session_id();
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, shutdown_receiver) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
@@ -1831,9 +1827,7 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||
let source_session = worker.session_id();
|
||||
let source_segment = worker.segment_id();
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, shutdown_receiver) =
|
||||
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
let (handle, shutdown_receiver) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
@@ -1997,8 +1991,7 @@ async fn controller_compact_method_publishes_progress_and_clear() {
|
||||
]);
|
||||
let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
|
||||
@@ -311,8 +311,7 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let runtime_base = tmp.path().to_owned();
|
||||
std::mem::forget(tmp);
|
||||
let bash_output_dir = runtime_base.join("bash-output");
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir)
|
||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
|
||||
.await
|
||||
.unwrap();
|
||||
handle
|
||||
@@ -342,11 +341,7 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(
|
||||
worker,
|
||||
&root.path().join("runtime"),
|
||||
&root.path().join("bash-output"),
|
||||
)
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, &root.path().join("runtime"))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = handle.subscribe();
|
||||
@@ -408,24 +403,20 @@ async fn canonical_worker_handle_uploads_and_submits_attachment() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_grants_read_scope_for_exact_bash_output_directory() {
|
||||
async fn controller_does_not_grant_or_create_a_model_visible_bash_output_directory() {
|
||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||
let shared_scope = worker.scope().clone();
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let worker_tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output");
|
||||
let original_rules = shared_scope.snapshot().allow_rules().to_vec();
|
||||
|
||||
let (handle, shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(bash_output_dir.is_dir());
|
||||
assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| {
|
||||
rule.target == bash_output_dir
|
||||
&& rule.permission == manifest::Permission::Read
|
||||
&& rule.recursive
|
||||
}));
|
||||
assert_eq!(
|
||||
shared_scope.snapshot().allow_rules(),
|
||||
original_rules.as_slice()
|
||||
);
|
||||
assert!(!handle.runtime_dir.path().join("bash-output").exists());
|
||||
|
||||
handle
|
||||
@@ -451,9 +442,9 @@ async fn shutdown_closes_bound_workdir_session() {
|
||||
.start_command(CommandRequest {
|
||||
command: "sleep 30".to_owned(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
@@ -461,9 +452,7 @@ async fn shutdown_closes_bound_workdir_session() {
|
||||
worker.bind_single_workdir_session(Some(Arc::clone(&session)));
|
||||
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
let (handle, shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
handle
|
||||
@@ -501,9 +490,9 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
|
||||
.start_command(CommandRequest {
|
||||
command: "sleep 0.2".to_owned(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: Some("foreign-tool-call".into()),
|
||||
})
|
||||
.await
|
||||
@@ -529,7 +518,10 @@ async fn controller_filters_workdir_command_not_owned_by_worker() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.status, workdir::CommandStatus::Completed);
|
||||
assert_eq!(
|
||||
output.terminal.unwrap().status,
|
||||
workdir::CommandStatus::Completed
|
||||
);
|
||||
handle
|
||||
.send(Method::Shutdown {
|
||||
command: worker_command(&handle),
|
||||
@@ -738,9 +730,8 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
||||
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
||||
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
||||
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
assert!(
|
||||
WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir)
|
||||
WorkerController::spawn(worker, &invalid_runtime_base)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
@@ -749,9 +740,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
||||
.start_command(CommandRequest {
|
||||
command: "printf unreachable".to_owned(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await,
|
||||
@@ -1553,8 +1544,7 @@ permission = "write"
|
||||
let client = MockClient::new(simple_text_events());
|
||||
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = tmp.path().join("bash-output");
|
||||
let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await;
|
||||
let result = WorkerController::spawn(worker, tmp.path()).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"feature exposure must not imply delegation authority"
|
||||
@@ -2927,9 +2917,7 @@ async fn status_json_reflects_worker_name() {
|
||||
async fn shutdown_closes_method_admission_before_terminal_confirmation() {
|
||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
let (handle, mut shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
let (handle, mut shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
handle
|
||||
@@ -2966,9 +2954,7 @@ async fn shutdown_joins_socket_server_with_active_connection() {
|
||||
|
||||
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||
let runtime_base = tempfile::tempdir().unwrap();
|
||||
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||
let (handle, shutdown_rx) =
|
||||
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
||||
.await
|
||||
.unwrap();
|
||||
let socket_path = handle.runtime_dir.socket_path();
|
||||
|
||||
@@ -475,6 +475,7 @@ struct WorkdirCommandSession {
|
||||
attachment_alias: String,
|
||||
source: WorkdirSessionHandle,
|
||||
provider_handle: CommandHandle,
|
||||
terminal_at: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
enum RegisteredWorkdirSession {
|
||||
@@ -515,6 +516,7 @@ impl WorkdirSessionRegistry {
|
||||
source: WorkdirSessionHandle,
|
||||
provider_handle: CommandHandle,
|
||||
) -> CommandHandle {
|
||||
self.prune_terminal_commands(&worker);
|
||||
let external_handle = loop {
|
||||
let candidate = CommandHandle(Uuid::now_v7().to_string());
|
||||
if !self
|
||||
@@ -530,6 +532,7 @@ impl WorkdirSessionRegistry {
|
||||
attachment_alias,
|
||||
source,
|
||||
provider_handle,
|
||||
terminal_at: None,
|
||||
},
|
||||
);
|
||||
external_handle
|
||||
@@ -547,6 +550,45 @@ impl WorkdirSessionRegistry {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn active_command_count(&self, worker: &RuntimeWorkerRef) -> usize {
|
||||
self.commands
|
||||
.iter()
|
||||
.filter(|((owner, _), command)| owner == worker && command.terminal_at.is_none())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn mark_terminal(&mut self, worker: &RuntimeWorkerRef, external_handle: &CommandHandle) {
|
||||
if let Some(command) = self
|
||||
.commands
|
||||
.get_mut(&(worker.clone(), external_handle.clone()))
|
||||
{
|
||||
command
|
||||
.terminal_at
|
||||
.get_or_insert_with(std::time::Instant::now);
|
||||
}
|
||||
self.prune_terminal_commands(worker);
|
||||
}
|
||||
|
||||
fn prune_terminal_commands(&mut self, worker: &RuntimeWorkerRef) {
|
||||
let mut terminal = self
|
||||
.commands
|
||||
.iter()
|
||||
.filter_map(|((owner, handle), command)| {
|
||||
(owner == worker)
|
||||
.then_some(command.terminal_at)
|
||||
.flatten()
|
||||
.map(|at| (handle.clone(), at))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
terminal.sort_by_key(|(_, at)| *at);
|
||||
let excess = terminal
|
||||
.len()
|
||||
.saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS);
|
||||
for (handle, _) in terminal.into_iter().take(excess) {
|
||||
self.commands.remove(&(worker.clone(), handle));
|
||||
}
|
||||
}
|
||||
|
||||
fn take_worker_commands_for_alias(
|
||||
&mut self,
|
||||
worker: &RuntimeWorkerRef,
|
||||
@@ -895,7 +937,27 @@ struct ExternalProviderWorkdirSession {
|
||||
audit: Option<ExternalProviderAuditContext>,
|
||||
closed: std::sync::atomic::AtomicBool,
|
||||
close_lock: tokio::sync::Mutex<()>,
|
||||
active_commands: tokio::sync::Mutex<HashMap<String, workdir::CommandHandle>>,
|
||||
active_commands: tokio::sync::Mutex<HashMap<String, Arc<ExternalProviderCommandState>>>,
|
||||
}
|
||||
|
||||
const MAX_RETAINED_TERMINAL_COMMANDS: usize = 128;
|
||||
const MAX_ACTIVE_SERVER_COMMANDS: usize = 128;
|
||||
|
||||
struct ExternalProviderCommandState {
|
||||
handle: workdir::CommandHandle,
|
||||
// Serialize output ACKs, not cancellation: cancel must unblock a waiting reader.
|
||||
cursor: tokio::sync::Mutex<u64>,
|
||||
terminal_at: Mutex<Option<std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl ExternalProviderCommandState {
|
||||
fn new(handle: workdir::CommandHandle) -> Self {
|
||||
Self {
|
||||
handle,
|
||||
cursor: tokio::sync::Mutex::new(0),
|
||||
terminal_at: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ExternalProviderWorkdirSession {
|
||||
@@ -986,8 +1048,13 @@ impl ExternalProviderWorkdirSession {
|
||||
"External Workdir grant has expired".to_string(),
|
||||
));
|
||||
}
|
||||
let _admission = self
|
||||
.connection
|
||||
// Cancellation must reach the provider even when waiting output readers
|
||||
// occupy every data-operation slot. The socket separately bounds control requests.
|
||||
let _admission = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
self.connection
|
||||
.admission
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
@@ -996,7 +1063,9 @@ impl ExternalProviderWorkdirSession {
|
||||
workdir::WorkdirError::Unavailable(
|
||||
"External Workdir provider admission is closed".to_string(),
|
||||
)
|
||||
})?;
|
||||
})?,
|
||||
)
|
||||
};
|
||||
if self
|
||||
.connection
|
||||
.expires_at
|
||||
@@ -1073,10 +1142,10 @@ impl ExternalProviderWorkdirSession {
|
||||
}
|
||||
}
|
||||
|
||||
async fn provider_command_handle(
|
||||
async fn provider_command(
|
||||
&self,
|
||||
external_handle: &workdir::CommandHandle,
|
||||
) -> std::result::Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
) -> std::result::Result<Arc<ExternalProviderCommandState>, workdir::WorkdirError> {
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(workdir::WorkdirError::SessionClosed);
|
||||
}
|
||||
@@ -1088,51 +1157,111 @@ impl ExternalProviderWorkdirSession {
|
||||
.ok_or_else(|| workdir::WorkdirError::UnknownCommand(external_handle.0.clone()))
|
||||
}
|
||||
|
||||
async fn provider_command_handle(
|
||||
&self,
|
||||
external_handle: &workdir::CommandHandle,
|
||||
) -> std::result::Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
Ok(self.provider_command(external_handle).await?.handle.clone())
|
||||
}
|
||||
|
||||
async fn remove_active_command_if_matches(
|
||||
&self,
|
||||
external_handle: &workdir::CommandHandle,
|
||||
provider_handle: &workdir::CommandHandle,
|
||||
command: &Arc<ExternalProviderCommandState>,
|
||||
) {
|
||||
let mut commands = self.active_commands.lock().await;
|
||||
if commands.get(&external_handle.0) == Some(provider_handle) {
|
||||
if commands
|
||||
.get(&external_handle.0)
|
||||
.is_some_and(|current| Arc::ptr_eq(current, command))
|
||||
{
|
||||
commands.remove(&external_handle.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn prune_terminal_commands(&self) {
|
||||
let mut commands = self.active_commands.lock().await;
|
||||
let mut terminal = commands
|
||||
.iter()
|
||||
.filter_map(|(key, command)| {
|
||||
command
|
||||
.terminal_at
|
||||
.lock()
|
||||
.unwrap()
|
||||
.map(|at| (key.clone(), at))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
terminal.sort_by_key(|(_, at)| *at);
|
||||
let excess = terminal
|
||||
.len()
|
||||
.saturating_sub(MAX_RETAINED_TERMINAL_COMMANDS);
|
||||
for (key, _) in terminal.into_iter().take(excess) {
|
||||
commands.remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminate_provider_command(
|
||||
&self,
|
||||
provider_handle: workdir::CommandHandle,
|
||||
command: Arc<ExternalProviderCommandState>,
|
||||
reused_handle: bool,
|
||||
) -> std::result::Result<(), workdir::WorkdirError> {
|
||||
// Cancellation is best effort because the command may already be terminal.
|
||||
// Terminal output is the proof that the provider reaped the process.
|
||||
if !reused_handle && command.terminal_at.lock().unwrap().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// Do not acquire the output lock until cancellation has unblocked any reader.
|
||||
// The reader records its last known cursor before cleanup acknowledges bytes.
|
||||
let _ = self
|
||||
.operate(WorkdirSessionOperation::CommandCancel(
|
||||
provider_handle.clone(),
|
||||
command.handle.clone(),
|
||||
))
|
||||
.await;
|
||||
let mut cursor =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), command.cursor.lock())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
workdir::WorkdirError::Unavailable(
|
||||
"External Workdir reader did not stop after cancellation".to_string(),
|
||||
)
|
||||
})?;
|
||||
if !reused_handle && command.terminal_at.lock().unwrap().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let drain = async {
|
||||
loop {
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(
|
||||
workdir::CommandOutputRequest {
|
||||
handle: provider_handle,
|
||||
cursor: 0,
|
||||
limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES,
|
||||
handle: command.handle.clone(),
|
||||
cursor: *cursor,
|
||||
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||
wait: true,
|
||||
},
|
||||
))
|
||||
.await?
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandOutput(output)
|
||||
if output.status != workdir::CommandStatus::Running =>
|
||||
{
|
||||
Ok(())
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
*cursor = output.next_cursor;
|
||||
if output.terminal.is_some() {
|
||||
*command.terminal_at.lock().unwrap() = Some(std::time::Instant::now());
|
||||
return Ok(());
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandOutput(_) => {
|
||||
Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir provider did not confirm command termination".to_string(),
|
||||
))
|
||||
if output.chunks.is_empty() {
|
||||
return Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir provider did not confirm command termination"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
_ => Err(Self::mismatch("command_output")),
|
||||
}
|
||||
_ => return Err(Self::mismatch("command_output")),
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), drain)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
workdir::WorkdirError::Unavailable(
|
||||
"External Workdir command drain timed out".to_string(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
fn mismatch(expected: &str) -> workdir::WorkdirError {
|
||||
@@ -1410,17 +1539,27 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
}
|
||||
async fn start_command(
|
||||
&self,
|
||||
mut request: workdir::CommandRequest,
|
||||
request: workdir::CommandRequest,
|
||||
) -> std::result::Result<workdir::CommandHandle, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
let _close_guard = self.close_lock.lock().await;
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(workdir::WorkdirError::SessionClosed);
|
||||
}
|
||||
// Bash supplies a Worker-host spill directory. It is neither meaningful
|
||||
// nor safe to send to a client-hosted provider, so External commands use
|
||||
// bounded inline output and report truncation without a provider path.
|
||||
request.spill_dir = None;
|
||||
self.prune_terminal_commands().await;
|
||||
if self
|
||||
.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.filter(|command| command.terminal_at.lock().unwrap().is_none())
|
||||
.count()
|
||||
>= MAX_ACTIVE_SERVER_COMMANDS
|
||||
{
|
||||
return Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir active command limit reached".to_string(),
|
||||
));
|
||||
}
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandStart(request))
|
||||
.await?
|
||||
@@ -1430,7 +1569,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
let commands = self.active_commands.lock().await;
|
||||
commands
|
||||
.iter()
|
||||
.filter(|(_, active)| *active == &provider_handle)
|
||||
.filter(|(_, active)| active.handle == provider_handle)
|
||||
.map(|(external, _)| external.clone())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
@@ -1439,20 +1578,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
// generation. Remove every ambiguous route before attempting
|
||||
// cleanup so no caller can address the wrong process.
|
||||
let mut commands = self.active_commands.lock().await;
|
||||
let mut quarantined = None;
|
||||
for external_handle in duplicate_handles {
|
||||
commands.remove(&external_handle);
|
||||
if let Some(command) = commands.remove(&external_handle) {
|
||||
quarantined.get_or_insert(command);
|
||||
}
|
||||
}
|
||||
let quarantine_handle =
|
||||
workdir::CommandHandle(format!("quarantined-command-{}", Uuid::now_v7()));
|
||||
commands.insert(quarantine_handle.0.clone(), provider_handle.clone());
|
||||
let command = quarantined.expect("duplicate command route disappeared");
|
||||
*command.terminal_at.lock().unwrap() = None;
|
||||
commands.insert(quarantine_handle.0.clone(), command.clone());
|
||||
drop(commands);
|
||||
if self
|
||||
.terminate_provider_command(provider_handle.clone())
|
||||
.terminate_provider_command(command.clone(), true)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
self.remove_active_command_if_matches(&quarantine_handle, &provider_handle)
|
||||
self.remove_active_command_if_matches(&quarantine_handle, &command)
|
||||
.await;
|
||||
} else {
|
||||
// An earlier read of the reused handle cannot establish that
|
||||
// the newly started, ambiguous process has been cleaned up.
|
||||
*command.terminal_at.lock().unwrap() = None;
|
||||
}
|
||||
return Err(workdir::WorkdirError::Unavailable(
|
||||
"External Workdir provider reused an active command handle".to_string(),
|
||||
@@ -1460,10 +1608,10 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
}
|
||||
let external_handle =
|
||||
workdir::CommandHandle(format!("external-command-{}", Uuid::now_v7()));
|
||||
self.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.insert(external_handle.0.clone(), provider_handle);
|
||||
self.active_commands.lock().await.insert(
|
||||
external_handle.0.clone(),
|
||||
Arc::new(ExternalProviderCommandState::new(provider_handle)),
|
||||
);
|
||||
Ok(external_handle)
|
||||
}
|
||||
_ => Err(Self::mismatch("command_start")),
|
||||
@@ -1489,21 +1637,29 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
) -> std::result::Result<workdir::CommandOutput, workdir::WorkdirError> {
|
||||
self.ensure_capability(workdir::WorkdirSessionCapability::Command)?;
|
||||
let external_handle = request.handle.clone();
|
||||
let provider_handle = self.provider_command_handle(&external_handle).await?;
|
||||
request.handle = provider_handle.clone();
|
||||
let command = self.provider_command(&external_handle).await?;
|
||||
let mut cursor = command.cursor.lock().await;
|
||||
request.handle = command.handle.clone();
|
||||
// If the response is lost, this request cursor still replays the provider page.
|
||||
match self
|
||||
.operate(WorkdirSessionOperation::CommandOutput(request))
|
||||
.await?
|
||||
.await
|
||||
{
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
if output.status != workdir::CommandStatus::Running && output.next_cursor.is_none()
|
||||
{
|
||||
self.remove_active_command_if_matches(&external_handle, &provider_handle)
|
||||
.await;
|
||||
Ok(WorkdirSessionOperationResult::CommandOutput(output)) => {
|
||||
*cursor = output.next_cursor;
|
||||
if output.terminal.is_some() {
|
||||
command
|
||||
.terminal_at
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get_or_insert_with(std::time::Instant::now);
|
||||
}
|
||||
drop(cursor);
|
||||
self.prune_terminal_commands().await;
|
||||
Ok(output)
|
||||
}
|
||||
_ => Err(Self::mismatch("command_output")),
|
||||
Ok(_) => Err(Self::mismatch("command_output")),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
async fn cancel_command(
|
||||
@@ -1540,21 +1696,25 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut cleanup_errors = Vec::new();
|
||||
for (external_handle, provider_handle) in commands {
|
||||
for (external_handle, command) in commands {
|
||||
// Detach/session replacement is terminal authority for commands
|
||||
// started through this exact provider generation. Do not report a
|
||||
// successful close until the provider confirms terminal output.
|
||||
match self
|
||||
.terminate_provider_command(provider_handle.clone())
|
||||
.terminate_provider_command(command.clone(), false)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.remove_active_command_if_matches(&external_handle, &provider_handle)
|
||||
self.remove_active_command_if_matches(&external_handle, &command)
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
let still_active = self.active_commands.lock().await.get(&external_handle.0)
|
||||
== Some(&provider_handle);
|
||||
let still_active = self
|
||||
.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.get(&external_handle.0)
|
||||
.is_some_and(|current| Arc::ptr_eq(current, &command));
|
||||
if still_active {
|
||||
cleanup_errors.push(error.to_string());
|
||||
}
|
||||
@@ -12534,6 +12694,14 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::root(api, &context, &workspace_id).await
|
||||
}
|
||||
async fn drive_capabilities(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
) -> std::result::Result<server_api::DriveCapabilities, server_api::DriveApiError> {
|
||||
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||
drive::capabilities(api, &context, &workspace_id).await
|
||||
}
|
||||
async fn drive_metadata(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -20811,6 +20979,9 @@ async fn scoped_execute_current_worker_workdir_operation(
|
||||
&api, &worker, &target_workdir, Some(&_session_guard),
|
||||
).await?;
|
||||
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id));
|
||||
if api.workdir_sessions.lock().unwrap().active_command_count(&worker) >= MAX_ACTIVE_SERVER_COMMANDS {
|
||||
return Err(InternalWorkdirOperationError::Provider(workdir_operation_transport_error(workdir::WorkdirError::Unavailable("Worker active command limit reached".to_string()))));
|
||||
}
|
||||
context.stage = "session_open";
|
||||
let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?;
|
||||
context.stage = "provider_dispatch";
|
||||
@@ -20852,14 +21023,16 @@ async fn scoped_execute_current_worker_workdir_operation(
|
||||
.map_err(workdir_operation_transport_error)?
|
||||
}
|
||||
WorkdirSessionOperation::CommandOutput(mut output) => {
|
||||
let external_handle = output.handle.clone();
|
||||
let (session, provider_handle) =
|
||||
current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context).await?;
|
||||
current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context).await?;
|
||||
output.handle = provider_handle;
|
||||
session
|
||||
.command_output(output)
|
||||
.await
|
||||
.map(WorkdirSessionOperationResult::CommandOutput)
|
||||
.map_err(workdir_operation_transport_error)?
|
||||
let page = session.command_output(output).await.map_err(workdir_operation_transport_error)?;
|
||||
if page.terminal.is_some() {
|
||||
// Retain the binding for a retry after an unknown HTTP outcome.
|
||||
api.workdir_sessions.lock().unwrap().mark_terminal(&worker, &external_handle);
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandOutput(page)
|
||||
}
|
||||
WorkdirSessionOperation::CommandCancel(external_handle) => {
|
||||
let (session, provider_handle) =
|
||||
@@ -26838,7 +27011,12 @@ async fn serve_external_workdir_provider(
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
if pending.len() >= 16 {
|
||||
let pending_limit = if matches!(&operation, WorkdirSessionOperation::CommandCancel(_)) {
|
||||
16 + MAX_ACTIVE_SERVER_COMMANDS
|
||||
} else {
|
||||
16
|
||||
};
|
||||
if pending.len() >= pending_limit {
|
||||
let _ = response.send(Err(WorkdirTransportError {
|
||||
denial_reason: None,
|
||||
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
|
||||
@@ -37009,6 +37187,7 @@ mod tests {
|
||||
|
||||
mod api_logging_tests;
|
||||
mod auth_logging_tests;
|
||||
mod command_transport_tests;
|
||||
mod drive_api_tests;
|
||||
mod drive_tests;
|
||||
mod drive_web_roundtrip_tests;
|
||||
@@ -37358,9 +37537,8 @@ mod tests {
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "touch forbidden".to_string(),
|
||||
timeout_secs: 1,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await;
|
||||
@@ -37509,9 +37687,8 @@ mod tests {
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf forbidden".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await;
|
||||
@@ -37563,9 +37740,8 @@ mod tests {
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf routed".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker/private/spill".into()),
|
||||
tool_call_id: Some("call-1".to_string()),
|
||||
})
|
||||
.await
|
||||
@@ -37581,10 +37757,7 @@ mod tests {
|
||||
};
|
||||
assert!(matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandStart(workdir::CommandRequest {
|
||||
spill_dir: None,
|
||||
..
|
||||
})
|
||||
WorkdirSessionOperation::CommandStart(workdir::CommandRequest { .. })
|
||||
));
|
||||
let provider_handle = workdir::CommandHandle("command-1".to_string());
|
||||
response
|
||||
@@ -37637,13 +37810,9 @@ mod tests {
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
status: workdir::CommandStatus::Running,
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
content: String::new(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
chunks: Vec::new(),
|
||||
next_cursor: 0,
|
||||
terminal: None,
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
@@ -37716,13 +37885,22 @@ mod tests {
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
chunks: Vec::new(),
|
||||
next_cursor: 0,
|
||||
terminal: Some(workdir::CommandTerminal {
|
||||
status: workdir::CommandStatus::Cancelled,
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
content: String::new(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
|
||||
stdout_bytes: 0,
|
||||
|
||||
stderr_bytes: 0,
|
||||
|
||||
stdout_delivered_bytes: 0,
|
||||
|
||||
stderr_delivered_bytes: 0,
|
||||
|
||||
truncation_reason: None,
|
||||
}),
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
@@ -37751,9 +37929,8 @@ mod tests {
|
||||
let request = || workdir::CommandRequest {
|
||||
command: "sleep 30".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
};
|
||||
let provider_handle = workdir::CommandHandle("command-reused".to_string());
|
||||
@@ -37810,13 +37987,22 @@ mod tests {
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
chunks: Vec::new(),
|
||||
next_cursor: 0,
|
||||
terminal: Some(workdir::CommandTerminal {
|
||||
status: workdir::CommandStatus::Cancelled,
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
content: String::new(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
|
||||
stdout_bytes: 0,
|
||||
|
||||
stderr_bytes: 0,
|
||||
|
||||
stdout_delivered_bytes: 0,
|
||||
|
||||
stderr_delivered_bytes: 0,
|
||||
|
||||
truncation_reason: None,
|
||||
}),
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
@@ -37867,9 +38053,8 @@ mod tests {
|
||||
let request = || workdir::CommandRequest {
|
||||
command: "sleep 30".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
};
|
||||
let provider_handle = workdir::CommandHandle("command-1".to_string());
|
||||
@@ -38766,9 +38951,8 @@ mod tests {
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf forbidden".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await;
|
||||
@@ -38789,9 +38973,8 @@ mod tests {
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf attached".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
@@ -38894,13 +39077,22 @@ mod tests {
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(
|
||||
workdir::CommandOutput {
|
||||
chunks: Vec::new(),
|
||||
next_cursor: 0,
|
||||
terminal: Some(workdir::CommandTerminal {
|
||||
status: workdir::CommandStatus::Cancelled,
|
||||
exit_code: None,
|
||||
timed_out: false,
|
||||
content: String::new(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
|
||||
stdout_bytes: 0,
|
||||
|
||||
stderr_bytes: 0,
|
||||
|
||||
stdout_delivered_bytes: 0,
|
||||
|
||||
stderr_delivered_bytes: 0,
|
||||
|
||||
truncation_reason: None,
|
||||
}),
|
||||
},
|
||||
)))
|
||||
.unwrap();
|
||||
@@ -38985,11 +39177,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let (mut old_provider, _) = connect_async(&url).await.unwrap();
|
||||
let old_registration = serde_json::to_string(®ister(1)).unwrap().replacen(
|
||||
r#""version":2"#,
|
||||
r#""version":1"#,
|
||||
1,
|
||||
);
|
||||
let mut old_registration = serde_json::to_value(register(1)).unwrap();
|
||||
old_registration["version"] = serde_json::json!(1);
|
||||
let old_registration = serde_json::to_string(&old_registration).unwrap();
|
||||
old_provider
|
||||
.send(Message::Text(old_registration.into()))
|
||||
.await
|
||||
@@ -39003,7 +39193,10 @@ mod tests {
|
||||
.reason
|
||||
.contains("unsupported External Workdir protocol version 1")
|
||||
);
|
||||
assert!(frame.reason.contains("requires 2"));
|
||||
assert!(frame.reason.contains(&format!(
|
||||
"requires {}",
|
||||
workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION
|
||||
)));
|
||||
|
||||
let (mut smaller_provider, _) = connect_async(&url).await.unwrap();
|
||||
let mut smaller_registration = serde_json::to_value(register(1)).unwrap();
|
||||
@@ -39244,37 +39437,106 @@ mod tests {
|
||||
fs::read_to_string(provider_root.path().join("created.txt")).unwrap(),
|
||||
"created"
|
||||
);
|
||||
let binary_handle = broker_session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "head -c 196609 /dev/zero".to_string(),
|
||||
timeout_secs: 30,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let (terminal_request, terminal_page) =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
let mut cursor = 0;
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let request = workdir::CommandOutputRequest {
|
||||
handle: binary_handle.clone(),
|
||||
cursor,
|
||||
limit: workdir::MAX_COMMAND_READ_BYTES,
|
||||
wait: true,
|
||||
};
|
||||
let page = broker_session
|
||||
.command_output(request.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
for chunk in &page.chunks {
|
||||
assert_eq!(chunk.stream, workdir::CommandStream::Stdout);
|
||||
assert_eq!(chunk.sequence, bytes.len() as u64);
|
||||
assert_eq!(chunk.offset, bytes.len() as u64);
|
||||
bytes.extend_from_slice(&chunk.bytes);
|
||||
}
|
||||
assert!(
|
||||
page.chunks
|
||||
.iter()
|
||||
.map(|chunk| chunk.bytes.len())
|
||||
.sum::<usize>()
|
||||
<= workdir::MAX_COMMAND_READ_BYTES
|
||||
);
|
||||
cursor = page.next_cursor;
|
||||
if let Some(terminal) = &page.terminal {
|
||||
assert_eq!(terminal.status, workdir::CommandStatus::Completed);
|
||||
assert_eq!(terminal.exit_code, Some(0));
|
||||
assert_eq!(bytes, vec![0; 196609]);
|
||||
break (request, page);
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("incremental provider output did not terminate");
|
||||
assert_eq!(
|
||||
broker_session
|
||||
.command_output(terminal_request.clone())
|
||||
.await
|
||||
.unwrap(),
|
||||
terminal_page
|
||||
);
|
||||
for cursor in [0, terminal_page.next_cursor + 1] {
|
||||
assert!(
|
||||
broker_session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
cursor,
|
||||
..terminal_request.clone()
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
let command_handle = broker_session
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "sleep 30 & child=$!; echo $child > child.pid; wait $child".to_string(),
|
||||
command: "sleep 30 & child=$!; echo $child > child.pid; printf ready; wait $child"
|
||||
.to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 1024,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: Some("/worker/private/spill".into()),
|
||||
tool_call_id: Some("call-e2e".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for _ in 0..100 {
|
||||
if provider_root.path().join("child.pid").is_file() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(provider_root.path().join("child.pid").is_file());
|
||||
|
||||
let command_output = broker_session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: command_handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: false,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(command_output.status, workdir::CommandStatus::Running);
|
||||
assert_eq!(command_output.output_path, None);
|
||||
assert!(command_output.terminal.is_none());
|
||||
|
||||
assert_eq!(
|
||||
command_output
|
||||
.chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.bytes.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
b"ready"
|
||||
);
|
||||
assert!(provider_root.path().join("child.pid").is_file());
|
||||
|
||||
let Json(revoked) = scoped_revoke_external_workdir_grant(
|
||||
State(api.clone()),
|
||||
@@ -40477,9 +40739,8 @@ mod tests {
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "printf ready; sleep 30".to_string(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 4096,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: Some("tool-call-command-session".to_string()),
|
||||
})
|
||||
.await
|
||||
@@ -40526,7 +40787,7 @@ mod tests {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(output.status, workdir::CommandStatus::Running));
|
||||
assert!(output.terminal.is_none());
|
||||
assert!(matches!(
|
||||
command
|
||||
.source
|
||||
@@ -40538,14 +40799,21 @@ mod tests {
|
||||
let waiting_source = command.source.clone();
|
||||
let waiting_handle = command.provider_handle.clone();
|
||||
let waiting = tokio::spawn(async move {
|
||||
waiting_source
|
||||
let mut cursor = output.next_cursor;
|
||||
loop {
|
||||
let page = waiting_source
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: waiting_handle,
|
||||
cursor: output.next_cursor.unwrap_or(0),
|
||||
handle: waiting_handle.clone(),
|
||||
cursor,
|
||||
limit: 4096,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.await?;
|
||||
cursor = page.next_cursor;
|
||||
if page.terminal.is_some() {
|
||||
return Ok::<_, workdir::WorkdirError>(page);
|
||||
}
|
||||
}
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
command
|
||||
@@ -40558,7 +40826,7 @@ mod tests {
|
||||
.expect("cancel should unblock a waiting output read")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!matches!(terminal.status, workdir::CommandStatus::Running));
|
||||
assert!(terminal.terminal.is_some());
|
||||
|
||||
let registered = registry.take_worker(&worker);
|
||||
assert_eq!(registered.len(), 1);
|
||||
@@ -40591,9 +40859,8 @@ mod tests {
|
||||
let request = || workdir::CommandRequest {
|
||||
command: "sleep 30".to_string(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 4096,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
};
|
||||
let left_provider = left.start_command(request()).await.unwrap();
|
||||
@@ -60067,9 +60334,8 @@ mod tests {
|
||||
.start_command(workdir::CommandRequest {
|
||||
command: "sleep 30".to_string(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 4096,
|
||||
transport_byte_budget: workdir::MAX_COMMAND_TRANSPORT_BYTES,
|
||||
cwd: workdir::WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -471,11 +471,10 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
|
||||
));
|
||||
// Seed an already registered command, without launching a real process.
|
||||
let provider_handle = CommandHandle("logging-command".to_string());
|
||||
session
|
||||
.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.insert(provider_handle.0.clone(), provider_handle.clone());
|
||||
session.active_commands.lock().await.insert(
|
||||
provider_handle.0.clone(),
|
||||
Arc::new(ExternalProviderCommandState::new(provider_handle.clone())),
|
||||
);
|
||||
let handle = fixture
|
||||
.api
|
||||
.workdir_sessions
|
||||
@@ -720,7 +719,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t
|
||||
Reason::ScopedCapabilityDenied,
|
||||
),
|
||||
(
|
||||
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}),
|
||||
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "transport_byte_budget": 1048576, "cwd": "", "tool_call_id": secret}}),
|
||||
"command_start",
|
||||
"denied",
|
||||
Reason::ScopedCapabilityDenied,
|
||||
@@ -942,8 +941,8 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc
|
||||
serde_json::to_vec(&json!({
|
||||
"target_workdir": "checkout",
|
||||
"operation": {"operation": "command_start", "request": {
|
||||
"command": "body-secret", "timeout_secs": 1, "output_limit": 1024,
|
||||
"cwd": "", "spill_dir": null, "tool_call_id": "body-secret"
|
||||
"command": "body-secret", "timeout_secs": 1, "transport_byte_budget": 1048576,
|
||||
"cwd": "", "tool_call_id": "body-secret"
|
||||
}}
|
||||
}))
|
||||
.unwrap(),
|
||||
@@ -1071,8 +1070,8 @@ async fn saturated_host_before_command_start_returns_availability_without_a_prov
|
||||
serde_json::to_vec(&json!({
|
||||
"target_workdir": "checkout",
|
||||
"operation": {"operation": "command_start", "request": {
|
||||
"command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024,
|
||||
"cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched"
|
||||
"command": "not-dispatched", "timeout_secs": 1, "transport_byte_budget": 1048576,
|
||||
"cwd": "", "tool_call_id": "not-dispatched"
|
||||
}}
|
||||
}))
|
||||
.unwrap(),
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Provider-bridge lifecycle tests. The production bridge runs against a controlled
|
||||
//! provider boundary; the websocket/CLI-pump test in the parent module covers real byte transport.
|
||||
use super::*;
|
||||
use workdir::WorkdirSession;
|
||||
|
||||
fn command_fixture() -> (
|
||||
Arc<ExternalProviderWorkdirSession>,
|
||||
tokio::sync::mpsc::Receiver<ExternalProviderCommand>,
|
||||
) {
|
||||
let (sender, receiver) = tokio::sync::mpsc::channel(16);
|
||||
let session = Arc::new(ExternalProviderWorkdirSession::new(
|
||||
Arc::new(ExternalProviderConnection {
|
||||
grant_id: "command-grant".into(),
|
||||
workdir_id: "command-workdir".into(),
|
||||
provider_instance_id: "command-provider".into(),
|
||||
generation: 1,
|
||||
expires_at: None,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(1)),
|
||||
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
sender,
|
||||
}),
|
||||
None,
|
||||
));
|
||||
(session, receiver)
|
||||
}
|
||||
|
||||
fn page(cursor: u64, bytes: &[u8], terminal: bool) -> workdir::CommandOutput {
|
||||
workdir::CommandOutput {
|
||||
chunks: if bytes.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![workdir::CommandByteChunk {
|
||||
sequence: cursor,
|
||||
stream: workdir::CommandStream::Stdout,
|
||||
offset: cursor,
|
||||
bytes: bytes.to_vec(),
|
||||
}]
|
||||
},
|
||||
next_cursor: cursor + bytes.len() as u64,
|
||||
terminal: terminal.then_some(workdir::CommandTerminal {
|
||||
status: workdir::CommandStatus::Cancelled,
|
||||
exit_code: None,
|
||||
stdout_bytes: cursor + bytes.len() as u64,
|
||||
stderr_bytes: 0,
|
||||
stdout_delivered_bytes: cursor + bytes.len() as u64,
|
||||
stderr_delivered_bytes: 0,
|
||||
truncation_reason: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive(
|
||||
receiver: &mut tokio::sync::mpsc::Receiver<ExternalProviderCommand>,
|
||||
) -> (
|
||||
WorkdirSessionOperation,
|
||||
tokio::sync::oneshot::Sender<
|
||||
std::result::Result<WorkdirSessionOperationResult, WorkdirTransportError>,
|
||||
>,
|
||||
) {
|
||||
let command = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
} = command
|
||||
else {
|
||||
panic!("expected provider operation")
|
||||
};
|
||||
(operation, response)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_cancels_waiting_reader_then_drains_from_latest_cursor() {
|
||||
let (session, mut receiver) = command_fixture();
|
||||
session.active_commands.lock().await.insert(
|
||||
"external".into(),
|
||||
Arc::new(ExternalProviderCommandState::new(CommandHandle(
|
||||
"provider".into(),
|
||||
))),
|
||||
);
|
||||
let reader = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: CommandHandle("external".into()),
|
||||
cursor: 0,
|
||||
limit: 3,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (_, response) = receive(&mut receiver).await;
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
0, b"one", false,
|
||||
))))
|
||||
.unwrap();
|
||||
assert_eq!(reader.await.unwrap().unwrap().next_cursor, 3);
|
||||
|
||||
let reader = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: CommandHandle("external".into()),
|
||||
cursor: 3,
|
||||
limit: 3,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (_, pending_read) = receive(&mut receiver).await;
|
||||
let closer = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move { session.close().await })
|
||||
};
|
||||
let (operation, response) = receive(&mut receiver).await;
|
||||
assert!(
|
||||
matches!(operation, WorkdirSessionOperation::CommandCancel(ref handle) if handle.0 == "provider")
|
||||
);
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandCancel))
|
||||
.unwrap();
|
||||
pending_read
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
3, b"two", false,
|
||||
))))
|
||||
.unwrap();
|
||||
assert_eq!(reader.await.unwrap().unwrap().next_cursor, 6);
|
||||
|
||||
for (cursor, bytes, terminal) in [(6, &b"end"[..], false), (9, &b""[..], true)] {
|
||||
let (operation, response) = receive(&mut receiver).await;
|
||||
assert!(
|
||||
matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == cursor && request.wait)
|
||||
);
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
cursor, bytes, terminal,
|
||||
))))
|
||||
.unwrap();
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), closer)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_output_retains_same_binding_for_current_page_replay() {
|
||||
let (session, mut receiver) = command_fixture();
|
||||
session.active_commands.lock().await.insert(
|
||||
"external".into(),
|
||||
Arc::new(ExternalProviderCommandState::new(CommandHandle(
|
||||
"provider".into(),
|
||||
))),
|
||||
);
|
||||
for _ in 0..2 {
|
||||
let reader = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: CommandHandle("external".into()),
|
||||
cursor: 0,
|
||||
limit: 3,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (operation, response) = receive(&mut receiver).await;
|
||||
assert!(
|
||||
matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == 0)
|
||||
);
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
0, b"end", true,
|
||||
))))
|
||||
.unwrap();
|
||||
assert_eq!(reader.await.unwrap().unwrap(), page(0, b"end", true));
|
||||
}
|
||||
assert_eq!(session.active_commands.lock().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_registry_retention_is_bounded_and_excludes_active_commands() {
|
||||
let (source, _) = command_fixture();
|
||||
let worker = RuntimeWorkerRef::new("runtime", "worker");
|
||||
let mut registry = WorkdirSessionRegistry::default();
|
||||
let active = registry.register_command(
|
||||
worker.clone(),
|
||||
"checkout".into(),
|
||||
source.clone(),
|
||||
CommandHandle("active".into()),
|
||||
);
|
||||
let mut last = None;
|
||||
for index in 0..MAX_RETAINED_TERMINAL_COMMANDS + 4 {
|
||||
let handle = registry.register_command(
|
||||
worker.clone(),
|
||||
"checkout".into(),
|
||||
source.clone(),
|
||||
CommandHandle(format!("terminal-{index}")),
|
||||
);
|
||||
registry.mark_terminal(&worker, &handle);
|
||||
last = Some(handle);
|
||||
}
|
||||
assert_eq!(registry.active_command_count(&worker), 1);
|
||||
assert_eq!(registry.commands.len(), MAX_RETAINED_TERMINAL_COMMANDS + 1);
|
||||
assert!(registry.command(&worker, "checkout", &active).is_some());
|
||||
assert!(
|
||||
registry
|
||||
.command(&worker, "checkout", &last.unwrap())
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
@@ -777,6 +777,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
|
||||
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
|
||||
let routes = [
|
||||
format!("{}/root", base()),
|
||||
format!("{}/capabilities", base()),
|
||||
format!(
|
||||
"{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}",
|
||||
base()
|
||||
@@ -1007,3 +1008,106 @@ async fn drive_download_stream_expires_instead_of_mixing_last_mutation_ids() {
|
||||
"next chunk must expire, not deliver generation b"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_capabilities_expose_only_authenticated_current_caller_ceiling() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(temp.path()).await;
|
||||
let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
let (_runtime, workers) = workers(&api).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
|
||||
let path = format!("{}/capabilities", base());
|
||||
let unsigned = build_router(api.clone())
|
||||
.oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
unsigned.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
|
||||
));
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
None,
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let mut grants = vec![];
|
||||
for (worker, access, write) in [
|
||||
(&workers[0], "read_only", false),
|
||||
(&workers[1], "read_write", true),
|
||||
] {
|
||||
grants.push(user_call(&api,&owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access})),StatusCode::OK).await);
|
||||
assert_eq!(
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
Some(&worker.worker_id),
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
json!({"write":write})
|
||||
);
|
||||
}
|
||||
// Altering a signed source cannot acquire the other Worker's larger ceiling.
|
||||
let mut forged =
|
||||
runtime_source_request(&identity, Some(&workers[0].worker_id), "GET", &path, vec![]);
|
||||
forged
|
||||
.headers_mut()
|
||||
.insert("x-yoi-worker-id", workers[1].worker_id.parse().unwrap());
|
||||
let response = build_router(api.clone()).oneshot(forged).await.unwrap();
|
||||
assert!(matches!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
|
||||
));
|
||||
let foreign = path.replace(&format!("/w/{TEST_WORKSPACE_ID}/"), "/w/other-workspace/");
|
||||
let response = build_router(api.clone())
|
||||
.oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(&workers[1].worker_id),
|
||||
"GET",
|
||||
&foreign,
|
||||
vec![],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!response.status().is_success());
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
Some("nonexistent-worker"),
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
for (worker, grant) in workers.iter().zip(&grants) {
|
||||
user_call(
|
||||
&api,
|
||||
&owner,
|
||||
"DELETE",
|
||||
&format!("{}/grants/{}", base(), grant["grant_id"].as_str().unwrap()),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
Some(&worker.worker_id),
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ async fn user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_
|
||||
read["entry"]["metadata"]["last_mutation_id"],
|
||||
uploaded["entry"]["last_mutation_id"]
|
||||
);
|
||||
value(native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":updated_text}})).await.unwrap())
|
||||
value(native_tool(&tools, "Invoke", json!({"path":path,"interface":file_write_reference(&inspected),"operation":"write","arguments":{"content":updated_text}})).await.unwrap())["metadata"].clone()
|
||||
} else {
|
||||
let read = value(
|
||||
tool(&f.features[0], "DriveRead", json!({"entry":id}))
|
||||
@@ -733,6 +733,19 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(f.api.runtime.worker(old).is_err());
|
||||
let client = f.clients[0].clone();
|
||||
let capability = tokio::task::spawn_blocking(move || {
|
||||
client.request(
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{}/capabilities", base()),
|
||||
vec![],
|
||||
65536,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!(200..300).contains(&capability.status));
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
@@ -1571,11 +1584,30 @@ impl agen::llm_client::LlmClient for NoLlm {
|
||||
panic!("native tool fixture must not call an LLM")
|
||||
}
|
||||
}
|
||||
fn file_write_reference(observed: &Value) -> Value {
|
||||
observed["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
.expect("write capability")["reference"]
|
||||
.clone()
|
||||
}
|
||||
fn native_tools(
|
||||
feature: &DriveFeature,
|
||||
) -> std::collections::BTreeMap<String, Arc<dyn agen::tool::Tool>> {
|
||||
native_tools_with_checkouts(feature, None)
|
||||
}
|
||||
fn native_tools_with_checkouts(
|
||||
feature: &DriveFeature,
|
||||
router: Option<Arc<WorkdirSessionRouter>>,
|
||||
) -> std::collections::BTreeMap<String, Arc<dyn agen::tool::Tool>> {
|
||||
let mut mounts = worker::wip::WipMountRegistry::new();
|
||||
worker::feature::builtin::drive::wip::mount_drive_wip(&mut mounts, feature).unwrap();
|
||||
if let Some(router) = router {
|
||||
worker::checkout::mount_checkouts(&mut mounts, router, tools::Tracker::new(), None)
|
||||
.unwrap();
|
||||
}
|
||||
let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoLlm);
|
||||
engine.register_tools(
|
||||
feature
|
||||
@@ -1651,7 +1683,14 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu
|
||||
);
|
||||
assert_eq!(read["text"], "native content");
|
||||
let denied_write = native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":"lost"}})).await;
|
||||
denied(denied_write);
|
||||
assert!(denied_write.is_err());
|
||||
assert!(
|
||||
!inspected["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
);
|
||||
f.revoke(1).await;
|
||||
assert!(
|
||||
native_tool(
|
||||
@@ -1705,12 +1744,12 @@ async fn native_create_and_write_use_client_managed_committed_requests_and_rejec
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap());
|
||||
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_first),"operation":"write","arguments":{"content":"committed"}})).await.unwrap())["metadata"].clone();
|
||||
assert_ne!(
|
||||
updated["entry"]["metadata"]["last_mutation_id"],
|
||||
created["entry"]["metadata"]["last_mutation_id"]
|
||||
);
|
||||
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
|
||||
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_second),"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().to_lowercase().contains("validator"),
|
||||
"stale native mutation must report validator conflict: {error:?}"
|
||||
@@ -1833,3 +1872,143 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
|
||||
"history attachment must not fetch latest Drive content"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_file_inspect_invoke_crosses_drive_and_checkout_without_transferring_authority() {
|
||||
let f = Fixture::new(["read_write", "read_only"]).await;
|
||||
let created = f.create("common.md", "first\n雪 needle\nlast\n").await;
|
||||
let path = created["entry"]["path"].as_str().unwrap();
|
||||
let checkout = f._temp.path().join("checkout");
|
||||
fs::create_dir(&checkout).unwrap();
|
||||
fs::write(checkout.join("file"), "first\n雪 needle\nlast\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
workdir::WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
Arc::new(workdir::LocalWorkdirSession::materialized(
|
||||
checkout.clone(),
|
||||
checkout.clone(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(&checkout).unwrap()),
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = native_tools_with_checkouts(&f.features[0], Some(router));
|
||||
let drive = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":path}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let local = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":"/checkouts/main/file"}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
for name in [
|
||||
"yoi.file/read-lines/v1",
|
||||
"yoi.file/search-text/v1",
|
||||
"yoi.file/edit-text/v1",
|
||||
"yoi.file/write-text/v1",
|
||||
] {
|
||||
let selected = drive["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["reference"]["name"] == name)
|
||||
.unwrap();
|
||||
let reference = &selected["reference"];
|
||||
assert_eq!(reference["scope"], "/");
|
||||
assert!(
|
||||
local["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"] == *reference)
|
||||
);
|
||||
let signature = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":selected["path"]}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
signature["interface_signature"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(name)
|
||||
);
|
||||
let (operation, arguments) = match name {
|
||||
"yoi.file/read-lines/v1" => ("read", json!({"offset":1,"limit":1})),
|
||||
"yoi.file/search-text/v1" => ("search", json!({"pattern":"needle","limit":2})),
|
||||
"yoi.file/edit-text/v1" => {
|
||||
("edit", json!({"old_string":"needle","new_string":"found"}))
|
||||
}
|
||||
_ => ("write", json!({"content":"saved\n"})),
|
||||
};
|
||||
let mut results = vec![];
|
||||
for target in [path, "/checkouts/main/file"] {
|
||||
results.push(value(native_tool(&tools,"Invoke",json!({"path":target,"interface":reference,"operation":operation,"arguments":arguments})).await.unwrap()));
|
||||
}
|
||||
if matches!(operation, "read" | "search") {
|
||||
assert_eq!(results[0], results[1]);
|
||||
} else {
|
||||
assert!(results.iter().all(|v| v.get("metadata").is_some()));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(checkout.join("file")).unwrap(),
|
||||
"saved\n"
|
||||
);
|
||||
assert_eq!(
|
||||
value(
|
||||
tool(
|
||||
&f.features[1],
|
||||
"DriveRead",
|
||||
json!({"entry":entry(&created)})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
)["text"],
|
||||
"saved\n"
|
||||
);
|
||||
// A capability query is not a grant: drop the writer to readonly after the common write was cached.
|
||||
f.revoke(0).await;
|
||||
owner_call(&f.api,&f.owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":f.clients[0].worker.runtime_id,"worker_id":f.clients[0].worker.worker_id,"access":"read_only"}))).await;
|
||||
assert!(native_tool(&tools,"Invoke",json!({"path":path,"interface":file_write_reference(&drive),"operation":"write","arguments":{"content":"lost"}})).await.is_err());
|
||||
let refreshed = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":path,"refresh":true}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
!refreshed["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
);
|
||||
let common_read = json!({"scope":"/","name":"yoi.file/read-lines/v1"});
|
||||
assert_eq!(
|
||||
value(
|
||||
native_tool(
|
||||
&tools,
|
||||
"Invoke",
|
||||
json!({"path":path,"interface":common_read,"operation":"read","arguments":{}})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
)["content"],
|
||||
" 1\tsaved\n"
|
||||
);
|
||||
assert_eq!(
|
||||
value(
|
||||
tool(
|
||||
&f.features[1],
|
||||
"DriveRead",
|
||||
json!({"entry":entry(&created)})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
)["text"],
|
||||
"saved\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ fn read_result() -> WorkdirSessionOperationResult {
|
||||
total_lines: 1,
|
||||
content_hash: sha2::Sha256::digest(b"visible\n").into(),
|
||||
truncated: false,
|
||||
byte_truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -466,7 +466,6 @@ impl AttentionWorkerFactory {
|
||||
worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)),
|
||||
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
||||
bash_output_dir: root.join("bash-output"),
|
||||
},
|
||||
worker::WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -799,7 +799,6 @@ impl DeliveryWorkerFactory {
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
||||
bash_output_dir: root.join("bash-output"),
|
||||
},
|
||||
worker::WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -238,6 +238,25 @@ pub(super) async fn root(
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
blocking(false, move || drive.root()).await.map(public_node)
|
||||
}
|
||||
/// Query only the authenticated caller's current ceiling using the operation authorizer.
|
||||
/// This is admission/publication evidence, never authority retained for a later mutation.
|
||||
pub(super) async fn capabilities(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
) -> DriveResult<DriveCapabilities> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let write = blocking(false, move || match drive.check_authorized(true) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(workspace_drive::Error::Denied) => {
|
||||
drive.check_authorized(false)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
})
|
||||
.await?;
|
||||
Ok(DriveCapabilities { write })
|
||||
}
|
||||
pub(super) async fn metadata(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
@@ -581,6 +600,9 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
.merge(streaming_router(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_root(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_metadata(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_capabilities(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_list(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_search(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_read_text(
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn config_call(
|
||||
runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
config_interface(path),
|
||||
if matches!(operation,"edit"|"write") {wip_protocol::InterfaceReference {scope:"/".into(),name:format!("yoi.file/{operation}-text/v1")}} else {config_interface(path)},
|
||||
operation.into(),
|
||||
args,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
|
||||
+121
-1
@@ -1951,6 +1951,19 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"DriveCapabilities": {
|
||||
"additionalProperties": false,
|
||||
"description": "Current caller's advisory publication ceiling, never a grant or execution credential.",
|
||||
"properties": {
|
||||
"write": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"write"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"DriveDownloadQuery": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -17831,7 +17844,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d"
|
||||
"x-yoi-source-digest": "sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -22228,6 +22241,113 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/drive/capabilities": {
|
||||
"get": {
|
||||
"operationId": "drive_capabilities",
|
||||
"parameters": [
|
||||
{
|
||||
"explode": false,
|
||||
"in": "path",
|
||||
"name": "workspace_id",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/string"
|
||||
},
|
||||
"style": "simple"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveCapabilities"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"503": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DriveApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/w/{workspace_id}/drive/download": {
|
||||
"get": {
|
||||
"operationId": "drive_download",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -31,6 +31,8 @@ export type DriveEntry = {
|
||||
latest_url: string;
|
||||
};
|
||||
|
||||
export type DriveCapabilities = { write: boolean };
|
||||
|
||||
export type DriveEntryQuery = { entry_workspace_id: string; id: string };
|
||||
|
||||
export type DriveListQuery = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d)
|
||||
// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
Reference in New Issue
Block a user