diff --git a/docs/tools/bash.md b/docs/tools/bash.md index 59bbc61b..be9f8205 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -9,7 +9,7 @@ invocation regardless of tool groups. ## Tools -### `run(cmd, timeout_secs?, wait_seconds?)` +### `run(cmd, timeout_secs?, wait_seconds?, name?)` Submit a shell command for background execution (`sh -c `). Stdout and stderr stream to `harness/bash-tasks/.{out,err}`. @@ -25,6 +25,14 @@ harness fires a wake with `from: "bash-task-"` and the exit code the task keeps running and the normal `task started: id=` response is returned. **Defaults to 3** — pass `wait_seconds: 0` to disable inline waiting and always get the immediate response. +- `name` — optional caller-chosen task id. When set it replaces the + auto-generated hex id, so it surfaces in the wake `from` + (`bash-task-`), in `status()` lookups, and in the + loose-ends list — a memorable label instead of an opaque id. A name + is **reusable once its previous task has finished**; submitting a + name whose task is still `pending`/`running` is rejected. Allowed + characters: ASCII letters, digits, `.`, `_`, `-` (max 64). Omit for + the auto-generated id. Exposed as `mcp__bash__run`. diff --git a/hive-bash-mcp/src/bin/mcp.rs b/hive-bash-mcp/src/bin/mcp.rs index af7a4b54..101882aa 100644 --- a/hive-bash-mcp/src/bin/mcp.rs +++ b/hive-bash-mcp/src/bin/mcp.rs @@ -166,6 +166,15 @@ struct BashRunArgs { /// `0` to disable inline waiting and always get the immediate response. #[serde(default = "default_wait")] wait_seconds: Option, + /// Optional task name. When set it becomes the task id, so it appears + /// in the completion wake (`from: "bash-task-"`), in `status` + /// lookups, and in the loose-ends list — handy for recognising a task + /// later instead of an opaque hex id. A name can be reused once its + /// previous task has finished; reusing a name whose task is still + /// running is rejected. Allowed chars: ASCII letters, digits, `.`, + /// `_`, `-` (max 64). Omit to get the auto-generated id. + #[serde(default)] + name: Option, } #[allow( @@ -204,13 +213,17 @@ impl BashMcp { wake is fired; when the timeout expires the task keeps running and the normal \ `task started: id=` response is returned. `wait_seconds` defaults to 3; \ pass `wait_seconds: 0` to disable inline waiting and always get the immediate \ - response." + response. Pass `name` to label the task with a memorable id (used in the wake \ + `from`, `status` lookups, and the loose-ends list) instead of an opaque hex id; \ + a name is reusable once its prior task has finished, and rejected while one is \ + still running." )] async fn run(&self, Parameters(args): Parameters) -> String { let req = DaemonRequest::BashRun { cmd: args.cmd, timeout_secs: args.timeout_secs, wait_seconds: args.wait_seconds, + name: args.name, }; let resp = round_trip(req).await; // Extract the id from the response to format the result. diff --git a/hive-bash-mcp/src/protocol.rs b/hive-bash-mcp/src/protocol.rs index cb239b55..e1e97b9c 100644 --- a/hive-bash-mcp/src/protocol.rs +++ b/hive-bash-mcp/src/protocol.rs @@ -70,6 +70,13 @@ pub enum DaemonRequest { /// task-started-id response immediately. #[serde(default)] wait_seconds: Option, + /// Optional caller-chosen task name, used as the task id (so it + /// flows into the wake `from`, status lookups, and the loose-ends + /// summary). Must be filesystem-safe. Reusable once any prior task + /// of the same name has finished; rejected while one is still + /// running. `None` falls back to the auto-generated id. + #[serde(default)] + name: Option, }, /// Query the current status of a task. Returns the full `TaskFile` diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index bf52b66d..485df2ff 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -20,7 +20,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use anyhow::Result; +use anyhow::{Result, bail}; use tokio::io::AsyncWriteExt as _; use crate::paths; @@ -122,15 +122,62 @@ fn refresh_loose_ends() { // Public API used by daemon dispatch // --------------------------------------------------------------------------- +/// Longest accepted caller-chosen task name. +const MAX_TASK_NAME_LEN: usize = 64; + +/// Validate a caller-chosen task name. The name doubles as the task id +/// and therefore as the `.json` filename, so it must be a single +/// safe path segment. Allows ASCII alphanumerics plus `.`, `_`, `-`; +/// rejects empties, over-long names, `.`/`..`, and anything that could +/// escape the tasks dir or collide with the `.json.tmp` scratch suffix. +fn validate_task_name(name: &str) -> Result<()> { + if name.is_empty() { + bail!("task name must not be empty"); + } + if name.len() > MAX_TASK_NAME_LEN { + bail!("task name too long (max {MAX_TASK_NAME_LEN} chars)"); + } + if name == "." || name == ".." { + bail!("task name {name:?} is reserved"); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + { + bail!("task name {name:?} may only contain ASCII letters, digits, '.', '_', '-'"); + } + Ok(()) +} + /// Submit a new pending task. Returns the task ID. /// +/// When `name` is `Some`, it is validated and used as the task id (so it +/// surfaces in the wake `from`, status lookups, and the loose-ends list). +/// A name may be reused once any prior task of that name has finished; +/// submitting a name whose task is still `Pending`/`Running` is rejected. +/// `None` falls back to the auto-generated timestamp id. +/// /// # Errors /// -/// Returns an error if the tasks directory cannot be created or the -/// task file cannot be written. -pub fn submit_task(cmd: String, timeout_secs: Option) -> Result { +/// Returns an error if the name is invalid, a task of that name is still +/// running, the tasks directory cannot be created, or the task file +/// cannot be written. +pub fn submit_task(cmd: String, timeout_secs: Option, name: Option) -> Result { std::fs::create_dir_all(paths::tasks_dir())?; - let id = new_task_id(); + let id = match name { + Some(name) => { + validate_task_name(&name)?; + if let Some(existing) = read_task(&name) + && matches!(existing.status, TaskStatus::Pending | TaskStatus::Running) + { + bail!( + "a bash task named `{name}` is already running — wait for it to finish or pick another name" + ); + } + name + } + None => new_task_id(), + }; let task = TaskFile { id: id.clone(), cmd, @@ -506,3 +553,38 @@ pub(crate) async fn send_wake( } } } + +#[cfg(test)] +mod tests { + use super::{MAX_TASK_NAME_LEN, validate_task_name}; + + #[test] + fn accepts_reasonable_names() { + for ok in ["build", "ci-check", "nix_flake.check", "t1", "A.B-C_9"] { + assert!(validate_task_name(ok).is_ok(), "{ok} should be valid"); + } + } + + #[test] + fn rejects_unsafe_names() { + // Empty, traversal, separators, control/space, and over-long. + for bad in [ + "", + ".", + "..", + "a/b", + "../escape", + "has space", + "tab\tname", + "slash\\back", + ] { + assert!( + validate_task_name(bad).is_err(), + "{bad:?} should be rejected" + ); + } + assert!(validate_task_name(&"x".repeat(MAX_TASK_NAME_LEN + 1)).is_err()); + // Exactly at the cap is allowed. + assert!(validate_task_name(&"x".repeat(MAX_TASK_NAME_LEN)).is_ok()); + } +} diff --git a/hive-bash-mcp/src/socket.rs b/hive-bash-mcp/src/socket.rs index 5334c976..d6c19dd6 100644 --- a/hive-bash-mcp/src/socket.rs +++ b/hive-bash-mcp/src/socket.rs @@ -61,8 +61,9 @@ async fn dispatch(req: DaemonRequest) -> DaemonResponse { cmd, timeout_secs, wait_seconds, + name, } => { - let id = match runner::submit_task(cmd, timeout_secs) { + let id = match runner::submit_task(cmd, timeout_secs, name) { Ok(id) => id, Err(e) => return DaemonResponse::error(format!("submit_task: {e:#}")), };