diff --git a/CLAUDE.md b/CLAUDE.md index 47b25f6..29f30f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,13 +69,13 @@ Files ≤300 LOC. One responsibility per module (SRP). Split when a module grows ## MCP tool design -**Constant, heavily-parametrized surface — 3 resource-oriented tools.** Group by resource; push composition into params (`action`, `cursor`, `limit`, `recursive`, `timeout`, `bg`) — do **NOT** add more tools. New capability = a new param or `action` on an existing tool, almost always. +**Constant, heavily-parametrized surface — 3 resource-oriented tools.** Group by resource; push composition into params (`action`, `cursor`, `limit`, `recursive`, `timeout`, `bg`, `interactive`) — do **NOT** add more tools. New capability = a new param or `action` on an existing tool, almost always. Current tools (three, constant): | Tool | Params | Does | |---|---|---| -| `bash` | `cmd`, `cwd?`, `timeout?`, `bg?` | run a command; inline if fast, else a job id (`bg` backgrounds at once) | +| `bash` | `cmd`, `cwd?`, `timeout?`, `bg?`, `interactive?` | run a command; inline if fast, else a job id (`bg` backgrounds at once; `interactive` sources `~/.bashrc` via `bash -ic` for aliases/version managers, default fast `sh -c`) | | `job` | `action`, `id?`, `cursor?`, `limit?` | jobs by `action`: `poll` (paginated output), `list` (jobs + status), `kill` | | `file` | `action`, `path?`, `content?`, `pattern?`, `recursive?`, `src?`, `dest?`, `cursor?`, `limit?` | file ops by `action`: `read`/`write`/`append`/`delete`/`list`/`grep`/`move` | diff --git a/Cargo.lock b/Cargo.lock index cd152c2..57a4a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,7 +716,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "mcp-ssh" -version = "1.0.0" +version = "1.1.0" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index bc6525a..791ac57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mcp-ssh" -version = "1.0.0" +version = "1.1.0" edition = "2024" rust-version = "1.85" description = "Remote shell + file access for AI agents over authenticated MCP-HTTP — an ssh replacement you talk to over /mcp" diff --git a/README.md b/README.md index ec40198..8710385 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,14 @@ Want to go further? Run `bash("claude -p 'fix the failing test and push'")` — ## ⚡ Quickstart +**One-liner (Debian/Ubuntu)** — downloads the latest release, asks for a username + password, installs the service, and starts it: + +```bash +curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh | sudo bash +``` + +
Or do it by hand + ```bash # 1. install (Debian/Ubuntu — grab the .deb from releases) sudo dpkg -i mcp-ssh_*.deb @@ -28,9 +36,14 @@ mcp-ssh set-auth admin # 3. start it as a systemd service sudo systemctl enable --now mcp-ssh -# 4. put TLS in front (see docs/deploy.md) and connect from Claude +# 4. verify it's up on loopback +curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server ``` +
+ +Then put TLS in front (see [docs/deploy.md](docs/deploy.md)) and connect from Claude. + mcp-ssh now listens on `127.0.0.1:1337` at `/mcp`. Expose it as `https://your-host/mcp` with a reverse proxy → **[docs/deploy.md](docs/deploy.md)**. ## 🧰 The tools @@ -86,6 +99,7 @@ mcp-ssh set-auth # configure the username/password | Method | How | |---|---| +| **One-liner** | `curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh \| sudo bash` — latest release, prompts for creds, installs + starts the service | | **Debian/Ubuntu** | download `mcp-ssh_*.deb` from [releases](https://github.com/developerz-ai/mcp-ssh/releases) → `sudo dpkg -i mcp-ssh_*.deb` | | **Docker** | pull the image and run it (see [docs/deploy.md](docs/deploy.md)) | | **From source** | `cargo build --release` → binary at `target/release/mcp-ssh` | diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..3fcc5f6 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# mcp-ssh one-shot installer. +# +# curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh | sudo bash +# +# Downloads the latest .deb from GitHub releases, installs it, prompts for the +# username + password, runs it as a systemd service, and prints how to verify. +# Re-runnable: it just updates the binary and re-applies your settings. +set -euo pipefail + +REPO="developerz-ai/mcp-ssh" +CONF_DIR="/etc/mcp-ssh" +CONF="$CONF_DIR/config.toml" +ENV_FILE="$CONF_DIR/mcp-ssh.env" +OVERRIDE_DIR="/etc/systemd/system/mcp-ssh.service.d" + +die() { echo "error: $*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] || die "run as root (use: curl -fsSL … | sudo bash)" + +# Prompts must read from the terminal, not the curl pipe feeding our stdin. +TTY=/dev/tty +ask() { local v; printf '%s' "$1" >"$TTY"; read -r v <"$TTY"; printf '%s' "$v"; } +ask_secret(){ local v; printf '%s' "$1" >"$TTY"; read -rs v <"$TTY"; printf '\n' >"$TTY"; printf '%s' "$v"; } + +# Escape a value for a double-quoted TOML string so a quote, backslash, or +# newline in the username/password can't corrupt or alter config.toml. +toml_escape() { + local s=$1 + s=${s//\\/\\\\} + s=${s//\"/\\\"} + s=${s//$'\n'/\\n} + s=${s//$'\r'/\\r} + printf '%s' "$s" +} + +echo "==> mcp-ssh installer" + +# 1. Download the latest release .deb for this architecture. +ARCH="$(dpkg --print-architecture)" # amd64 / arm64 +echo "==> Fetching latest release for $ARCH …" +API="https://api.github.com/repos/$REPO/releases/latest" +URL="$(curl -fsSL "$API" | grep -oE "https://[^\"]*_${ARCH}\.deb" | head -n1)" \ + || die "could not query GitHub releases" +[ -n "$URL" ] || die "no .deb asset for $ARCH in the latest release" + +DEB="$(mktemp --suffix=.deb)" +trap 'rm -f "$DEB"' EXIT +echo "==> Downloading $(basename "$URL") …" +curl -fSL --progress-bar "$URL" -o "$DEB" + +# 2. Install (apt resolves the .deb's own dependencies). +echo "==> Installing the package …" +apt-get install -y "$DEB" >/dev/null 2>&1 || { dpkg -i "$DEB" || true; apt-get -fy install; } + +# 3. Credentials. +echo "==> Set the MCP login (one username + password)" +USER_NAME="$(ask 'Username: ')"; [ -n "$USER_NAME" ] || die "username required" +while :; do + PASS1="$(ask_secret 'Password: ')"; [ -n "$PASS1" ] || { echo " password required" >"$TTY"; continue; } + PASS2="$(ask_secret 'Confirm password: ')" + [ "$PASS1" = "$PASS2" ] && break || echo " passwords did not match — try again" >"$TTY" +done + +# 4. Which OS user the agent's shell runs as (its ~/.bashrc/aliases/version +# managers are what `bash` commands will see). Defaults to the dedicated, +# low-privilege mcp-ssh service user; a broader account is an explicit opt-in. +DEFAULT_RUN_USER="mcp-ssh" +RUN_USER="$(ask "Run the service as which user? [$DEFAULT_RUN_USER]: ")" +RUN_USER="${RUN_USER:-$DEFAULT_RUN_USER}" +# Auto-provision only the dedicated service user; never silently create an +# arbitrary account the operator may have typed by mistake. +if ! id "$RUN_USER" >/dev/null 2>&1; then + [ "$RUN_USER" = "mcp-ssh" ] || die "user '$RUN_USER' does not exist" + echo "==> Creating dedicated service user '$RUN_USER' …" + useradd --system --create-home --shell /bin/bash "$RUN_USER" \ + || die "could not create user '$RUN_USER'" +fi +RUN_GROUP="$(id -gn "$RUN_USER")" + +# 5. Public hostname for the DNS-rebinding guard (optional; loopback default). +PUBLIC_HOST="$(ask 'Public hostname for TLS proxy (blank = localhost only): ')" + +# 6. Write config (chmod 600, owned by the run-as user so the service reads it). +install -d -m 755 "$CONF_DIR" +umask 077 +cat >"$CONF" <"$ENV_FILE" <"$OVERRIDE_DIR/override.conf" < Enabling + starting the service …" +systemctl daemon-reload +systemctl enable --now mcp-ssh +sleep 1 + +# 9. Verify on loopback. +echo "==> Verifying …" +if curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server >/dev/null; then + echo "✅ mcp-ssh is up on 127.0.0.1:1337 (running as $RUN_USER)." +else + echo "⚠️ Service did not answer yet — check: journalctl -u mcp-ssh -e" >&2 +fi + +cat <.log`). Logging to a file — not memory — is what lets long output be paginated later without holding it all in RAM. +1. The command is spawned via a bare `sh -c` by default — fast, no rc files. Pass `interactive=true` to `bash` and it runs through an **interactive bash** (`bash -ic`) instead, sourcing the service user's `~/.bashrc` so aliases and version managers (`mise`, `nvm`, `rbenv`) resolve exactly as in a real shell. Either way stdout **and** stderr are merged into a single per-job **log file** (`MCP_SSH_JOB_DIR/.log`): the child's own stdio goes to `/dev/null` and the command re-points stdout+stderr at the log after startup, so bash's "no job control" warnings (no controlling TTY under systemd) never reach the log. Logging to a file — not memory — is what lets long output be paginated later without holding it all in RAM. 2. A background task owns the child process, waits for it to exit, and records the final `JobState` (`Running` / `Exited{code}` / `Failed{error}`). 3. The caller waits for **either** completion **or** the inline window (`MCP_SSH_INLINE_TIMEOUT_SECS`, default 2s; overridable per call via `bash`'s `timeout`). Passing `bg=true` skips the wait entirely and backgrounds at once: - **Finished in time** → `RunResult::Inline` — status + first page of the log, returned now. diff --git a/docs/deploy.md b/docs/deploy.md index d48ff96..39168f6 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -8,6 +8,18 @@ The login flow needs more than `/mcp` on the public origin: both the browser OAu ## ⚡ Install (Debian/Ubuntu) +### One-liner (recommended) + +Downloads the latest release for your arch, prompts for a username + password, asks which OS user to run as, writes the config + a systemd drop-in, and starts the service: + +```bash +curl -fsSL https://raw.githubusercontent.com/developerz-ai/mcp-ssh/main/deploy/install.sh | sudo bash +``` + +Re-running it just updates the binary and re-applies your answers. Source: [`deploy/install.sh`](../deploy/install.sh). + +### By hand + ```bash # 1. install from the .deb (GitHub releases) sudo dpkg -i mcp-ssh_*.deb @@ -25,6 +37,37 @@ curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server The `.deb` installs the binary, a systemd unit, and `/etc/mcp-ssh/config.toml`. +## 🩺 Verify / debug with curl + +All checks hit the loopback bind (`127.0.0.1:1337`); swap in `https://your-host` once the proxy is up. + +```bash +# 1. OAuth discovery returns JSON ⇒ server is up +curl -fsS http://127.0.0.1:1337/.well-known/oauth-authorization-server | jq . + +# 2. /mcp is bearer-only — no creds ⇒ 401 +curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:1337/mcp # → 401 + +# 3. Mint a bearer from your username/password (runs the OAuth PKCE flow). +# `bin/mcp-token` ships in the source checkout, NOT the .deb — run it from a +# repo clone with MCP_SSH_USER/MCP_SSH_PASS in env or .env, or skip to a GUI +# client's browser OAuth (see Connect a client). +read -rp 'MCP_SSH_USER: ' MCP_SSH_USER +read -rsp 'MCP_SSH_PASS: ' MCP_SSH_PASS; echo +TOKEN="$(MCP_SSH_USER="$MCP_SSH_USER" MCP_SSH_PASS="$MCP_SSH_PASS" bin/mcp-token)" +unset MCP_SSH_PASS + +# 4. initialize a session — look for the `mcp-session-id:` response header +curl -sS -D - -o /dev/null -X POST http://127.0.0.1:1337/mcp \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'Host: localhost' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' +``` + +Discovery 404s behind your proxy ⇒ it isn't forwarding `/.well-known/*`, `/authorize`, `/token`, `/register` (see [Connect a client](#-connect-a-client)). Logs: `journalctl -u mcp-ssh -e`. + ## 🔧 systemd The packaged unit runs `mcp-ssh serve`. Run it as a **dedicated low-privilege user** and lock it down: diff --git a/docs/usage.md b/docs/usage.md index aaa9564..57b4192 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -51,8 +51,8 @@ Output and stderr are merged into one stream, terminal-style. ## 🐚 Shell & jobs -### `bash(cmd, cwd?, timeout?, bg?)` -Run a shell command (`sh -c`). Returns output inline if it finishes within the inline window, else a job id to monitor with `job`. +### `bash(cmd, cwd?, timeout?, bg?, interactive?)` +Run a shell command. By default it's a fast bare `sh -c`. Pass `interactive=true` to run it in an **interactive bash** (`bash -ic`) that sources your `~/.bashrc`, so aliases and version managers (`mise`, `nvm`, `rbenv`) work just like a normal shell. Returns output inline if it finishes within the inline window, else a job id to monitor with `job`. | Param | Required | Default | Meaning | |---|---|---|---| @@ -60,6 +60,7 @@ Run a shell command (`sh -c`). Returns output inline if it finishes within the i | `cwd` | no | process cwd | working directory | | `timeout` | no | 2s | seconds to wait inline before backgrounding | | `bg` | no | false | `true` backgrounds immediately, returning the job id without waiting | +| `interactive` | no | false | `true` sources `~/.bashrc` (aliases, mise/nvm/rbenv) via `bash -ic` | ``` bash("ls -la /var/www") diff --git a/src/app_tests.rs b/src/app_tests.rs index 94323e4..319ba57 100644 --- a/src/app_tests.rs +++ b/src/app_tests.rs @@ -25,7 +25,12 @@ fn test_app() -> Router { fn test_app_with(public_url: Option<&str>) -> Router { let dir = tempfile::tempdir().unwrap().keep(); - let jobs = JobStore::new(dir, std::time::Duration::from_secs(2)).unwrap(); + let jobs = JobStore::new( + dir, + std::time::Duration::from_secs(2), + crate::jobs::Shell::sh(), + ) + .unwrap(); let state = oauth::AuthState { creds: auth::Credentials { user: "admin".into(), @@ -40,7 +45,12 @@ fn test_app_with(public_url: Option<&str>) -> Router { /// Build a test app with a pre-minted bearer token ready for session tests. async fn test_app_with_token() -> (Router, String) { let dir = tempfile::tempdir().unwrap().keep(); - let jobs = JobStore::new(dir, std::time::Duration::from_secs(2)).unwrap(); + let jobs = JobStore::new( + dir, + std::time::Duration::from_secs(2), + crate::jobs::Shell::sh(), + ) + .unwrap(); let oauth_store = Arc::new(oauth::Store::default()); let token = "test-bearer-token".to_string(); oauth_store.insert_token(&token).await; diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs index 944d878..6d50576 100644 --- a/src/jobs/mod.rs +++ b/src/jobs/mod.rs @@ -63,22 +63,76 @@ pub struct JobSummary { pub state: JobState, } +/// How a user command is launched. The default is a bare `sh -c`; a `bash` call +/// that opts into `interactive` instead gets an interactive bash that sources +/// the service user's `~/.bashrc` — so aliases and version managers +/// (mise/nvm/rbenv) resolve, matching a real shell. +/// +/// `program` plus `args` form the launcher prefix; `run` appends the (wrapped) +/// command string as the final argument. +#[derive(Debug, Clone)] +pub struct Shell { + program: String, + args: Vec, +} + +impl Shell { + /// Bare `sh -c` — the default, fast path: no rc files, no per-call startup + /// cost. Output never depends on the host's shell config. + pub fn sh() -> Self { + Self { + program: "sh".into(), + args: vec!["-c".into()], + } + } + + /// Interactive bash. `-i` sources `~/.bashrc`, where aliases and version + /// managers live behind its `case $- in *i*) ;; *) return;; esac` + /// non-interactive guard — so commands see the same environment an + /// interactive shell does. Opt-in per call (`bash` tool's `interactive` + /// flag) because sourcing `~/.bashrc` adds startup cost. Startup job-control + /// warnings (no controlling TTY under systemd) are discarded by the + /// exec-redirect in `run`. + pub fn interactive_bash() -> Self { + Self { + program: "bash".into(), + args: vec!["-ic".into()], + } + } +} + +/// Single-quote a path for safe interpolation into a shell command. The job log +/// path is engine-controlled, but quoting keeps an odd `job_dir` (spaces, `$`) +/// from breaking the `exec` redirect in `run`. +fn sh_single_quote(path: &std::path::Path) -> String { + let escaped = path.to_string_lossy().replace('\'', r"'\''"); + format!("'{escaped}'") +} + #[derive(Clone)] pub struct JobStore { dir: PathBuf, inline_timeout: Duration, + /// Shell used when a `bash` call opts into `interactive` (sources `~/.bashrc`). + /// The default path uses a bare `sh -c` (`Shell::sh`). + interactive_shell: Shell, seq: Arc, jobs: Arc>>>, } impl JobStore { - pub fn new(dir: PathBuf, inline_timeout: Duration) -> std::io::Result { + pub fn new( + dir: PathBuf, + inline_timeout: Duration, + interactive_shell: Shell, + ) -> std::io::Result { std::fs::create_dir_all(&dir)?; let jobs = Arc::new(Mutex::new(HashMap::new())); spawn_reaper(jobs.clone()); Ok(Self { dir, inline_timeout, + interactive_shell, seq: Arc::new(AtomicU64::new(1)), jobs, }) @@ -86,12 +140,15 @@ impl JobStore { /// Spawn `cmd`. With `background`, return a job id immediately; otherwise wait /// up to the inline window and return output if it finishes in time. + /// `interactive` runs it through `~/.bashrc` (aliases, version managers); + /// otherwise the fast bare `sh -c` is used. pub async fn run( &self, cmd: String, cwd: Option, timeout_secs: Option, background: bool, + interactive: bool, ) -> std::io::Result { // Hold the jobs lock across id generation and insertion so the id is // reserved atomically: two jobs launched in the same minute can't both @@ -107,18 +164,31 @@ impl JobStore { let id = JobId::generate(&self.seq, |candidate| jobs.contains_key(candidate)); let log_path = self.dir.join(format!("{id}.log")); - // ponytail: stdout+stderr merged into one log (terminal-style). Split into - // two files if a caller ever needs them apart. - let out = std::fs::File::create(&log_path)?; - let err = out.try_clone()?; - - let mut command = tokio::process::Command::new("sh"); + // Create the log up front so a poll racing the spawn reads an empty page, + // not a NotFound — the command appends to it (see `wrapped`). + std::fs::File::create(&log_path)?; + + // An interactive bash (production shell) prints two job-control warnings + // to stderr at startup when there's no controlling TTY (always, under + // systemd). So the child's own stdio goes to /dev/null and the command + // re-points stdout+stderr at the log itself, *after* startup: only the + // command's output is captured, merged terminal-style. `sh -c` (tests) + // runs the identical wrapper with no warnings to discard. + let wrapped = format!("exec >>{} 2>&1\n{}", sh_single_quote(&log_path), cmd); + + let fast = Shell::sh(); + let shell = if interactive { + &self.interactive_shell + } else { + &fast + }; + let mut command = tokio::process::Command::new(&shell.program); command - .arg("-c") - .arg(&cmd) + .args(&shell.args) + .arg(&wrapped) .stdin(Stdio::null()) - .stdout(Stdio::from(out)) - .stderr(Stdio::from(err)); + .stdout(Stdio::null()) + .stderr(Stdio::null()); if let Some(dir) = cwd { command.current_dir(dir); } @@ -240,19 +310,67 @@ impl JobStore { } } +#[cfg(test)] +impl Shell { + /// Interactive bash pinned to a specific rc file — lets a test prove alias + /// resolution against a controlled rc instead of the host's `~/.bashrc`. + fn bash_with_rcfile(rcfile: &str) -> Self { + Self { + program: "bash".into(), + args: vec!["--rcfile".into(), rcfile.into(), "-ic".into()], + } + } +} + #[cfg(test)] mod tests { use super::*; fn store(inline: Duration) -> JobStore { let dir = tempfile::tempdir().unwrap().keep(); - JobStore::new(dir, inline).unwrap() + JobStore::new(dir, inline, Shell::sh()).unwrap() + } + + /// The production shell (interactive bash) must expand aliases defined in + /// the sourced rc file — that's the whole point of `-i`. A controlled + /// rcfile keeps the test independent of the host's `~/.bashrc`, and the + /// startup job-control warnings must NOT leak into the captured log. + #[tokio::test] + async fn interactive_shell_expands_rc_aliases_without_leaking_startup_noise() { + let dir = tempfile::tempdir().unwrap().keep(); + let rc = dir.join("rc"); + std::fs::write(&rc, "alias greet='echo ALIAS_OK'\n").unwrap(); + let store = JobStore::new( + dir, + Duration::from_secs(5), + Shell::bash_with_rcfile(rc.to_str().unwrap()), + ) + .unwrap(); + + let r = store + .run("greet".into(), None, None, false, true) + .await + .unwrap(); + let RunResult::Inline { state, page } = r else { + panic!("fast command should be inline"); + }; + assert!(matches!(state, JobState::Exited { code: 0 })); + assert!( + page.lines.iter().any(|l| l.contains("ALIAS_OK")), + "alias should expand: {:?}", + page.lines + ); + assert!( + !page.lines.iter().any(|l| l.contains("no job control")), + "startup job-control noise leaked into the log: {:?}", + page.lines + ); } #[tokio::test] async fn fast_command_returns_inline() { let r = store(Duration::from_secs(5)) - .run("echo hello".into(), None, None, false) + .run("echo hello".into(), None, None, false, false) .await .unwrap(); match r { @@ -268,7 +386,7 @@ mod tests { async fn bg_flag_backgrounds_a_fast_command() { // Even though `echo` is instant, bg=true must return an id without waiting. let r = store(Duration::from_secs(5)) - .run("echo hi".into(), None, None, true) + .run("echo hi".into(), None, None, true, false) .await .unwrap(); assert!(matches!(r, RunResult::Backgrounded { .. })); @@ -278,7 +396,13 @@ mod tests { async fn slow_command_backgrounds_then_completes() { let store = store(Duration::from_millis(100)); let r = store - .run("echo start; sleep 1; echo done".into(), None, None, false) + .run( + "echo start; sleep 1; echo done".into(), + None, + None, + false, + false, + ) .await .unwrap(); let id = match r { @@ -315,7 +439,13 @@ mod tests { // `sh` backgrounds a long sleep, prints its pid, then waits on it. Job // control is off in `sh -c`, so the child shares the shell's group. let r = store - .run("sleep 300 & echo \"pid:$!\"; wait".into(), None, None, true) + .run( + "sleep 300 & echo \"pid:$!\"; wait".into(), + None, + None, + true, + false, + ) .await .unwrap(); let RunResult::Backgrounded { id } = r else { @@ -361,7 +491,7 @@ mod tests { let store = store(Duration::from_secs(5)); // Runs inline, so it has already exited by the time `run` returns. let r = store - .run("echo bye".into(), None, None, false) + .run("echo bye".into(), None, None, false, false) .await .unwrap(); assert!(matches!(r, RunResult::Inline { .. })); @@ -383,6 +513,7 @@ mod tests { None, None, true, + false, ) .await .unwrap(); @@ -410,7 +541,13 @@ mod tests { let store = store(Duration::from_millis(100)); // Backgrounded descendant; print its pid so we can probe it post-eviction. let r = store - .run("sleep 300 & echo \"pid:$!\"; wait".into(), None, None, true) + .run( + "sleep 300 & echo \"pid:$!\"; wait".into(), + None, + None, + true, + false, + ) .await .unwrap(); let RunResult::Backgrounded { id } = r else { @@ -481,7 +618,7 @@ mod tests { async fn kill_terminates_child_process() { let store = store(Duration::from_millis(100)); let r = store - .run("sleep 1000".into(), None, None, true) + .run("sleep 1000".into(), None, None, true, false) .await .unwrap(); let RunResult::Backgrounded { id } = r else { @@ -511,11 +648,11 @@ mod tests { let store = store(Duration::from_secs(5)); // Two commands — one inline, one explicitly backgrounded — both tracked. store - .run("echo alpha".into(), None, None, false) + .run("echo alpha".into(), None, None, false, false) .await .unwrap(); store - .run("echo beta".into(), None, None, true) + .run("echo beta".into(), None, None, true, false) .await .unwrap(); @@ -529,7 +666,7 @@ mod tests { async fn poll_paginates() { let store = store(Duration::from_secs(5)); store - .run("seq 1 10".into(), None, None, false) + .run("seq 1 10".into(), None, None, false, false) .await .unwrap(); // seq finishes inline; fetch its id from the listing to re-poll and diff --git a/src/main.rs b/src/main.rs index 9898e5d..edaaec5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,7 +52,11 @@ async fn serve(port: Option) -> anyhow::Result<()> { }) { cfg.bind.set_port(p); } - let store = jobs::JobStore::new(cfg.job_dir.clone(), cfg.inline_timeout)?; + let store = jobs::JobStore::new( + cfg.job_dir.clone(), + cfg.inline_timeout, + jobs::Shell::interactive_bash(), + )?; let auth_state = oauth::AuthState { creds: auth::Credentials { diff --git a/src/tools/mod.rs b/src/tools/mod.rs index e8d63a9..8323cb1 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -35,6 +35,10 @@ pub struct BashArgs { pub timeout: Option, /// Background immediately and return a job id without waiting. pub bg: Option, + /// Run in an interactive bash that sources `~/.bashrc` so aliases and + /// version managers (mise/nvm/rbenv) resolve. Default false (faster bare + /// `sh -c`); set true when the command needs the user's shell setup. + pub interactive: Option, } // ---- job ---- @@ -108,7 +112,7 @@ impl Tools { } #[tool( - description = "Run a shell command on the host (locally, as the service user). Returns output inline if it finishes within the inline window (default 2s); otherwise returns a job id — monitor it with the `job` tool. Pass bg=true to background immediately and get the id without waiting. Use it to launch long tasks (builds, deploys, `claude -p ...`) without blocking." + description = "Run a shell command on the host (locally, as the service user). Returns output inline if it finishes within the inline window (default 2s); otherwise returns a job id — monitor it with the `job` tool. Pass bg=true to background immediately and get the id without waiting. Pass interactive=true to source the user's ~/.bashrc so aliases and version managers (mise/nvm/rbenv) resolve (default is the faster bare sh -c). Use it to launch long tasks (builds, deploys, `claude -p ...`) without blocking." )] async fn bash( &self, @@ -117,6 +121,7 @@ impl Tools { cwd, timeout, bg, + interactive, }): Parameters, RequestId(request_id): RequestId, ) -> Result { @@ -124,7 +129,17 @@ impl Tools { // Emit inside the span so the prod subscriber (FmtSpan::NONE) logs the // dispatch with the span's `tool`/`request_id`; a bare span logs nothing. tracing::info!("dispatch"); - match self.jobs.run(cmd, cwd, timeout, bg.unwrap_or(false)).await { + match self + .jobs + .run( + cmd, + cwd, + timeout, + bg.unwrap_or(false), + interactive.unwrap_or(false), + ) + .await + { Ok(RunResult::Inline { state, page }) => Ok(ok(render(&state, &page))), Ok(RunResult::Backgrounded { id }) => Ok(ok(format!( "job {id} still running after the inline window. Monitor it with job(action=\"poll\", id=\"{id}\")." @@ -304,7 +319,12 @@ mod tests { fn tools() -> Tools { let dir = tempfile::tempdir().unwrap().keep(); - let store = JobStore::new(dir, std::time::Duration::from_secs(2)).unwrap(); + let store = JobStore::new( + dir, + std::time::Duration::from_secs(2), + crate::jobs::Shell::sh(), + ) + .unwrap(); Tools::new(store) } @@ -331,6 +351,7 @@ mod tests { cwd: None, timeout: None, bg: None, + interactive: None, }), RequestId(NumberOrString::Number(42)), )