| 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 | } |