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