feat(#1653): optional name param for bash run tool

This commit is contained in:
damocles 2026-06-13 16:08:31 +02:00 committed by mara
commit 9bd51a7440
5 changed files with 119 additions and 8 deletions

View file

@ -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 <cmd>`).
Stdout and stderr stream to `harness/bash-tasks/<id>.{out,err}`.
@ -25,6 +25,14 @@ harness fires a wake with `from: "bash-task-<id>"` and the exit code
the task keeps running and the normal `task started: id=<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-<name>`), in `status(<name>)` 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`.

View file

@ -166,6 +166,15 @@ struct BashRunArgs {
/// `0` to disable inline waiting and always get the immediate response.
#[serde(default = "default_wait")]
wait_seconds: Option<u64>,
/// Optional task name. When set it becomes the task id, so it appears
/// in the completion wake (`from: "bash-task-<name>"`), 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<String>,
}
#[allow(
@ -204,13 +213,17 @@ impl BashMcp {
wake is fired; when the timeout expires the task keeps running and the normal \
`task started: id=<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<BashRunArgs>) -> 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.

View file

@ -70,6 +70,13 @@ pub enum DaemonRequest {
/// task-started-id response immediately.
#[serde(default)]
wait_seconds: Option<u64>,
/// 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<String>,
},
/// Query the current status of a task. Returns the full `TaskFile`

View file

@ -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 `<name>.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<u64>) -> Result<String> {
/// 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<u64>, name: Option<String>) -> Result<String> {
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());
}
}

View file

@ -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:#}")),
};