Skip to content
614 linesCodeBlameRaw

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
18use std::io::Read;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21use std::time::Duration;
22
23use base64::Engine;
24use base64::engine::general_purpose::STANDARD;
25use serde_json::{Value, json};
26use sha2::{Digest, Sha256};
27
28/// Where plugins are written, one folder each.
29pub(crate) const PLUGINS_DIR: &str = "/work/.g1t/plugins";
30/// Downloads and clones on their way to `PLUGINS_DIR`.
31const TEMP_DIR: &str = "/work/.g1t/tmp";
32/// The library's skills, as a plugin of their own.
33pub(crate) const LIBRARY: &str = "g1t-library";
34/// How long one download or clone may take.
35const TIMEOUT: Duration = Duration::from_secs(120);
36/// The most a plugin's archive may be.
37const 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)]
42pub 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
54impl 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.
79pub(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 `-`.
86fn 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.
94fn 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.
99fn 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.
108fn 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)]
114pub(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)]
124pub(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.
131pub(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.
180pub(crate) fn plugin_dir(name: &str) -> String {
181 format!("{PLUGINS_DIR}/{name}")
182}
183
184/// `--plugin-dir <dir>` for each folder.
185pub(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.
192pub(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.
237pub(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.`.
262pub(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.
274fn 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.
290fn 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`.
302fn 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`.
320fn 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`.
333fn 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
340fn 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
351fn 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
373fn 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
400fn 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.
413fn 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`.
435pub 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)]
475mod 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.