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)),
)