Skip to content
707 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 the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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`).
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.8//!
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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.
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.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.
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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.
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.34
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.35use std::ffi::OsString;
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.36use std::path::Path;
37use std::process::{Command, Stdio};
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.38use std::time::Duration;
39
40use serde::Deserialize;
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.41
42use super::backend::quiet;
43
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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";
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.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);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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>,
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.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>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.110}
111
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.112impl Capability {
113 pub fn off(&self) -> bool {
114 self.turned_off == Some(true)
115 }
116}
117
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.165 /// A capability that can be had here, and is on, but was never set
166 /// up: registering it needs a token.
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.167 pub fn unregistered(&self) -> bool {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.168 let wanting = |c: &Capability| c.available && !c.off() && c.state == State::Absent;
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.169 if self.capabilities.is_empty() { wanting(&self.linux()) } else { self.capabilities.iter().any(wanting) }
170 }
171
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.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
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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();
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.201 // Stopped by the person: nothing is wrong with it.
202 if linux.off() {
203 return None;
204 }
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.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
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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>,
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.270 /// The kinds of job to start (`--only`): those the person has on.
271 pub only: Vec<&'a str>,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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 {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.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()
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.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`]).
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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 }
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.302 if !up.only.is_empty() {
303 args.push("--only".into());
304 args.push(up.only.join(",").into());
305 }
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.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);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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}
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.327
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.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
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.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
The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report.347/// `down --only <kind> --drain`: as [`down_only_args`], after the job
348/// that kind is running, if any. An update stops a kind this way while
349/// its program is replaced, and starts it again after.
350pub fn drain_only_args(dir: &Path, kind: &str) -> Vec<OsString> {
351 let mut args = down_only_args(dir, kind);
352 args.push("--drain".into());
353 args
354}
355
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.356pub fn status_args(dir: &Path) -> Vec<OsString> {
357 args("status", dir)
358}
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.359
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.360pub fn update_args(dir: &Path) -> Vec<OsString> {
361 args("update", dir)
362}
363
364/// A launcher command whose output the app reads a line at a time
365/// (`up` and `update`, which take minutes the first time).
366pub fn command(binary: &Path, args: Vec<OsString>) -> Command {
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.367 let mut command = quiet(binary);
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.368 // What the app's own environment may have set for another runner is not this one's.
369 for (name, _) in std::env::vars_os() {
370 if name.to_string_lossy().starts_with("G1T_RUNNER_") {
371 command.env_remove(&name);
372 }
373 }
374 command.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.375 command
376}
377
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.378/// Runs a short launcher command to its end and reads its status, which
379/// it prints whether it worked or not.
380fn run(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.381 run_done(binary, args).map(|(status, _)| status)
382}
383
384/// [`run`], and whether the command did what it was asked (its exit code).
385fn run_done(binary: &Path, args: Vec<OsString>) -> Result<(Status, bool), String> {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.386 let out = command(binary, args).output().map_err(|e| format!("g1t-runner could not start: {e}"))?;
387 let printed = String::from_utf8_lossy(&out.stdout);
388 match parse(&printed) {
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.389 Ok(status) => Ok((status, out.status.success())),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.390 Err(_) if !out.status.success() && !out.stderr.is_empty() => Err(String::from_utf8_lossy(&out.stderr).trim().trim_start_matches("g1t-runner: ").to_owned()),
391 Err(why) => Err(why),
392 }
393}
394
395/// How the sandbox is now.
396pub fn status(binary: &Path, dir: &Path) -> Result<Status, String> {
397 run(binary, status_args(dir))
398}
399
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.400/// Stops the sandbox, waiting for it: how it is now.
401pub fn down(binary: &Path, dir: &Path, how: Down) -> Result<Status, String> {
402 down_with(binary, down_args(dir, how))
403}
404
405/// Stops one kind of job, and keeps it off, waiting for it.
406pub fn down_only(binary: &Path, dir: &Path, kind: &str) -> Result<Status, String> {
407 down_with(binary, down_only_args(dir, kind))
408}
409
410fn down_with(binary: &Path, args: Vec<OsString>) -> Result<Status, String> {
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.411 if !binary.exists() {
412 return Err("g1t-runner isn't installed".into());
413 }
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.414 match run_done(binary, args)? {
415 (status, true) => Ok(status),
416 (status, false) => Err(status.error.unwrap_or_else(|| "g1t-runner sandbox down failed.".into())),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.417 }
418}
419
420/// What a launcher's sentence is about: the words the app shows for it
421/// are diagnosis.rs's. Read from the causes and errors the launcher
422/// writes (crates/runner/src/selfhosted/launcher and native.rs).
423#[derive(Clone, Copy, Debug, PartialEq, Eq)]
424pub enum Why {
425 NoWsl,
426 WslMirrored,
427 NoVirtualization,
428 NoVfkit,
429 /// Linux, with neither Sysbox in Docker nor KVM.
430 NoSysboxOrKvm,
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.431 /// The newest release is published without the sandbox's images (for
432 /// this computer), so there is nothing to download yet.
433 NoImages,
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.434 /// The sandbox's image couldn't be downloaded, or isn't the release's.
435 Image,
436 /// g1t refused the registration token.
437 TokenRefused,
438 /// g1t no longer knows the runner.
439 Forgotten,
440 /// The sandbox has no registration, and no token was given.
441 Unregistered,
442 /// Windows Sandbox is off, or this edition of Windows has none.
443 WindowsSandboxOff,
444 /// Windows Sandbox without `wsb`, from before Windows 11 24H2.
445 NoWsb,
446 /// Sandboxed macOS jobs are not released.
447 MacNotReleased,
448 /// Anything else: shown in the launcher's own words.
449 Other,
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.450}
451
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.452/// The release a "has no sandbox images" sentence names, and the image
453/// it lacks when it is only this computer's: `release 0.3.0 has no
454/// sandbox image vm-arm64-disk for this machine`.
455pub fn missing_images(text: &str) -> Option<(String, Option<String>)> {
456 let (before, after) = text.split_once(" has no sandbox image")?;
457 let version = before.rsplit("release ").next()?.trim().to_owned();
458 let image = after.strip_prefix(' ').and_then(|rest| rest.split_whitespace().next()).map(str::to_owned);
459 (!version.is_empty() && !version.contains(' ')).then_some((version, image))
460}
461
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.462pub fn why(text: &str) -> Why {
463 let lower = text.to_ascii_lowercase();
464 let has = |words: &str| lower.contains(words);
465 if has("wsl is not installed") {
466 Why::NoWsl
467 } else if has("mirrored") {
468 Why::WslMirrored
469 } else if has("virtualization is not enabled")
470 || has("enable virtualization")
471 || has("virtual machine platform")
472 || has("hcs_e_hyperv_not_installed")
473 || has("vt-x")
474 || has("hypervisor is not present")
475 {
476 Why::NoVirtualization
477 } else if has("vfkit") && has("not installed") {
478 Why::NoVfkit
479 } else if has("neither sysbox nor kvm") || has("docker has no sysbox") {
480 Why::NoSysboxOrKvm
481 } else if has("token") && (has("not valid") || has("expired") || has("invalid")) {
482 Why::TokenRefused
483 } else if has("no longer knows this runner") || has("was removed from g1t") {
484 Why::Forgotten
485 } else if has("is not registered") {
486 Why::Unregistered
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.487 } else if has("has no sandbox image") {
488 Why::NoImages
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.489 } 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") {
490 Why::Image
491 } else if has("windows sandbox is not turned on") {
492 Why::WindowsSandboxOff
493 } else if has("24h2") {
494 Why::NoWsb
495 } else if has("macos jobs are not released") {
496 Why::MacNotReleased
497 } else {
498 Why::Other
499 }
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.500}
501
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.502#[cfg(test)]
503mod tests {
504 use super::*;
505 use std::path::PathBuf;
506
507 /// What `status --json` printed on a Windows 11 Pro machine with WSL
508 /// and Windows Sandbox, before `up` (g1t-runner 0.2.1).
509 const ABSENT: &str = r#"{
510 "schema": 1,
511 "state": "absent",
512 "backend": "wsl2",
513 "runner_version": null,
514 "launcher_version": "0.2.1",
515 "image_version": null,
516 "isolation": "nested-container",
517 "job": null,
518 "error": null,
519 "resources": {
520 "cpus": 0,
521 "memory_mib": 0,
522 "disk_gib": 0
523 },
524 "capabilities": [
525 {
526 "id": "linux",
527 "available": true,
528 "state": "absent",
529 "isolation": "nested-container",
530 "runner_version": null,
531 "job": null,
532 "cause": null,
533 "fix": null
534 },
535 {
536 "id": "windows",
537 "available": true,
538 "state": "absent",
539 "isolation": "vm",
540 "runner_version": null,
541 "job": null,
542 "cause": null,
543 "fix": null
544 }
545 ],
546 "state_dir": "C:\\Users\\syntaqx\\.g1t-runner\\sandbox"
547}"#;
548
549 /// The launcher's documented example: running a job, Windows Sandbox off.
550 const RUNNING: &str = r#"{
551 "schema": 1, "state": "running", "backend": "wsl2",
552 "runner_version": "0.3.0", "launcher_version": "0.3.0", "image_version": "0.3.0",
553 "isolation": "nested-container",
554 "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" },
555 "error": null,
556 "resources": { "cpus": 4, "memory_mib": 8192, "disk_gib": 64 },
557 "capabilities": [
558 { "id": "linux", "available": true, "state": "running", "isolation": "nested-container",
559 "runner_version": "0.3.0", "job": { "kind": "workflow", "id": "job_1", "name": "build", "repo": "acme/web" }, "cause": null, "fix": null },
560 { "id": "windows", "available": false, "state": "absent", "isolation": "vm",
561 "runner_version": null, "job": null,
562 "cause": "Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).",
563 "fix": "On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox under Settings, System, Optional features, More Windows features, then restart." }
564 ],
565 "state_dir": "C:\\Users\\a\\.g1t-runner\\sandbox",
566 "something_new": { "a": 1 }
567}"#;
568
569 #[test]
570 fn a_status_is_read_from_after_the_log() {
571 let printed = format!("2026-10-12T08:00:00Z Downloading the sandbox image wsl-x64.\n2026-10-12T08:03:00Z {{not json}}\n{RUNNING}\n");
572 let status = parse(&printed).unwrap();
573 assert_eq!(status.state, State::Running);
574 assert_eq!(status.backend.as_deref(), Some("wsl2"));
575 assert_eq!(status.job().map(Job::said).as_deref(), Some("build (acme/web)"));
576 assert_eq!(status.version().as_deref(), Some("0.3.0"));
577 assert!(status.running());
578 assert!(!status.unregistered());
579 let windows = status.capability("windows").unwrap();
580 assert!(!windows.available);
581 assert_eq!(why(windows.cause.as_deref().unwrap()), Why::WindowsSandboxOff);
582 assert_eq!(status.trouble(), None);
583 }
584
585 #[test]
586 fn a_sandbox_never_made_needs_registering() {
587 let status = parse(ABSENT).unwrap();
588 assert_eq!(status.state, State::Absent);
589 assert!(status.anything_available());
590 assert!(status.unregistered());
591 assert!(!status.running());
592 assert_eq!(status.version().as_deref(), Some("0.2.1"));
593 }
594
595 #[test]
596 fn what_is_not_a_status_is_said_so() {
597 assert!(parse("g1t-runner: unknown command sandbox\n").unwrap_err().contains("older than its sandbox launcher"));
598 assert!(parse(&ABSENT.replace("\"schema\": 1", "\"schema\": 2")).unwrap_err().contains("schema 2"));
599 let unknown = parse(&ABSENT.replace("\"state\": \"absent\",\n \"backend\"", "\"state\": \"hibernating\",\n \"backend\"")).unwrap();
600 assert_eq!(unknown.state, State::Unknown);
601 assert!(starts_status("{") && !starts_status(" \"schema\": 1,"));
602 }
603
604 #[test]
605 fn without_wsl_linux_says_why_and_nothing_else_can_run_on_home() {
606 let status = parse(
607 r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
608 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
609 "capabilities":[
610 {"id":"linux","available":false,"state":"error",
611 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
612 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows."},
613 {"id":"windows","available":false,"state":"absent","cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"…"}]}"#,
614 )
615 .unwrap();
616 assert!(!status.anything_available());
617 let (cause, fix) = status.trouble().unwrap();
618 assert_eq!(why(&cause), Why::NoWsl);
619 assert!(fix.unwrap().contains(WSL_INSTALL));
620 }
621
622 #[test]
623 fn the_launchers_sentences_are_told_apart() {
624 for (text, expected) in [
625 ("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.", Why::WslMirrored),
626 ("WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.", Why::NoVirtualization),
627 ("vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.", Why::NoVfkit),
628 ("This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.", Why::NoSysboxOrKvm),
629 ("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it", Why::Image),
630 ("could not fetch https://g1t.sh/downloads/runner/0.3.0/wsl-x64.tar.gz: dns error", Why::Image),
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.631 ("release 0.3.0 has no sandbox images", Why::NoImages),
632 ("Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images", Why::NoImages),
633 ("release 0.3.0 has no sandbox image vm-arm64-disk for this machine", Why::NoImages),
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.634 ("The registration token is not valid, or has expired.", Why::TokenRefused),
635 ("g1t no longer knows this runner (401 unauthorized). Register it again.", Why::Forgotten),
636 ("the sandbox's runner is not registered, and no --url and --token were given", Why::Unregistered),
637 ("This Windows has Windows Sandbox but not its command line, wsb, which came in Windows 11 24H2.", Why::NoWsb),
638 ("Sandboxed macOS jobs are not released yet.", Why::MacNotReleased),
639 ("This user may not use /dev/kvm.", Why::Other),
640 ] {
641 assert_eq!(why(text), expected, "{text}");
642 }
643 }
644
645 #[test]
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.646 fn a_kind_turned_off_is_stopped_by_the_person() {
647 let status = parse(
648 r#"{"schema":1,"state":"stopped","capabilities":[
649 {"id":"linux","available":true,"state":"stopped","turned_off":true},
650 {"id":"windows","available":true,"state":"absent","turned_off":false}]}"#,
651 )
652 .unwrap();
653 assert!(status.per_kind());
654 assert!(status.capability("linux").unwrap().off());
655 assert!(!status.all_off());
656 assert_eq!(status.trouble(), None);
657 // Only Windows, never set up, wants registering; Linux is off.
658 assert!(status.unregistered());
659 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();
660 assert!(all.all_off());
661 assert!(!all.unregistered());
662 // A launcher from before `--only` says nothing of it.
663 assert!(!parse(RUNNING).unwrap().per_kind());
664 }
665
666 #[test]
667 fn a_download_says_how_far_it_is() {
668 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 }));
669 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 }));
670 assert_eq!(download("2026-10-12T08:01:00Z Took build (acme/web)"), None);
671 assert_eq!(missing_images("release 0.3.0 has no sandbox images"), Some(("0.3.0".into(), None)));
672 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)));
673 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()))));
674 assert_eq!(missing_images("could not fetch"), None);
675 }
676
677 #[test]
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.678 fn the_commands_are_the_launchers() {
679 let dir = PathBuf::from("data").join("runner").join(FOLDER);
680 let text = |args: Vec<OsString>| args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>();
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.681 let first = Up { url: "https://g1t.sh", name: "build-box", labels: "desktop", token: Some("g1trt_secret"), only: vec!["linux", "windows"] };
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.682 let up = text(up_args(&dir, &first));
683 assert_eq!(&up[..4], ["sandbox", "up", "--json", "--state-dir"]);
684 assert_eq!(up[4], dir.to_string_lossy());
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.685 // Never the token: it goes in the environment, out of the process list.
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.686 assert_eq!(&up[5..], ["--url", "https://g1t.sh", "--name", "build-box", "--labels", "desktop", "--only", "linux,windows"]);
687 assert_eq!(text(down_only_args(&dir, "windows"))[5..], ["--only", "windows"]);
The desktop app is 0.3.2, and its runner updates are fast, seen and only ever g1t's. The app now trusts a g1t-runner release only when its manifest's Ed25519 signature, latest.json.sig, checks out against g1t's runner release key, which is built into the app (the public half of RUNNER_RELEASE_PUBLIC_KEY, kept in runner/native.rs, where development builds have it too; G1T_RUNNER_RELEASE_KEY overrides it for a g1t with its own key), as the runner already did with its own updates. A manifest with no signature, or one that doesn't check out, is refused and nothing in it is used: the runner keeps the version it has and says "Update refused: not signed by g1t", or, with no runner installed yet, "Not running: the runner's release isn't signed by g1t", each with why and what to do. It looks every hour rather than every six, on a thread of its own so the runner is looked after meanwhile, and what it finds waits, downloaded and checked, until each kind of job is idle, then goes in kind by kind, never cutting a job short: Linux jobs take the release's image (sandbox update) as soon as they have no job, while Windows jobs, whose runner is the program itself, are stopped after their job (sandbox down --only windows --drain), the program is replaced and they start again, the other kind running on throughout; a runner in Docker or on the computer stops between jobs and starts on the new one. The program there is moved aside rather than overwritten, so a status the app is asking at that moment can't stop it. Each kind of job's card in Settings shows its runner version, when the app last looked ("Checked 12 min ago") and a newer one waiting ("0.3.2 ready, installs after this job", "Installing 0.3.2"), and "Check for runner updates" beside the computer's name looks at once and installs what it finds, even with "Download updates by themselves" off, which otherwise stops the hourly look. The desktop report carries the same (snake_case: the diagnosis's update, with checked_at, ready and note, and each kind's update), so the Runners page's Desktops cards show it, "Checked" with how long ago, and this computer's row has the button too. All the words are diagnosis.rs's. The desktop guide says how updates work, what each card shows, the new troubleshooting rows and where the release key lives. Tests cover the signature with a key pair of their own and with release 0.3.0's manifest and signature as g1t.sh published them, kept byte for byte (.gitattributes), the wording, the cards and the report.688 assert_eq!(text(drain_only_args(&dir, "windows"))[5..], ["--only", "windows", "--drain"]);
A registration token never shows in the process list. The desktop app passed it to g1t-runner sandbox up as --token, which anyone on the computer can read while up runs; it goes in up's environment now, as G1T_RUNNER_TOKEN, which the launcher reads when --token is left out, as register-and-run already did.689 assert!(!up.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
690 let mut command = command(Path::new("g1t-runner"), up_args(&dir, &first));
691 with_token(&mut command, first.token);
692 assert!(command.get_envs().any(|(name, value)| name == "G1T_RUNNER_TOKEN" && value == Some(std::ffi::OsStr::new("g1trt_secret"))));
693 assert!(!command.get_args().any(|a| a.to_string_lossy().contains("g1trt_")));
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.694 // Its name and labels as the app has them: the launcher adds `-linux` and the OS labels.
Merge the desktop runner's settings: a card for each kind of job, Linux and Windows, each with its state, job, cause and fix, and its own Start and Stop; an honest wait for a release's sandbox images, checked again by itself; and the same on the Runners page.695 assert!(!up.iter().any(|a| a.ends_with("-linux")));
696 let again = text(up_args(&dir, &Up { token: None, only: vec![], ..first.clone() }));
697 assert!(!again.iter().any(|a| a == "--only"));
Merge the desktop app's sandbox: its default runner now starts g1t-runner's sandbox on Windows, macOS and Linux, and says per kind of job (Linux, Windows) what it is doing or why it cannot and how to fix it.698 assert!(!again.iter().any(|a| a == "--token" || a.starts_with("g1trt_")));
699 assert!(!format!("{first:?}").contains("g1trt_secret"));
700 assert_eq!(text(down_args(&dir, Down::Now))[..3], ["sandbox", "down", "--json"]);
701 assert_eq!(text(down_args(&dir, Down::Now)).len(), 5);
702 assert_eq!(text(down_args(&dir, Down::Drain)).last().unwrap(), "--drain");
703 assert_eq!(text(down_args(&dir, Down::Delete)).last().unwrap(), "--delete");
704 assert_eq!(text(status_args(&dir))[..3], ["sandbox", "status", "--json"]);
705 assert_eq!(text(update_args(&dir))[..3], ["sandbox", "update", "--json"]);
706 }
Merge the desktop app's runner: it starts with the app for the signed-in person, sandboxed by default and running nothing where no sandbox is available yet, registers through the site, keeps itself up to date, and says everywhere what it is doing, why it is not, and how to fix it.707}

This file's history is long; its oldest lines are credited to the oldest commit read.