Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell. | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.