Skip to content

Commit

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.

syntaqxcommitted Parent2cf32cfBrowse files
53 files+206−102680/53 viewed
+0−2
9898 # keeps the version it has, since the manifest names no file for it.
9999 env:
100100 G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
101− RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }}
102101 run: node scripts/runner-release.mjs build --targets ${{ matrix.target }}
103102 - name: sccache's hits
104103 if: always()
238237 env:
239238 RUNNER_RELEASE_KEY: ${{ secrets.RUNNER_RELEASE_KEY }}
240239 G1T_RUNNER_RELEASE_KEY: ${{ vars.RUNNER_RELEASE_PUBLIC_KEY }}
241− RUNNER_AGENT_IMAGE: ${{ vars.RUNNER_AGENT_IMAGE }}
242240 run: |
243241 node scripts/runner-release.mjs manifest
244242 node scripts/runner-release.mjs sign
+1−1
33 version = "0.3.1"
44 edition.workspace = true
55 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."
77
88 [dependencies]
99 g1t-actions = { path = "../actions" }
+5−6
66 //!
77 //! 1. **Known miners, by name.** `miner_in` matches miner programs (xmrig,
88 //! 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
1211 //! stops the sandbox if a running process's command line matches.
1312 //! 2. **The CPU signature.** A background thread samples the sandbox every
1413 //! `SAMPLE_SECONDS`: CPU use (from `/proc/stat`), file and disk I/O
1514 //! (`/proc/diskstats` and every process's `/proc/<pid>/io`), network
1615 //! 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
1918 //! sandbox only when, for a whole `WINDOW_SECONDS` (10 minutes):
2019 //! - CPU stayed at or above `MIN_CPU` (90%) in every sample but one;
2120 //! - file and disk I/O averaged under `MAX_IO_PER_SECOND` (64 KB/s);
511510 }
512511
513512 #[test]
514− fn an_agent_that_keeps_working_is_not_flagged() {
513+ fn work_that_keeps_making_progress_is_not_flagged() {
515514 assert!(run(30, 1.0, 0, 0, 0, "unknown", true).is_none());
516515 }
517516
+2−3
33 //! on issues before a pull request's checks were its workflows. g1t no
44 //! longer starts that mode; it stays for a sandbox already under way.
55 //!
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.
98 //!
109 //! Configuration comes from the environment:
1110 //!
+4−4
11 //! 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
44 //! 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
77 //! everything only as the last resort.
88 //!
99 //! Environment, for the operator or a self-hosted runner:
+0−738
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−}
+0−136
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−}
+0−614
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−}
+13−1042
A large diff (1,081 lines) is not shown until you ask for it.
+0−374
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−}
+0−129
Loading the diff…
+35−208
Loading the diff…
+0−147
Loading the diff…
+0−185
Loading the diff…
+3−4
Loading the diff…
+0−99
Loading the diff…
+0−176
Loading the diff…
+0−136
Loading the diff…
+0−43
Loading the diff…
+0−4
Loading the diff…
+41−43
Loading the diff…
+6−6
Loading the diff…
+0−8
Loading the diff…
+0−116
Loading the diff…
+0−176
Loading the diff…
+0−230
Loading the diff…
+0−212
Loading the diff…
+0−101
Loading the diff…
+3−4
Loading the diff…
+2−2
Loading the diff…
+5−4
Loading the diff…
+0−99
Loading the diff…
+0−58
Loading the diff…
+0−658
Loading the diff…
+5−68
Loading the diff…
+7−65
Loading the diff…
+0−60
Loading the diff…
+0−77
Loading the diff…
+0−37
Loading the diff…
+0−28
Loading the diff…
+0−25
Loading the diff…
+0−35
Loading the diff…
+58−1909
A large diff (2,344 lines) is not shown until you ask for it.
+0−202
Loading the diff…
+0−296
Loading the diff…
+0−172
Loading the diff…
+0−204
Loading the diff…
+0−103
Loading the diff…
+0−327
Loading the diff…
+0−635
Loading the diff…
+0−188
Loading the diff…
+9−11
Loading the diff…
+7−58
Loading the diff…