Skip to content
697 linesCodeBlameRaw
1//! The one place the app meets `g1t-runner sandbox`, the launcher that
2//! makes a sandbox of the runner's own on this computer and runs the
3//! runner inside it (crates/runner/src/selfhosted/launcher): Linux jobs in
4//! a Linux VM (a WSL 2 distribution on Windows, vfkit on macOS, Sysbox or
5//! QEMU with KVM on Linux), each a fresh container inside it
6//! (`nested-container`), and, on Windows 11 Pro and up, Windows jobs each
7//! in a Windows Sandbox of its own (`vm`).
8//!
9//! It is the `g1t-runner` the app already keeps up to date (native.rs),
10//! with its state in the app's runner folder (`--state-dir`). The app runs
11//! four commands, each with `--json`, and reads the one object each prints
12//! last (the launcher's documented status, schema 1):
13//!
14//! - `up --url … --name … --labels desktop [--token …]`, after the window
15//! opens: makes the sandbox if there is none and starts it, waiting until
16//! the runner asks for work. The token goes only to the first `up`; the
17//! launcher keeps the registration from then on, across updates too. It
18//! adds `-linux` and `-windows` to the name, and the OS labels, itself.
19//! - `status`, every few seconds, for the tray, settings and the site.
20//! - `down` as g1t quits or the person stops it; `down --drain` when they
21//! choose another way of running jobs; `down --delete` when the
22//! registration is no longer the one wanted.
23//! - `up --only linux` and `down --only windows` when the person starts
24//! or stops one kind of job in settings: the other is left as it is.
25//! Every `up` names the kinds that are on (`--only`), so the launcher
26//! never starts one the person stopped. A launcher from before `--only`
27//! says no `turned_off` in its status, and is never sent `down --only`,
28//! which it would read as `down`, stopping everything.
29//! - `update`, while nothing runs: the newest signed image, the same
30//! registration.
31//!
32//! What a status's causes and errors mean is read here (`why`); what the
33//! app says about them is diagnosis.rs's.
34
35use std::ffi::OsString;
36use std::path::Path;
37use std::process::{Command, Stdio};
38use std::time::Duration;
39
40use serde::Deserialize;
41
42use super::backend::quiet;
43
44/// The launcher's state folder, under the app's runner folder.
45pub const FOLDER: &str = "sandbox";
46/// The status shape the app reads (`schema`).
47pub const SCHEMA: u32 = 1;
48/// How often the app asks the sandbox how it is: a call takes about a second.
49pub const STATUS_EVERY: Duration = Duration::from_secs(8);
50/// What installs WSL on Windows, for the person to run as administrator.
51pub const WSL_INSTALL: &str = "wsl --install --no-distribution";
52/// What installs vfkit on a Mac.
53pub const VFKIT_INSTALL: &str = "brew install vfkit";
54/// How often the app looks again for a release with the sandbox's images,
55/// while the newest is published without them.
56pub const IMAGES_AGAIN: Duration = Duration::from_secs(5 * 60);
57
58/// A sandbox's, or one capability's, state.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum State {
62 /// Never made, or deleted.
63 #[default]
64 Absent,
65 Stopped,
66 Starting,
67 Running,
68 Updating,
69 Error,
70 /// A state a newer launcher has that this app does not know.
71 #[serde(other)]
72 Unknown,
73}
74
75/// What a runner is running now.
76#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
77#[serde(default)]
78pub struct Job {
79 /// `workflow` or `agent`.
80 pub kind: String,
81 pub id: String,
82 pub name: String,
83 pub repo: String,
84}
85
86impl Job {
87 /// How the tray says it: `build (acme/web)`.
88 pub fn said(&self) -> String {
89 if self.repo.is_empty() { self.name.clone() } else { format!("{} ({})", self.name, self.repo) }
90 }
91}
92
93/// One kind of job the computer can run sandboxed: `linux`, `windows` or `macos`.
94#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
95#[serde(default)]
96pub struct Capability {
97 pub id: String,
98 pub available: bool,
99 pub state: State,
100 pub isolation: Option<String>,
101 pub runner_version: Option<String>,
102 pub job: Option<Job>,
103 /// Why it is not available, in a sentence.
104 pub cause: Option<String>,
105 /// What to do about it, in a sentence.
106 pub fix: Option<String>,
107 /// The person stopped it (`down --only`): stopped, not failing. None
108 /// from a launcher before it could stop one kind of job on its own.
109 pub turned_off: Option<bool>,
110}
111
112impl Capability {
113 pub fn off(&self) -> bool {
114 self.turned_off == Some(true)
115 }
116}
117
118/// `g1t-runner sandbox status --json`. The top-level fields are the Linux
119/// VM's. Fields the app does not use (`resources`, `state_dir`) are left
120/// out, and any a newer launcher adds are ignored.
121#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)]
122#[serde(default)]
123pub struct Status {
124 pub schema: u32,
125 pub state: State,
126 /// `wsl2`, `vfkit`, `qemu` or `sysbox`.
127 pub backend: Option<String>,
128 pub runner_version: Option<String>,
129 pub launcher_version: Option<String>,
130 pub image_version: Option<String>,
131 pub isolation: Option<String>,
132 pub job: Option<Job>,
133 /// A sentence for a person, whenever something is wrong.
134 pub error: Option<String>,
135 pub capabilities: Vec<Capability>,
136}
137
138impl Status {
139 pub fn capability(&self, id: &str) -> Option<&Capability> {
140 self.capabilities.iter().find(|c| c.id == id)
141 }
142
143 /// The Linux capability, as the top-level fields say it when a launcher lists none.
144 pub fn linux(&self) -> Capability {
145 self.capability("linux").cloned().unwrap_or_else(|| Capability {
146 id: "linux".into(),
147 available: self.state != State::Error || self.error.is_none(),
148 state: self.state,
149 runner_version: self.runner_version.clone(),
150 job: self.job.clone(),
151 ..Capability::default()
152 })
153 }
154
155 /// Whether any of its runners is running.
156 pub fn running(&self) -> bool {
157 self.state == State::Running || self.capabilities.iter().any(|c| c.state == State::Running)
158 }
159
160 /// Whether anything here can be sandboxed at all.
161 pub fn anything_available(&self) -> bool {
162 if self.capabilities.is_empty() { self.linux().available } else { self.capabilities.iter().any(|c| c.available) }
163 }
164
165 /// A capability that can be had here, and is on, but was never set
166 /// up: registering it needs a token.
167 pub fn unregistered(&self) -> bool {
168 let wanting = |c: &Capability| c.available && !c.off() && c.state == State::Absent;
169 if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) }
170 }
171
172 /// Whether this launcher starts and stops each kind of job on its own
173 /// (`--only`), which it says by saying which are turned off.
174 pub fn per_kind(&self) -> bool {
175 !self.capabilities.is_empty() && self.capabilities.iter().all(|c| c.turned_off.is_some())
176 }
177
178 /// Whether everything that could run here was turned off by the person.
179 pub fn all_off(&self) -> bool {
180 let available: Vec<&Capability> = self.capabilities.iter().filter(|c| c.available || c.off()).collect();
181 !available.is_empty() && available.iter().all(|c| c.off())
182 }
183
184 /// What a runner is running, the Linux one's first.
185 pub fn job(&self) -> Option<&Job> {
186 self.job.as_ref().or_else(|| self.capabilities.iter().find_map(|c| c.job.as_ref()))
187 }
188
189 /// The runner's version, as the tray and the site say it.
190 pub fn version(&self) -> Option<String> {
191 self.runner_version
192 .clone()
193 .or_else(|| self.capabilities.iter().find_map(|c| c.runner_version.clone()))
194 .or_else(|| self.launcher_version.clone())
195 }
196
197 /// What is wrong with Linux jobs, in the launcher's words: its error,
198 /// else why the capability is unavailable.
199 pub fn trouble(&self) -> Option<(String, Option<String>)> {
200 let linux = self.linux();
201 // Stopped by the person: nothing is wrong with it.
202 if linux.off() {
203 return None;
204 }
205 if !linux.available {
206 let cause = linux.cause.or_else(|| self.error.clone())?;
207 return Some((cause, linux.fix));
208 }
209 if linux.state == State::Error || self.state == State::Error {
210 return Some((self.error.clone().unwrap_or_else(|| "The sandbox stopped, and didn't say why.".into()), None));
211 }
212 None
213 }
214}
215
216/// Reads what a launcher command printed: log lines, then the status as
217/// one JSON object, which starts on a line of its own. A launcher whose
218/// schema is not this app's is not read, nor is a `g1t-runner` from before
219/// the launcher, which prints no status at all.
220pub fn parse(output: &str) -> Result<Status, String> {
221 let start = output
222 .match_indices('\n')
223 .map(|(at, _)| at + 1)
224 .chain(std::iter::once(0))
225 .filter(|at| output[*at..].starts_with('{'))
226 .max()
227 .ok_or_else(|| "this g1t-runner printed no sandbox status: it is older than its sandbox launcher".to_owned())?;
228 let status: Status = serde_json::from_str(output[start..].trim()).map_err(|e| format!("the sandbox's status doesn't read: {e}"))?;
229 if status.schema != SCHEMA {
230 return Err(format!("the sandbox's status is in schema {}, and this g1t reads schema {SCHEMA}: update g1t", status.schema));
231 }
232 Ok(status)
233}
234
235/// Whether a line of a launcher's output starts its status, rather than being a log line.
236pub fn starts_status(line: &str) -> bool {
237 line.starts_with('{')
238}
239
240/// How far the sandbox's image has downloaded, from the launcher's log.
241#[derive(Clone, Debug, PartialEq, Eq)]
242pub struct Download {
243 /// `wsl-x64`.
244 pub key: String,
245 pub percent: u8,
246}
247
248/// Reads `Fetching the sandbox's wsl-x64 image (612 MB)`, the start of a
249/// download, and `Fetching the sandbox's wsl-x64 image: 40% of 612 MB`,
250/// each tenth of it (the launcher's image.rs), after the log's time.
251pub fn download(line: &str) -> Option<Download> {
252 let (_, rest) = line.split_once("Fetching the sandbox's ")?;
253 let (key, rest) = rest.split_once(" image")?;
254 let percent = match rest.strip_prefix(": ") {
255 Some(said) => said.split_once('%')?.0.trim().parse::<u8>().ok()?.min(100),
256 None => 0,
257 };
258 Some(Download { key: key.to_owned(), percent })
259}
260
261/// What `up` is told the first time, and again on every start (the
262/// launcher keeps what it was told, and `--token` only matters once).
263#[derive(Clone, PartialEq, Eq)]
264pub struct Up<'a> {
265 pub url: &'a str,
266 pub name: &'a str,
267 pub labels: &'a str,
268 /// The registration token: only for a sandbox that has no registration yet.
269 pub token: Option<&'a str>,
270 /// The kinds of job to start (`--only`): those the person has on.
271 pub only: Vec<&'a str>,
272}
273
274// Never print a token, in a log or a panic.
275impl std::fmt::Debug for Up<'_> {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 f.debug_struct("Up")
278 .field("url", &self.url)
279 .field("name", &self.name)
280 .field("labels", &self.labels)
281 .field("token", &self.token.map(|_| "…"))
282 .field("only", &self.only)
283 .finish()
284 }
285}
286
287fn args(action: &str, dir: &Path) -> Vec<OsString> {
288 let mut args: Vec<OsString> = vec!["sandbox".into(), action.into(), "--json".into(), "--state-dir".into()];
289 args.push(dir.as_os_str().to_owned());
290 args
291}
292
293/// `sandbox up`'s arguments. The token is never one of them: anyone on the
294/// computer can read a process's command line while it runs, so it goes in
295/// `up`'s environment instead ([`with_token`]).
296pub fn up_args(dir: &Path, up: &Up) -> Vec<OsString> {
297 let mut args = args("up", dir);
298 for (name, value) in [("--url", up.url), ("--name", up.name), ("--labels", up.labels)] {
299 args.push(name.into());
300 args.push(value.into());
301 }
302 if !up.only.is_empty() {
303 args.push("--only".into());
304 args.push(up.only.join(",").into());
305 }
306 args
307}
308
309/// Gives a launcher command the registration token, as `G1T_RUNNER_TOKEN`
310/// in its environment and nowhere else.
311pub fn with_token(command: &mut Command, token: Option<&str>) {
312 if let Some(token) = token {
313 command.env("G1T_RUNNER_TOKEN", token);
314 }
315}
316
317/// How `down` stops the sandbox.
318#[derive(Clone, Copy, Debug, PartialEq, Eq)]
319pub enum Down {
320 /// Now: a running job is cut short (as g1t quits, or Stop).
321 Now,
322 /// After the running job (another way of running jobs was chosen).
323 Drain,
324 /// Now, and the VM and the registration with it (another workspace or server).
325 Delete,
326}
327
328pub fn down_args(dir: &Path, how: Down) -> Vec<OsString> {
329 let mut args = args("down", dir);
330 match how {
331 Down::Now => {}
332 Down::Drain => args.push("--drain".into()),
333 Down::Delete => args.push("--delete".into()),
334 }
335 args
336}
337
338/// `down --only <kind>`: that kind of job stops, and stays off; the others
339/// are left as they are. Only for a launcher whose status is [`Status::per_kind`].
340pub fn down_only_args(dir: &Path, kind: &str) -> Vec<OsString> {
341 let mut args = args("down", dir);
342 args.push("--only".into());
343 args.push(kind.into());
344 args
345}
346
347pub fn status_args(dir: &Path) -> Vec<OsString> {
348 args("status", dir)
349}
350
351pub fn update_args(dir: &Path) -> Vec<OsString> {
352 args("update", dir)
353}
354
355/// A launcher command whose output the app reads a line at a time
356/// (`up` and `update`, which take minutes the first time).
357pub fn command(binary: &Path, args: Vec<OsString>) -> Command {
358 let mut command = quiet(binary);
359 // What the app's own environment may have set for another runner is not this one's.
360 for (name, _) in std::env::vars_os() {
361 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
362 command.env_remove(&name);
363 }
364 }
365 command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
366 command
367}
368
369/// Runs a short launcher command to its end and reads its status, which
370/// it prints whether it worked or not.
371fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
372 run_done(binary, args).map(|(status, _)| status)
373}
374
375/// [`run`], and whether the command did what it was asked (its exit code).
376fn run_done(binary: &Path, args: Vec<OsString>) -> Result<(Status, bool), String> {
377 let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
378 let printed = String::from_utf8_lossy(&out.stdout);
379 match parse(&printed) {
380 Ok(status) => Ok((status, out.status.success())),
381 Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
382 Err(why) => Err(why),
383 }
384}
385
386/// How the sandbox is now.
387pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> {
388 run(binary, status_args(dir))
389}
390
391/// Stops the sandbox, waiting for it: how it is now.
392pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<Status, String> {
393 down_with(binary, down_args(dir, how))
394}
395
396/// Stops one kind of job, and keeps it off, waiting for it.
397pub fn down_only(binary: &Path, dir: &Path, kind: &str) -> Result<Status, String> {
398 down_with(binary, down_only_args(dir, kind))
399}
400
401fn down_with(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
402 if !binary.exists() {
403 return Err("g1t-runner isn't installed".into());
404 }
405 match run_done(binary, args)? {
406 (status, true) => Ok(status),
407 (status, false) => Err(status.error.unwrap_or_else(|| "g1t-runner sandbox down failed.".into())),
408 }
409}
410
411/// What a launcher's sentence is about: the words the app shows for it
412/// are diagnosis.rs's. Read from the causes and errors the launcher
413/// writes (crates/runner/src/selfhosted/launcher and native.rs).
414#[derive(Clone, Copy, Debug, PartialEq, Eq)]
415pub enum Why {
416 NoWsl,
417 WslMirrored,
418 NoVirtualization,
419 NoVfkit,
420 /// Linux, with neither Sysbox in Docker nor KVM.
421 NoSysboxOrKvm,
422 /// The newest release is published without the sandbox's images (for
423 /// this computer), so there is nothing to download yet.
424 NoImages,
425 /// The sandbox's image couldn't be downloaded, or isn't the release's.
426 Image,
427 /// g1t refused the registration token.
428 TokenRefused,
429 /// g1t no longer knows the runner.
430 Forgotten,
431 /// The sandbox has no registration, and no token was given.
432 Unregistered,
433 /// Windows Sandbox is off, or this edition of Windows has none.
434 WindowsSandboxOff,
435 /// Windows Sandbox without `wsb`, from before Windows 11 24H2.
436 NoWsb,
437 /// Sandboxed macOS jobs are not released.
438 MacNotReleased,
439 /// Anything else: shown in the launcher's own words.
440 Other,
441}
442
443/// The release a "has no sandbox images" sentence names, and the image
444/// it lacks when it is only this computer's: `release 0.3.0 has no
445/// sandbox image vm-arm64-disk for this machine`.
446pub fn missing_images(text: &str) -> Option<(String, Option<String>)> {
447 let (before, after) = text.split_once(" has no sandbox image")?;
448 let version = before.rsplit("release ").next()?.trim().to_owned();
449 let image = after.strip_prefix(' ').and_then(|rest| rest.split_whitespace().next()).map(str::to_owned);
450 (!version.is_empty() && !version.contains(' ')).then_some((version, image))
451}
452
453pub fn why(text: &str) -> Why {
454 let lower = text.to_ascii_lowercase();
455 let has = |words: &str| lower.contains(words);
456 if has("wsl is not installed") {
457 Why::NoWsl
458 } else if has("mirrored") {
459 Why::WslMirrored
460 } else if has("virtualization is not enabled")
461 || has("enable virtualization")
462 || has("virtual machine platform")
463 || has("hcs_e_hyperv_not_installed")
464 || has("vt-x")
465 || has("hypervisor is not present")
466 {
467 Why::NoVirtualization
468 } else if has("vfkit") && has("not installed") {
469 Why::NoVfkit
470 } else if has("neither sysbox nor kvm") || has("docker has no sysbox") {
471 Why::NoSysboxOrKvm
472 } else if has("token") && (has("not valid") || has("expired") || has("invalid")) {
473 Why::TokenRefused
474 } else if has("no longer knows this runner") || has("was removed from g1t") {
475 Why::Forgotten
476 } else if has("is not registered") {
477 Why::Unregistered
478 } else if has("has no sandbox image") {
479 Why::NoImages
480 } else if has("sandbox image") || has("sandbox's images") || has("no sandbox images") || has("sha-256") || has("signature") || has("release key") || has("could not fetch") || has("broke off") {
481 Why::Image
482 } else if has("windows sandbox is not turned on") {
483 Why::WindowsSandboxOff
484 } else if has("24h2") {
485 Why::NoWsb
486 } else if has("macos jobs are not released") {
487 Why::MacNotReleased
488 } else {
489 Why::Other
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496 use std::path::PathBuf;
497
498 /// What `status --json` printed on a Windows 11 Pro machine with WSL
499 /// and Windows Sandbox, before `up` (g1t-runner 0.2.1).
500 const ABSENT: &str = r#"{
501 "schema": 1,
502 "state": "absent",
503 "backend": "wsl2",
504 "runner_version": null,
505 "launcher_version": "0.2.1",
506 "image_version": null,
507 "isolation": "nested-container",
508 "job": null,
509 "error": null,
510 "resources": {
511 "cpus": 0,
512 "memory_mib": 0,
513 "disk_gib": 0
514 },
515 "capabilities": [
516 {
517 "id": "linux",
518 "available": true,
519 "state": "absent",
520 "isolation": "nested-container",
521 "runner_version": null,
522 "job": null,
523 "cause": null,
524 "fix": null
525 },
526 {
527 "id": "windows",
528 "available": true,
529 "state": "absent",
530 "isolation": "vm",
531 "runner_version": null,
532 "job": null,
533 "cause": null,
534 "fix": null
535 }
536 ],
537 "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox"
538}"#;
539
540 /// The launcher's documented example: running a job, Windows Sandbox off.
541 const RUNNING: &str = r#"{
542 "schema": 1, "state": "running", "backend": "wsl2",
543 "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0",
544 "isolation": "nested-container",
545 "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" },
546 "error": null,
547 "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
548 "capabilities": [
549 { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
550 "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null },
551 { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
552 "runner_version": null, "job": null,
553 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
554 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
555 ],
556 "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
557 "something_new": { "a": 1 }
558}"#;
559
560 #[test]
561 fn a_status_is_read_from_after_the_log() {
562 let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n");
563 let status = parse(&printed).unwrap();
564 assert_eq!(status.state, State::Running);
565 assert_eq!(status.backend.as_deref(), Some("wsl2"));
566 assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)"));
567 assert_eq!(status.version().as_deref(), Some("0.3.0"));
568 assert!(status.running());
569 assert!(!status.unregistered());
570 let windows = status.capability("windows").unwrap();
571 assert!(!windows.available);
572 assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff);
573 assert_eq!(status.trouble(), None);
574 }
575
576 #[test]
577 fn a_sandbox_never_made_needs_registering() {
578 let status = parse(ABSENT).unwrap();
579 assert_eq!(status.state, State::Absent);
580 assert!(status.anything_available());
581 assert!(status.unregistered());
582 assert!(!status.running());
583 assert_eq!(status.version().as_deref(), Some("0.2.1"));
584 }
585
586 #[test]
587 fn what_is_not_a_status_is_said_so() {
588 assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher"));
589 assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2"));
590 let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap();
591 assert_eq!(unknown.state, State::Unknown);
592 assert!(starts_status("{") && !starts_status(" \"schema\": 1,"));
593 }
594
595 #[test]
596 fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() {
597 let status = parse(
598 r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
599 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
600 "capabilities":[
601 {"id":"linux","available":false,"state":"error",
602 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
603 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."},
604 {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#,
605 )
606 .unwrap();
607 assert!(!status.anything_available());
608 let (cause, fix) = status.trouble().unwrap();
609 assert_eq!(why(&cause), Why::NoWsl);
610 assert!(fix.unwrap().contains(WSL_INSTALL));
611 }
612
613 #[test]
614 fn the_launchers_sentences_are_told_apart() {
615 for (text, expected) in [
616 ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored),
617 ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization),
618 ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit),
619 ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
620 ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
621 ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
622 ("release 0.3.0 has no sandbox images", Why::NoImages),
623 ("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", Why::NoImages),
624 ("release 0.3.0 has no sandbox image vm-arm64-disk for this machine", Why::NoImages),
625 ("The registration token is not valid, or has expired.", Why::TokenRefused),
626 ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
627 ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
628 ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb),
629 ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased),
630 ("This user may not use /dev/kvm.", Why::Other),
631 ] {
632 assert_eq!(why(text), expected, "{text}");
633 }
634 }
635
636 #[test]
637 fn a_kind_turned_off_is_stopped_by_the_person() {
638 let status = parse(
639 r#"{"schema":1,"state":"stopped","capabilities":[
640 {"id":"linux","available":true,"state":"stopped","turned_off":true},
641 {"id":"windows","available":true,"state":"absent","turned_off":false}]}"#,
642 )
643 .unwrap();
644 assert!(status.per_kind());
645 assert!(status.capability("linux").unwrap().off());
646 assert!(!status.all_off());
647 assert_eq!(status.trouble(), None);
648 // Only Windows, never set up, wants registering; Linux is off.
649 assert!(status.unregistered());
650 let all = parse(r#"{"schema":1,"state":"stopped","capabilities":[{"id":"linux","available":true,"state":"stopped","turned_off":true},{"id":"windows","available":false,"state":"absent","turned_off":false}]}"#).unwrap();
651 assert!(all.all_off());
652 assert!(!all.unregistered());
653 // A launcher from before `--only` says nothing of it.
654 assert!(!parse(RUNNING).unwrap().per_kind());
655 }
656
657 #[test]
658 fn a_download_says_how_far_it_is() {
659 assert_eq!(download("2026-10-12T08:00:00Z Fetching the sandbox's wsl-x64 image (612 MB)"), Some(Download { key: "wsl-x64".into(), percent: 0 }));
660 assert_eq!(download("2026-10-12T08:01:00Z Fetching the sandbox's wsl-x64 image: 40% of 612 MB"), Some(Download { key: "wsl-x64".into(), percent: 40 }));
661 assert_eq!(download("2026-10-12T08:01:00Z Took build (acme/web)"), None);
662 assert_eq!(missing_images("release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
663 assert_eq!(missing_images("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
664 assert_eq!(missing_images("release 0.4.0 has no sandbox image vm-arm64-disk for this machine"), Some(("0.4.0".into(), Some("vm-arm64-disk".into()))));
665 assert_eq!(missing_images("could not fetch"), None);
666 }
667
668 #[test]
669 fn the_commands_are_the_launchers() {
670 let dir = PathBuf::from("data").join("runner").join(FOLDER);
671 let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
672 let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret"), only: vec!["linux", "windows"] };
673 let up = text(up_args(&dir, &first));
674 assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
675 assert_eq!(up[4], dir.to_string_lossy());
676 // Never the token: it goes in the environment, out of the process list.
677 assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]);
678 assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]);
679 assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
680 let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first));
681 with_token(&mut command, first.token);
682 assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret"))));
683 assert!(!command.get_args().any(|a| a.to_string_lossy().contains("g1trt_")));
684 // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
685 assert!(!up.iter().any(|a| a.ends_with("-linux")));
686 let again = text(up_args(&dir, &Up { token: None, only: vec![], ..first.clone() }));
687 assert!(!again.iter().any(|a| a == "--only"));
688 assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
689 assert!(!format!("{first:?}").contains("g1trt_secret"));
690 assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
691 assert_eq!(text(down_args(&dir, Down::Now)).len(), 5);
692 assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain");
693 assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete");
694 assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]);
695 assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]);
696 }
697}