The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.
The runner Worker drops the /rpc/run, /rpc/delegate, /rpc/plan, /rpc/apply_plan and /rpc/computer_* endpoints, the AgentComputer container and its Durable Object (removed with a v4 migration that deletes the class), the agent homes bucket, and its bindings to context, projects and integrations and the model and AI Gateway settings. The g1t-runner binary drops its implement, review, revise, answer, plan, steer, update and computer modes, and an unknown or missing mode stops with a message. A self-hosted runner still takes queue, merge-check and checks tasks, refuses any other agent task from an older server before it starts, and no longer accepts --agent-image; the release manifest no longer names an agent image. The routing-savings report, which priced model routing, goes too. The base image is unchanged here: it still carries the Claude Code CLI until it is next rebuilt.
| 98 | 98 | # keeps the version it has, since the manifest names no file for it. | |
| 99 | 99 | env: | |
| 100 | 100 | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} | |
| 101 | − | RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }} | |
| 102 | 101 | run: node scripts/runner-release.mjs build --targets ${{ matrix.target }} | |
| 103 | 102 | - name: sccache's hits | |
| 104 | 103 | if: always() | |
| ⋯ | |||
| 238 | 237 | env: | |
| 239 | 238 | RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }} | |
| 240 | 239 | G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }} | |
| 241 | − | RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }} | |
| 242 | 240 | run: | | |
| 243 | 241 | node scripts/runner-release.mjs manifest | |
| 244 | 242 | node scripts/runner-release.mjs sign | |
| 3 | 3 | version = "0.3.1" | |
| 4 | 4 | edition.workspace = true | |
| 5 | 5 | license.workspace = true | |
| 6 | − | description = "The program inside a g1t sandbox: runs an agent, checks, a catch-up merge, a review or a GitHub Actions job, and reports back. Also the self-hosted runner, g1t-runner register/run, on your own machines." | |
| 6 | + | description = "The program inside a g1t sandbox: runs a GitHub Actions job, a merge queue state, a merge check, a build, a dependency update or a backup, and reports back. Also the self-hosted runner, g1t-runner register/run, on your own machines." | |
| 7 | 7 | ||
| 8 | 8 | [dependencies] | |
| 9 | 9 | g1t-actions = { path = "../actions" } |
| 6 | 6 | //! | |
| 7 | 7 | //! 1. **Known miners, by name.** `miner_in` matches miner programs (xmrig, | |
| 8 | 8 | //! cpuminer, ...), pool URLs (`stratum+tcp://`), their flags | |
| 9 | − | //! (`--donate-level`) and well-known pools. The agent harness's command | |
| 10 | − | //! hook refuses a matching shell command (guard.rs), checks, deploy | |
| 11 | − | //! builds and workflow steps refuse to run one, and the sampler below | |
| 9 | + | //! (`--donate-level`) and well-known pools. Checks, deploy builds and | |
| 10 | + | //! workflow steps refuse to run one, and the sampler below | |
| 12 | 11 | //! stops the sandbox if a running process's command line matches. | |
| 13 | 12 | //! 2. **The CPU signature.** A background thread samples the sandbox every | |
| 14 | 13 | //! `SAMPLE_SECONDS`: CPU use (from `/proc/stat`), file and disk I/O | |
| 15 | 14 | //! (`/proc/diskstats` and every process's `/proc/<pid>/io`), network | |
| 16 | 15 | //! bytes (`/proc/net/dev`), how many new processes started, and whether | |
| 17 | − | //! the run did anything a person would call progress (a tool call, an | |
| 18 | − | //! agent step, a new check command or workflow step). `Detector` flags a | |
| 16 | + | //! the run did anything a person would call progress (a new check | |
| 17 | + | //! command or workflow step). `Detector` flags a | |
| 19 | 18 | //! sandbox only when, for a whole `WINDOW_SECONDS` (10 minutes): | |
| 20 | 19 | //! - CPU stayed at or above `MIN_CPU` (90%) in every sample but one; | |
| 21 | 20 | //! - file and disk I/O averaged under `MAX_IO_PER_SECOND` (64 KB/s); | |
| ⋯ | |||
| 511 | 510 | } | |
| 512 | 511 | ||
| 513 | 512 | #[test] | |
| 514 | − | fn an_agent_that_keeps_working_is_not_flagged() { | |
| 513 | + | fn work_that_keeps_making_progress_is_not_flagged() { | |
| 515 | 514 | assert!(run(30, 1.0, 0, 0, 0, "unknown", true).is_none()); | |
| 516 | 515 | } | |
| 517 | 516 | ||
| 3 | 3 | //! on issues before a pull request's checks were its workflows. g1t no | |
| 4 | 4 | //! longer starts that mode; it stays for a sandbox already under way. | |
| 5 | 5 | //! | |
| 6 | − | //! The sandbox holds nothing but that commit: no agent has run here, so a | |
| 7 | − | //! passing result says something about the code and not about what an | |
| 8 | − | //! agent left lying around. | |
| 6 | + | //! The sandbox holds nothing but that commit, so a passing result says | |
| 7 | + | //! something about the code and not about what was left lying around. | |
| 9 | 8 | //! | |
| 10 | 9 | //! Configuration comes from the environment: | |
| 11 | 10 | //! |
| 1 | 1 | //! How a sandbox clones and fetches: shallow, and deeper only when it needs | |
| 2 | − | //! to be. A sandbox's work starts from one commit (an agent's branch, the | |
| 3 | − | //! commit checks run on, a build), so the history behind it is usually | |
| 2 | + | //! to be. A sandbox's work starts from one commit (a pull request's head, | |
| 3 | + | //! the commit checks run on, a build), so the history behind it is usually | |
| 4 | 4 | //! never read; a full clone of g1t took 5.4 s against 3.8 s at depth 1. | |
| 5 | − | //! Work that merges (catching up, the merge queue, a merge check, a | |
| 6 | − | //! review's diff) deepens until the two sides share a commit, and fetches | |
| 5 | + | //! Work that merges (the merge queue, a merge check) deepens until the two | |
| 6 | + | //! sides share a commit, and fetches | |
| 7 | 7 | //! everything only as the last resort. | |
| 8 | 8 | //! | |
| 9 | 9 | //! Environment, for the operator or a self-hosted runner: |
| 1 | − | //! An agent's own computer (`MODE=computer`): the long-lived process inside | |
| 2 | − | //! the container that is a workspace agent's persistent home | |
| 3 | − | //! (docs.g1t.sh/guides/agents/, "Its computer"). | |
| 4 | − | //! | |
| 5 | − | //! Unlike every other mode, which does one job and exits, this one serves | |
| 6 | − | //! the runner's `AgentComputer` Durable Object over HTTP on `PORT` for as | |
| 7 | − | //! long as the computer is awake: | |
| 8 | − | //! | |
| 9 | − | //! - `GET /health`: uptime, disk used under the home, the cap. | |
| 10 | − | //! - `POST /exec`: runs a command under `bash -lc` with the home as `$HOME`, | |
| 11 | − | //! streaming stdout and stderr lines as NDJSON, then one closing line | |
| 12 | − | //! with the exit code and how long it took. Output past `MAX_OUTPUT_BYTES` | |
| 13 | − | //! is dropped and the closing line says so; a command past its timeout is | |
| 14 | − | //! killed and reported as exit 124. | |
| 15 | − | //! - `GET /files?path=` and `PUT /files?path=`: small files under the home. | |
| 16 | − | //! - `POST /restore`: a tar+zstd of the home, unpacked into it (how the | |
| 17 | − | //! Durable Object puts a snapshot back when it wakes the computer). | |
| 18 | − | //! - `POST /snapshot`: the home as tar+zstd, streamed; refused with the | |
| 19 | − | //! largest top-level directories when the home is over `DISK_CAP_BYTES`. | |
| 20 | − | //! - `POST /stop`: exits cleanly, once the snapshot was taken. | |
| 21 | − | //! | |
| 22 | − | //! Every path is checked to lie under the home (`within_home`); nothing | |
| 23 | − | //! outside it is read, written or run from. `G1T_HOME_RESTORE`, an HTTPS | |
| 24 | − | //! address with a one-time token in `G1T_HOME_RESTORE_TOKEN`, restores the | |
| 25 | − | //! home at start for installations that serve snapshots that way; g1t's | |
| 26 | − | //! cloud streams it in through `/restore` instead. Requests carry the token | |
| 27 | − | //! in `G1T_COMPUTER_TOKEN` as a bearer, so nothing else on the network | |
| 28 | − | //! drives the computer. | |
| 29 | − | //! | |
| 30 | − | //! Only the pure parts (path checks, framing, the closing line) are used | |
| 31 | − | //! off Linux, by tests. | |
| 32 | − | #![cfg_attr(not(unix), allow(dead_code))] | |
| 33 | − | ||
| 34 | − | use std::collections::BTreeMap; | |
| 35 | − | use std::io::{self, BufRead, BufReader, Read, Write}; | |
| 36 | − | use std::net::{TcpListener, TcpStream}; | |
| 37 | − | use std::path::{Path, PathBuf}; | |
| 38 | − | use std::process::{Child, Command, Stdio}; | |
| 39 | − | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 40 | − | use std::sync::mpsc; | |
| 41 | − | use std::sync::Arc; | |
| 42 | − | use std::time::{Duration, Instant}; | |
| 43 | − | ||
| 44 | − | use serde_json::{Value, json}; | |
| 45 | − | ||
| 46 | − | use crate::docker::http::{self, Body, Head}; | |
| 47 | − | ||
| 48 | − | /// Where the agent lives: its files, clones, caches and notes. | |
| 49 | − | pub(crate) const HOME: &str = "/home/agent"; | |
| 50 | − | /// Where a session's work goes by default, under the home. | |
| 51 | − | pub(crate) const SESSIONS_DIR: &str = "sessions"; | |
| 52 | − | /// The port the Durable Object reaches the computer on. | |
| 53 | − | pub(crate) const PORT: u16 = 8787; | |
| 54 | − | /// How much the home may hold: 5 GB, a hard cap in v1 (no disk charge). | |
| 55 | − | pub(crate) const DISK_CAP_BYTES: u64 = 5_000_000_000; | |
| 56 | − | /// The most output one command streams back; the rest is dropped and said so. | |
| 57 | − | pub(crate) const MAX_OUTPUT_BYTES: usize = 1024 * 1024; | |
| 58 | − | /// The most a file read or written through `/files` may be. | |
| 59 | − | pub(crate) const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; | |
| 60 | − | /// A command's timeout when none is given, and the longest one may ask for. | |
| 61 | − | pub(crate) const DEFAULT_TIMEOUT_SECONDS: u64 = 120; | |
| 62 | − | pub(crate) const MAX_TIMEOUT_SECONDS: u64 = 1800; | |
| 63 | − | /// The exit code of a command that was killed at its timeout, as `timeout(1)` reports it. | |
| 64 | − | pub(crate) const TIMED_OUT_EXIT: i32 = 124; | |
| 65 | − | ||
| 66 | − | /// What the process knows while it serves. | |
| 67 | − | struct Server { | |
| 68 | − | token: Option<String>, | |
| 69 | − | started: Instant, | |
| 70 | − | /// Commands running now: the Durable Object does not put a busy computer to sleep. | |
| 71 | − | running: AtomicUsize, | |
| 72 | − | } | |
| 73 | − | ||
| 74 | − | /// `MODE=computer`: prepares the home, restores it if asked, then serves until `/stop`. | |
| 75 | − | pub fn main() -> i32 { | |
| 76 | − | let server = Arc::new(Server { | |
| 77 | − | token: std::env::var("G1T_COMPUTER_TOKEN").ok().filter(|t| !t.is_empty()), | |
| 78 | − | started: Instant::now(), | |
| 79 | − | running: AtomicUsize::new(0), | |
| 80 | − | }); | |
| 81 | − | if let Err(error) = prepare_home(Path::new(HOME)) { | |
| 82 | − | eprintln!("g1t-runner: the home could not be prepared: {error}"); | |
| 83 | − | return 2; | |
| 84 | − | } | |
| 85 | − | if let Ok(url) = std::env::var("G1T_HOME_RESTORE") { | |
| 86 | − | if !url.trim().is_empty() { | |
| 87 | − | let token = std::env::var("G1T_HOME_RESTORE_TOKEN").unwrap_or_default(); | |
| 88 | − | match restore_from_url(&url, &token) { | |
| 89 | − | Ok(()) => eprintln!("g1t-runner: the home was restored"), | |
| 90 | − | Err(error) => eprintln!("g1t-runner: the home could not be restored, so it starts fresh: {error}"), | |
| 91 | − | } | |
| 92 | − | } | |
| 93 | − | } | |
| 94 | − | let listener = match TcpListener::bind(("0.0.0.0", PORT)) { | |
| 95 | − | Ok(listener) => listener, | |
| 96 | − | Err(error) => { | |
| 97 | − | eprintln!("g1t-runner: could not listen on {PORT}: {error}"); | |
| 98 | − | return 2; | |
| 99 | − | } | |
| 100 | − | }; | |
| 101 | − | eprintln!("g1t-runner: the computer is awake on {PORT}"); | |
| 102 | − | for client in listener.incoming() { | |
| 103 | − | let Ok(client) = client else { continue }; | |
| 104 | − | let server = server.clone(); | |
| 105 | − | std::thread::spawn(move || { | |
| 106 | − | if let Err(error) = serve(client, &server) { | |
| 107 | − | eprintln!("g1t-runner: a request failed: {error}"); | |
| 108 | − | } | |
| 109 | − | }); | |
| 110 | − | } | |
| 111 | − | 0 | |
| 112 | − | } | |
| 113 | − | ||
| 114 | − | /// Makes sure the home exists and is ours to write, with its sessions | |
| 115 | − | /// directory. The image runs as `node` with sudo, and `/home` is root's. | |
| 116 | − | fn prepare_home(home: &Path) -> io::Result<()> { | |
| 117 | − | if std::fs::create_dir_all(home).is_err() || !writable(home) { | |
| 118 | − | let user = Command::new("id").arg("-un").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).unwrap_or_default(); | |
| 119 | − | let made = Command::new("sudo").args(["-n", "mkdir", "-p"]).arg(home).status().is_ok_and(|s| s.success()); | |
| 120 | − | let owned = Command::new("sudo").args(["-n", "chown", "-R", &format!("{user}:{user}")]).arg(home).status().is_ok_and(|s| s.success()); | |
| 121 | − | if !made || !owned || !writable(home) { | |
| 122 | − | return Err(io::Error::new(io::ErrorKind::PermissionDenied, format!("{} is not writable", home.display()))); | |
| 123 | − | } | |
| 124 | − | } | |
| 125 | − | std::fs::create_dir_all(home.join(SESSIONS_DIR)) | |
| 126 | − | } | |
| 127 | − | ||
| 128 | − | fn writable(dir: &Path) -> bool { | |
| 129 | − | let probe = dir.join(".g1t-probe"); | |
| 130 | − | let ok = std::fs::write(&probe, b"").is_ok(); | |
| 131 | − | let _ = std::fs::remove_file(&probe); | |
| 132 | − | ok | |
| 133 | − | } | |
| 134 | − | ||
| 135 | − | /// Downloads a tar+zstd snapshot and unpacks it into the home. | |
| 136 | − | fn restore_from_url(url: &str, token: &str) -> Result<(), String> { | |
| 137 | − | let response = ureq::get(url) | |
| 138 | − | .set("Authorization", &format!("Bearer {token}")) | |
| 139 | − | .timeout(Duration::from_secs(600)) | |
| 140 | − | .call() | |
| 141 | − | .map_err(|error| error.to_string())?; | |
| 142 | − | let mut reader = response.into_reader(); | |
| 143 | − | let mut tar = untar(Path::new(HOME)).map_err(|error| error.to_string())?; | |
| 144 | − | { | |
| 145 | − | let mut stdin = tar.stdin.take().ok_or("tar has no stdin")?; | |
| 146 | − | io::copy(&mut reader, &mut stdin).map_err(|error| error.to_string())?; | |
| 147 | − | } | |
| 148 | − | let status = tar.wait().map_err(|error| error.to_string())?; | |
| 149 | − | if !status.success() { | |
| 150 | − | return Err(format!("tar exited with {status}")); | |
| 151 | − | } | |
| 152 | − | Ok(()) | |
| 153 | − | } | |
| 154 | − | ||
| 155 | − | /// `tar --zstd -x` into `home`, reading from its stdin. | |
| 156 | − | fn untar(home: &Path) -> io::Result<Child> { | |
| 157 | − | Command::new("tar").args(["--zstd", "-x", "-f", "-", "-C"]).arg(home).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::inherit()).spawn() | |
| 158 | − | } | |
| 159 | − | ||
| 160 | − | /// Serves one connection: one request, one response. | |
| 161 | − | fn serve(client: TcpStream, server: &Server) -> io::Result<()> { | |
| 162 | − | let mut reader = BufReader::new(client.try_clone()?); | |
| 163 | − | let mut writer = client; | |
| 164 | − | let Some(head) = http::read_head(&mut reader)? else { return Ok(()) }; | |
| 165 | − | let body = http::request_body(&head); | |
| 166 | − | let (path, query) = split_target(head.target()); | |
| 167 | − | let authorised = match &server.token { | |
| 168 | − | None => true, | |
| 169 | − | Some(token) => head.header("authorization").is_some_and(|value| value.trim() == format!("Bearer {token}")), | |
| 170 | − | }; | |
| 171 | − | if !authorised { | |
| 172 | − | let _ = http::read_body(&mut reader, body); | |
| 173 | − | return respond(&mut writer, 401, "Unauthorized", &json!({ "message": "This computer takes requests from its own Durable Object only." })); | |
| 174 | − | } | |
| 175 | − | match (head.method(), path) { | |
| 176 | − | ("GET", "/health") => { | |
| 177 | − | let _ = http::read_body(&mut reader, body); | |
| 178 | − | respond(&mut writer, 200, "OK", &health(server)) | |
| 179 | − | } | |
| 180 | − | ("POST", "/exec") => { | |
| 181 | − | let raw = http::read_body(&mut reader, body)?; | |
| 182 | − | let request: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null); | |
| 183 | − | let Some(cmd) = request.get("cmd").and_then(Value::as_str).filter(|c| !c.trim().is_empty()) else { | |
| 184 | − | return respond(&mut writer, 400, "Bad Request", &json!({ "message": "Give the command to run as cmd." })); | |
| 185 | − | }; | |
| 186 | − | let cwd = match within_home(request.get("cwd").and_then(Value::as_str).unwrap_or("")) { | |
| 187 | − | Some(cwd) => cwd, | |
| 188 | − | None => return respond(&mut writer, 400, "Bad Request", &json!({ "message": format!("cwd must be under {HOME}.") })), | |
| 189 | − | }; | |
| 190 | − | let timeout = request.get("timeout_seconds").and_then(Value::as_u64).unwrap_or(DEFAULT_TIMEOUT_SECONDS).clamp(1, MAX_TIMEOUT_SECONDS); | |
| 191 | − | let env = request.get("env").and_then(Value::as_object).map(|map| map.iter().filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_owned()))).collect()).unwrap_or_default(); | |
| 192 | − | server.running.fetch_add(1, Ordering::SeqCst); | |
| 193 | − | let result = stream_exec(&mut writer, cmd, &cwd, Duration::from_secs(timeout), &env); | |
| 194 | − | server.running.fetch_sub(1, Ordering::SeqCst); | |
| 195 | − | result | |
| 196 | − | } | |
| 197 | − | ("GET", "/files") => { | |
| 198 | − | let _ = http::read_body(&mut reader, body); | |
| 199 | − | let Some(path) = query.get("path").and_then(|p| resolve_in_home(p)) else { | |
| 200 | − | return respond(&mut writer, 400, "Bad Request", &json!({ "message": format!("path must be under {HOME}.") })); | |
| 201 | − | }; | |
| 202 | − | let size = match std::fs::metadata(&path) { | |
| 203 | − | Ok(meta) if meta.is_file() => meta.len(), | |
| 204 | − | Ok(_) => return respond(&mut writer, 400, "Bad Request", &json!({ "message": "That is a directory, not a file." })), | |
| 205 | − | Err(_) => return respond(&mut writer, 404, "Not Found", &json!({ "message": "There is no such file." })), | |
| 206 | − | }; | |
| 207 | − | if size > MAX_FILE_BYTES { | |
| 208 | − | return respond(&mut writer, 413, "Payload Too Large", &json!({ "message": format!("The file is {size} bytes; files read this way are at most {MAX_FILE_BYTES}."), "bytes": size })); | |
| 209 | − | } | |
| 210 | − | let bytes = std::fs::read(&path)?; | |
| 211 | − | let head = Head { start: "HTTP/1.1 200 OK".into(), headers: vec![("Content-Type".into(), "application/octet-stream".into())] }; | |
| 212 | − | writer.write_all(&http::with_length(head, &bytes))?; | |
| 213 | − | writer.flush() | |
| 214 | − | } | |
| 215 | − | ("PUT", "/files") => { | |
| 216 | − | let Some(path) = query.get("path").and_then(|p| resolve_in_home(p)) else { | |
| 217 | − | let _ = http::read_body(&mut reader, body); | |
| 218 | − | return respond(&mut writer, 400, "Bad Request", &json!({ "message": format!("path must be under {HOME}.") })); | |
| 219 | − | }; | |
| 220 | − | if let Body::Length(n) = body { | |
| 221 | − | if n > MAX_FILE_BYTES { | |
| 222 | − | return respond(&mut writer, 413, "Payload Too Large", &json!({ "message": format!("Files written this way are at most {MAX_FILE_BYTES} bytes.") })); | |
| 223 | − | } | |
| 224 | − | } | |
| 225 | − | let bytes = http::read_body(&mut reader, body)?; | |
| 226 | − | if bytes.len() as u64 > MAX_FILE_BYTES { | |
| 227 | − | return respond(&mut writer, 413, "Payload Too Large", &json!({ "message": format!("Files written this way are at most {MAX_FILE_BYTES} bytes.") })); | |
| 228 | − | } | |
| 229 | − | if let Some(parent) = path.parent() { | |
| 230 | − | std::fs::create_dir_all(parent)?; | |
| 231 | − | } | |
| 232 | − | std::fs::write(&path, &bytes)?; | |
| 233 | − | respond(&mut writer, 200, "OK", &json!({ "ok": true, "path": path.to_string_lossy(), "bytes": bytes.len() })) | |
| 234 | − | } | |
| 235 | − | ("POST", "/restore") => { | |
| 236 | − | let mut tar = untar(Path::new(HOME))?; | |
| 237 | − | let copied = { | |
| 238 | − | let mut stdin = tar.stdin.take().expect("tar's stdin"); | |
| 239 | − | http::copy_body(&mut reader, &mut stdin, body) | |
| 240 | − | }; | |
| 241 | − | let status = tar.wait()?; | |
| 242 | − | match (copied, status.success()) { | |
| 243 | − | (Ok(()), true) => respond(&mut writer, 200, "OK", &json!({ "ok": true, "disk_used_bytes": dir_size(Path::new(HOME)) })), | |
| 244 | − | (Err(error), _) => respond(&mut writer, 400, "Bad Request", &json!({ "message": format!("The snapshot could not be read: {error}") })), | |
| 245 | − | (Ok(()), false) => respond(&mut writer, 500, "Internal Server Error", &json!({ "message": format!("tar exited with {status}") })), | |
| 246 | − | } | |
| 247 | − | } | |
| 248 | − | ("POST", "/snapshot") => { | |
| 249 | − | let _ = http::read_body(&mut reader, body); | |
| 250 | − | let home = Path::new(HOME); | |
| 251 | − | let used = dir_size(home); | |
| 252 | − | if used > DISK_CAP_BYTES { | |
| 253 | − | let largest: Vec<Value> = largest_top_level(home).into_iter().take(8).map(|(name, bytes)| json!({ "path": name, "bytes": bytes })).collect(); | |
| 254 | − | return respond( | |
| 255 | − | &mut writer, | |
| 256 | − | 413, | |
| 257 | − | "Payload Too Large", | |
| 258 | − | &json!({ "message": over_cap_message(used, &largest_top_level(home)), "disk_used_bytes": used, "disk_cap_bytes": DISK_CAP_BYTES, "largest": largest }), | |
| 259 | − | ); | |
| 260 | − | } | |
| 261 | − | stream_snapshot(&mut writer, home, used) | |
| 262 | − | } | |
| 263 | − | ("POST", "/stop") => { | |
| 264 | − | let _ = http::read_body(&mut reader, body); | |
| 265 | − | respond(&mut writer, 200, "OK", &json!({ "ok": true }))?; | |
| 266 | − | let _ = writer.shutdown(std::net::Shutdown::Both); | |
| 267 | − | eprintln!("g1t-runner: the computer is going to sleep"); | |
| 268 | − | std::process::exit(0); | |
| 269 | − | } | |
| 270 | − | _ => { | |
| 271 | − | let _ = http::read_body(&mut reader, body); | |
| 272 | − | respond(&mut writer, 404, "Not Found", &json!({ "message": "No such route." })) | |
| 273 | − | } | |
| 274 | − | } | |
| 275 | − | } | |
| 276 | − | ||
| 277 | − | fn respond(writer: &mut TcpStream, status: u16, reason: &str, body: &Value) -> io::Result<()> { | |
| 278 | − | writer.write_all(&http::json_response(status, reason, body))?; | |
| 279 | − | writer.flush() | |
| 280 | − | } | |
| 281 | − | ||
| 282 | − | fn health(server: &Server) -> Value { | |
| 283 | − | json!({ | |
| 284 | − | "uptime_seconds": server.started.elapsed().as_secs(), | |
| 285 | − | "disk_used_bytes": dir_size(Path::new(HOME)), | |
| 286 | − | "disk_cap_bytes": DISK_CAP_BYTES, | |
| 287 | − | "running_commands": server.running.load(Ordering::SeqCst), | |
| 288 | − | "home": HOME, | |
| 289 | − | }) | |
| 290 | − | } | |
| 291 | − | ||
| 292 | − | /// A request target as its path and its query, decoded. | |
| 293 | − | pub(crate) fn split_target(target: &str) -> (&str, BTreeMap<String, String>) { | |
| 294 | − | let (path, query) = target.split_once('?').unwrap_or((target, "")); | |
| 295 | − | let mut out = BTreeMap::new(); | |
| 296 | − | for pair in query.split('&').filter(|p| !p.is_empty()) { | |
| 297 | − | let (key, value) = pair.split_once('=').unwrap_or((pair, "")); | |
| 298 | − | out.insert(percent_decode(key), percent_decode(value)); | |
| 299 | − | } | |
| 300 | − | (path, out) | |
| 301 | − | } | |
| 302 | − | ||
| 303 | − | fn percent_decode(text: &str) -> String { | |
| 304 | − | let bytes = text.as_bytes(); | |
| 305 | − | let mut out = Vec::with_capacity(bytes.len()); | |
| 306 | − | let mut i = 0; | |
| 307 | − | while i < bytes.len() { | |
| 308 | − | match bytes[i] { | |
| 309 | − | b'%' if i + 2 < bytes.len() => { | |
| 310 | − | let hex = &text[i + 1..i + 3]; | |
| 311 | − | match u8::from_str_radix(hex, 16) { | |
| 312 | − | Ok(byte) => { | |
| 313 | − | out.push(byte); | |
| 314 | − | i += 3; | |
| 315 | − | } | |
| 316 | − | Err(_) => { | |
| 317 | − | out.push(b'%'); | |
| 318 | − | i += 1; | |
| 319 | − | } | |
| 320 | − | } | |
| 321 | − | } | |
| 322 | − | b'+' => { | |
| 323 | − | out.push(b' '); | |
| 324 | − | i += 1; | |
| 325 | − | } | |
| 326 | − | byte => { | |
| 327 | − | out.push(byte); | |
| 328 | − | i += 1; | |
| 329 | − | } | |
| 330 | − | } | |
| 331 | − | } | |
| 332 | − | String::from_utf8_lossy(&out).into_owned() | |
| 333 | − | } | |
| 334 | − | ||
| 335 | − | // ── Paths ───────────────────────────────────────────────────────────────── | |
| 336 | − | ||
| 337 | − | /// `given` as a path under `home`, or `None` when it would leave it: a | |
| 338 | − | /// relative path is taken from the home, `..` is resolved without touching | |
| 339 | − | /// the filesystem, and anything that climbs above the home is refused. The | |
| 340 | − | /// empty path is the home itself. Pure, so it is tested everywhere. | |
| 341 | − | pub(crate) fn within(home: &str, given: &str) -> Option<PathBuf> { | |
| 342 | − | if given.contains('\0') { | |
| 343 | − | return None; | |
| 344 | − | } | |
| 345 | − | let given = given.trim(); | |
| 346 | − | let home = home.trim_end_matches('/'); | |
| 347 | − | let mut parts: Vec<&str> = if given.starts_with('/') { Vec::new() } else { home.split('/').filter(|p| !p.is_empty()).collect() }; | |
| 348 | − | for part in given.split('/') { | |
| 349 | − | match part { | |
| 350 | − | "" | "." => {} | |
| 351 | − | ".." => { | |
| 352 | − | parts.pop()?; | |
| 353 | − | } | |
| 354 | − | name => parts.push(name), | |
| 355 | − | } | |
| 356 | − | } | |
| 357 | − | let joined = format!("/{}", parts.join("/")); | |
| 358 | − | if joined == home || joined.starts_with(&format!("{home}/")) { Some(PathBuf::from(joined)) } else { None } | |
| 359 | − | } | |
| 360 | − | ||
| 361 | − | /// `within` for the real home. | |
| 362 | − | pub(crate) fn within_home(given: &str) -> Option<PathBuf> { | |
| 363 | − | within(HOME, given) | |
| 364 | − | } | |
| 365 | − | ||
| 366 | − | /// `within_home`, and then the path as the filesystem resolves it: a | |
| 367 | − | /// symlink inside the home that points out of it is refused too. The | |
| 368 | − | /// deepest existing ancestor is what is resolved, so a file not made yet | |
| 369 | − | /// can still be written. | |
| 370 | − | fn resolve_in_home(given: &str) -> Option<PathBuf> { | |
| 371 | − | let path = within_home(given)?; | |
| 372 | − | let home = std::fs::canonicalize(HOME).unwrap_or_else(|_| PathBuf::from(HOME)); | |
| 373 | − | let mut probe = path.clone(); | |
| 374 | − | let mut tail: Vec<std::ffi::OsString> = Vec::new(); | |
| 375 | − | loop { | |
| 376 | − | if let Ok(real) = std::fs::canonicalize(&probe) { | |
| 377 | − | if real != home && !real.starts_with(&home) { | |
| 378 | − | return None; | |
| 379 | − | } | |
| 380 | − | let mut out = real; | |
| 381 | − | for part in tail.iter().rev() { | |
| 382 | − | out.push(part); | |
| 383 | − | } | |
| 384 | − | return Some(out); | |
| 385 | − | } | |
| 386 | − | let name = probe.file_name()?.to_owned(); | |
| 387 | − | tail.push(name); | |
| 388 | − | probe = probe.parent()?.to_path_buf(); | |
| 389 | − | } | |
| 390 | − | } | |
| 391 | − | ||
| 392 | − | // ── Running commands ────────────────────────────────────────────────────── | |
| 393 | − | ||
| 394 | − | /// One line of a command's output, as streamed. | |
| 395 | − | pub(crate) fn output_line(stream: &str, line: &str) -> Value { | |
| 396 | − | json!({ "stream": stream, "line": line }) | |
| 397 | − | } | |
| 398 | − | ||
| 399 | − | /// The closing line of a command: how it ended. | |
| 400 | − | pub(crate) fn final_line(exit_code: i32, duration: Duration, dropped_bytes: usize, timed_out: bool) -> Value { | |
| 401 | − | let mut line = json!({ "exit_code": exit_code, "duration_ms": duration.as_millis() as u64, "truncated": dropped_bytes > 0, "timed_out": timed_out }); | |
| 402 | − | if dropped_bytes > 0 { | |
| 403 | − | line["note"] = json!(format!("Output past {} was dropped: {} more bytes.", human_bytes(MAX_OUTPUT_BYTES as u64), dropped_bytes)); | |
| 404 | − | } | |
| 405 | − | if timed_out { | |
| 406 | − | line["note"] = json!(format!( | |
| 407 | − | "{}The command was still running at its timeout and was killed.", | |
| 408 | − | line.get("note").and_then(Value::as_str).map(|n| format!("{n} ")).unwrap_or_default() | |
| 409 | − | )); | |
| 410 | − | } | |
| 411 | − | line | |
| 412 | − | } | |
| 413 | − | ||
| 414 | − | /// Environment the agent may set for a command: plain names, and never the | |
| 415 | − | /// process's own credentials or anything that changes how programs load. | |
| 416 | − | pub(crate) fn safe_env(given: &BTreeMap<String, String>) -> BTreeMap<String, String> { | |
| 417 | − | given | |
| 418 | − | .iter() | |
| 419 | − | .filter(|(name, _)| { | |
| 420 | − | let plain = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !name.starts_with(|c: char| c.is_ascii_digit()); | |
| 421 | − | let risky = name.starts_with("G1T_") || name.starts_with("LD_") || matches!(name.as_str(), "BASH_ENV" | "ENV" | "PROMPT_COMMAND" | "HOME" | "SHELLOPTS" | "BASHOPTS"); | |
| 422 | − | plain && !risky | |
| 423 | − | }) | |
| 424 | − | .map(|(name, value)| (name.clone(), value.clone())) | |
| 425 | − | .collect() | |
| 426 | − | } | |
| 427 | − | ||
| 428 | − | /// Something a command's output produced. | |
| 429 | − | enum Produced { | |
| 430 | − | Line(&'static str, String), | |
| 431 | − | Closed, | |
| 432 | − | } | |
| 433 | − | ||
| 434 | − | /// Runs `cmd` and gives every line to `sink` as it comes, then the closing | |
| 435 | − | /// line. Returns what the closing line said. | |
| 436 | − | pub(crate) fn run_streaming(cmd: &str, cwd: &Path, timeout: Duration, env: &BTreeMap<String, String>, sink: &mut dyn FnMut(Value) -> io::Result<()>) -> io::Result<Value> { | |
| 437 | − | std::fs::create_dir_all(cwd)?; | |
| 438 | − | let started = Instant::now(); | |
| 439 | − | let mut command = Command::new("bash"); | |
| 440 | − | command.args(["-lc", cmd]).current_dir(cwd).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()); | |
| 441 | − | command.env_remove("G1T_COMPUTER_TOKEN").env_remove("G1T_HOME_RESTORE").env_remove("G1T_HOME_RESTORE_TOKEN"); | |
| 442 | − | command.env("HOME", HOME).env("G1T_COMPUTER", "1"); | |
| 443 | − | for (name, value) in safe_env(env) { | |
| 444 | − | command.env(name, value); | |
| 445 | − | } | |
| 446 | − | let mut child = match command.spawn() { | |
| 447 | − | Ok(child) => child, | |
| 448 | − | Err(error) => { | |
| 449 | − | let line = json!({ "exit_code": 127, "duration_ms": 0, "truncated": false, "timed_out": false, "note": format!("bash could not be started: {error}") }); | |
| 450 | − | sink(line.clone())?; | |
| 451 | − | return Ok(line); | |
| 452 | − | } | |
| 453 | − | }; | |
| 454 | − | let (sender, receiver) = mpsc::channel::<Produced>(); | |
| 455 | − | let mut readers = Vec::new(); | |
| 456 | − | for (name, pipe) in [("stdout", child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>)), ("stderr", child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>))] { | |
| 457 | − | let Some(pipe) = pipe else { continue }; | |
| 458 | − | let sender = sender.clone(); | |
| 459 | − | readers.push(std::thread::spawn(move || { | |
| 460 | − | let mut reader = BufReader::new(pipe); | |
| 461 | − | let mut buffer = Vec::new(); | |
| 462 | − | loop { | |
| 463 | − | buffer.clear(); | |
| 464 | − | match reader.read_until(b'\n', &mut buffer) { | |
| 465 | − | Ok(0) | Err(_) => break, | |
| 466 | − | Ok(_) => { | |
| 467 | − | let text = String::from_utf8_lossy(&buffer).trim_end_matches(['\r', '\n']).to_owned(); | |
| 468 | − | if sender.send(Produced::Line(name, text)).is_err() { | |
| 469 | − | break; | |
| 470 | − | } | |
| 471 | − | } | |
| 472 | − | } | |
| 473 | − | } | |
| 474 | − | let _ = sender.send(Produced::Closed); | |
| 475 | − | })); | |
| 476 | − | } | |
| 477 | − | drop(sender); | |
| 478 | − | let mut open = readers.len(); | |
| 479 | − | let mut sent = 0usize; | |
| 480 | − | let mut dropped = 0usize; | |
| 481 | − | let mut timed_out = false; | |
| 482 | − | let deadline = started + timeout; | |
| 483 | − | while open > 0 { | |
| 484 | − | let now = Instant::now(); | |
| 485 | − | if now >= deadline { | |
| 486 | − | timed_out = true; | |
| 487 | − | let _ = child.kill(); | |
| 488 | − | break; | |
| 489 | − | } | |
| 490 | − | match receiver.recv_timeout(deadline - now) { | |
| 491 | − | Ok(Produced::Line(stream, text)) => { | |
| 492 | − | let bytes = text.len() + 1; | |
| 493 | − | if sent + bytes > MAX_OUTPUT_BYTES { | |
| 494 | − | dropped += bytes; | |
| 495 | − | continue; | |
| 496 | − | } | |
| 497 | − | sent += bytes; | |
| 498 | − | sink(output_line(stream, &text))?; | |
| 499 | − | } | |
| 500 | − | Ok(Produced::Closed) => open -= 1, | |
| 501 | − | Err(mpsc::RecvTimeoutError::Timeout) => { | |
| 502 | − | timed_out = true; | |
| 503 | − | let _ = child.kill(); | |
| 504 | − | break; | |
| 505 | − | } | |
| 506 | − | Err(mpsc::RecvTimeoutError::Disconnected) => break, | |
| 507 | − | } | |
| 508 | − | } | |
| 509 | − | let status = child.wait()?; | |
| 510 | − | for reader in readers { | |
| 511 | − | let _ = reader.join(); | |
| 512 | − | } | |
| 513 | − | let exit_code = if timed_out { TIMED_OUT_EXIT } else { status.code().unwrap_or(-1) }; | |
| 514 | − | let line = final_line(exit_code, started.elapsed(), dropped, timed_out); | |
| 515 | − | sink(line.clone())?; | |
| 516 | − | Ok(line) | |
| 517 | − | } | |
| 518 | − | ||
| 519 | − | /// `/exec`: the command's lines as a chunked NDJSON response. | |
| 520 | − | fn stream_exec(writer: &mut TcpStream, cmd: &str, cwd: &Path, timeout: Duration, env: &BTreeMap<String, String>) -> io::Result<()> { | |
| 521 | − | writer.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nTransfer-Encoding: chunked\r\n\r\n")?; | |
| 522 | − | writer.flush()?; | |
| 523 | − | let mut sink = |line: Value| -> io::Result<()> { | |
| 524 | − | let mut text = line.to_string(); | |
| 525 | − | text.push('\n'); | |
| 526 | − | write_chunk(writer, text.as_bytes()) | |
| 527 | − | }; | |
| 528 | − | run_streaming(cmd, cwd, timeout, env, &mut sink)?; | |
| 529 | − | writer.write_all(b"0\r\n\r\n")?; | |
| 530 | − | writer.flush() | |
| 531 | − | } | |
| 532 | − | ||
| 533 | − | /// One chunk of a chunked body, flushed so it is seen as it is written. | |
| 534 | − | pub(crate) fn write_chunk<W: Write>(writer: &mut W, bytes: &[u8]) -> io::Result<()> { | |
| 535 | − | if bytes.is_empty() { | |
| 536 | − | return Ok(()); | |
| 537 | − | } | |
| 538 | − | write!(writer, "{:x}\r\n", bytes.len())?; | |
| 539 | − | writer.write_all(bytes)?; | |
| 540 | − | writer.write_all(b"\r\n")?; | |
| 541 | − | writer.flush() | |
| 542 | − | } | |
| 543 | − | ||
| 544 | − | // ── Snapshots ───────────────────────────────────────────────────────────── | |
| 545 | − | ||
| 546 | − | /// Every file's size under `dir`, symlinks not followed. | |
| 547 | − | pub(crate) fn dir_size(dir: &Path) -> u64 { | |
| 548 | − | let Ok(entries) = std::fs::read_dir(dir) else { return 0 }; | |
| 549 | − | let mut total = 0; | |
| 550 | − | for entry in entries.flatten() { | |
| 551 | − | let Ok(meta) = entry.metadata() else { continue }; | |
| 552 | − | if meta.is_dir() { | |
| 553 | − | total += dir_size(&entry.path()); | |
| 554 | − | } else if meta.is_file() { | |
| 555 | − | total += meta.len(); | |
| 556 | − | } | |
| 557 | − | } | |
| 558 | − | total | |
| 559 | − | } | |
| 560 | − | ||
| 561 | − | /// The home's top-level entries by size, largest first. | |
| 562 | − | pub(crate) fn largest_top_level(home: &Path) -> Vec<(String, u64)> { | |
| 563 | − | let Ok(entries) = std::fs::read_dir(home) else { return Vec::new() }; | |
| 564 | − | let mut out: Vec<(String, u64)> = entries | |
| 565 | − | .flatten() | |
| 566 | − | .map(|entry| { | |
| 567 | − | let size = entry.metadata().map(|meta| if meta.is_dir() { dir_size(&entry.path()) } else { meta.len() }).unwrap_or(0); | |
| 568 | − | (entry.file_name().to_string_lossy().into_owned(), size) | |
| 569 | − | }) | |
| 570 | − | .collect(); | |
| 571 | − | out.sort_by(|a, b| b.1.cmp(&a.1)); | |
| 572 | − | out | |
| 573 | − | } | |
| 574 | − | ||
| 575 | − | /// What the computer says when its home is over the cap. | |
| 576 | − | pub(crate) fn over_cap_message(used: u64, largest: &[(String, u64)]) -> String { | |
| 577 | − | let named: Vec<String> = largest.iter().take(5).map(|(name, bytes)| format!("{name} ({})", human_bytes(*bytes))).collect(); | |
| 578 | − | format!( | |
| 579 | − | "The home holds {}, over its {} cap, so it can't be saved. Largest at the top level: {}. Remove or trim some of it, or reset the computer.", | |
| 580 | − | human_bytes(used), | |
| 581 | − | human_bytes(DISK_CAP_BYTES), | |
| 582 | − | if named.is_empty() { "nothing".to_owned() } else { named.join(", ") } | |
| 583 | − | ) | |
| 584 | − | } | |
| 585 | − | ||
| 586 | − | pub(crate) fn human_bytes(bytes: u64) -> String { | |
| 587 | − | const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; | |
| 588 | − | let mut value = bytes as f64; | |
| 589 | − | let mut unit = 0; | |
| 590 | − | while value >= 1000.0 && unit < UNITS.len() - 1 { | |
| 591 | − | value /= 1000.0; | |
| 592 | − | unit += 1; | |
| 593 | − | } | |
| 594 | − | if unit == 0 { format!("{bytes} B") } else { format!("{value:.1} {}", UNITS[unit]) } | |
| 595 | − | } | |
| 596 | − | ||
| 597 | − | /// `/snapshot`: the home as tar+zstd, chunked as tar writes it. A tar | |
| 598 | − | /// that fails ends the connection without the closing chunk, so the reader | |
| 599 | − | /// knows the archive is not whole. | |
| 600 | − | fn stream_snapshot(writer: &mut TcpStream, home: &Path, used: u64) -> io::Result<()> { | |
| 601 | − | let mut tar = Command::new("tar") | |
| 602 | − | .args(["--zstd", "-c", "-f", "-", "-C"]) | |
| 603 | − | .arg(home) | |
| 604 | − | .arg(".") | |
| 605 | − | .stdin(Stdio::null()) | |
| 606 | − | .stdout(Stdio::piped()) | |
| 607 | − | .stderr(Stdio::inherit()) | |
| 608 | − | .spawn()?; | |
| 609 | − | let mut stdout = tar.stdout.take().expect("tar's stdout"); | |
| 610 | − | writer.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/zstd\r\nTransfer-Encoding: chunked\r\nX-G1t-Disk-Used: {used}\r\n\r\n").as_bytes())?; | |
| 611 | − | let mut buffer = vec![0u8; 256 * 1024]; | |
| 612 | − | loop { | |
| 613 | − | let n = stdout.read(&mut buffer)?; | |
| 614 | − | if n == 0 { | |
| 615 | − | break; | |
| 616 | − | } | |
| 617 | − | write_chunk(writer, &buffer[..n])?; | |
| 618 | − | } | |
| 619 | − | let status = tar.wait()?; | |
| 620 | − | if !status.success() { | |
| 621 | − | let _ = writer.shutdown(std::net::Shutdown::Both); | |
| 622 | − | return Err(io::Error::other(format!("tar exited with {status}"))); | |
| 623 | − | } | |
| 624 | − | writer.write_all(b"0\r\n\r\n")?; | |
| 625 | − | writer.flush() | |
| 626 | − | } | |
| 627 | − | ||
| 628 | − | #[cfg(test)] | |
| 629 | − | mod tests { | |
| 630 | − | use super::*; | |
| 631 | − | ||
| 632 | − | #[test] | |
| 633 | − | fn paths_stay_under_the_home() { | |
| 634 | − | let home = "/home/agent"; | |
| 635 | − | assert_eq!(within(home, ""), Some(PathBuf::from("/home/agent"))); | |
| 636 | − | assert_eq!(within(home, "notes.md"), Some(PathBuf::from("/home/agent/notes.md"))); | |
| 637 | − | assert_eq!(within(home, "./sessions/asn_1/"), Some(PathBuf::from("/home/agent/sessions/asn_1"))); | |
| 638 | − | assert_eq!(within(home, "/home/agent/x/../y"), Some(PathBuf::from("/home/agent/y"))); | |
| 639 | − | assert_eq!(within(home, "a/b/../../c"), Some(PathBuf::from("/home/agent/c"))); | |
| 640 | − | assert_eq!(within(home, "/home/agent"), Some(PathBuf::from("/home/agent"))); | |
| 641 | − | } | |
| 642 | − | ||
| 643 | − | #[test] | |
| 644 | − | fn paths_that_leave_the_home_are_refused() { | |
| 645 | − | let home = "/home/agent"; | |
| 646 | − | assert_eq!(within(home, "../etc/passwd"), None); | |
| 647 | − | assert_eq!(within(home, "/etc/passwd"), None); | |
| 648 | − | assert_eq!(within(home, "/home/agentx/secret"), None); | |
| 649 | − | assert_eq!(within(home, "/home/agent/../node/.ssh"), None); | |
| 650 | − | assert_eq!(within(home, "a/../../.."), None); | |
| 651 | − | assert_eq!(within(home, "/"), None); | |
| 652 | − | assert_eq!(within(home, "ok\0bad"), None); | |
| 653 | − | } | |
| 654 | − | ||
| 655 | − | #[test] | |
| 656 | − | fn targets_split_into_path_and_query() { | |
| 657 | − | let (path, query) = split_target("/files?path=%2Fhome%2Fagent%2Fa+b.txt&x=1"); | |
| 658 | − | assert_eq!(path, "/files"); | |
| 659 | − | assert_eq!(query.get("path").map(String::as_str), Some("/home/agent/a b.txt")); | |
| 660 | − | assert_eq!(query.get("x").map(String::as_str), Some("1")); | |
| 661 | − | assert_eq!(split_target("/health").0, "/health"); | |
| 662 | − | } | |
| 663 | − | ||
| 664 | − | #[test] | |
| 665 | − | fn the_closing_line_says_how_it_ended() { | |
| 666 | − | let plain = final_line(0, Duration::from_millis(1234), 0, false); | |
| 667 | − | assert_eq!(plain["exit_code"], 0); | |
| 668 | − | assert_eq!(plain["duration_ms"], 1234); | |
| 669 | − | assert_eq!(plain["truncated"], false); | |
| 670 | − | assert_eq!(plain["timed_out"], false); | |
| 671 | − | assert!(plain.get("note").is_none()); | |
| 672 | − | let cut = final_line(TIMED_OUT_EXIT, Duration::from_secs(2), 10, true); | |
| 673 | − | assert_eq!(cut["truncated"], true); | |
| 674 | − | assert_eq!(cut["timed_out"], true); | |
| 675 | − | let note = cut["note"].as_str().unwrap(); | |
| 676 | − | assert!(note.contains("dropped") && note.contains("killed"), "{note}"); | |
| 677 | − | } | |
| 678 | − | ||
| 679 | − | #[test] | |
| 680 | − | fn the_environment_keeps_out_what_it_must() { | |
| 681 | − | let mut given = BTreeMap::new(); | |
| 682 | − | for (k, v) in [("FOO", "1"), ("G1T_TOKEN", "x"), ("LD_PRELOAD", "evil.so"), ("HOME", "/root"), ("bad-name", "1"), ("1ABC", "1"), ("CI", "true")] { | |
| 683 | − | given.insert(k.to_owned(), v.to_owned()); | |
| 684 | − | } | |
| 685 | − | let kept = safe_env(&given); | |
| 686 | − | assert_eq!(kept.keys().cloned().collect::<Vec<_>>(), vec!["CI".to_owned(), "FOO".to_owned()]); | |
| 687 | − | } | |
| 688 | − | ||
| 689 | − | #[test] | |
| 690 | − | fn chunks_are_framed() { | |
| 691 | − | let mut out = Vec::new(); | |
| 692 | − | write_chunk(&mut out, b"hello").unwrap(); | |
| 693 | − | write_chunk(&mut out, b"").unwrap(); | |
| 694 | − | assert_eq!(out, b"5\r\nhello\r\n"); | |
| 695 | − | } | |
| 696 | − | ||
| 697 | − | #[test] | |
| 698 | − | fn bytes_read_as_words() { | |
| 699 | − | assert_eq!(human_bytes(512), "512 B"); | |
| 700 | − | assert_eq!(human_bytes(5_000_000_000), "5.0 GB"); | |
| 701 | − | assert!(over_cap_message(6_000_000_000, &[("node_modules".into(), 4_000_000_000)]).contains("node_modules (4.0 GB)")); | |
| 702 | − | } | |
| 703 | − | ||
| 704 | − | #[cfg(unix)] | |
| 705 | − | #[test] | |
| 706 | − | fn a_command_streams_its_lines_then_how_it_ended() { | |
| 707 | − | let dir = std::env::temp_dir().join(format!("g1t-computer-{}", std::process::id())); | |
| 708 | − | let mut lines = Vec::new(); | |
| 709 | − | let last = run_streaming("echo one; echo two 1>&2; exit 3", &dir, Duration::from_secs(10), &BTreeMap::new(), &mut |line| { | |
| 710 | − | lines.push(line); | |
| 711 | − | Ok(()) | |
| 712 | − | }) | |
| 713 | − | .unwrap(); | |
| 714 | − | let _ = std::fs::remove_dir_all(&dir); | |
| 715 | − | assert_eq!(lines.len(), 3, "{lines:?}"); | |
| 716 | − | assert!(lines.iter().any(|l| l["stream"] == "stdout" && l["line"] == "one")); | |
| 717 | − | assert!(lines.iter().any(|l| l["stream"] == "stderr" && l["line"] == "two")); | |
| 718 | − | assert_eq!(last["exit_code"], 3); | |
| 719 | − | assert_eq!(last["timed_out"], false); | |
| 720 | − | assert_eq!(lines[2], last); | |
| 721 | − | } | |
| 722 | − | ||
| 723 | − | #[cfg(unix)] | |
| 724 | − | #[test] | |
| 725 | − | fn a_command_past_its_timeout_is_killed() { | |
| 726 | − | let dir = std::env::temp_dir().join(format!("g1t-computer-slow-{}", std::process::id())); | |
| 727 | − | let mut lines = Vec::new(); | |
| 728 | − | let last = run_streaming("echo start; sleep 30; echo never", &dir, Duration::from_millis(300), &BTreeMap::new(), &mut |line| { | |
| 729 | − | lines.push(line); | |
| 730 | − | Ok(()) | |
| 731 | − | }) | |
| 732 | − | .unwrap(); | |
| 733 | − | let _ = std::fs::remove_dir_all(&dir); | |
| 734 | − | assert_eq!(last["exit_code"], TIMED_OUT_EXIT); | |
| 735 | − | assert_eq!(last["timed_out"], true); | |
| 736 | − | assert!(!lines.iter().any(|l| l["line"] == "never")); | |
| 737 | − | } | |
| 738 | − | } |
| 1 | − | //! How sure the agent is of its own change, asked for at the end of every | |
| 2 | − | //! run that makes or revises one and reported to g1t. | |
| 3 | − | //! | |
| 4 | − | //! Like `learned`, the question rides on the prompt: the agent ends its | |
| 5 | − | //! closing summary with a `<g1t-confidence>` block of JSON, which is taken | |
| 6 | − | //! out of the summary and sent with the run's own token. g1t combines it | |
| 7 | − | //! with what it observes of the change (its checks, revisions, review, | |
| 8 | − | //! tests, size, guardrails); what it observes can only lower what the agent | |
| 9 | − | //! says, never raise it. | |
| 10 | − | ||
| 11 | − | use std::time::Duration; | |
| 12 | − | ||
| 13 | − | use serde_json::{Value, json}; | |
| 14 | − | ||
| 15 | − | const OPEN: &str = "<g1t-confidence>"; | |
| 16 | − | const CLOSE: &str = "</g1t-confidence>"; | |
| 17 | − | const MAX_ITEMS: usize = 5; | |
| 18 | − | const MAX_ITEM_CHARS: usize = 160; | |
| 19 | − | ||
| 20 | − | /// What the agent is asked, after its task. | |
| 21 | − | pub const ASK: &str = "Also end your final message with how sure you are that your change is right and complete, as JSON inside <g1t-confidence></g1t-confidence> tags: {\"confidence\": \"high\" | \"medium\" | \"low\", \"uncertain_about\": [\"a few words for each thing you could not verify or had to guess\"]}. Say high only if you ran the project's tests (and any other check you can run here) and they passed and nothing was guessed. The repository's CI workflows run as checks on the pull request after you push, and count on their own, so not having run them is not something to be unsure about. The block is taken out of your summary."; | |
| 22 | − | ||
| 23 | − | /// The prompt with the question added. | |
| 24 | − | pub fn ask(prompt: &str) -> String { | |
| 25 | − | format!("{prompt}\n\n{ASK}") | |
| 26 | − | } | |
| 27 | − | ||
| 28 | − | /// What the agent said: its level and what it was unsure about. | |
| 29 | − | #[derive(Debug, PartialEq)] | |
| 30 | − | pub struct SelfReport { | |
| 31 | − | pub confidence: &'static str, | |
| 32 | − | pub uncertain_about: Vec<String>, | |
| 33 | − | } | |
| 34 | − | ||
| 35 | − | fn clip(text: &str) -> String { | |
| 36 | − | let text = text.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 37 | − | text.chars().take(MAX_ITEM_CHARS).collect() | |
| 38 | − | } | |
| 39 | − | ||
| 40 | − | /// The summary without its `<g1t-confidence>` block, and what the block | |
| 41 | − | /// said. A missing or unreadable block reports nothing. | |
| 42 | − | pub fn split(summary: &str) -> (String, Option<SelfReport>) { | |
| 43 | − | let Some(start) = summary.rfind(OPEN) else { | |
| 44 | − | return (summary.trim().to_owned(), None); | |
| 45 | − | }; | |
| 46 | − | let body_start = start + OPEN.len(); | |
| 47 | − | let end = summary[body_start..].find(CLOSE).map(|at| body_start + at); | |
| 48 | − | let body = &summary[body_start..end.unwrap_or(summary.len())]; | |
| 49 | − | let rest = end.map_or("", |end| &summary[end + CLOSE.len()..]); | |
| 50 | − | let clean = format!("{}{}", summary[..start].trim_end(), rest.trim_end()).trim().to_owned(); | |
| 51 | − | let body = body.trim().trim_start_matches("```json").trim_start_matches("```").trim_end_matches("```").trim(); | |
| 52 | − | let Ok(value) = serde_json::from_str::<Value>(body) else { | |
| 53 | − | return (clean, None); | |
| 54 | − | }; | |
| 55 | − | let confidence = match value["confidence"].as_str().map(|level| level.trim().to_ascii_lowercase()) { | |
| 56 | − | Some(level) if level == "high" => "high", | |
| 57 | − | Some(level) if level == "medium" => "medium", | |
| 58 | − | Some(level) if level == "low" => "low", | |
| 59 | − | _ => return (clean, None), | |
| 60 | − | }; | |
| 61 | − | let uncertain_about = value["uncertain_about"] | |
| 62 | − | .as_array() | |
| 63 | − | .map(|items| { | |
| 64 | − | items | |
| 65 | − | .iter() | |
| 66 | − | .filter_map(Value::as_str) | |
| 67 | − | .map(clip) | |
| 68 | − | .filter(|item| !item.is_empty()) | |
| 69 | − | .take(MAX_ITEMS) | |
| 70 | − | .collect() | |
| 71 | − | }) | |
| 72 | − | .unwrap_or_default(); | |
| 73 | − | (clean, Some(SelfReport { confidence, uncertain_about })) | |
| 74 | − | } | |
| 75 | − | ||
| 76 | − | /// Sends what the agent said with the run's token. Never fails the run. | |
| 77 | − | pub fn report(said: &SelfReport) { | |
| 78 | − | let (Ok(run), Ok(token)) = (std::env::var("AGENT_RUN"), std::env::var("AGENT_RUN_TOKEN")) else { | |
| 79 | − | return; | |
| 80 | − | }; | |
| 81 | − | let api = std::env::var("G1T_API").unwrap_or_else(|_| "https://api.g1t.sh".to_owned()); | |
| 82 | − | let sent = ureq::post(&format!("{}/agent-runs/{run}/confidence", api.trim_end_matches('/'))) | |
| 83 | − | .timeout(Duration::from_secs(10)) | |
| 84 | − | .send_json(json!({ | |
| 85 | − | "token": token, | |
| 86 | − | "confidence": said.confidence, | |
| 87 | − | "uncertain_about": said.uncertain_about, | |
| 88 | − | })); | |
| 89 | − | if let Err(error) = sent { | |
| 90 | − | eprintln!("g1t-runner: could not report how sure the agent is: {error}"); | |
| 91 | − | } | |
| 92 | − | } | |
| 93 | − | ||
| 94 | − | /// The summary with its block taken out, after reporting what it said. | |
| 95 | − | pub fn finish(summary: String) -> String { | |
| 96 | − | let (clean, said) = split(&summary); | |
| 97 | − | if let Some(said) = &said { | |
| 98 | − | report(said); | |
| 99 | − | } | |
| 100 | − | clean | |
| 101 | − | } | |
| 102 | − | ||
| 103 | − | #[cfg(test)] | |
| 104 | − | mod tests { | |
| 105 | − | use super::*; | |
| 106 | − | ||
| 107 | − | #[test] | |
| 108 | − | fn the_block_is_taken_out_and_read() { | |
| 109 | − | let summary = "Added retries with backoff.\n\n<g1t-confidence>{\"confidence\": \"Medium\", \"uncertain_about\": [\" the retry limit \", \"\", 3]}</g1t-confidence>"; | |
| 110 | − | let (clean, said) = split(summary); | |
| 111 | − | assert_eq!(clean, "Added retries with backoff."); | |
| 112 | − | assert_eq!( | |
| 113 | − | said, | |
| 114 | − | Some(SelfReport { confidence: "medium", uncertain_about: vec!["the retry limit".to_owned()] }) | |
| 115 | − | ); | |
| 116 | − | } | |
| 117 | − | ||
| 118 | − | #[test] | |
| 119 | − | fn a_missing_or_unknown_level_reports_nothing() { | |
| 120 | − | assert_eq!(split("Done."), ("Done.".to_owned(), None)); | |
| 121 | − | assert_eq!(split("Done.\n<g1t-confidence>not json</g1t-confidence>").1, None); | |
| 122 | − | assert_eq!(split("Done.\n<g1t-confidence>{\"confidence\": \"very\"}</g1t-confidence>").1, None); | |
| 123 | − | let (clean, said) = split("Done.\n<g1t-confidence>\n```json\n{\"confidence\": \"high\"}\n```\n</g1t-confidence>"); | |
| 124 | − | assert_eq!(clean, "Done."); | |
| 125 | − | assert_eq!(said.map(|said| said.confidence), Some("high")); | |
| 126 | − | } | |
| 127 | − | ||
| 128 | − | #[test] | |
| 129 | − | fn it_leaves_the_learned_block_alone() { | |
| 130 | − | let summary = "Fixed it.\n<g1t-learned>[]</g1t-learned>\n<g1t-confidence>{\"confidence\": \"low\"}</g1t-confidence>"; | |
| 131 | − | let (clean, said) = split(summary); | |
| 132 | − | assert_eq!(clean, "Fixed it.\n<g1t-learned>[]</g1t-learned>"); | |
| 133 | − | assert_eq!(said.map(|said| said.confidence), Some("low")); | |
| 134 | − | assert!(ask("Fix it.").starts_with("Fix it.\n\n")); | |
| 135 | − | } | |
| 136 | − | } |
| 1 | − | //! What a job's Claude Code gets from the workspace's Customize | |
| 2 | − | //! (docs.g1t.sh/guides/self-hosted-runners/, "What a run gets from | |
| 3 | − | //! Customize"): the plugins the workspace installed, fetched at the commit | |
| 4 | − | //! each is pinned to; the library's skills, written as a plugin of their | |
| 5 | − | //! own; and the connectors that reach the job's agent, signed in. Read once | |
| 6 | − | //! per run with the run's agent token, before Claude Code starts, and never | |
| 7 | − | //! fatal: a run whose bundle cannot be read goes on without it, and the | |
| 8 | − | //! transcript says so. | |
| 9 | − | //! | |
| 10 | − | //! Plugins land in `/work/.g1t/plugins/<name>/` and are passed with | |
| 11 | − | //! `--plugin-dir`, one flag each, so a plugin's skills are | |
| 12 | − | //! `/<plugin>:<skill>` and the library's `/g1t-library:<skill>`. Connectors | |
| 13 | − | //! join the `--mcp-config` file's `mcpServers` by name, beside g1t's own | |
| 14 | − | //! server, so their tools are `mcp__<name>__<tool>`. The bundle holds the | |
| 15 | − | //! connectors' secrets: they go into that file and nowhere else, and are | |
| 16 | − | //! kept out of the transcript. | |
| 17 | − | ||
| 18 | − | use std::io::Read; | |
| 19 | − | use std::path::{Path, PathBuf}; | |
| 20 | − | use std::process::Command; | |
| 21 | − | use std::time::Duration; | |
| 22 | − | ||
| 23 | − | use base64::Engine; | |
| 24 | − | use base64::engine::general_purpose::STANDARD; | |
| 25 | − | use serde_json::{Value, json}; | |
| 26 | − | use sha2::{Digest, Sha256}; | |
| 27 | − | ||
| 28 | − | /// Where plugins are written, one folder each. | |
| 29 | − | pub(crate) const PLUGINS_DIR: &str = "/work/.g1t/plugins"; | |
| 30 | − | /// Downloads and clones on their way to `PLUGINS_DIR`. | |
| 31 | − | const TEMP_DIR: &str = "/work/.g1t/tmp"; | |
| 32 | − | /// The library's skills, as a plugin of their own. | |
| 33 | − | pub(crate) const LIBRARY: &str = "g1t-library"; | |
| 34 | − | /// How long one download or clone may take. | |
| 35 | − | const TIMEOUT: Duration = Duration::from_secs(120); | |
| 36 | − | /// The most a plugin's archive may be. | |
| 37 | − | const MAX_ARCHIVE: u64 = 256 * 1024 * 1024; | |
| 38 | − | ||
| 39 | − | /// What a run loaded from Customize: for Claude Code's command line and the | |
| 40 | − | /// run's transcript. | |
| 41 | − | #[derive(Debug, Default, PartialEq)] | |
| 42 | − | pub struct Loaded { | |
| 43 | − | /// `--plugin-dir <dir>` for each plugin, the library included. | |
| 44 | − | pub flags: Vec<String>, | |
| 45 | − | pub plugins: usize, | |
| 46 | − | pub skills: usize, | |
| 47 | − | pub connectors: usize, | |
| 48 | − | /// The connectors' header values, to keep out of the transcript. | |
| 49 | − | pub secrets: Vec<String>, | |
| 50 | − | /// What could not be loaded, one line each. | |
| 51 | − | pub problems: Vec<String>, | |
| 52 | − | } | |
| 53 | − | ||
| 54 | − | impl Loaded { | |
| 55 | − | /// Whether there is anything to say in the transcript. | |
| 56 | − | pub fn is_empty(&self) -> bool { | |
| 57 | − | self.plugins == 0 && self.skills == 0 && self.connectors == 0 && self.problems.is_empty() | |
| 58 | − | } | |
| 59 | − | ||
| 60 | − | /// One note for the run's transcript. | |
| 61 | − | pub fn note(&self) -> String { | |
| 62 | − | let count = |n: usize, one: &str| if n == 1 { format!("1 {one}") } else { format!("{n} {one}s") }; | |
| 63 | − | let mut note = format!( | |
| 64 | − | "Loaded {}, {} and {} from the workspace's Customize.", | |
| 65 | − | count(self.plugins, "plugin"), | |
| 66 | − | count(self.skills, "library skill"), | |
| 67 | − | count(self.connectors, "connector") | |
| 68 | − | ); | |
| 69 | − | for problem in &self.problems { | |
| 70 | − | note.push(' '); | |
| 71 | − | note.push_str(problem); | |
| 72 | − | } | |
| 73 | − | note | |
| 74 | − | } | |
| 75 | − | } | |
| 76 | − | ||
| 77 | − | /// The workspace a job's repository belongs to: the `owner` of | |
| 78 | − | /// `owner/name`, which is the workspace for a workspace's repositories. | |
| 79 | − | pub(crate) fn workspace_of(repo: &str) -> Option<&str> { | |
| 80 | − | let (owner, name) = repo.trim().trim_matches('/').split_once('/')?; | |
| 81 | − | (!owner.is_empty() && !name.is_empty() && !name.contains('/')).then_some(owner) | |
| 82 | − | } | |
| 83 | − | ||
| 84 | − | /// A name that is safe as one path segment and as a plugin's: letters, | |
| 85 | − | /// digits, `.`, `_` and `-`, not starting with `.` or `-`. | |
| 86 | − | fn safe_name(name: &str) -> bool { | |
| 87 | − | let mut chars = name.chars(); | |
| 88 | − | matches!(chars.next(), Some(first) if first.is_ascii_alphanumeric() || first == '_') | |
| 89 | − | && name.len() <= 100 | |
| 90 | − | && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) | |
| 91 | − | } | |
| 92 | − | ||
| 93 | − | /// `owner/name`, both safe. | |
| 94 | − | fn safe_repo(repo: &str) -> bool { | |
| 95 | − | matches!(repo.split_once('/'), Some((owner, name)) if safe_name(owner) && safe_name(name)) | |
| 96 | − | } | |
| 97 | − | ||
| 98 | − | /// A git ref or commit as an argument: never an option, never a path trick. | |
| 99 | − | fn safe_ref(git_ref: &str) -> bool { | |
| 100 | − | let mut chars = git_ref.chars(); | |
| 101 | − | matches!(chars.next(), Some(first) if first.is_ascii_alphanumeric()) | |
| 102 | − | && git_ref.len() <= 200 | |
| 103 | − | && !git_ref.contains("..") | |
| 104 | − | && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '/')) | |
| 105 | − | } | |
| 106 | − | ||
| 107 | − | /// A path inside a repository, `a/b/c` or empty for its root. | |
| 108 | − | fn safe_path(path: &str) -> bool { | |
| 109 | − | path.is_empty() || path.split('/').all(safe_name) | |
| 110 | − | } | |
| 111 | − | ||
| 112 | − | /// Where a plugin is fetched from, as the bundle's `fetch` says. | |
| 113 | − | #[derive(Debug, PartialEq, Eq)] | |
| 114 | − | pub(crate) enum Source { | |
| 115 | − | /// GitHub's tarball of `repo` at a commit or ref (`HEAD` when neither). | |
| 116 | − | Github { repo: String, at: String, path: String }, | |
| 117 | − | /// A repository on this g1t, cloned at a ref (`HEAD` when none). | |
| 118 | − | G1t { repo: String, git_ref: String, path: String }, | |
| 119 | − | /// A zip at a URL, checked against `sha256` when given. | |
| 120 | − | Archive { url: String, sha256: Option<String> }, | |
| 121 | − | } | |
| 122 | − | ||
| 123 | − | #[derive(Debug, PartialEq, Eq)] | |
| 124 | − | pub(crate) struct Plugin { | |
| 125 | − | pub name: String, | |
| 126 | − | pub source: Source, | |
| 127 | − | } | |
| 128 | − | ||
| 129 | − | /// A plugin entry of the bundle, read: its name as a folder and where its | |
| 130 | − | /// files come from, or why it cannot be fetched here. | |
| 131 | − | pub(crate) fn plugin_of(entry: &Value) -> Result<Plugin, String> { | |
| 132 | − | let name = entry["name"].as_str().unwrap_or_default().trim(); | |
| 133 | − | let shown = entry["ref"].as_str().unwrap_or(name); | |
| 134 | − | if !safe_name(name) || name == LIBRARY { | |
| 135 | − | return Err(format!("{shown}: not a name a plugin folder can have.")); | |
| 136 | − | } | |
| 137 | − | let fetch = &entry["fetch"]; | |
| 138 | − | let text = |key: &str| fetch[key].as_str().map(str::trim).filter(|value| !value.is_empty()).map(str::to_owned); | |
| 139 | − | // `./plugins/x/`, `plugins/x` and `.` all name the same folder. | |
| 140 | − | let given = text("path").unwrap_or_default(); | |
| 141 | − | let parts: Vec<&str> = given.split('/').filter(|part| !part.is_empty() && *part != ".").collect(); | |
| 142 | − | if !parts.iter().all(|part| safe_name(part)) { | |
| 143 | − | return Err(format!("{shown}: its path in the repository will not do.")); | |
| 144 | − | } | |
| 145 | − | let path = parts.join("/"); | |
| 146 | − | let source = match fetch["kind"].as_str() { | |
| 147 | − | Some("github") => { | |
| 148 | − | let repo = text("repo").filter(|repo| safe_repo(repo)).ok_or_else(|| format!("{shown}: no GitHub repository to read."))?; | |
| 149 | − | let at = text("sha").or_else(|| text("ref")).unwrap_or_else(|| "HEAD".to_owned()); | |
| 150 | − | if !safe_ref(&at) { | |
| 151 | − | return Err(format!("{shown}: {at} is not a commit or ref.")); | |
| 152 | − | } | |
| 153 | − | Source::Github { repo, at, path } | |
| 154 | − | } | |
| 155 | − | Some("g1t") => { | |
| 156 | − | let repo = text("repo").filter(|repo| safe_repo(repo)).ok_or_else(|| format!("{shown}: no repository on g1t to read."))?; | |
| 157 | − | let git_ref = text("ref").unwrap_or_else(|| "HEAD".to_owned()); | |
| 158 | − | if !safe_ref(&git_ref) { | |
| 159 | − | return Err(format!("{shown}: {git_ref} is not a commit or ref.")); | |
| 160 | − | } | |
| 161 | − | Source::G1t { repo, git_ref, path } | |
| 162 | − | } | |
| 163 | − | Some("archive") => { | |
| 164 | − | let url = text("url").filter(|url| url.starts_with("https://")).ok_or_else(|| format!("{shown}: its archive has no https address."))?; | |
| 165 | − | let sha256 = text("sha256").map(|sha| sha.to_ascii_lowercase()); | |
| 166 | − | if sha256.as_deref().is_some_and(|sha| sha.len() != 64 || !sha.bytes().all(|b| b.is_ascii_hexdigit())) { | |
| 167 | − | return Err(format!("{shown}: its archive's sha256 will not do.")); | |
| 168 | − | } | |
| 169 | − | Source::Archive { url, sha256 } | |
| 170 | − | } | |
| 171 | − | Some("unsupported") => { | |
| 172 | − | return Err(format!("{shown}: {}", fetch["why"].as_str().unwrap_or("g1t cannot fetch it here."))); | |
| 173 | − | } | |
| 174 | − | _ => return Err(format!("{shown}: g1t does not know where to fetch it from.")), | |
| 175 | − | }; | |
| 176 | − | Ok(Plugin { name: name.to_owned(), source }) | |
| 177 | − | } | |
| 178 | − | ||
| 179 | − | /// A plugin's folder. | |
| 180 | − | pub(crate) fn plugin_dir(name: &str) -> String { | |
| 181 | − | format!("{PLUGINS_DIR}/{name}") | |
| 182 | − | } | |
| 183 | − | ||
| 184 | − | /// `--plugin-dir <dir>` for each folder. | |
| 185 | − | pub(crate) fn plugin_flags<'a>(dirs: impl IntoIterator<Item = &'a str>) -> Vec<String> { | |
| 186 | − | dirs.into_iter().flat_map(|dir| ["--plugin-dir".to_owned(), dir.to_owned()]).collect() | |
| 187 | − | } | |
| 188 | − | ||
| 189 | − | /// The library plugin's files, relative to its folder: its manifest and | |
| 190 | − | /// each skill's files under `skills/<name>/`. Skills that cannot be written | |
| 191 | − | /// as given are left out; how many were kept comes back with the files. | |
| 192 | − | pub(crate) fn library_files(skills: &[Value]) -> (Vec<(String, Vec<u8>)>, usize) { | |
| 193 | − | let mut files = vec![( | |
| 194 | − | ".claude-plugin/plugin.json".to_owned(), | |
| 195 | − | json!({ "name": LIBRARY, "description": "The workspace's skill library on g1t." }).to_string().into_bytes(), | |
| 196 | − | )]; | |
| 197 | − | let mut kept = 0; | |
| 198 | − | for skill in skills { | |
| 199 | − | let name = skill["name"].as_str().unwrap_or_default().trim(); | |
| 200 | − | let Some(entries) = skill["files"].as_array().filter(|files| !files.is_empty()) else { continue }; | |
| 201 | − | if !safe_name(name) { | |
| 202 | − | continue; | |
| 203 | − | } | |
| 204 | − | let mut own = Vec::new(); | |
| 205 | − | for file in entries { | |
| 206 | − | let path = file["path"].as_str().unwrap_or_default().trim().trim_matches('/'); | |
| 207 | − | if path.is_empty() || !safe_path(path) { | |
| 208 | − | own.clear(); | |
| 209 | − | break; | |
| 210 | − | } | |
| 211 | − | let content = file["content"].as_str().unwrap_or_default(); | |
| 212 | − | let bytes = if file["encoding"] == "base64" { | |
| 213 | − | match STANDARD.decode(content.trim()) { | |
| 214 | − | Ok(bytes) => bytes, | |
| 215 | − | Err(_) => { | |
| 216 | − | own.clear(); | |
| 217 | − | break; | |
| 218 | − | } | |
| 219 | − | } | |
| 220 | − | } else { | |
| 221 | − | content.as_bytes().to_vec() | |
| 222 | − | }; | |
| 223 | − | own.push((format!("skills/{name}/{path}"), bytes)); | |
| 224 | − | } | |
| 225 | − | if own.iter().any(|(path, _)| path.ends_with("/SKILL.md")) { | |
| 226 | − | files.extend(own); | |
| 227 | − | kept += 1; | |
| 228 | − | } | |
| 229 | − | } | |
| 230 | − | (files, kept) | |
| 231 | − | } | |
| 232 | − | ||
| 233 | − | /// Adds the connectors to the MCP config's `mcpServers`, each as an HTTP | |
| 234 | − | /// server by its name with the headers that sign it in. g1t's own server | |
| 235 | − | /// stays whatever a connector is called. Returns how many were added and | |
| 236 | − | /// the header values, which must never reach the transcript. | |
| 237 | − | pub(crate) fn merge_connectors(config: &mut Value, connectors: &[Value]) -> (usize, Vec<String>) { | |
| 238 | − | if !config["mcpServers"].is_object() { | |
| 239 | − | config["mcpServers"] = json!({}); | |
| 240 | − | } | |
| 241 | − | let mut added = 0; | |
| 242 | − | let mut secrets = Vec::new(); | |
| 243 | − | for connector in connectors { | |
| 244 | − | let name = connector["name"].as_str().unwrap_or_default().trim(); | |
| 245 | − | let url = connector["url"].as_str().unwrap_or_default().trim(); | |
| 246 | − | if name.is_empty() || name == "g1t" || !safe_name(name) || !url.starts_with("https://") { | |
| 247 | − | continue; | |
| 248 | − | } | |
| 249 | − | let headers: serde_json::Map<String, Value> = connector["headers"] | |
| 250 | − | .as_object() | |
| 251 | − | .map(|headers| headers.iter().filter(|(_, value)| value.is_string()).map(|(key, value)| (key.clone(), value.clone())).collect()) | |
| 252 | − | .unwrap_or_default(); | |
| 253 | − | secrets.extend(headers.values().filter_map(Value::as_str).filter(|value| value.len() >= 8).map(str::to_owned)); | |
| 254 | − | config["mcpServers"][name] = json!({ "type": "http", "url": url, "headers": headers }); | |
| 255 | − | added += 1; | |
| 256 | − | } | |
| 257 | − | (added, secrets) | |
| 258 | − | } | |
| 259 | − | ||
| 260 | − | /// This g1t's git origin, for cloning its repositories: where the job's | |
| 261 | − | /// own clone came from, else the API's host without its `api.`. | |
| 262 | − | pub(crate) fn origin_of(remote: Option<&str>, api: Option<&str>) -> String { | |
| 263 | − | if let Some(remote) = remote | |
| 264 | − | && let Some((scheme, rest)) = remote.split_once("://") | |
| 265 | − | && let Some(host) = rest.split('/').next().filter(|host| !host.is_empty() && !host.contains('@')) | |
| 266 | − | { | |
| 267 | − | return format!("{scheme}://{host}"); | |
| 268 | − | } | |
| 269 | − | let api = api.unwrap_or("https://api.g1t.sh").trim_end_matches('/'); | |
| 270 | − | api.replacen("://api.", "://", 1) | |
| 271 | − | } | |
| 272 | − | ||
| 273 | − | /// Reads the bundle with the run's agent token. | |
| 274 | − | fn fetch_bundle(api: &str, workspace: &str, token: &str) -> Result<Value, String> { | |
| 275 | − | let response = ureq::get(&format!("{api}/workspaces/{workspace}/customize/bundle")) | |
| 276 | − | .set("authorization", &format!("Bearer {token}")) | |
| 277 | − | .timeout(TIMEOUT) | |
| 278 | − | .call() | |
| 279 | − | .map_err(|error| match error { | |
| 280 | − | ureq::Error::Status(code, response) => { | |
| 281 | − | let body: Value = response.into_json().unwrap_or(Value::Null); | |
| 282 | − | body["error"]["message"].as_str().map_or_else(|| format!("g1t answered {code}"), str::to_owned) | |
| 283 | − | } | |
| 284 | − | other => other.to_string(), | |
| 285 | − | })?; | |
| 286 | − | response.into_json().map_err(|error| error.to_string()) | |
| 287 | − | } | |
| 288 | − | ||
| 289 | − | /// Downloads `url` to `to`, up to `MAX_ARCHIVE` bytes. | |
| 290 | − | fn download(url: &str, to: &Path) -> Result<(), String> { | |
| 291 | − | let response = ureq::get(url).timeout(TIMEOUT).call().map_err(|error| error.to_string())?; | |
| 292 | − | let mut file = std::fs::File::create(to).map_err(|error| error.to_string())?; | |
| 293 | − | let mut reader = response.into_reader().take(MAX_ARCHIVE + 1); | |
| 294 | − | let copied = std::io::copy(&mut reader, &mut file).map_err(|error| error.to_string())?; | |
| 295 | − | if copied > MAX_ARCHIVE { | |
| 296 | − | return Err("it is larger than 256 MB".to_owned()); | |
| 297 | − | } | |
| 298 | − | Ok(()) | |
| 299 | − | } | |
| 300 | − | ||
| 301 | − | /// `tar -xzf archive -C into --strip-components=1`. | |
| 302 | − | fn untar(archive: &Path, into: &Path) -> Result<(), String> { | |
| 303 | − | std::fs::create_dir_all(into).map_err(|error| error.to_string())?; | |
| 304 | − | let output = Command::new("tar") | |
| 305 | − | .arg("-xzf") | |
| 306 | − | .arg(archive) | |
| 307 | − | .arg("-C") | |
| 308 | − | .arg(into) | |
| 309 | − | .arg("--strip-components=1") | |
| 310 | − | .output() | |
| 311 | − | .map_err(|error| format!("could not run tar: {error}"))?; | |
| 312 | − | if output.status.success() { | |
| 313 | − | Ok(()) | |
| 314 | − | } else { | |
| 315 | − | Err(format!("tar failed: {}", String::from_utf8_lossy(&output.stderr).trim())) | |
| 316 | − | } | |
| 317 | − | } | |
| 318 | − | ||
| 319 | − | /// Moves `path` inside `extracted` (its root when empty) to `dest`. | |
| 320 | − | fn take_path(extracted: &Path, path: &str, dest: &Path) -> Result<(), String> { | |
| 321 | − | let source = if path.is_empty() { extracted.to_path_buf() } else { extracted.join(path) }; | |
| 322 | − | if !source.is_dir() { | |
| 323 | − | return Err(format!("it has no folder {}", if path.is_empty() { "at its root" } else { path })); | |
| 324 | − | } | |
| 325 | − | let _ = std::fs::remove_dir_all(dest); | |
| 326 | − | if let Some(parent) = dest.parent() { | |
| 327 | − | std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; | |
| 328 | − | } | |
| 329 | − | std::fs::rename(&source, dest).map_err(|error| error.to_string()) | |
| 330 | − | } | |
| 331 | − | ||
| 332 | − | /// A fresh folder under `TEMP_DIR`. | |
| 333 | − | fn scratch(label: &str) -> Result<PathBuf, String> { | |
| 334 | − | let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); | |
| 335 | − | let dir = Path::new(TEMP_DIR).join(format!("{label}-{nanos}")); | |
| 336 | − | std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; | |
| 337 | − | Ok(dir) | |
| 338 | − | } | |
| 339 | − | ||
| 340 | − | fn fetch_github(repo: &str, at: &str, path: &str, dest: &Path) -> Result<(), String> { | |
| 341 | − | let temp = scratch("github")?; | |
| 342 | − | let archive = temp.join("plugin.tar.gz"); | |
| 343 | − | let extracted = temp.join("extracted"); | |
| 344 | − | let result = download(&format!("https://codeload.github.com/{repo}/tar.gz/{at}"), &archive) | |
| 345 | − | .and_then(|()| untar(&archive, &extracted)) | |
| 346 | − | .and_then(|()| take_path(&extracted, path, dest)); | |
| 347 | − | let _ = std::fs::remove_dir_all(&temp); | |
| 348 | − | result | |
| 349 | − | } | |
| 350 | − | ||
| 351 | − | fn fetch_g1t(repo: &str, git_ref: &str, path: &str, dest: &Path, token: &str) -> Result<(), String> { | |
| 352 | − | let temp = scratch("g1t")?; | |
| 353 | − | let clone = temp.join("clone"); | |
| 354 | − | let origin = origin_of(std::env::var("GIT_REMOTE").ok().as_deref(), std::env::var("G1T_API").ok().as_deref()); | |
| 355 | − | let auth = crate::auth_option("x-access-token", token); | |
| 356 | − | let result = std::fs::create_dir_all(&clone) | |
| 357 | − | .map_err(|error| error.to_string()) | |
| 358 | − | .and_then(|()| crate::git(&clone, &["init", "--quiet"]).map_err(|error| error.to_string())) | |
| 359 | − | .and_then(|_| crate::git(&clone, &["remote", "add", "origin", &format!("{origin}/{repo}.git")]).map_err(|error| error.to_string())) | |
| 360 | − | .and_then(|_| { | |
| 361 | − | crate::git(&clone, &["-c", &auth, "fetch", "--quiet", "--depth=1", "--no-tags", "origin", git_ref]).map_err(|error| error.to_string()) | |
| 362 | − | }) | |
| 363 | − | .and_then(|_| crate::git(&clone, &["checkout", "--quiet", "--force", "--detach", "FETCH_HEAD"]).map_err(|error| error.to_string())) | |
| 364 | − | .and_then(|_| { | |
| 365 | − | // The clone's history is not the plugin's business. | |
| 366 | − | let _ = std::fs::remove_dir_all(clone.join(".git")); | |
| 367 | − | take_path(&clone, path, dest) | |
| 368 | − | }); | |
| 369 | − | let _ = std::fs::remove_dir_all(&temp); | |
| 370 | − | result | |
| 371 | − | } | |
| 372 | − | ||
| 373 | − | fn fetch_archive(url: &str, sha256: Option<&str>, dest: &Path) -> Result<(), String> { | |
| 374 | − | let temp = scratch("archive")?; | |
| 375 | − | let zip = temp.join("plugin.zip"); | |
| 376 | − | let extracted = temp.join("extracted"); | |
| 377 | − | let result = download(url, &zip) | |
| 378 | − | .and_then(|()| match sha256 { | |
| 379 | − | Some(expected) => { | |
| 380 | − | let bytes = std::fs::read(&zip).map_err(|error| error.to_string())?; | |
| 381 | − | let actual = hex::encode(Sha256::digest(&bytes)); | |
| 382 | − | if actual == expected { Ok(()) } else { Err("its sha256 is not the one the marketplace gave".to_owned()) } | |
| 383 | − | } | |
| 384 | − | None => Ok(()), | |
| 385 | − | }) | |
| 386 | − | .and_then(|()| crate::actions::zip::extract(&zip, &extracted).map(|_| ()).map_err(|error| error.to_string())) | |
| 387 | − | .and_then(|()| { | |
| 388 | − | // A zip of one folder holds the plugin inside it. | |
| 389 | − | let mut entries: Vec<PathBuf> = std::fs::read_dir(&extracted).map_err(|error| error.to_string())?.filter_map(|entry| entry.ok().map(|entry| entry.path())).collect(); | |
| 390 | − | let root = match entries.as_slice() { | |
| 391 | − | [only] if only.is_dir() && !extracted.join(".claude-plugin").exists() => entries.remove(0), | |
| 392 | − | _ => extracted.clone(), | |
| 393 | − | }; | |
| 394 | − | take_path(&root, "", dest) | |
| 395 | − | }); | |
| 396 | − | let _ = std::fs::remove_dir_all(&temp); | |
| 397 | − | result | |
| 398 | − | } | |
| 399 | − | ||
| 400 | − | fn fetch_plugin(plugin: &Plugin, token: &str) -> Result<String, String> { | |
| 401 | − | let dir = plugin_dir(&plugin.name); | |
| 402 | − | let dest = Path::new(&dir); | |
| 403 | − | match &plugin.source { | |
| 404 | − | Source::Github { repo, at, path } => fetch_github(repo, at, path, dest)?, | |
| 405 | − | Source::G1t { repo, git_ref, path } => fetch_g1t(repo, git_ref, path, dest, token)?, | |
| 406 | − | Source::Archive { url, sha256 } => fetch_archive(url, sha256.as_deref(), dest)?, | |
| 407 | − | } | |
| 408 | − | Ok(dir) | |
| 409 | − | } | |
| 410 | − | ||
| 411 | − | /// Writes the library plugin; its folder, or None when there is nothing | |
| 412 | − | /// to write or it could not be. | |
| 413 | − | fn write_library(skills: &[Value]) -> Result<Option<(String, usize)>, String> { | |
| 414 | − | let (files, kept) = library_files(skills); | |
| 415 | − | if kept == 0 { | |
| 416 | − | return Ok(None); | |
| 417 | − | } | |
| 418 | − | let dir = plugin_dir(LIBRARY); | |
| 419 | − | let root = Path::new(&dir); | |
| 420 | − | let _ = std::fs::remove_dir_all(root); | |
| 421 | − | for (path, bytes) in files { | |
| 422 | − | let target = root.join(&path); | |
| 423 | − | if let Some(parent) = target.parent() { | |
| 424 | − | std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; | |
| 425 | − | } | |
| 426 | − | std::fs::write(&target, bytes).map_err(|error| error.to_string())?; | |
| 427 | − | } | |
| 428 | − | Ok(Some((dir, kept))) | |
| 429 | − | } | |
| 430 | − | ||
| 431 | − | /// Reads the workspace's bundle with the run's agent token and makes it | |
| 432 | − | /// ready for Claude Code: plugins and the library on disk, as `--plugin-dir` | |
| 433 | − | /// flags, and connectors merged into `config`, the `--mcp-config` file. | |
| 434 | − | /// Never fails the run: whatever could not be loaded is in `problems`. | |
| 435 | − | pub fn prepare(token: &str, config: &mut Value) -> Loaded { | |
| 436 | − | let mut loaded = Loaded::default(); | |
| 437 | − | let api = std::env::var("G1T_API").unwrap_or_else(|_| "https://api.g1t.sh".to_owned()); | |
| 438 | − | let repo = std::env::var("G1T_REPO").unwrap_or_default(); | |
| 439 | − | let Some(workspace) = workspace_of(&repo) else { | |
| 440 | − | return loaded; | |
| 441 | − | }; | |
| 442 | − | let bundle = match fetch_bundle(&api, workspace, token) { | |
| 443 | − | Ok(bundle) => bundle, | |
| 444 | − | Err(why) => { | |
| 445 | − | loaded.problems.push(format!("Could not read the workspace's Customize ({why}); the run goes on without its plugins, skills and connectors.")); | |
| 446 | − | return loaded; | |
| 447 | − | } | |
| 448 | − | }; | |
| 449 | − | let mut dirs = Vec::new(); | |
| 450 | − | for entry in bundle["plugins"].as_array().map(Vec::as_slice).unwrap_or_default() { | |
| 451 | − | match plugin_of(entry).and_then(|plugin| fetch_plugin(&plugin, token)) { | |
| 452 | − | Ok(dir) => { | |
| 453 | − | dirs.push(dir); | |
| 454 | − | loaded.plugins += 1; | |
| 455 | − | } | |
| 456 | − | Err(why) => loaded.problems.push(format!("The plugin {why}", why = why.trim_end_matches('.')) + " was not loaded."), | |
| 457 | − | } | |
| 458 | − | } | |
| 459 | − | match write_library(bundle["skills"].as_array().map(Vec::as_slice).unwrap_or_default()) { | |
| 460 | − | Ok(Some((dir, kept))) => { | |
| 461 | − | dirs.push(dir); | |
| 462 | − | loaded.skills = kept; | |
| 463 | − | } | |
| 464 | − | Ok(None) => {} | |
| 465 | − | Err(why) => loaded.problems.push(format!("The library's skills were not written ({why}).")), | |
| 466 | − | } | |
| 467 | − | let (connectors, secrets) = merge_connectors(config, bundle["connectors"].as_array().map(Vec::as_slice).unwrap_or_default()); | |
| 468 | − | loaded.connectors = connectors; | |
| 469 | − | loaded.secrets = secrets; | |
| 470 | − | loaded.flags = plugin_flags(dirs.iter().map(String::as_str)); | |
| 471 | − | loaded | |
| 472 | − | } | |
| 473 | − | ||
| 474 | − | #[cfg(test)] | |
| 475 | − | mod tests { | |
| 476 | − | use super::*; | |
| 477 | − | ||
| 478 | − | #[test] | |
| 479 | − | fn the_workspace_is_the_repositorys_owner() { | |
| 480 | − | assert_eq!(workspace_of("flagon-io/g1t"), Some("flagon-io")); | |
| 481 | − | assert_eq!(workspace_of("/acme/web/"), Some("acme")); | |
| 482 | − | assert_eq!(workspace_of("acme"), None); | |
| 483 | − | assert_eq!(workspace_of("a/b/c"), None); | |
| 484 | − | assert_eq!(workspace_of(""), None); | |
| 485 | − | } | |
| 486 | − | ||
| 487 | − | #[test] | |
| 488 | − | fn a_plugin_is_fetched_at_its_pinned_commit() { | |
| 489 | − | let entry = json!({ | |
| 490 | − | "ref": "commit-commands@claude-plugins-official", | |
| 491 | − | "name": "commit-commands", | |
| 492 | − | "fetch": { "kind": "github", "repo": "anthropics/claude-plugins-official", "ref": "main", "sha": "9c1f4a6e8b0d2c4f6a8e1b3d5f7a9c1e3b5d7f90", "path": "plugins/commit-commands" }, | |
| 493 | − | }); | |
| 494 | − | assert_eq!( | |
| 495 | − | plugin_of(&entry).unwrap(), | |
| 496 | − | Plugin { | |
| 497 | − | name: "commit-commands".into(), | |
| 498 | − | source: Source::Github { | |
| 499 | − | repo: "anthropics/claude-plugins-official".into(), | |
| 500 | − | at: "9c1f4a6e8b0d2c4f6a8e1b3d5f7a9c1e3b5d7f90".into(), | |
| 501 | − | path: "plugins/commit-commands".into(), | |
| 502 | − | }, | |
| 503 | − | } | |
| 504 | − | ); | |
| 505 | − | // Without a commit, the ref; without either, HEAD. | |
| 506 | − | let by_ref = json!({ "name": "x", "fetch": { "kind": "github", "repo": "a/b", "ref": "v1.2", "sha": null, "path": "" } }); | |
| 507 | − | assert!(matches!(plugin_of(&by_ref).unwrap().source, Source::Github { at, path, .. } if at == "v1.2" && path.is_empty())); | |
| 508 | − | let head = json!({ "name": "x", "fetch": { "kind": "github", "repo": "a/b", "ref": null, "sha": null, "path": "./" } }); | |
| 509 | − | assert!(matches!(plugin_of(&head).unwrap().source, Source::Github { at, .. } if at == "HEAD")); | |
| 510 | − | let here = json!({ "name": "release-notes", "fetch": { "kind": "g1t", "repo": "flagon-io/plugins", "ref": null, "path": "plugins/release-notes" } }); | |
| 511 | − | assert_eq!( | |
| 512 | − | plugin_of(&here).unwrap().source, | |
| 513 | − | Source::G1t { repo: "flagon-io/plugins".into(), git_ref: "HEAD".into(), path: "plugins/release-notes".into() } | |
| 514 | − | ); | |
| 515 | − | let zipped = json!({ "name": "z", "fetch": { "kind": "archive", "url": "https://example.com/z.zip", "sha256": "A".repeat(64) } }); | |
| 516 | − | assert_eq!(plugin_of(&zipped).unwrap().source, Source::Archive { url: "https://example.com/z.zip".into(), sha256: Some("a".repeat(64)) }); | |
| 517 | − | } | |
| 518 | − | ||
| 519 | − | #[test] | |
| 520 | − | fn plugins_that_cannot_be_fetched_say_why_and_never_escape_their_folder() { | |
| 521 | − | let bad = |entry: Value| plugin_of(&entry).unwrap_err(); | |
| 522 | − | assert!(bad(json!({ "name": "npm-thing", "fetch": { "kind": "unsupported", "why": "npm packages are not read on g1t." } })).contains("npm packages")); | |
| 523 | − | assert!(bad(json!({ "name": "../etc", "fetch": { "kind": "github", "repo": "a/b" } })).contains("folder")); | |
| 524 | − | assert!(bad(json!({ "name": "g1t-library", "fetch": { "kind": "github", "repo": "a/b" } })).contains("folder")); | |
| 525 | − | assert!(bad(json!({ "name": "x", "fetch": { "kind": "github", "repo": "a/b", "path": "../../home" } })).contains("path")); | |
| 526 | − | assert!(bad(json!({ "name": "x", "fetch": { "kind": "github", "repo": "a/b", "ref": "--upload-pack=evil" } })).contains("not a commit")); | |
| 527 | − | assert!(bad(json!({ "name": "x", "fetch": { "kind": "github", "repo": "a b/c" } })).contains("no GitHub repository")); | |
| 528 | − | assert!(bad(json!({ "name": "x", "fetch": { "kind": "archive", "url": "http://plain.example/z.zip" } })).contains("https")); | |
| 529 | − | assert!(bad(json!({ "name": "x", "fetch": { "kind": "archive", "url": "https://x/z.zip", "sha256": "abc" } })).contains("sha256")); | |
| 530 | − | assert!(bad(json!({ "name": "x", "fetch": { "kind": "command" } })).contains("where to fetch")); | |
| 531 | − | } | |
| 532 | − | ||
| 533 | − | #[test] | |
| 534 | − | fn each_plugin_folder_is_one_flag() { | |
| 535 | − | assert_eq!( | |
| 536 | − | plugin_flags([plugin_dir("a").as_str(), plugin_dir("g1t-library").as_str()]), | |
| 537 | − | vec!["--plugin-dir", "/work/.g1t/plugins/a", "--plugin-dir", "/work/.g1t/plugins/g1t-library"] | |
| 538 | − | ); | |
| 539 | − | assert!(plugin_flags([]).is_empty()); | |
| 540 | − | } | |
| 541 | − | ||
| 542 | − | #[test] | |
| 543 | − | fn library_skills_become_a_plugin_of_their_own() { | |
| 544 | − | let skills = vec![ | |
| 545 | − | json!({ "name": "house-style", "plugin": null, "files": [ | |
| 546 | − | { "path": "SKILL.md", "content": "---\nname: house-style\n---\nHow we write.", "encoding": "utf8" }, | |
| 547 | − | { "path": "examples/commit.md", "content": "IyBBIGdvb2QgY29tbWl0", "encoding": "base64" }, | |
| 548 | − | ] }), | |
| 549 | − | // No SKILL.md: not a skill Claude Code can read. | |
| 550 | − | json!({ "name": "broken", "plugin": null, "files": [{ "path": "notes.md", "content": "x", "encoding": "utf8" }] }), | |
| 551 | − | // A path that leaves its folder takes the whole skill out. | |
| 552 | − | json!({ "name": "sneaky", "plugin": null, "files": [ | |
| 553 | − | { "path": "SKILL.md", "content": "x", "encoding": "utf8" }, | |
| 554 | − | { "path": "../../etc/passwd", "content": "x", "encoding": "utf8" }, | |
| 555 | − | ] }), | |
| 556 | − | json!({ "name": "../up", "plugin": null, "files": [{ "path": "SKILL.md", "content": "x", "encoding": "utf8" }] }), | |
| 557 | − | json!({ "name": "empty", "plugin": null, "files": [] }), | |
| 558 | − | ]; | |
| 559 | − | let (files, kept) = library_files(&skills); | |
| 560 | − | assert_eq!(kept, 1); | |
| 561 | − | let paths: Vec<&str> = files.iter().map(|(path, _)| path.as_str()).collect(); | |
| 562 | − | assert_eq!(paths, vec![".claude-plugin/plugin.json", "skills/house-style/SKILL.md", "skills/house-style/examples/commit.md"]); | |
| 563 | − | let manifest: Value = serde_json::from_slice(&files[0].1).unwrap(); | |
| 564 | − | assert_eq!(manifest["name"], "g1t-library"); | |
| 565 | − | assert_eq!(files[2].1, b"# A good commit"); | |
| 566 | − | } | |
| 567 | − | ||
| 568 | − | #[test] | |
| 569 | − | fn connectors_join_the_mcp_config_beside_g1ts_own_server() { | |
| 570 | − | let mut config = json!({ "mcpServers": { "g1t": { "type": "http", "url": "https://mcp.g1t.sh", "headers": { "Authorization": "Bearer run-token" } } } }); | |
| 571 | − | let connectors = vec![ | |
| 572 | − | json!({ "name": "linear", "url": "https://mcp.linear.app/mcp", "headers": { "Authorization": "Bearer lin_oauth_secret_value" } }), | |
| 573 | − | json!({ "name": "docs-search", "url": "https://mcp.flagon.dev/docs", "headers": { "X-Api-Key": "fd_live_key_value" } }), | |
| 574 | − | // Nothing takes g1t's own place, and nothing unsafe is written. | |
| 575 | − | json!({ "name": "g1t", "url": "https://evil.example/mcp", "headers": {} }), | |
| 576 | − | json!({ "name": "plain", "url": "http://insecure.example/mcp", "headers": {} }), | |
| 577 | − | json!({ "name": "bad name", "url": "https://x.example/mcp", "headers": {} }), | |
| 578 | − | ]; | |
| 579 | − | let (added, secrets) = merge_connectors(&mut config, &connectors); | |
| 580 | − | assert_eq!(added, 2); | |
| 581 | − | assert_eq!(secrets, vec!["Bearer lin_oauth_secret_value", "fd_live_key_value"]); | |
| 582 | − | let servers = config["mcpServers"].as_object().unwrap(); | |
| 583 | − | assert_eq!(servers.len(), 3); | |
| 584 | − | assert_eq!(servers["g1t"]["url"], "https://mcp.g1t.sh"); | |
| 585 | − | assert_eq!(servers["linear"], json!({ "type": "http", "url": "https://mcp.linear.app/mcp", "headers": { "Authorization": "Bearer lin_oauth_secret_value" } })); | |
| 586 | − | assert_eq!(servers["docs-search"]["type"], "http"); | |
| 587 | − | // A config without servers gets the map. | |
| 588 | − | let mut bare = json!({}); | |
| 589 | − | assert_eq!(merge_connectors(&mut bare, &connectors[..1]).0, 1); | |
| 590 | − | assert!(bare["mcpServers"]["linear"].is_object()); | |
| 591 | − | } | |
| 592 | − | ||
| 593 | − | #[test] | |
| 594 | − | fn the_g1t_origin_comes_from_the_clone_or_the_api() { | |
| 595 | − | assert_eq!(origin_of(Some("https://g1t.sh/flagon-io/g1t.git"), None), "https://g1t.sh"); | |
| 596 | − | assert_eq!(origin_of(Some("https://git.example.com/acme/web"), Some("https://api.example.com")), "https://git.example.com"); | |
| 597 | − | assert_eq!(origin_of(None, Some("https://api.g1t.sh")), "https://g1t.sh"); | |
| 598 | − | assert_eq!(origin_of(None, Some("https://api.self-hosted.example/")), "https://self-hosted.example"); | |
| 599 | − | assert_eq!(origin_of(Some("not a url"), None), "https://g1t.sh"); | |
| 600 | − | // A credential in the remote is never copied into a clone address. | |
| 601 | − | assert_eq!(origin_of(Some("https://user:token@g1t.sh/a/b"), Some("https://api.g1t.sh")), "https://g1t.sh"); | |
| 602 | − | } | |
| 603 | − | ||
| 604 | − | #[test] | |
| 605 | − | fn the_note_counts_what_was_loaded_and_says_what_was_not() { | |
| 606 | − | let loaded = Loaded { plugins: 2, skills: 1, connectors: 0, problems: vec!["The plugin x@y: tar failed was not loaded.".into()], ..Loaded::default() }; | |
| 607 | − | assert_eq!( | |
| 608 | − | loaded.note(), | |
| 609 | − | "Loaded 2 plugins, 1 library skill and 0 connectors from the workspace's Customize. The plugin x@y: tar failed was not loaded." | |
| 610 | − | ); | |
| 611 | − | assert!(Loaded::default().is_empty()); | |
| 612 | − | assert!(!loaded.is_empty()); | |
| 613 | − | } | |
| 614 | − | } |
| 1 | − | //! The agent harness. g1t does not implement its own agent loop: it runs | |
| 2 | − | //! Claude Code headless and translates its event stream into session | |
| 3 | − | //! entries. | |
| 4 | − | ||
| 5 | − | use std::io::{BufRead, BufReader}; | |
| 6 | − | use std::path::Path; | |
| 7 | − | use std::process::{Command, Stdio}; | |
| 8 | − | use std::sync::Arc; | |
| 9 | − | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 10 | − | use std::time::Duration; | |
| 11 | − | ||
| 12 | − | use anyhow::{Context, Result, bail}; | |
| 13 | − | use serde_json::Value; | |
| 14 | − | ||
| 15 | − | use crate::guard::{self, Halted, Policy}; | |
| 16 | − | use crate::progress::{self, Progress}; | |
| 17 | − | use crate::report::{Entry, Reporter}; | |
| 18 | − | ||
| 19 | − | const MAX_TURNS: &str = "80"; | |
| 20 | − | ||
| 21 | − | /// A compact, readable rendering of a tool's input. | |
| 22 | − | fn describe_input(tool: &str, input: &Value) -> String { | |
| 23 | − | let field = |name: &str| input.get(name).and_then(Value::as_str); | |
| 24 | − | match tool { | |
| 25 | − | "Bash" => field("command").unwrap_or_default().to_owned(), | |
| 26 | − | "Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => { | |
| 27 | − | field("file_path").unwrap_or_default().to_owned() | |
| 28 | − | } | |
| 29 | − | "Glob" | "Grep" => field("pattern").unwrap_or_default().to_owned(), | |
| 30 | − | _ => serde_json::to_string(input).unwrap_or_default(), | |
| 31 | − | } | |
| 32 | − | } | |
| 33 | − | ||
| 34 | − | /// The text of a tool result, which is either a string or content blocks. | |
| 35 | − | fn result_text(content: &Value) -> String { | |
| 36 | − | match content { | |
| 37 | − | Value::String(text) => text.clone(), | |
| 38 | − | Value::Array(blocks) => blocks | |
| 39 | − | .iter() | |
| 40 | − | .filter_map(|block| block.get("text").and_then(Value::as_str)) | |
| 41 | − | .collect::<Vec<_>>() | |
| 42 | − | .join("\n"), | |
| 43 | − | _ => String::new(), | |
| 44 | − | } | |
| 45 | − | } | |
| 46 | − | ||
| 47 | − | /// The tokens a run used, from Claude Code's closing `usage`: input, | |
| 48 | − | /// output, and prompt-cache reads and writes. On a workspace's own model | |
| 49 | − | /// key this is what g1t charges its agent rate on (the model proxy's own | |
| 50 | − | /// count, when it has more, wins), so it is reported with the cost. | |
| 51 | − | fn run_tokens(event: &Value) -> Value { | |
| 52 | − | let usage = &event["usage"]; | |
| 53 | − | let count = |field: &str| usage[field].as_u64().unwrap_or_default(); | |
| 54 | − | serde_json::json!({ | |
| 55 | − | "input": count("input_tokens"), | |
| 56 | − | "output": count("output_tokens"), | |
| 57 | − | "cache_read": count("cache_read_input_tokens"), | |
| 58 | − | "cache_write": count("cache_creation_input_tokens"), | |
| 59 | − | }) | |
| 60 | − | } | |
| 61 | − | ||
| 62 | − | /// Tells g1t what the run cost, so that the workspace it was for can be | |
| 63 | − | /// charged. The run's own token, given to this sandbox and to nothing | |
| 64 | − | /// else, is the credential. Does nothing where runs are not billed. | |
| 65 | − | fn report_cost(cost_usd: f64, turns: u64, tokens: Value) { | |
| 66 | − | let (Ok(api), Ok(run), Ok(token)) = ( | |
| 67 | − | std::env::var("G1T_API"), | |
| 68 | − | std::env::var("BILLING_RUN"), | |
| 69 | − | std::env::var("BILLING_TOKEN"), | |
| 70 | − | ) else { | |
| 71 | − | return; | |
| 72 | − | }; | |
| 73 | − | let sent = ureq::post(&format!("{api}/runs/{run}/usage")).send_json(serde_json::json!({ | |
| 74 | − | "token": token, | |
| 75 | − | "cost_usd": cost_usd, | |
| 76 | − | "turns": turns, | |
| 77 | − | "tokens": tokens, | |
| 78 | − | })); | |
| 79 | − | if let Err(error) = sent { | |
| 80 | − | eprintln!("g1t-runner: could not report what the run cost: {error}"); | |
| 81 | − | } | |
| 82 | − | } | |
| 83 | − | ||
| 84 | − | /// Records one line of Claude Code's `stream-json` output. Returns the final | |
| 85 | − | /// result when the line is the one that ends the run. | |
| 86 | − | fn handle_event( | |
| 87 | − | event: &Value, | |
| 88 | − | reporter: &mut Reporter, | |
| 89 | − | progress: &mut Option<Progress>, | |
| 90 | − | ) -> Option<Result<String>> { | |
| 91 | − | let blocks = || { | |
| 92 | − | event["message"]["content"] | |
| 93 | − | .as_array() | |
| 94 | − | .map(Vec::as_slice) | |
| 95 | − | .unwrap_or_default() | |
| 96 | − | }; | |
| 97 | − | match event["type"].as_str()? { | |
| 98 | − | "assistant" => { | |
| 99 | − | for block in blocks() { | |
| 100 | − | match block["type"].as_str() { | |
| 101 | − | Some("text") => { | |
| 102 | − | let text = block["text"].as_str().unwrap_or_default().trim(); | |
| 103 | − | if !text.is_empty() { | |
| 104 | − | reporter.record(Entry::new("message", text)); | |
| 105 | − | if let Some(progress) = progress { | |
| 106 | − | progress.step(&format!("Said: {}", text.lines().next().unwrap_or_default())); | |
| 107 | − | } | |
| 108 | − | } | |
| 109 | − | } | |
| 110 | − | Some("tool_use") => { | |
| 111 | − | crate::abuse::touch(); | |
| 112 | − | let tool = block["name"].as_str().unwrap_or("tool"); | |
| 113 | − | if let Some(progress) = progress { | |
| 114 | − | progress.step(&progress::describe_tool(tool, &block["input"])); | |
| 115 | − | } | |
| 116 | − | reporter.record(Entry::tool( | |
| 117 | − | "tool_call", | |
| 118 | − | tool, | |
| 119 | − | &describe_input(tool, &block["input"]), | |
| 120 | − | )); | |
| 121 | − | } | |
| 122 | − | _ => {} | |
| 123 | − | } | |
| 124 | − | } | |
| 125 | − | None | |
| 126 | − | } | |
| 127 | − | "user" => { | |
| 128 | − | for block in blocks() { | |
| 129 | − | if block["type"] == "tool_result" { | |
| 130 | − | let text = result_text(&block["content"]); | |
| 131 | − | if !text.trim().is_empty() { | |
| 132 | − | reporter.record(Entry::tool("tool_result", "result", text.trim())); | |
| 133 | − | } | |
| 134 | − | } | |
| 135 | − | } | |
| 136 | − | None | |
| 137 | − | } | |
| 138 | − | "result" => { | |
| 139 | − | let text = event["result"].as_str().unwrap_or_default().to_owned(); | |
| 140 | − | // What the run cost, as the harness worked it out, kept with | |
| 141 | − | // the session so that spend can be read per pull request. | |
| 142 | − | if let Some(cost) = event["total_cost_usd"].as_f64() { | |
| 143 | − | let turns = event["num_turns"].as_u64().unwrap_or_default(); | |
| 144 | − | report_cost(cost, turns, run_tokens(event)); | |
| 145 | − | if let Some(progress) = progress { | |
| 146 | − | progress.cost(cost, turns); | |
| 147 | − | } | |
| 148 | − | reporter.record(Entry::new( | |
| 149 | − | "note", | |
| 150 | − | &format!("This run cost ${cost:.4} over {turns} turns."), | |
| 151 | − | )); | |
| 152 | − | } | |
| 153 | − | // Claude Code stopped at the run's cost cap (`--max-budget-usd`). | |
| 154 | − | if event["subtype"] == "error_max_budget_usd" { | |
| 155 | − | return Some(Err(anyhow::Error::new(Halted::Budget))); | |
| 156 | − | } | |
| 157 | − | Some(if event["is_error"].as_bool().unwrap_or(false) { | |
| 158 | − | Err(anyhow::anyhow!("the agent reported an error: {text}")) | |
| 159 | − | } else { | |
| 160 | − | Ok(text) | |
| 161 | − | }) | |
| 162 | − | } | |
| 163 | − | _ => None, | |
| 164 | − | } | |
| 165 | − | } | |
| 166 | − | ||
| 167 | − | /// Runs Claude Code on `prompt` in `workdir` and returns its closing | |
| 168 | − | /// summary. | |
| 169 | − | pub fn run_claude(workdir: &Path, prompt: &str, reporter: &mut Reporter) -> Result<String> { | |
| 170 | − | // g1t's own tools, through a token that can do a few things in this one | |
| 171 | − | // repository: read its issues, pull requests and merge queue, open an | |
| 172 | − | // issue, and comment. | |
| 173 | − | let mut tools = Vec::new(); | |
| 174 | − | if let Ok(token) = std::env::var("G1T_AGENT_TOKEN") { | |
| 175 | − | let mut config = serde_json::json!({ | |
| 176 | − | "mcpServers": { | |
| 177 | − | "g1t": { | |
| 178 | − | "type": "http", | |
| 179 | − | "url": std::env::var("G1T_MCP").unwrap_or_else(|_| "https://mcp.g1t.sh".to_owned()), | |
| 180 | − | "headers": { "Authorization": format!("Bearer {token}") }, | |
| 181 | − | } | |
| 182 | − | } | |
| 183 | − | }); | |
| 184 | − | // What the workspace's Customize gives the run (customize.rs): its | |
| 185 | − | // plugins and library skills as plugin folders, and its connectors | |
| 186 | − | // beside g1t's own server. The connectors' keys stay in the config | |
| 187 | − | // file and out of the transcript. | |
| 188 | − | let loaded = crate::customize::prepare(&token, &mut config); | |
| 189 | − | for secret in &loaded.secrets { | |
| 190 | − | reporter.hide(secret); | |
| 191 | − | } | |
| 192 | − | let path = "/work/g1t-mcp.json"; | |
| 193 | − | if std::fs::write(path, config.to_string()).is_ok() { | |
| 194 | − | tools = vec!["--mcp-config".to_owned(), path.to_owned()]; | |
| 195 | − | } | |
| 196 | − | tools.extend(loaded.flags.iter().cloned()); | |
| 197 | − | if !loaded.is_empty() { | |
| 198 | − | reporter.record(Entry::new("note", &loaded.note())); | |
| 199 | − | } | |
| 200 | − | // People can message the agent while it works: after each tool call | |
| 201 | − | // a hook asks g1t for messages and hands any to the agent. | |
| 202 | − | if let (Ok(repo), Ok(number)) = (std::env::var("G1T_REPO"), std::env::var("PULL_NUMBER")) { | |
| 203 | − | let steer = serde_json::json!({ | |
| 204 | − | "api": std::env::var("G1T_API").unwrap_or_else(|_| "https://api.g1t.sh".to_owned()), | |
| 205 | − | "token": token, | |
| 206 | − | "repo": repo, | |
| 207 | − | "number": number.parse::<u32>().unwrap_or_default(), | |
| 208 | − | }); | |
| 209 | − | let hooks = serde_json::json!({ | |
| 210 | − | "hooks": { | |
| 211 | − | "PostToolUse": [{ | |
| 212 | − | "matcher": "*", | |
| 213 | − | "hooks": [{ "type": "command", "command": "MODE=steer /usr/local/bin/g1t-runner", "timeout": 15 }], | |
| 214 | − | }], | |
| 215 | − | "Stop": [{ | |
| 216 | − | "hooks": [{ "type": "command", "command": "MODE=steer G1T_HOOK=stop /usr/local/bin/g1t-runner", "timeout": 15 }], | |
| 217 | − | }], | |
| 218 | − | } | |
| 219 | − | }); | |
| 220 | − | let home = std::env::var("HOME").unwrap_or_else(|_| "/home/node".to_owned()); | |
| 221 | − | let settings = format!("{home}/.claude/settings.json"); | |
| 222 | − | if std::fs::write(crate::steer::CONFIG, steer.to_string()).is_ok() | |
| 223 | − | && std::fs::create_dir_all(format!("{home}/.claude")).is_ok() | |
| 224 | − | { | |
| 225 | − | let _ = std::fs::write(settings, hooks.to_string()); | |
| 226 | − | } | |
| 227 | − | } | |
| 228 | − | } | |
| 229 | − | // The run's guardrails (guard.rs): the hook before every tool call, the | |
| 230 | − | // permission rules, and the cost cap, which Claude Code enforces itself. | |
| 231 | − | let policy = guard::Policy::from_env(); | |
| 232 | − | if let Some(policy) = &policy { | |
| 233 | − | let installed = guard::install(policy, &std::env::var("GUARDRAILS").unwrap_or_default()); | |
| 234 | − | if let Some(file) = installed.settings_file { | |
| 235 | − | tools.extend(["--settings".to_owned(), file]); | |
| 236 | − | } | |
| 237 | − | if let Some(budget) = policy.budget_usd.filter(|budget| *budget > 0.0) { | |
| 238 | − | tools.extend(["--max-budget-usd".to_owned(), format!("{budget:.2}")]); | |
| 239 | − | } | |
| 240 | − | } | |
| 241 | − | // A fork's checkout is anyone's: none of its CLAUDE.md, .claude | |
| 242 | − | // settings, hooks, MCP servers or commands are loaded (guard.rs). | |
| 243 | − | let trusted = guard::checkout_trusted_from_env(); | |
| 244 | − | if !trusted { | |
| 245 | − | tools.extend(guard::UNTRUSTED_FLAGS.iter().map(|flag| (*flag).to_owned())); | |
| 246 | − | reporter.record(Entry::new( | |
| 247 | − | "note", | |
| 248 | − | "This checkout is not the repository's own branch, so its CLAUDE.md and .claude settings, hooks, MCP servers and commands were not loaded.", | |
| 249 | − | )); | |
| 250 | − | } | |
| 251 | − | let mut denials = guard::Denials::default(); | |
| 252 | − | // How the run goes, step by step, for people watching it live. | |
| 253 | − | let mut progress = Progress::from_env(); | |
| 254 | − | if let Some(progress) = &mut progress { | |
| 255 | − | progress.step("Started the agent"); | |
| 256 | − | } | |
| 257 | − | let mut command = Command::new("claude"); | |
| 258 | − | if !trusted { | |
| 259 | − | command.env(guard::UNTRUSTED_ENV.0, guard::UNTRUSTED_ENV.1); | |
| 260 | − | } | |
| 261 | − | let mut child = command | |
| 262 | − | .current_dir(workdir) | |
| 263 | − | .args(&tools) | |
| 264 | − | .args([ | |
| 265 | − | "--print", | |
| 266 | − | prompt, | |
| 267 | − | "--output-format", | |
| 268 | − | "stream-json", | |
| 269 | − | "--verbose", | |
| 270 | − | "--max-turns", | |
| 271 | − | MAX_TURNS, | |
| 272 | − | // The sandbox is the permission boundary: it holds one fork and | |
| 273 | − | // one short-lived token, and nothing else. | |
| 274 | − | "--dangerously-skip-permissions", | |
| 275 | − | ]) | |
| 276 | − | // The agent needs the model key and nothing else of ours. | |
| 277 | − | .env_remove("G1T_TOKEN") | |
| 278 | − | .env_remove("REVIEW_TOKEN") | |
| 279 | − | .env_remove("CHECK_TOKEN") | |
| 280 | − | .env_remove("BILLING_TOKEN") | |
| 281 | − | .env_remove("PLAN_TOKEN") | |
| 282 | − | .env_remove("G1T_AGENT_TOKEN") | |
| 283 | − | .env_remove("AGENT_RUN_TOKEN") | |
| 284 | − | .stdin(Stdio::null()) | |
| 285 | − | .stdout(Stdio::piped()) | |
| 286 | − | .stderr(Stdio::inherit()) | |
| 287 | − | .spawn() | |
| 288 | − | .context("could not start Claude Code")?; | |
| 289 | − | ||
| 290 | − | // The time cap: the agent is stopped when it passes, and the run with it. | |
| 291 | − | let timed_out = Arc::new(AtomicBool::new(false)); | |
| 292 | − | let finished = Arc::new(AtomicBool::new(false)); | |
| 293 | − | if let Some(minutes) = policy.as_ref().and_then(|policy| policy.minutes) { | |
| 294 | − | let (pid, timed_out, finished) = (child.id(), timed_out.clone(), finished.clone()); | |
| 295 | − | std::thread::spawn(move || { | |
| 296 | − | std::thread::sleep(Duration::from_secs(u64::from(minutes) * 60)); | |
| 297 | − | if !finished.load(Ordering::SeqCst) { | |
| 298 | − | timed_out.store(true, Ordering::SeqCst); | |
| 299 | − | let _ = Command::new("kill").arg(pid.to_string()).status(); | |
| 300 | − | } | |
| 301 | − | }); | |
| 302 | − | } | |
| 303 | − | ||
| 304 | − | let stdout = child.stdout.take().context("no output from Claude Code")?; | |
| 305 | − | let mut outcome = None; | |
| 306 | − | for line in BufReader::new(stdout).lines() { | |
| 307 | − | let line = line?; | |
| 308 | − | let Ok(event) = serde_json::from_str::<Value>(&line) else { | |
| 309 | − | continue; | |
| 310 | − | }; | |
| 311 | − | if let Some(result) = handle_event(&event, reporter, &mut progress) { | |
| 312 | − | outcome = Some(result); | |
| 313 | − | } | |
| 314 | − | // What the guardrails refused, as steps people can see. | |
| 315 | − | for denied in denials.take() { | |
| 316 | − | reporter.record(Entry::new("note", &denied)); | |
| 317 | − | if let Some(progress) = &mut progress { | |
| 318 | − | progress.step(&denied); | |
| 319 | − | } | |
| 320 | − | } | |
| 321 | − | } | |
| 322 | − | let status = child.wait()?; | |
| 323 | − | finished.store(true, Ordering::SeqCst); | |
| 324 | − | if timed_out.load(Ordering::SeqCst) { | |
| 325 | − | outcome = Some(Err(anyhow::Error::new(Halted::Time))); | |
| 326 | − | } | |
| 327 | − | if let Some(progress) = &mut progress { | |
| 328 | − | progress.flush(); | |
| 329 | − | if let Some(halted) = outcome.as_ref().and_then(|o| o.as_ref().err()).and_then(|e| e.downcast_ref::<Halted>()) { | |
| 330 | − | let message = match (halted, &policy) { | |
| 331 | − | (Halted::Budget, Some(Policy { budget_usd: Some(budget), .. })) => { | |
| 332 | − | format!("Stopped: it reached its cost cap of ${budget:.2}.") | |
| 333 | − | } | |
| 334 | − | (Halted::Time, Some(Policy { minutes: Some(minutes), .. })) => { | |
| 335 | − | format!("Stopped: it reached its time cap of {minutes} minutes.") | |
| 336 | − | } | |
| 337 | − | _ => format!("Stopped: {halted}."), | |
| 338 | − | }; | |
| 339 | − | progress.halt(halted.reason(), &message); | |
| 340 | − | } | |
| 341 | − | } | |
| 342 | − | match outcome { | |
| 343 | − | Some(result) => result, | |
| 344 | − | None => bail!("Claude Code exited ({status}) without a result"), | |
| 345 | − | } | |
| 346 | − | } | |
| 347 | − | ||
| 348 | − | #[cfg(test)] | |
| 349 | − | mod tests { | |
| 350 | − | use super::*; | |
| 351 | − | ||
| 352 | − | #[test] | |
| 353 | − | fn a_runs_tokens_come_from_the_closing_usage_by_kind() { | |
| 354 | − | let event = serde_json::json!({ | |
| 355 | − | "type": "result", | |
| 356 | − | "total_cost_usd": 0.42, | |
| 357 | − | "usage": { | |
| 358 | − | "input_tokens": 1200, | |
| 359 | − | "output_tokens": 340, | |
| 360 | − | "cache_read_input_tokens": 90000, | |
| 361 | − | "cache_creation_input_tokens": 5000, | |
| 362 | − | }, | |
| 363 | − | }); | |
| 364 | − | assert_eq!( | |
| 365 | − | run_tokens(&event), | |
| 366 | − | serde_json::json!({ "input": 1200, "output": 340, "cache_read": 90000, "cache_write": 5000 }) | |
| 367 | − | ); | |
| 368 | − | // An older harness without usage reports nothing, not an error. | |
| 369 | − | assert_eq!( | |
| 370 | − | run_tokens(&serde_json::json!({ "type": "result" })), | |
| 371 | − | serde_json::json!({ "input": 0, "output": 0, "cache_read": 0, "cache_write": 0 }) | |
| 372 | − | ); | |
| 373 | − | } | |
| 374 | − | } |