Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Keep this accurate — it's the navigation aid.
| `src/auth.rs` | HTTP Basic auth middleware |
| `src/oauth/` | minimal OAuth 2.1 server: discovery metadata, dynamic client registration, authorize + token with PKCE, bearer validation |
| `src/jobs/mod.rs` | job engine: run a command, return inline if fast (<2s) else a job id (or immediately when `bg`); output streams to a per-job log file, polled paginated |
| `src/jobs/id.rs` | JobId newtype: human-readable ids — neutral `job` prefix + local `HH:MM` (e.g., `job-23:30`); free of command text so secrets can't leak into an id, log line, or filename |
| `src/jobs/log.rs` | job log pagination: read per-job log files by page (cursor + limit) |
| `src/jobs/reaper.rs` | hourly reaper drops jobs >24h old (killing any still-`Running` group first); process-group kill helpers (TERM→KILL escalation), shared with `job(action="kill")` |
| `src/tools/mod.rs` | MCP tool surface (`#[tool_router]`/`#[tool]` from rmcp): 3 tools (`bash`/`job`/`file`) dispatching on `action`. Thin adapters over jobs + files |
| `src/tools/files.rs` | file operations (`tokio::fs`; `ls`/`find`/`grep` shelled out) |
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
schemars = "1"

# time — local HH:MM stamped into human-readable job ids
chrono = { version = "0.4", default-features = false, features = ["clock"] }

# cli + config
clap = { version = "4", features = ["derive"] }
toml = "0.8"
Expand Down
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ TLS is deliberately **not** in the binary (keeps deps minimal). A reverse proxy
| `config.rs` | Runtime config from env + file. Fails fast at boot if credentials are missing. |
| `auth.rs` | The auth middleware — **bearer-only** guard on `/mcp`; rejects everything that is not a valid OAuth 2.1 access token. |
| `oauth` | A minimal **OAuth 2.1 authorization server** (per the MCP authorization spec) so GUI clients can log in. |
| `jobs.rs` | The job engine: run a command, return inline-or-background (incl. immediate `bg`), paginated logs, and an hourly reaper that drops jobs >24h old. |
| `jobs/mod.rs` | The job engine: run a command, return inline-or-background (incl. immediate `bg`), paginated logs, and an hourly reaper that drops jobs >24h old. |
| `jobs/id.rs` | JobId newtype: human-readable ids — neutral `job` prefix + local `HH:MM` (e.g., `job-23:30`); free of command text so secrets can't leak. |
| `jobs/log.rs` | Job log pagination: read log files by page (cursor + limit). |
| `jobs/reaper.rs` | Hourly reaper evicts jobs >24h old; process-group kill helpers (TERM→KILL escalation). |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
| `tools/mod.rs` | The MCP tool surface — **three tools** (`bash`/`job`/`file`) dispatching on an `action` param; thin adapters over `jobs` and `files`. |
| `tools/files.rs` | File operations (read/write/append/delete/list/grep/move). |

Expand All @@ -38,7 +41,7 @@ client ──HTTPS──▶ reverse proxy ──HTTP──▶ axum (127.0.0.1:13
Tools (tool_router)
bash · job · file (action)
╱ ╲
jobs.rs files.rs
jobs/mod.rs tools/files.rs
(bash, job) (file ops)
```

Expand All @@ -56,7 +59,7 @@ When a command starts (`JobStore::run`):
- **Finished in time** → `RunResult::Inline` — status + first page of the log, returned now.
- **Still running (or `bg`)** → `RunResult::Backgrounded { id }` — the agent gets a job id to poll.

Jobs live in an in-memory map keyed by id (`j1`, `j2`, …); the log files persist on disk under the job dir. An **hourly reaper** drops any job whose age exceeds 24h — evicting it from the map and deleting its log file — so the map and the job dir stay bounded without manual cleanup.
Jobs live in an in-memory map keyed by human-readable id (e.g., `job-23:30`); the log files persist on disk under the job dir. An **hourly reaper** drops any job whose age exceeds 24h — evicting it from the map and deleting its log file — so the map and the job dir stay bounded without manual cleanup. Job ids are a neutral `job` prefix + local `HH:MM` — deliberately free of command text so a secret on the command line can't leak into an id, log line, or filename. For the same reason `job(action="list")` exposes only non-sensitive metadata — the id and status — never the command text (which could carry a pasted secret).

```
JobState = Running | Exited { code } | Failed { error }
Expand Down
25 changes: 13 additions & 12 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,25 @@ Three tools — `bash`, `job`, `file` — each heavily parametrized. Their param
| Outcome | What you get back |
|---|---|
| Finishes within the window | Status + the first page of output, **inline** |
| Still running after the window | A **job id** (`j1`, `j2`, …) — monitor it with `job(action="poll")` |
| Still running after the window | A **job id** (e.g., `job-23:30`) — monitor it with `job(action="poll")` |

This is what makes a 20-minute build safe: it backgrounds automatically, and you stream the log a page at a time instead of dumping it all into context.

```
```text
bash("cargo build --release")
→ "job j7 still running after the inline window. Poll it with job(action=\"poll\", id=\"j7\")."
→ "job job-23:30 still running after the inline window. Poll it with job(action=\"poll\", id=\"job-23:30\")."

job(action="poll", id="j7")
job(action="poll", id="job-23:30")
→ {"status":"running"}
Compiling mcp-ssh v0.1.0
...200 lines...
[lines ..200 of 540; next_cursor=200]

job(action="poll", id="j7", cursor=200)
→ ...next page...
job(action="poll", id="job-23:30", cursor=200)
→ ...next 200 lines...
[lines ..400 of 540; next_cursor=400]

job(action="poll", id="j7", cursor=540)
job(action="poll", id="job-23:30", cursor=400)
→ {"status":"exited","code":0}
Finished `release` profile [optimized] target(s)
```
Expand Down Expand Up @@ -80,11 +81,11 @@ Manage jobs created by `bash`. `action` is one of `poll`, `list`, `kill`.
`action="poll"` returns the job's status plus **one page** of merged stdout+stderr, with `next_cursor`/`has_more` so you walk long logs forward without flooding context. `action="list"` returns all jobs with their status (`running` / `exited` / `failed`). `action="kill"` kills a running job.

```
job(action="poll", id="j7") # first page
job(action="poll", id="j7", cursor=200) # next page
job(action="poll", id="j7", cursor=400, limit=500)
job(action="list") # all jobs + status
job(action="kill", id="j7") # kill a running job
job(action="poll", id="job-23:30") # first page
job(action="poll", id="job-23:30", cursor=200) # next page
job(action="poll", id="job-23:30", cursor=400, limit=500)
job(action="list") # all jobs + status
job(action="kill", id="job-23:30") # kill a running job
```

## 📁 Files
Expand Down
146 changes: 146 additions & 0 deletions src/jobs/id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
//! Human-readable job ids: a neutral `job` prefix plus the local `HH:MM` it
//! started, e.g. `job-23:30` — easier to refer to in conversation than an opaque
//! counter. The prefix is hard-coded, never derived from the command: a command
//! can carry a secret in its leading tokens, and the id surfaces in replies,
//! `job(list)`, reaper logs, and the log filename, so the constructor refuses to
//! accept command text at all. A monotonic sequence suffix disambiguates jobs
//! starting within the same minute.

use std::borrow::Borrow;
use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};

use chrono::Local;

/// A human-readable job identifier: `job-<HH:MM>` with an optional `-<seq>`
/// collision suffix. A generated id matches `^job-\d{2}:\d{2}(-\d+)?$`;
/// ids wrapped from client input for lookup are not revalidated.
#[derive(Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord, serde::Serialize)]
pub struct JobId(String);

impl JobId {
/// Build an id from the hard-coded `job` prefix and the current local time.
/// The prefix is fixed here, never caller-supplied, so command text — which
/// can carry a secret in its leading tokens — can't leak into the id. `exists`
/// reports whether a candidate is already taken; only on a clash do we append
/// a `-<seq>` drawn from `seq`, so clean ids stay clean. The predicate keeps
/// this decoupled from however the caller tracks live ids.
pub fn generate(seq: &AtomicU64, exists: impl Fn(&str) -> bool) -> Self {
let base = format!("job-{}", Local::now().format("%H:%M"));
if !exists(&base) {
return Self(base);
}
// Same minute: a monotonic suffix keeps the id unique without sacrificing
// readability.
let n = seq.fetch_add(1, Ordering::Relaxed);
Self(format!("{base}-{n}"))
}
}

impl fmt::Display for JobId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}

impl AsRef<str> for JobId {
fn as_ref(&self) -> &str {
&self.0
}
}

/// Lets a `HashMap<JobId, _>` be probed with a `&str` key — used when generating
/// an id to test a candidate against the live ids.
impl Borrow<str> for JobId {
fn borrow(&self) -> &str {
&self.0
}
}

/// Wrap a client-supplied id for lookup. Not validated — an unknown or malformed
/// id simply matches no job; only `generate` mints well-formed ids.
impl From<String> for JobId {
fn from(s: String) -> Self {
Self(s)
}
}

impl From<&str> for JobId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}

#[cfg(test)]
mod tests {
use super::*;

/// Mirror the documented `^job-\d{2}:\d{2}(-\d+)?$` shape without pulling in a
/// regex dependency. The id has exactly one `:` (prefix and suffix never
/// contain one), so split on it to isolate the time.
fn valid_format(s: &str) -> bool {
let two_digits = |x: &str| x.len() == 2 && x.bytes().all(|b| b.is_ascii_digit());
let Some((left, right)) = s.split_once(':') else {
return false;
};
let Some((prefix, hh)) = left.rsplit_once('-') else {
return false;
};
let (mm, suffix_ok) = match right.split_once('-') {
Some((mm, sfx)) => (
mm,
!sfx.is_empty() && sfx.bytes().all(|b| b.is_ascii_digit()),
),
None => (right, true),
};
two_digits(hh) && two_digits(mm) && suffix_ok && prefix == "job"
}

#[test]
fn generated_id_matches_documented_format() {
let seq = AtomicU64::new(1);
let id = JobId::generate(&seq, |_| false);
assert!(valid_format(id.as_ref()), "bad format: {}", id.as_ref());
}

#[test]
fn no_collision_leaves_id_clean_and_seq_untouched() {
let seq = AtomicU64::new(1);
let id = JobId::generate(&seq, |_| false);
let s = id.as_ref();
assert!(valid_format(s));
let (_, right) = s.split_once(':').expect("id carries a time");
assert!(!right.contains('-'), "unexpected collision suffix: {s}");
assert_eq!(
seq.load(Ordering::Relaxed),
1,
"seq advanced without a clash"
);
}

#[test]
fn collision_appends_sequence_suffix() {
let seq = AtomicU64::new(1);
// `exists` always true => the base is "taken", forcing the suffix path.
let id = JobId::generate(&seq, |_| true);
let s = id.as_ref();
assert!(valid_format(s));
assert!(s.ends_with("-1"), "expected `-1` seq suffix, got {s}");
assert_eq!(seq.load(Ordering::Relaxed), 2, "clash must advance seq");
}

#[test]
fn display_and_as_ref_agree() {
let seq = AtomicU64::new(1);
let id = JobId::generate(&seq, |_| false);
assert_eq!(id.to_string(), id.as_ref());
}

#[test]
fn serializes_as_a_plain_string() {
let seq = AtomicU64::new(1);
let id = JobId::generate(&seq, |_| false);
let json = serde_json::to_string(&id).expect("serialize");
assert_eq!(json, format!("\"{}\"", id.as_ref()));
}
}
112 changes: 112 additions & 0 deletions src/jobs/log.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! Paginated log reading for per-job output files.

/// One page of log lines plus a cursor to fetch the next page.
#[derive(Debug, serde::Serialize)]
pub struct Page {
pub lines: Vec<String>,
pub next_cursor: usize,
pub total_lines: usize,
pub has_more: bool,
}

pub const DEFAULT_PAGE: usize = 200;

/// A job log could not be read. Kept distinct from a legitimately empty log so
/// `job(action="poll")` can surface "log unavailable" instead of silently
/// returning an empty page (a missing or unreadable log must not look identical
/// to one that simply has no output yet).
#[derive(Debug, thiserror::Error)]
pub enum JobLogError {
#[error("reading job log: {0}")]
Read(#[from] std::io::Error),
}

/// Convert back to `io::Error` at the `JobStore::run` boundary, which already
/// reports in `std::io::Result`. The typed error matters for `poll`, which
/// surfaces it to the caller rather than collapsing it into an empty page.
impl From<JobLogError> for std::io::Error {
fn from(e: JobLogError) -> Self {
match e {
JobLogError::Read(io) => io,
}
}
}

/// Read lines `[cursor, cursor+limit)` from a log file. Re-reads the whole file
/// each call — fine for typical logs; seek by byte offset if they get huge.
pub async fn read_page(
path: &std::path::Path,
cursor: usize,
limit: usize,
) -> Result<Page, JobLogError> {
// A zero limit would yield an empty page whose `next_cursor == cursor` with
// `has_more` still true, so a client advancing on `next_cursor` would spin
// forever on the same window. Clamp to at least one line.
let limit = limit.max(1);
// A failed read (missing/unreadable log) propagates as a typed error. Only
// non-UTF-8 *content* is rendered lossily: a command that writes raw bytes
// (compiled output, escape sequences) must not produce a silently-empty page.
let bytes = tokio::fs::read(path).await?;
let content = String::from_utf8_lossy(&bytes);
let all: Vec<&str> = content.lines().collect();
let total = all.len();
// cursor + limit come from tool input; saturate so an oversized request can't
// overflow into a bogus window.
let end = cursor.saturating_add(limit).min(total);
let lines = all
.get(cursor..end)
.unwrap_or(&[])
.iter()
.map(|s| s.to_string())
.collect();
Ok(Page {
lines,
next_cursor: end,
total_lines: total,
has_more: end < total,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
})
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn read_page_handles_binary_log_without_empty_output() {
// Simulate a command that writes non-UTF-8 bytes to its log.
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("bin.log");
// Valid UTF-8 prefix + invalid bytes + valid suffix.
tokio::fs::write(&log, b"line1\nline2\xff\xfeline3\n")
.await
.unwrap();
let page = read_page(&log, 0, 100).await.unwrap();
// Must return lines, not an empty page.
assert!(
!page.lines.is_empty(),
"binary log must not produce empty page"
);
assert!(page.lines[0].contains("line1"));
}

#[tokio::test]
async fn read_page_clamps_zero_limit_to_avoid_infinite_loop() {
// limit=0 must not produce an empty, non-advancing page: a client paging
// on next_cursor would otherwise loop forever on the same window.
let dir = tempfile::tempdir().unwrap();
let log = dir.path().join("multi.log");
tokio::fs::write(&log, b"a\nb\nc\n").await.unwrap();
let page = read_page(&log, 0, 0).await.unwrap();
assert_eq!(page.lines.len(), 1, "zero limit must clamp to one line");
assert!(page.next_cursor > 0, "cursor must advance past the start");
assert!(page.has_more, "more lines remain after the clamped page");
}

#[tokio::test]
async fn read_page_errors_on_missing_log() {
// A missing log is an error, not a silently-empty page.
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.log");
assert!(read_page(&missing, 0, 100).await.is_err());
}
}
Loading
Loading