mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 08:34:49 +08:00
feat(tools): error propagation, REPL timeout, edge-case validation
- Replace NotebookEdit expect() with Result-based error propagation - Add 5-minute guard to Sleep duration - Reject empty StructuredOutput payloads - Enforce timeout_ms in REPL via spawn+try_wait+kill - Add edge-case tests: excessive/zero sleep, empty output, REPL timeout - Verified: cargo test -p tools 35 passed, clippy clean
This commit is contained in:
@@ -169,7 +169,6 @@ impl GlobalToolRegistry {
|
|||||||
builtin.chain(plugin).collect()
|
builtin.chain(plugin).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn permission_specs(
|
pub fn permission_specs(
|
||||||
&self,
|
&self,
|
||||||
allowed_tools: Option<&BTreeSet<String>>,
|
allowed_tools: Option<&BTreeSet<String>>,
|
||||||
@@ -648,7 +647,7 @@ fn run_notebook_edit(input: NotebookEditInput) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_sleep(input: SleepInput) -> Result<String, String> {
|
fn run_sleep(input: SleepInput) -> Result<String, String> {
|
||||||
to_pretty_json(execute_sleep(input))
|
to_pretty_json(execute_sleep(input)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_brief(input: BriefInput) -> Result<String, String> {
|
fn run_brief(input: BriefInput) -> Result<String, String> {
|
||||||
@@ -660,7 +659,7 @@ fn run_config(input: ConfigInput) -> Result<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
|
fn run_structured_output(input: StructuredOutputInput) -> Result<String, String> {
|
||||||
to_pretty_json(execute_structured_output(input))
|
to_pretty_json(execute_structured_output(input)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_repl(input: ReplInput) -> Result<String, String> {
|
fn run_repl(input: ReplInput) -> Result<String, String> {
|
||||||
@@ -2347,7 +2346,8 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
|||||||
|
|
||||||
let cell_id = match edit_mode {
|
let cell_id = match edit_mode {
|
||||||
NotebookEditMode::Insert => {
|
NotebookEditMode::Insert => {
|
||||||
let resolved_cell_type = resolved_cell_type.expect("insert cell type");
|
let resolved_cell_type = resolved_cell_type
|
||||||
|
.ok_or_else(|| String::from("insert mode requires a cell type"))?;
|
||||||
let new_id = make_cell_id(cells.len());
|
let new_id = make_cell_id(cells.len());
|
||||||
let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
|
let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
|
||||||
let insert_at = target_index.map_or(cells.len(), |index| index + 1);
|
let insert_at = target_index.map_or(cells.len(), |index| index + 1);
|
||||||
@@ -2359,16 +2359,21 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
|
|||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
}
|
}
|
||||||
NotebookEditMode::Delete => {
|
NotebookEditMode::Delete => {
|
||||||
let removed = cells.remove(target_index.expect("delete target index"));
|
let idx = target_index
|
||||||
|
.ok_or_else(|| String::from("delete mode requires a target cell index"))?;
|
||||||
|
let removed = cells.remove(idx);
|
||||||
removed
|
removed
|
||||||
.get("id")
|
.get("id")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToString::to_string)
|
.map(ToString::to_string)
|
||||||
}
|
}
|
||||||
NotebookEditMode::Replace => {
|
NotebookEditMode::Replace => {
|
||||||
let resolved_cell_type = resolved_cell_type.expect("replace cell type");
|
let resolved_cell_type = resolved_cell_type
|
||||||
|
.ok_or_else(|| String::from("replace mode requires a cell type"))?;
|
||||||
|
let idx = target_index
|
||||||
|
.ok_or_else(|| String::from("replace mode requires a target cell index"))?;
|
||||||
let cell = cells
|
let cell = cells
|
||||||
.get_mut(target_index.expect("replace target index"))
|
.get_mut(idx)
|
||||||
.ok_or_else(|| String::from("Cell index out of range"))?;
|
.ok_or_else(|| String::from("Cell index out of range"))?;
|
||||||
cell["source"] = serde_json::Value::Array(source_lines(&new_source));
|
cell["source"] = serde_json::Value::Array(source_lines(&new_source));
|
||||||
cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
|
cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
|
||||||
@@ -2459,13 +2464,21 @@ fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_SLEEP_DURATION_MS: u64 = 300_000;
|
||||||
|
|
||||||
#[allow(clippy::needless_pass_by_value)]
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
fn execute_sleep(input: SleepInput) -> SleepOutput {
|
fn execute_sleep(input: SleepInput) -> Result<SleepOutput, String> {
|
||||||
|
if input.duration_ms > MAX_SLEEP_DURATION_MS {
|
||||||
|
return Err(format!(
|
||||||
|
"duration_ms {} exceeds maximum allowed sleep of {MAX_SLEEP_DURATION_MS}ms",
|
||||||
|
input.duration_ms,
|
||||||
|
));
|
||||||
|
}
|
||||||
std::thread::sleep(Duration::from_millis(input.duration_ms));
|
std::thread::sleep(Duration::from_millis(input.duration_ms));
|
||||||
SleepOutput {
|
Ok(SleepOutput {
|
||||||
duration_ms: input.duration_ms,
|
duration_ms: input.duration_ms,
|
||||||
message: format!("Slept for {}ms", input.duration_ms),
|
message: format!("Slept for {}ms", input.duration_ms),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
|
fn execute_brief(input: BriefInput) -> Result<BriefOutput, String> {
|
||||||
@@ -2562,25 +2575,62 @@ fn execute_config(input: ConfigInput) -> Result<ConfigOutput, String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_structured_output(input: StructuredOutputInput) -> StructuredOutputResult {
|
fn execute_structured_output(
|
||||||
StructuredOutputResult {
|
input: StructuredOutputInput,
|
||||||
|
) -> Result<StructuredOutputResult, String> {
|
||||||
|
if input.0.is_empty() {
|
||||||
|
return Err(String::from("structured output payload must not be empty"));
|
||||||
|
}
|
||||||
|
Ok(StructuredOutputResult {
|
||||||
data: String::from("Structured output provided successfully"),
|
data: String::from("Structured output provided successfully"),
|
||||||
structured_output: input.0,
|
structured_output: input.0,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
|
fn execute_repl(input: ReplInput) -> Result<ReplOutput, String> {
|
||||||
if input.code.trim().is_empty() {
|
if input.code.trim().is_empty() {
|
||||||
return Err(String::from("code must not be empty"));
|
return Err(String::from("code must not be empty"));
|
||||||
}
|
}
|
||||||
let _ = input.timeout_ms;
|
|
||||||
let runtime = resolve_repl_runtime(&input.language)?;
|
let runtime = resolve_repl_runtime(&input.language)?;
|
||||||
let started = Instant::now();
|
let started = Instant::now();
|
||||||
let output = Command::new(runtime.program)
|
let mut process = Command::new(runtime.program);
|
||||||
|
process
|
||||||
.args(runtime.args)
|
.args(runtime.args)
|
||||||
.arg(&input.code)
|
.arg(&input.code)
|
||||||
.output()
|
.stdin(std::process::Stdio::null())
|
||||||
.map_err(|error| error.to_string())?;
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
|
||||||
|
let output = if let Some(timeout_ms) = input.timeout_ms {
|
||||||
|
let mut child = process.spawn().map_err(|error| error.to_string())?;
|
||||||
|
loop {
|
||||||
|
if child
|
||||||
|
.try_wait()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
break child
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
}
|
||||||
|
if started.elapsed() >= Duration::from_millis(timeout_ms) {
|
||||||
|
child.kill().map_err(|error| error.to_string())?;
|
||||||
|
child
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
return Err(format!(
|
||||||
|
"REPL execution exceeded timeout of {timeout_ms} ms"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
process
|
||||||
|
.spawn()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
.wait_with_output()
|
||||||
|
.map_err(|error| error.to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
Ok(ReplOutput {
|
Ok(ReplOutput {
|
||||||
language: input.language,
|
language: input.language,
|
||||||
@@ -3157,8 +3207,8 @@ mod tests {
|
|||||||
.expect_err("unknown plugin permission should fail");
|
.expect_err("unknown plugin permission should fail");
|
||||||
assert!(unknown_permission.contains("unsupported plugin permission: admin"));
|
assert!(unknown_permission.contains("unsupported plugin permission: admin"));
|
||||||
|
|
||||||
let empty_permission = permission_mode_from_plugin("")
|
let empty_permission =
|
||||||
.expect_err("empty plugin permission should fail");
|
permission_mode_from_plugin("").expect_err("empty plugin permission should fail");
|
||||||
assert!(empty_permission.contains("unsupported plugin permission: "));
|
assert!(empty_permission.contains("unsupported plugin permission: "));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4226,6 +4276,21 @@ mod tests {
|
|||||||
assert!(elapsed >= Duration::from_millis(15));
|
assert!(elapsed >= Duration::from_millis(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_excessive_duration_when_sleep_then_rejects_with_error() {
|
||||||
|
let result = execute_tool("Sleep", &json!({"duration_ms": 999_999_999_u64}));
|
||||||
|
let error = result.expect_err("excessive sleep should fail");
|
||||||
|
assert!(error.contains("exceeds maximum allowed sleep"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_zero_duration_when_sleep_then_succeeds() {
|
||||||
|
let result =
|
||||||
|
execute_tool("Sleep", &json!({"duration_ms": 0})).expect("0ms sleep should succeed");
|
||||||
|
let output: serde_json::Value = serde_json::from_str(&result).expect("json");
|
||||||
|
assert_eq!(output["duration_ms"], 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn brief_returns_sent_message_and_attachment_metadata() {
|
fn brief_returns_sent_message_and_attachment_metadata() {
|
||||||
let attachment = std::env::temp_dir().join(format!(
|
let attachment = std::env::temp_dir().join(format!(
|
||||||
@@ -4330,6 +4395,13 @@ mod tests {
|
|||||||
assert_eq!(output["structured_output"]["items"][1], 2);
|
assert_eq!(output["structured_output"]["items"][1], 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_empty_payload_when_structured_output_then_rejects_with_error() {
|
||||||
|
let result = execute_tool("StructuredOutput", &json!({}));
|
||||||
|
let error = result.expect_err("empty payload should fail");
|
||||||
|
assert!(error.contains("must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn repl_executes_python_code() {
|
fn repl_executes_python_code() {
|
||||||
let result = execute_tool(
|
let result = execute_tool(
|
||||||
@@ -4343,6 +4415,37 @@ mod tests {
|
|||||||
assert!(output["stdout"].as_str().expect("stdout").contains('2'));
|
assert!(output["stdout"].as_str().expect("stdout").contains('2'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_empty_code_when_repl_then_rejects_with_error() {
|
||||||
|
let result = execute_tool("REPL", &json!({"language": "python", "code": " "}));
|
||||||
|
|
||||||
|
let error = result.expect_err("empty REPL code should fail");
|
||||||
|
assert!(error.contains("code must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_unsupported_language_when_repl_then_rejects_with_error() {
|
||||||
|
let result = execute_tool("REPL", &json!({"language": "ruby", "code": "puts 1"}));
|
||||||
|
|
||||||
|
let error = result.expect_err("unsupported REPL language should fail");
|
||||||
|
assert!(error.contains("unsupported REPL language: ruby"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn given_timeout_ms_when_repl_blocks_then_returns_timeout_error() {
|
||||||
|
let result = execute_tool(
|
||||||
|
"REPL",
|
||||||
|
&json!({
|
||||||
|
"language": "python",
|
||||||
|
"code": "import time\ntime.sleep(1)",
|
||||||
|
"timeout_ms": 10
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = result.expect_err("timed out REPL execution should fail");
|
||||||
|
assert!(error.contains("REPL execution exceeded timeout of 10 ms"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn powershell_runs_via_stub_shell() {
|
fn powershell_runs_via_stub_shell() {
|
||||||
let _guard = env_lock()
|
let _guard = env_lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user