Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

489 linesCodeBlameRaw
1//! Jobs native to the runner's own OS, each in a throwaway virtual machine
2//! made for it and destroyed after: `vm` isolation (sandbox.rs).
3//!
4//! - **Windows: Windows Sandbox**, through its command line (`wsb`, in
5//! Windows 11 24H2 and later). Each job gets a fresh sandbox: a Hyper-V
6//! VM with its own kernel, started from the machine's own Windows image
7//! and discarded, disk and all, when it stops. Its configuration turns
8//! off everything that reaches the machine: no GPU, no clipboard, no
9//! printers, no microphone or camera, and one folder mapped read-only,
10//! which holds only a copy of the harness and the job's script. The
11//! runner on the machine starts the sandbox, runs the script in it as
12//! SYSTEM (`wsb exec`, which waits and returns the exit code) and stops
13//! it. Windows Sandbox needs Windows 11 Pro, Enterprise or Education
14//! with the Windows Sandbox feature on; it is not in Home.
15//!
16//! Chosen over Hyper-V-isolated Windows containers, which need the
17//! Containers and Hyper-V features (Windows 11 Pro or Enterprise, and an
18//! administrator to turn them on), a container engine in Windows
19//! containers mode, and an image matching the machine's Windows build.
20//! A Windows Sandbox needs none of that, only the one feature, and its
21//! disposable VM is the same kind of wall.
22//!
23//! - **macOS: a cloned macOS VM** (designed, not run on a Mac yet). Each
24//! job gets an APFS clone of a signed macOS image, booted with Apple's
25//! Virtualization framework through `tart` with no display, no audio,
26//! no clipboard and no shared folder, and deleted after. Apple's licence
27//! allows two macOS VMs per Mac at once ([`MacVm::MAX_VMS`]); a runner
28//! runs one job at a time, so a Mac runs at most two such runners.
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::process::{Command, Stdio};
33
34use anyhow::{Context, Result, anyhow, bail};
35use serde::Serialize;
36
37use super::needs::Need;
38use super::sandbox::{Isolation, SandboxDriver};
39
40/// Whether a kind of sandbox can be made here, and if not, why and what
41/// to do about it: what `g1t-runner sandbox status` reports for each
42/// capability, and what the desktop app shows.
43#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
44pub struct Availability {
45 pub available: bool,
46 /// Why it is not, in a sentence.
47 pub cause: Option<String>,
48 /// What the person can do about it, in a sentence.
49 pub fix: Option<String>,
50 /// What is missing, by name, first what must come first (needs.rs).
51 pub needs: Vec<Need>,
52}
53
54impl Availability {
55 pub fn yes() -> Availability {
56 Availability { available: true, cause: None, fix: None, needs: vec![] }
57 }
58 pub fn no(cause: impl Into<String>, fix: impl Into<String>) -> Availability {
59 Availability { available: false, cause: Some(cause.into()), fix: Some(fix.into()), needs: vec![] }
60 }
61 /// Not here until `needs` are met, said in the first one's words; here when there are none.
62 pub fn needing(needs: Vec<Need>, kind: &str) -> Availability {
63 let Some(first) = needs.first() else { return Availability::yes() };
64 let (cause, fix) = first.said(kind);
65 Availability { needs, ..Availability::no(cause, fix) }
66 }
67}
68
69// --- Windows Sandbox --------------------------------------------------------
70
71/// Where the job's read-only folder appears inside the sandbox.
72pub const DROP_IN_SANDBOX: &str = r"C:\g1t";
73/// Where a workflow job keeps its files inside the sandbox.
74pub const WORK_IN_SANDBOX: &str = r"C:\work";
75
76/// Windows Sandbox, one per job.
77pub struct WindowsSandbox;
78
79/// What a machine has: the feature's program, and its command line.
80#[derive(Clone, Copy, Debug, Default)]
81pub struct WsbFound {
82 /// `WindowsSandbox.exe` in System32: the feature is on.
83 pub feature: bool,
84 /// `wsb.exe` answers: Windows 11 24H2 or later.
85 pub cli: bool,
86}
87
88impl WsbFound {
89 pub fn look() -> WsbFound {
90 let windir = std::env::var_os("windir").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
91 let feature = windir.join("System32").join("WindowsSandbox.exe").exists();
92 let cli = feature && wsb().arg("--version").stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success());
93 WsbFound { feature, cli }
94 }
95
96 pub fn availability(self) -> Availability {
97 if !cfg!(windows) {
98 return Availability::no("Windows Sandbox is only on Windows.", "Use a Windows machine for Windows jobs.");
99 }
100 if !self.feature {
101 return Availability::no(
102 "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
103 "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart.",
104 );
105 }
106 if !self.cli {
107 return Availability::no(
108 "This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.",
109 "Update Windows to 24H2 or later.",
110 );
111 }
112 Availability::yes()
113 }
114}
115
116impl SandboxDriver for WindowsSandbox {
117 fn name(&self) -> &'static str {
118 "Windows Sandbox"
119 }
120 fn preflight(&self) -> Result<Isolation> {
121 let found = WsbFound::look().availability();
122 if found.available {
123 return Ok(Isolation::Vm);
124 }
125 bail!("{} {}", found.cause.unwrap_or_default(), found.fix.unwrap_or_default())
126 }
127}
128
129fn wsb() -> Command {
130 let mut command = Command::new("wsb");
131 hide_window(&mut command);
132 command
133}
134
135/// No console window flashes up for a command the runner runs.
136pub fn hide_window(command: &mut Command) {
137 #[cfg(windows)]
138 {
139 use std::os::windows::process::CommandExt;
140 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
141 command.creation_flags(CREATE_NO_WINDOW);
142 }
143 #[cfg(not(windows))]
144 let _ = command;
145}
146
147fn xml_escape(text: &str) -> String {
148 text.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&apos;")
149}
150
151/// A job's sandbox's configuration (a `.wsb` file's XML, which `wsb start
152/// -c` takes as a string). Every redirection that reaches the machine is
153/// off; the only folder is `drop`, read-only.
154pub fn wsb_config(drop: &Path, memory_mib: u64) -> String {
155 format!(
156 "<Configuration>\
157<vGPU>Disable</vGPU>\
158<Networking>Enable</Networking>\
159<AudioInput>Disable</AudioInput>\
160<VideoInput>Disable</VideoInput>\
161<ClipboardRedirection>Disable</ClipboardRedirection>\
162<PrinterRedirection>Disable</PrinterRedirection>\
163<ProtectedClient>Enable</ProtectedClient>\
164<MemoryInMB>{memory_mib}</MemoryInMB>\
165<MappedFolders><MappedFolder><HostFolder>{}</HostFolder><SandboxFolder>{DROP_IN_SANDBOX}</SandboxFolder><ReadOnly>true</ReadOnly></MappedFolder></MappedFolders>\
166</Configuration>",
167 xml_escape(&drop.display().to_string())
168 )
169}
170
171/// A string in PowerShell's single quotes, where nothing is expanded and a
172/// quote is written twice.
173pub fn ps_quote(text: &str) -> String {
174 format!("'{}'", text.replace('\'', "''"))
175}
176
177/// The script a job runs in its sandbox: a firewall rule against the
178/// machine, its variables, then the harness. The variables are written
179/// here, in the job's read-only folder, rather than on `wsb exec`'s
180/// command line, where the machine's other programs could read them.
181///
182/// The firewall rule keeps the job from the machine's own services on the
183/// address the sandbox reaches it by (its gateway), but the job runs as
184/// SYSTEM in its sandbox and could remove it: it is a speed bump, not a
185/// wall. The wall is the VM.
186pub fn job_script(env: &BTreeMap<String, String>) -> String {
187 let mut script = String::from(
188 "$ErrorActionPreference = 'Stop'\r\n\
189$gateway = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Select-Object -First 1).NextHop\r\n\
190if ($gateway) { New-NetFirewallRule -DisplayName 'g1t: not the machine' -Direction Outbound -Action Block -Protocol TCP -RemoteAddress $gateway | Out-Null }\r\n",
191 );
192 script.push_str(&format!("New-Item -ItemType Directory -Force -Path {} | Out-Null\r\n", ps_quote(WORK_IN_SANDBOX)));
193 for (name, value) in env {
194 // Names are the harness's own (`ACTIONS_TOKEN`, `MODE`…): only
195 // letters, digits and underscores reach `$env:`.
196 if name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
197 script.push_str(&format!("${{env:{name}}} = {}\r\n", ps_quote(value)));
198 }
199 }
200 script.push_str(&format!("$env:G1T_RUNNER_ROOT = {}\r\n", ps_quote(WORK_IN_SANDBOX)));
201 script.push_str(&format!("Set-Location {}\r\n", ps_quote(WORK_IN_SANDBOX)));
202 script.push_str(&format!("& {}\r\nexit $LASTEXITCODE\r\n", ps_quote(&format!(r"{DROP_IN_SANDBOX}\g1t-runner.exe"))));
203 script
204}
205
206/// What `wsb exec` runs.
207pub fn exec_command() -> String {
208 format!(r"powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File {DROP_IN_SANDBOX}\job.ps1")
209}
210
211/// `{"ExitCode": 7}`, from `wsb exec --raw`.
212pub fn parse_exit_code(raw: &str) -> Option<i32> {
213 let start = raw.find('{')?;
214 let value: serde_json::Value = serde_json::from_str(raw[start..].trim()).ok()?;
215 value.get("ExitCode").and_then(serde_json::Value::as_i64).map(|code| code as i32)
216}
217
218/// `{"Id": "…"}`, from `wsb start --raw`.
219pub fn parse_id(raw: &str) -> Option<String> {
220 let start = raw.find('{')?;
221 let value: serde_json::Value = serde_json::from_str(raw[start..].trim()).ok()?;
222 value.get("Id").and_then(serde_json::Value::as_str).map(str::to_owned)
223}
224
225/// A job's sandbox, while it exists.
226pub struct WsbJob {
227 pub id: String,
228 /// The job's read-only folder on the machine, removed after.
229 pub drop: PathBuf,
230}
231
232impl WsbJob {
233 /// Makes the job's folder (a copy of this program, which is the
234 /// harness, and its script) and starts its sandbox.
235 pub fn start(drop: PathBuf, env: &BTreeMap<String, String>, memory_mib: u64) -> Result<WsbJob> {
236 let _ = std::fs::remove_dir_all(&drop);
237 std::fs::create_dir_all(&drop).with_context(|| format!("could not make {}", drop.display()))?;
238 let me = std::env::current_exe().context("could not find this program, the job's harness")?;
239 std::fs::copy(&me, drop.join("g1t-runner.exe")).context("could not copy the harness for the sandbox")?;
240 std::fs::write(drop.join("job.ps1"), job_script(env))?;
241 let output = wsb().args(["start", "--raw", "-c", &wsb_config(&drop, memory_mib)]).output().context("could not run wsb")?;
242 let text = String::from_utf8_lossy(&output.stdout);
243 let Some(id) = parse_id(&text).filter(|_| output.status.success()) else {
244 let _ = std::fs::remove_dir_all(&drop);
245 bail!("Windows Sandbox did not start: {} {}", text.trim(), String::from_utf8_lossy(&output.stderr).trim());
246 };
247 if let Some(parent) = drop.parent() {
248 let _ = std::fs::write(parent.join("sandbox-id"), &id);
249 }
250 Ok(WsbJob { id, drop })
251 }
252
253 /// The process that runs the job and waits for it: its standard output
254 /// carries the exit code ([`parse_exit_code`]).
255 pub fn exec(&self) -> Command {
256 let mut command = wsb();
257 command.args(["exec", "--raw", "--id", &self.id, "-c", &exec_command(), "-r", "System"]);
258 command.stdout(Stdio::piped()).stderr(Stdio::null()).stdin(Stdio::null());
259 command
260 }
261
262 /// Stops the sandbox, which discards it.
263 pub fn stop(&self) {
264 let _ = wsb().args(["stop", "--id", &self.id]).stdout(Stdio::null()).stderr(Stdio::null()).status();
265 }
266
267 pub fn clean(&self) {
268 self.stop();
269 let _ = std::fs::remove_dir_all(&self.drop);
270 if let Some(parent) = self.drop.parent() {
271 let _ = std::fs::remove_file(parent.join("sandbox-id"));
272 }
273 }
274}
275
276/// Where a runner notes the sandbox it is running, so that one, and only
277/// that one, is stopped if the runner stopped mid-job: the person may have
278/// Windows Sandboxes of their own open.
279pub fn noted(folder: &Path) -> PathBuf {
280 folder.join("native").join("sandbox-id")
281}
282
283/// Stops the sandbox an earlier run of this runner left, if any, and
284/// removes the job folders it left: a runner stopped mid-job (a crash, or
285/// `taskkill /F`) never cleaned its own, and a job's script holds the
286/// job's token.
287pub fn sweep(folder: &Path) {
288 let note = noted(folder);
289 if let Ok(id) = std::fs::read_to_string(&note) {
290 let id = id.trim();
291 if parse_list(&String::from_utf8_lossy(&wsb().args(["list", "--raw"]).output().map(|o| o.stdout).unwrap_or_default())).iter().any(|running| running == id) {
292 let _ = wsb().args(["stop", "--id", id]).stdout(Stdio::null()).stderr(Stdio::null()).status();
293 }
294 let _ = std::fs::remove_file(&note);
295 }
296 for left in job_folders(&folder.join("native")) {
297 let _ = std::fs::remove_dir_all(left);
298 }
299}
300
301/// The job folders in a runner's `native` folder: `g1t-<job id>`, as
302/// exec.rs names them.
303pub fn job_folders(native: &Path) -> Vec<PathBuf> {
304 let Ok(entries) = std::fs::read_dir(native) else { return Vec::new() };
305 entries
306 .flatten()
307 .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()) && entry.file_name().to_string_lossy().starts_with("g1t-"))
308 .map(|entry| entry.path())
309 .collect()
310}
311
312/// The ids in `wsb list --raw`.
313pub fn parse_list(raw: &str) -> Vec<String> {
314 let Some(start) = raw.find('{') else { return Vec::new() };
315 let Ok(value) = serde_json::from_str::<serde_json::Value>(raw[start..].trim()) else { return Vec::new() };
316 value
317 .get("WindowsSandboxEnvironments")
318 .and_then(serde_json::Value::as_array)
319 .map(|all| all.iter().filter_map(|e| e.get("Id").and_then(serde_json::Value::as_str).map(str::to_owned)).collect())
320 .unwrap_or_default()
321}
322
323// --- macOS VMs (designed) -----------------------------------------------------
324
325/// A cloned macOS VM per job, through `tart` on Apple's Virtualization
326/// framework. **Designed, not run on a Mac yet:** its commands are built
327/// and tested here, and [`SandboxDriver::preflight`] says it is not
328/// available, so no Mac registers as able to take macOS jobs until it has
329/// been run on one.
330///
331/// - The image is an OCI image of a macOS VM, named by digest in the
332/// signed release manifest, so `tart clone` fetches exactly that.
333/// - `tart run --no-graphics --no-audio --no-clipboard`, with no `--dir`:
334/// no display, no sound, no clipboard, no folder of the Mac's.
335/// - The job runs through `tart exec` (the guest agent in the image), and
336/// the VM is stopped and deleted after.
337pub struct MacVm;
338
339// The commands are built and tested ahead of the driver that runs them.
340#[allow(dead_code)]
341impl MacVm {
342 /// Apple's macOS licence: two virtual copies per Mac.
343 pub const MAX_VMS: u32 = 2;
344
345 pub fn clone_args(image: &str, name: &str) -> Vec<String> {
346 vec!["clone".into(), image.into(), name.into()]
347 }
348
349 pub fn run_args(name: &str) -> Vec<String> {
350 vec!["run".into(), "--no-graphics".into(), "--no-audio".into(), "--no-clipboard".into(), name.into()]
351 }
352
353 pub fn exec_args(name: &str, script: &str) -> Vec<String> {
354 vec!["exec".into(), name.into(), "/bin/bash".into(), "-lc".into(), script.into()]
355 }
356
357 pub fn delete_args(name: &str) -> Vec<String> {
358 vec!["delete".into(), name.into()]
359 }
360
361 pub fn availability() -> Availability {
362 if !cfg!(target_os = "macos") {
363 return Availability::no("macOS VMs are only on a Mac.", "Use a Mac for macOS jobs.");
364 }
365 Availability::no(
366 "Sandboxed macOS jobs are not released yet.",
367 "Run macOS jobs on a runner registered with --no-docker (host mode, unsandboxed) for now, or wait for a release that has them.",
368 )
369 }
370}
371
372impl SandboxDriver for MacVm {
373 fn name(&self) -> &'static str {
374 "a cloned macOS VM per job"
375 }
376 fn preflight(&self) -> Result<Isolation> {
377 let why = MacVm::availability();
378 Err(anyhow!("{} {}", why.cause.unwrap_or_default(), why.fix.unwrap_or_default()))
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn a_job_sandbox_reaches_nothing_of_the_machines_but_its_read_only_folder() {
388 let xml = wsb_config(Path::new(r"C:\Users\a & b\.g1t-runner\native\job_1"), 4096);
389 for off in ["vGPU", "AudioInput", "VideoInput", "ClipboardRedirection", "PrinterRedirection"] {
390 assert!(xml.contains(&format!("<{off}>Disable</{off}>")), "{off} in {xml}");
391 }
392 assert!(xml.contains("<ProtectedClient>Enable</ProtectedClient>"));
393 assert_eq!(xml.matches("<MappedFolder>").count(), 1);
394 assert!(xml.contains("<ReadOnly>true</ReadOnly>"));
395 assert!(xml.contains(r"<HostFolder>C:\Users\a &amp; b\.g1t-runner\native\job_1</HostFolder>"));
396 assert!(xml.contains("<MemoryInMB>4096</MemoryInMB>"));
397 assert!(!xml.contains("LogonCommand"), "the job runs through wsb exec, not at logon");
398 }
399
400 #[test]
401 fn the_job_script_quotes_every_value() {
402 let mut env = BTreeMap::new();
403 env.insert("ACTIONS_TOKEN".to_owned(), "it's $(evil) `x`".to_owned());
404 env.insert("MODE".to_owned(), "actions".to_owned());
405 env.insert("BAD NAME;rm".to_owned(), "x".to_owned());
406 let script = job_script(&env);
407 assert!(script.contains("${env:ACTIONS_TOKEN} = 'it''s $(evil) `x`'"), "{script}");
408 assert!(script.contains("${env:MODE} = 'actions'"));
409 assert!(!script.contains("BAD NAME"));
410 assert!(script.contains(r"$env:G1T_RUNNER_ROOT = 'C:\work'"));
411 assert!(script.ends_with("& 'C:\\g1t\\g1t-runner.exe'\r\nexit $LASTEXITCODE\r\n"));
412 assert!(script.contains("New-NetFirewallRule"));
413 }
414
415 #[test]
416 fn a_runner_stopped_mid_job_has_its_job_folders_swept() {
417 let folder = std::env::temp_dir().join(format!("g1t-native-sweep-{}", std::process::id()));
418 let native = folder.join("native");
419 std::fs::create_dir_all(native.join("g1t-job_1")).unwrap();
420 std::fs::write(native.join("g1t-job_1").join("job.ps1"), "${env:ACTIONS_TOKEN} = 't'").unwrap();
421 std::fs::create_dir_all(native.join("native")).unwrap();
422 std::fs::write(native.join("runner.log"), "").unwrap();
423 assert_eq!(job_folders(&native), [native.join("g1t-job_1")]);
424 // No sandbox was noted, so nothing is stopped; the job's folder goes.
425 sweep(&folder);
426 assert!(!native.join("g1t-job_1").exists());
427 assert!(native.join("native").exists() && native.join("runner.log").exists(), "only job folders");
428 let _ = std::fs::remove_dir_all(&folder);
429 }
430
431 #[test]
432 fn wsb_output_reads() {
433 assert_eq!(parse_exit_code("{\n \"ExitCode\": 7\n}\n"), Some(7));
434 assert_eq!(parse_exit_code("nothing"), None);
435 assert_eq!(parse_id("{\n \"Id\": \"902c8565-4b36\"\n}"), Some("902c8565-4b36".into()));
436 assert_eq!(parse_list("{\"WindowsSandboxEnvironments\": [{\"Id\": \"a\"}, {\"Id\": \"b\"}]}"), vec!["a", "b"]);
437 assert!(parse_list("{\"WindowsSandboxEnvironments\": []}").is_empty());
438 assert!(exec_command().ends_with(r"-File C:\g1t\job.ps1"));
439 }
440
441 #[test]
442 fn what_is_missing_says_how_to_fix_it() {
443 if cfg!(windows) {
444 let off = WsbFound { feature: false, cli: false }.availability();
445 assert!(!off.available);
446 assert!(off.cause.unwrap().contains("not turned on"));
447 assert!(off.fix.unwrap().contains("Pro, Enterprise or Education"));
448 assert!(WsbFound { feature: true, cli: false }.availability().cause.unwrap().contains("24H2"));
449 assert!(WsbFound { feature: true, cli: true }.availability().available);
450 } else {
451 assert!(!WsbFound { feature: true, cli: true }.availability().available);
452 }
453 }
454
455 #[test]
456 fn a_macos_vm_shares_nothing_and_is_not_offered_until_run() {
457 let run = MacVm::run_args("g1t-job_1");
458 assert!(run.iter().any(|a| a == "--no-graphics") && run.iter().any(|a| a == "--no-clipboard"));
459 assert!(!run.iter().any(|a| a.starts_with("--dir")), "no folder of the Mac's");
460 assert_eq!(MacVm::clone_args("g1t.sh/flagon-io/macos@sha256:ab", "g1t-job_1"), vec!["clone", "g1t.sh/flagon-io/macos@sha256:ab", "g1t-job_1"]);
461 assert_eq!(MacVm::delete_args("x"), vec!["delete", "x"]);
462 assert_eq!(MacVm::exec_args("x", "echo hi")[0], "exec");
463 assert!(MacVm.preflight().is_err());
464 assert_eq!(MacVm::MAX_VMS, 2);
465 }
466
467 /// Starts a real Windows Sandbox: run with `cargo test -p g1t-runner -- --ignored windows_sandbox`
468 /// on a machine with the feature on.
469 #[test]
470 #[ignore]
471 fn windows_sandbox_runs_a_script_and_keeps_its_folder_read_only() {
472 let drop = std::env::temp_dir().join(format!("g1t-wsb-test-{}", std::process::id()));
473 let _ = std::fs::remove_dir_all(&drop);
474 std::fs::create_dir_all(&drop).unwrap();
475 std::fs::write(
476 drop.join("job.ps1"),
477 "try { 'x' | Out-File C:\\g1t\\written.txt -ErrorAction Stop; exit 3 } catch { }\r\nif (Test-Path 'C:\\Users\\Public\\g1t-from-host') { exit 4 }\r\nexit 7\r\n",
478 )
479 .unwrap();
480 let output = wsb().args(["start", "--raw", "-c", &wsb_config(&drop, 2048)]).output().unwrap();
481 let id = parse_id(&String::from_utf8_lossy(&output.stdout)).expect("a sandbox id");
482 let job = WsbJob { id, drop: drop.clone() };
483 let out = job.exec().output().unwrap();
484 let code = parse_exit_code(&String::from_utf8_lossy(&out.stdout));
485 job.clean();
486 assert_eq!(code, Some(7), "the script ran, and could not write to its folder");
487 assert!(!drop.exists());
488 }
489}