Skip to main content

qcode/profile/
harness.rs

1//! The harnesses QCode can install into a profile image, and what each one needs.
2//!
3//! Every field below is taken from the harness's own documentation or source, never from
4//! memory, and then checked against the installed package in a container built from the base
5//! image; the comment above each record says where the fact was read and what the installed
6//! version said, and `harness_live.rs` is the check. A harness whose login path could not be
7//! established does not belong here: a wrong path silently loses a login, a missing harness
8//! only limits the list.
9//!
10//! On the installs: npm 11 warns about a package's install scripts and runs them anyway. All
11//! four command-line harnesses installed clean with the plain command, and the two that need a
12//! script (Claude Code and opencode copy a native binary over a stub in `postinstall`) answered
13//! afterwards, so no `--allow-scripts` is written here; the live test is what would notice if npm
14//! stopped running them.
15//!
16//! One harness here opens a window instead of drawing in a terminal. Its record carries a
17//! [`Desktop`] beside the fields every harness has, and the fields that only mean something in a
18//! terminal — the registry install, the unattended argument, the login file QCode carries, the
19//! resume argument — are empty for it. [`HarnessKind::TERMINAL`] is the list to walk when a rule
20//! is about those.
21
22/// A harness QCode can build a profile image for.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum HarnessKind {
25    /// Claude Code.
26    ClaudeCode,
27    /// opencode.
28    OpenCode,
29    /// Gemini CLI.
30    GeminiCli,
31    /// Codex CLI.
32    Codex,
33    /// Kimi Code CLI.
34    KimiCode,
35    /// Qwen Code.
36    QwenCode,
37    /// Antigravity IDE, which opens a window instead of drawing in a terminal.
38    AntigravityIde,
39}
40
41/// What a profile signs in with.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AccountKind {
44    /// Nothing: the harness runs on models its makers offer for free, without an account. There
45    /// is no login to make, store or carry.
46    Free,
47    /// A plan the user logs in to in a browser.
48    Subscription,
49    /// A key the user pastes in.
50    ApiKey,
51    /// A login the person makes inside the harness's own window, once, in the profile wizard:
52    /// QCode opens the window in a sign-in container, the person signs in there through their own
53    /// browser, and QCode takes the login out into the profile's credentials volume, from where
54    /// every workspace's home is given it before its window opens. Written `in-app` in definition
55    /// files, as it always was, so a profile made before QCode stored this login loads as one that
56    /// is not signed in yet.
57    InApp,
58    /// A provider the person added on the Providers page, named in the profile by its tag and a
59    /// model of its own. Nothing for QCode's sign-in container to make either: the key already
60    /// lives in `providers.toml`, and a tab of this profile is pointed at the workspace's relay
61    /// instead of the provider's own address, so the key never enters the container.
62    Provider,
63}
64
65/// A configuration file a template writes into the image, by its path under the home directory.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct ConfigFile {
68    /// Where the file goes, relative to the harness's home directory in the container.
69    pub path: &'static str,
70    /// What the file holds, written as the harness expects to read it.
71    pub contents: &'static str,
72}
73
74/// A file the image carries outside the home directory, written as root and readable by everyone:
75/// the harness reads it on every machine the image runs on, and no home volume a workspace keeps
76/// can hold an older copy of it.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct SystemFile {
79    /// Where the file goes, as an absolute path in the container.
80    pub path: &'static str,
81    /// What the file holds, written as the harness expects to read it.
82    pub contents: &'static str,
83}
84
85/// Where a harness draws.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Surface {
88    /// In the tab's own terminal, which is where every command-line harness draws.
89    Terminal,
90    /// In a window of its own on the person's desktop, opened by a container. The tab has no
91    /// terminal then; it says where the window stands and offers the two things that can be done
92    /// to it.
93    Desktop(&'static Desktop),
94}
95
96/// A harness that opens a window: where its application comes from, what the image needs beside
97/// the base image to run it, and how the window is started.
98///
99/// The application is never carried inside a QCode image. Its terms permit running it, not
100/// redistributing it, so the image is built on the person's own machine and fetches the archive
101/// from the maker's address at install time, exactly as a command-line harness is installed from
102/// its own registry.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct Desktop {
105    /// The version this record was read from and checked against.
106    pub version: &'static str,
107    /// The maker's archives of that version, one for each processor the image may be built on.
108    ///
109    /// The image picks one while it is built, by the machine it is built on, and not QCode by the
110    /// machine it was compiled for: the engine may build for another processor than QCode's own.
111    pub archives: &'static [Archive],
112    /// Where the archive unpacks to in the image, the one directory inside it stripped away.
113    pub install_dir: &'static str,
114    /// The program that opens the window, by its name under [`Desktop::install_dir`].
115    ///
116    /// The archive's own launcher script is not used: it runs the real program through `sh`, and
117    /// a container whose first process is that shell never passes the stop signal on, so every
118    /// stop costs the engine's ten-second timeout and a kill.
119    pub program: &'static str,
120    /// The arguments the window is always opened with.
121    pub flags: &'static [&'static str],
122    /// The Debian packages the application needs beside what the base image brings.
123    pub packages: &'static [&'static str],
124    /// How much room the built image takes, in whole mebibytes, as it was measured **on top of
125    /// `qcode/base`**, which is what the recipe builds on. The person is told this before they
126    /// ask for the image: a desktop image is an order of magnitude larger than a command-line
127    /// harness's.
128    ///
129    /// Measure it the way the recipe builds it, not on a bare Debian: the first number here was
130    /// taken on `debian:trixie-slim` during the trial and understated the image by 900 MiB,
131    /// which is a promise broken before the download even starts. The live test
132    /// `the_image_builds_from_the_makers_archive_and_carries_the_application_and_its_settings`
133    /// prints the built size, so a re-measure is one run away.
134    pub image_mib: u64,
135}
136
137/// One of a desktop application's archives: the build of one version for one processor.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct Archive {
140    /// The processor, as `uname -m` names it inside the image being built.
141    pub machine: &'static str,
142    /// Where the archive is downloaded from while the image is built.
143    pub address: &'static str,
144    /// How many bytes that archive is, as the server reports its length.
145    pub bytes: u64,
146    /// The SHA-256 of the archive, so an image is never built from something else that answered
147    /// at the same address.
148    pub sha256: &'static str,
149}
150
151impl Desktop {
152    /// The program that opens the window, as an absolute path in the image.
153    #[must_use]
154    pub fn command(&self) -> String {
155        format!("{}/{}", self.install_dir, self.program)
156    }
157
158    /// The whole line that opens the window on the workspace at `workspace`: the program, the
159    /// arguments it always takes, and the folder to open.
160    #[must_use]
161    pub fn command_line(&self, workspace: &str) -> Vec<String> {
162        let mut line = vec![self.command()];
163        line.extend(self.flags.iter().map(|flag| (*flag).to_owned()));
164        line.push(workspace.to_owned());
165        line
166    }
167}
168
169/// Where a harness reads the MCP servers it starts in every workspace, and in which shape.
170///
171/// These are the user-level settings, the ones a harness reads without asking for trust or
172/// approval, because QCode registers its bridge between tabs there (see [`crate::bridge`]).
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct McpSettings {
175    /// Where the file is, relative to the harness's home directory in the container.
176    pub path: &'static str,
177    /// How a server is written into it.
178    pub shape: McpShape,
179}
180
181/// How a harness writes the servers of its settings file.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum McpShape {
184    /// JSON, servers under `mcpServers` with `type`, `command` and `args`.
185    Claude,
186    /// JSON, servers under `mcp` with `type` and one `command` list.
187    OpenCode,
188    /// JSON, servers under `mcpServers` with `command`, `args` and `trust`.
189    Gemini,
190    /// TOML, one `[mcp_servers.<name>]` table per server with `command` and `args`.
191    Codex,
192    /// JSON, servers under `mcpServers` with `command` and `args` alone: a server with a `command`
193    /// is read as one started on standard input and output, so no `type` is needed, and there is
194    /// no `trust` to give.
195    Kimi,
196    /// JSON, servers under `mcpServers` with `command`, `args` and `env`. Its schema names every
197    /// field a server may have and takes no other, so neither Claude Code's `type` nor Gemini
198    /// CLI's `trust` belongs in this file.
199    Antigravity,
200}
201
202/// Everything QCode needs to know about one harness to build its image, start it and carry its
203/// login from one volume to another.
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub struct Harness {
206    /// How the harness is written in definition files.
207    pub id: &'static str,
208    /// The name the harness gives itself, shown as it writes it.
209    pub display_name: &'static str,
210    /// The account types a new profile of the harness can sign in with.
211    pub accounts: &'static [AccountKind],
212    /// Account types the harness's makers closed to most people after profiles were made with
213    /// them. A profile that has one still loads, because the few for whom it still works would
214    /// otherwise lose it, but no new profile is offered it and loading it says why.
215    pub withdrawn: &'static [AccountKind],
216    /// The shell commands that install the harness into the image. A harness that opens a window
217    /// has none: what its image is made of is in [`Desktop`], which the recipe reads instead.
218    pub install: &'static [&'static str],
219    /// The program that starts the harness in the container.
220    pub command: &'static str,
221    /// The arguments that let the harness work without asking for permission. The container is
222    /// the isolation, so they are always passed. A harness that opens a window has none: it asks
223    /// the person, in its own window, and the arguments its window needs are in [`Desktop`].
224    pub auto_run: &'static [&'static str],
225    /// Environment variables the container must set for the harness to behave as described here.
226    pub environment: &'static [(&'static str, &'static str)],
227    /// The files the login lives in, relative to the home directory. Each one is a file, not a
228    /// directory, so a copy never drags settings along with the login.
229    ///
230    /// A harness that opens a window names the database its login is kept in, which holds far more
231    /// than the login: what is taken out of it and given to a workspace is the login's rows alone
232    /// ([`crate::desktop::login`]), never the file.
233    pub identity: &'static [&'static str],
234    /// The configuration the `recommended` template writes, when the harness reads one.
235    pub settings: Option<ConfigFile>,
236    /// What the harness keeps of the questions it asks on its very first start, already
237    /// answered, so that a tab opens on its prompt instead of on a question whose highlighted
238    /// answer leaves. Written by both QCode templates; `None` for a harness that asks nothing.
239    pub first_start: Option<ConfigFile>,
240    /// How the harness is told to open a conversation it had before, by that conversation's id;
241    /// `None` for one that keeps no conversations QCode can list.
242    pub resume: Option<Resume>,
243    /// Where the harness draws.
244    pub surface: Surface,
245    /// Where the harness reads the MCP servers it starts; `None` for one that reads none.
246    pub mcp: Option<McpSettings>,
247    /// What keeps the harness's own sign-in to an API key, in the image of a profile whose
248    /// account is [`AccountKind::ApiKey`]; `None` for a harness that offers nothing else there,
249    /// or cannot be told to.
250    pub key_only: Option<SystemFile>,
251}
252
253/// Where npm keeps what it downloads while an install step of an image runs, and where nothing is
254/// left once that step is over.
255pub const BUILD_NPM_CACHE: &str = "/tmp/qcode-npm-cache";
256
257impl Harness {
258    /// The install as the `RUN` steps of an image, one per command in [`Harness::install`].
259    ///
260    /// Each step gives npm a cache of its own and removes it before the step ends, so the layer
261    /// the step leaves never holds npm's downloads: left in the base image's shared cache
262    /// (`NPM_CONFIG_CACHE=/var/cache/npm`) they were measured at 323 MB of opencode's image, all
263    /// of it tarballs of what the same layer already holds unpacked.
264    ///
265    /// A private directory rather than emptying the shared one afterwards: the shared cache is
266    /// made by the base image, open to every user, for whatever npm fetches while a container
267    /// runs, and a step that removed and remade it would have to know and repeat how the base
268    /// image made it. The variable is exported rather than written before the command, so it
269    /// holds for the whole step and not only for its first command.
270    #[must_use]
271    pub fn image_steps(&self) -> Vec<String> {
272        self.install
273            .iter()
274            .map(|step| {
275                format!("RUN export NPM_CONFIG_CACHE={BUILD_NPM_CACHE} \\\n && {step} \\\n && rm -rf {BUILD_NPM_CACHE}")
276            })
277            .collect()
278    }
279}
280
281/// How a harness opens an earlier conversation from its command line.
282///
283/// Each form was checked in a throwaway container against the version its record names: the
284/// line [`HarnessKind::command_line`] builds was run with an id no conversation has, and every
285/// harness got past its argument parser to its own "no such conversation" answer (Codex, which
286/// wants a terminal before it looks, got past its parser to that complaint instead). An argument
287/// the parser did not know was refused on the same line, so reaching the lookup is the proof
288/// that the whole line was read as meant.
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum Resume {
291    /// An option that takes the id, written after the unattended-mode arguments:
292    /// `claude --dangerously-skip-permissions --settings <settings> --resume <id>`.
293    Option(&'static str),
294    /// A subcommand that takes the id, written straight after the program so that the
295    /// unattended-mode arguments are read as the subcommand's own:
296    /// `codex resume --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust <id>`.
297    Subcommand(&'static str),
298}
299
300impl HarnessKind {
301    /// Every harness, in the order the profile wizard offers them. The ones that draw in a
302    /// terminal come first, because that is what nearly every profile is.
303    pub const ALL: [Self; 7] = [
304        Self::ClaudeCode,
305        Self::OpenCode,
306        Self::GeminiCli,
307        Self::Codex,
308        Self::KimiCode,
309        Self::QwenCode,
310        Self::AntigravityIde,
311    ];
312
313    /// The harnesses that draw in the tab's own terminal, in the same order.
314    ///
315    /// Most of what QCode knows about a harness — the install from a registry, the unattended
316    /// arguments, the conversation script, the login file it carries — is only true of these.
317    pub const TERMINAL: [Self; 6] =
318        [Self::ClaudeCode, Self::OpenCode, Self::GeminiCli, Self::Codex, Self::KimiCode, Self::QwenCode];
319
320    /// What QCode knows about this harness.
321    #[must_use]
322    pub fn record(self) -> &'static Harness {
323        match self {
324            Self::ClaudeCode => &CLAUDE_CODE,
325            Self::OpenCode => &OPENCODE,
326            Self::GeminiCli => &GEMINI_CLI,
327            Self::Codex => &CODEX,
328            Self::KimiCode => &KIMI_CODE,
329            Self::QwenCode => &QWEN_CODE,
330            Self::AntigravityIde => &ANTIGRAVITY_IDE,
331        }
332    }
333
334    /// The window this harness opens, when it opens one rather than drawing in a terminal.
335    #[must_use]
336    pub fn desktop(self) -> Option<&'static Desktop> {
337        match self.record().surface {
338            Surface::Terminal => None,
339            Surface::Desktop(desktop) => Some(desktop),
340        }
341    }
342
343    /// Whether a profile whose containers reach no network is offered for this harness.
344    ///
345    /// Antigravity IDE is not: it does not open without signing in to Google, and every answer it
346    /// gives comes from Google's servers, so a container without the network holds a window that
347    /// can do nothing. Every other harness here can at least be pointed at a provider on this
348    /// machine, or carries a login made once and kept.
349    #[must_use]
350    pub fn offered_offline(self) -> bool {
351        !matches!(self, Self::AntigravityIde)
352    }
353
354    /// The harness written as `id`, if there is one.
355    #[must_use]
356    pub fn parse(id: &str) -> Option<Self> {
357        Self::ALL.into_iter().find(|harness| harness.record().id == id)
358    }
359
360    /// Whether the harness can sign in with `account`.
361    #[must_use]
362    pub fn supports(self, account: AccountKind) -> bool {
363        self.record().accounts.contains(&account) || self.withdrawn(account)
364    }
365
366    /// Whether `account` is one the harness no longer offers to new profiles.
367    #[must_use]
368    pub fn withdrawn(self, account: AccountKind) -> bool {
369        self.record().withdrawn.contains(&account)
370    }
371
372    /// The program and arguments a tab runs inside the profile's container: the harness in
373    /// unattended mode, opening `conversation` when one is given and a new one otherwise.
374    ///
375    /// An id that [`history::is_safe_id`](super::history::is_safe_id) would not have let through
376    /// is not passed on: a word starting with `-` would be read as an option, so the harness is
377    /// started as if none had been asked for rather than with an argument nobody chose. Nor is one
378    /// given to a harness that has no way of being told to open a conversation again.
379    #[must_use]
380    pub fn command_line(self, conversation: Option<&str>) -> Vec<String> {
381        let record = self.record();
382        let auto_run = record.auto_run.iter().map(|arg| (*arg).to_owned());
383        let mut line = vec![record.command.to_owned()];
384        match conversation.filter(|id| super::history::is_safe_id(id)).zip(record.resume) {
385            None => line.extend(auto_run),
386            Some((id, Resume::Option(option))) => {
387                line.extend(auto_run);
388                line.extend([option.to_owned(), id.to_owned()]);
389            }
390            Some((id, Resume::Subcommand(command))) => {
391                line.push(command.to_owned());
392                line.extend(auto_run);
393                line.push(id.to_owned());
394            }
395        }
396        line
397    }
398}
399
400impl AccountKind {
401    /// Every account type. The wizard offers each harness's own list, in that harness's order.
402    pub const ALL: [Self; 5] = [Self::Free, Self::Subscription, Self::ApiKey, Self::InApp, Self::Provider];
403
404    /// How the account type is written in definition files.
405    #[must_use]
406    pub fn id(self) -> &'static str {
407        match self {
408            Self::Free => "free",
409            Self::Subscription => "subscription",
410            Self::ApiKey => "api-key",
411            Self::InApp => "in-app",
412            Self::Provider => "provider",
413        }
414    }
415
416    /// The account type written as `id`, if there is one.
417    #[must_use]
418    pub fn parse(id: &str) -> Option<Self> {
419        Self::ALL.into_iter().find(|account| account.id() == id)
420    }
421
422    /// Whether QCode has a login to make for a profile with this account. One that has none is
423    /// ready as soon as its image is, and nothing about it waits for a credentials volume.
424    ///
425    /// An in-app login is made in the harness's own window, but once and in the wizard, so that no
426    /// workspace has to ask for it again: it is a login QCode makes and stores like a
427    /// subscription's.
428    ///
429    /// A provider is the same: its key already lives in `providers.toml`, added on the Providers
430    /// page long before this profile existed, so there is nothing for a sign-in container to
431    /// capture either.
432    #[must_use]
433    pub fn needs_login(self) -> bool {
434        matches!(self, Self::Subscription | Self::ApiKey | Self::InApp)
435    }
436}
437
438/// Claude Code. Install, start command and credential file from the Claude Code documentation
439/// (`code.claude.com/docs/en/setup`, `code.claude.com/docs/en/iam`: "On Linux, credentials are
440/// stored in `~/.claude/.credentials.json`"); the argument and the settings key from
441/// `code.claude.com/docs/en/cli-reference` and `code.claude.com/docs/en/settings-reference`.
442///
443/// Checked against 2.1.275 in the image. The program is one binary
444/// (`node_modules/@anthropic-ai/claude-code-linux-x64/claude`), whose text holds
445/// `storagePath:z(e,".credentials.json")` next to the configuration directory and the line
446/// `dangerously-skip-permissions / --permission-mode bypassPermissions / a settings defaultMode
447/// of bypassPermissions`. `claude auth status` reads the file: with one placed at
448/// `~/.claude/.credentials.json` it answers `"loggedIn": true`. `claude doctor` reads the
449/// settings file and names it under `Invalid settings` when it is broken; with the file below
450/// it finds nothing to say.
451///
452/// Resuming, from `code.claude.com/docs/en/cli-reference` (`-r, --resume` takes a session id)
453/// and checked against 2.1.276: `claude --dangerously-skip-permissions --resume <id> --print hi`
454/// with an id no transcript has answers `No conversation found with session ID: <id>`, and with
455/// the id [`history`](super::history) lists for a transcript it goes on to `Not logged in`.
456///
457/// MCP servers, from `code.claude.com/docs/en/mcp` (user scope lives in `~/.claude.json`, under
458/// `mcpServers`) and checked against 2.1.278: with the entry [`crate::bridge::config`] writes,
459/// `claude mcp list` checks the server's health and prints `qcode: node … - ✔ Connected`, and
460/// the harness keeps the entry when it rewrites the file at its next start.
461///
462/// First start, checked against 2.1.278 on a terminal in a container at `/work`: a fresh home
463/// asks for a text style, shows its security notes, then asks whether the folder is trusted and
464/// whether the permission mode it was started in is accepted — the last two with "No, exit"
465/// highlighted, so a person pressing Return leaves. Answered by hand, it writes
466/// `hasCompletedOnboarding` and `projects["/work"].hasTrustDialogAccepted` into `~/.claude.json`
467/// and `theme` and `skipDangerousModePermissionPrompt` into `~/.claude/settings.json`. With the
468/// files below and no key pressed it draws its prompt at once; without
469/// `skipDangerousModePermissionPrompt` the permission warning comes back, without
470/// `hasTrustDialogAccepted` the folder question, and without `hasCompletedOnboarding` the text
471/// style. `theme` is not needed: the style question belongs to the onboarding that key closes.
472static CLAUDE_CODE: Harness = Harness {
473    id: "claude-code",
474    display_name: "Claude Code",
475    // A provider is offered by `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN`, read once at
476    // start (see `ProviderChoice::environment`), and was proven in a container with no network
477    // on an ollama server and on OpenRouter. Codex and Gemini CLI are not offered one: neither
478    // was ever run through the relay, and offering it to them would be a claim nobody checked.
479    accounts: &[AccountKind::Subscription, AccountKind::ApiKey, AccountKind::Provider],
480    withdrawn: &[],
481    install: &["npm install -g @anthropic-ai/claude-code"],
482    command: "claude",
483    auto_run: &["--dangerously-skip-permissions", "--settings", CLAUDE_NO_MODE_WARNING],
484    environment: &[],
485    identity: &[".claude/.credentials.json"],
486    settings: Some(ConfigFile {
487        path: ".claude/settings.json",
488        contents: "{\n  \"permissions\": {\n    \"defaultMode\": \"bypassPermissions\"\n  },\n  \"skipDangerousModePermissionPrompt\": true\n}\n",
489    }),
490    first_start: Some(ConfigFile { path: ".claude.json", contents: CLAUDE_FIRST_START }),
491    resume: Some(Resume::Option("--resume")),
492    surface: Surface::Terminal,
493    mcp: Some(McpSettings { path: ".claude.json", shape: McpShape::Claude }),
494    key_only: None,
495};
496
497/// The settings Claude Code is started with beside every other source of settings, so that the
498/// argument that starts it without asking for permission never opens on a warning about itself.
499///
500/// The warning is a question of QCode's making: it asks whether the mode the argument chose is
501/// accepted, with "No, exit" highlighted, so a person who presses Return leaves. The templates
502/// answer it in `~/.claude/settings.json`, but `base` writes nothing, and no template may decide
503/// whether a harness asks for permission. Given with `--settings`, the key comes with the argument
504/// under every template. Checked against 2.1.281 on a terminal at `/work` in a home with no files:
505/// without it the warning came up after the folder question; with it the prompt came up instead,
506/// and `~/.claude/settings.json` still held nothing but the text style the person chose.
507const CLAUDE_NO_MODE_WARNING: &str = "{\"skipDangerousModePermissionPrompt\":true}";
508
509/// Claude Code's answers to its first-start questions, for the workspace mounted at
510/// [`CODE_DIR`](crate::base::paths::CODE_DIR), and to its offer (2.1.285) to trade the mode the
511/// settings name for its auto mode. The same file is where the bridge registers its
512/// server, and it merges into what it finds, so these keys stay.
513const CLAUDE_FIRST_START: &str = "{\n  \"hasCompletedOnboarding\": true,\n  \"projects\": {\n    \"/work\": {\n      \"hasTrustDialogAccepted\": true\n    }\n  },\n  \"hasSeenAutoDefaultNudge\": true\n}\n";
514
515/// opencode. Install and start command from the opencode documentation (`opencode.ai/docs`), the
516/// argument from `opencode.ai/docs/cli` and `opencode.ai/docs/permissions`, the configuration
517/// file from `opencode.ai/docs/config`, and the login file from the source: `auth/index.ts`
518/// keeps it at `auth.json` under `Global.Path.data`, which `core/src/global.ts` resolves to the
519/// XDG data directory.
520///
521/// Checked against 1.18.31 in the image. The program is one binary
522/// (`node_modules/opencode-linux-x64/bin/opencode`), whose text holds
523/// `process.env.XDG_DATA_HOME; if(X)return X7.join(X,"opencode","auth.json"); return
524/// X7.join($,".local","share","opencode","auth.json")`. `opencode --help` lists `--auto
525/// auto-approve permissions that are not explicitly denied` under the default command, the one
526/// that opens the interface. `opencode providers list` prints `Credentials
527/// ~/.local/share/opencode/auth.json` and counts a file placed there, and `opencode debug
528/// config` prints the resolved configuration with the permission below in it.
529///
530/// Free use comes first: opencode starts and works without any login, on the free models it
531/// offers itself, so a profile that signs in to nothing is a complete one here.
532///
533/// Resuming, from `opencode.ai/docs/cli` (`-s, --session` continues a session by id) and
534/// checked against 1.18.31: `opencode --auto --session <id>` with an id no session has answers
535/// `Error: Session not found: <id>`, and with the id [`history`](super::history) lists for a
536/// session made by `opencode run` it opens the interface.
537///
538/// MCP servers, from `opencode.ai/docs/mcp-servers` (`mcp` in the configuration, a `local`
539/// server by one `command` list) and checked against 1.18.31: with the entry written into the
540/// file the template writes, `opencode mcp list` starts it and prints `✓ qcode connected`.
541///
542/// The image holds one binary out of the several npm brings for it. `opencode-ai` is a metapackage
543/// whose postinstall script (read in 1.18.33) picks the package for the processor the build runs
544/// on and puts that package's binary at `bin/opencode.exe`; npm installs the other optional
545/// platform packages that fit the machine as well, and nothing reads them once the program is
546/// there — on x86_64 that was 177 MB each for `opencode-linux-x64` and
547/// `opencode-linux-x64-baseline`, in every opencode image including the bare one. So the step
548/// takes those packages away and then asks the program for its version: an image whose binary
549/// went missing with them fails the build instead of being made.
550static OPENCODE: Harness = Harness {
551    id: "opencode",
552    display_name: "opencode",
553    // A provider is offered by an OpenAI-compatible provider handed over in
554    // `OPENCODE_CONFIG_CONTENT` (see `ProviderChoice::environment`), proven in a container with
555    // no network on an ollama server.
556    accounts: &[AccountKind::Free, AccountKind::Subscription, AccountKind::ApiKey, AccountKind::Provider],
557    withdrawn: &[],
558    install: &[OPENCODE_INSTALL],
559    command: "opencode",
560    auto_run: &["--auto"],
561    environment: &[],
562    identity: &[".local/share/opencode/auth.json"],
563    settings: Some(ConfigFile {
564        path: ".config/opencode/opencode.json",
565        contents: "{\n  \"$schema\": \"https://opencode.ai/config.json\",\n  \"permission\": {\n    \"*\": \"allow\"\n  }\n}\n",
566    }),
567    first_start: None,
568    resume: Some(Resume::Option("--session")),
569    surface: Surface::Terminal,
570    mcp: Some(McpSettings { path: ".config/opencode/opencode.json", shape: McpShape::OpenCode }),
571    key_only: None,
572};
573
574/// What the opencode install step runs, as one command so that it stays one `RUN` step
575/// ([`Harness::image_steps`]): install the metapackage, take the platform packages the postinstall
576/// did not use away, and then ask the program that was left for its version. The glob is inside
577/// the metapackage's own `node_modules`, where the postinstall put them, and matches nothing when
578/// the processor's package is the only one npm brought — `rm -rf` is given the name as written
579/// then and removes nothing.
580const OPENCODE_INSTALL: &str = "npm install -g opencode-ai \
581     && rm -rf /usr/local/npm/lib/node_modules/opencode-ai/node_modules/opencode-* \
582     && opencode --version";
583
584/// Gemini CLI. Install and start command from the workspace's readme, the argument from
585/// `docs/cli/cli-reference.md` (`--yolo` is deprecated in favour of `--approval-mode=yolo`), and
586/// the login files from the source: `services/fileKeychain.ts` writes `gemini-credentials.json`
587/// under `~/.gemini` when no OS keyring answers, `config/storage.ts` keeps
588/// `google_accounts.json` beside it, and `services/keychainService.ts` reads
589/// `GEMINI_FORCE_FILE_STORAGE` to skip the keyring, which a container has none of.
590///
591/// Checked against 0.60.0 in the image, whose `bundle/chunk-*.js` carries the source with its
592/// file names in comments. `packages/core/dist/src/services/fileKeychain.js`: `const configDir
593/// = path23.join(homedir(), GEMINI_DIR); this.tokenFilePath = path23.join(configDir,
594/// "gemini-credentials.json")`, with `var GEMINI_DIR = ".gemini"` and `homedir()` answering
595/// `GEMINI_CLI_HOME` or the user's home. `packages/core/dist/src/config/storage.js`:
596/// `getGoogleAccountsPath() { return path4.join(_Storage.getGlobalGeminiDir(),
597/// GOOGLE_ACCOUNTS_FILENAME) }` with `GOOGLE_ACCOUNTS_FILENAME = "google_accounts.json"`.
598/// `oauth_creds.json`, which the same file names, is the store of older versions and is only
599/// read to migrate. `packages/core/dist/src/services/keychainService.js`: `const
600/// forceFileStorage = process.env[FORCE_FILE_STORAGE_ENV_VAR] === "true"; const nativeKeychain
601/// = forceFileStorage ? null : await this.getNativeKeychain()`. The keyring module is there but
602/// does not load in the image (`Failed to load keytar native addon`), so the file store would
603/// be chosen anyway; the variable makes that a decision rather than an accident.
604/// `gemini --help` lists `--approval-mode` with the choices `default, auto_edit, yolo, plan`,
605/// and a value outside them is refused before anything else runs.
606///
607/// The settings exist because of one line in `packages/cli/src/config/config.ts` of the same
608/// build: `if (!trustedFolder && approvalMode !== "default") { debugLogger.warn('Approval mode
609/// overridden to "default" because the current folder is not trusted.'); approvalMode =
610/// "default" }`, with `isFolderTrustEnabled` in `packages/cli/src/config/trustedFolders.ts`
611/// reading `settings.security?.folderTrust?.enabled ?? true`. The workspace directory of a fresh container is not trusted, so without the file
612/// below the argument is taken and then undone. The documentation's `docs/cli/settings.md`
613/// lists the key with its default of `true`. Under the `base` template the harness asks once,
614/// in its own trust dialog, and keeps the answer in `~/.gemini/trustedFolders.json`.
615///
616/// The login file is encrypted with a key made from the machine name and the user name, in
617/// the same `fileKeychain.js` (the bundle numbers the `os` import): `deriveEncryptionKey() {
618/// const salt = `${os15.hostname()}-${os15.userInfo().username}-gemini-cli`; return
619/// crypto4.scryptSync("gemini-cli-oauth", salt, 32); }`. A copy of the file only decrypts
620/// where both halves are the same as where it was written. That is why every container QCode
621/// creates gets the one machine name in [`crate::engine::names::HOSTNAME`], and why the user
622/// inside is always `qcode`; the live test checks the salt is still made of those two.
623///
624/// Resuming, from `geminicli.com/docs/cli/session-management` (`--resume` takes `latest`, an
625/// index or a session id) and checked against 0.60.0, whose `SessionSelector.findSession` in
626/// the bundle matches the argument against each session's `id` before trying it as an index:
627/// `gemini --approval-mode=yolo --resume <id> --prompt hi` with an id the workspace's
628/// conversations do not have answers `Error resuming session: Invalid session identifier`, and
629/// with the id [`history`](super::history) lists it goes on to ask for an auth method.
630///
631/// Google stopped serving Gemini CLI to personal Google accounts (Code Assist for individuals and
632/// the paid Pro and Ultra plans) and removed "Login with Google" for them on 2026-06-18
633/// (`developers.google.com/gemini-code-assist/docs/deprecations/code-assist-individuals`). Code
634/// Assist Standard and Enterprise still sign in that way, so a profile made with a sign-in keeps
635/// loading; a new one is offered an API key. On 2026-09-23 a personal account signing in from
636/// 0.60.0 was answered "This client is no longer supported for Gemini Code Assist for
637/// individuals. To continue using Gemini, please migrate to the Antigravity suite of products".
638///
639/// The harness's own sign-in dialog still offers Google first, so an API-key profile's image
640/// keeps it to the key, read from 0.60.0 and then watched in a container. `AuthDialog.tsx` in
641/// `interactiveCli-*.js` lists "Sign in with Google", "Use Gemini API Key" and "Vertex AI", then
642/// `if (settings.merged.security.auth.enforcedType) { items = items.filter((item) => item.value
643/// === settings.merged.security.auth.enforcedType) }`, with `AuthType.USE_GEMINI =
644/// "gemini-api-key"`. The file is the system settings, `getSystemSettingsPath()` answering
645/// `/etc/gemini-cli/settings.json` on Linux, which is merged above the user's: it is in the image
646/// rather than in `~/.gemini/settings.json` because that one lives in each workspace's home
647/// volume, where an image built later never reaches, and because the `base` template writes
648/// none. `selectedType` is left out on purpose: set with no key stored, the start answers
649/// "Authenticated with gemini-api-key" and asks nothing, while without it the dialog opens. Run
650/// in a pty with the file in place, a fresh home showed "How would you like to authenticate for
651/// this project? ● 1. Use Gemini API Key" and nothing else, then "Enter Gemini API Key … You can
652/// get an API key from https://aistudio.google.com/app/apikey". The key typed there went through
653/// `saveApiKey` in `core/apiKeyCredentialStorage.js` (service `gemini-cli-api-key`) into the file
654/// keychain, which is `.gemini/gemini-credentials.json`: decrypted with the salt below it held
655/// the one entry `gemini-cli-api-key` with the key typed, and the next start answered
656/// "Authenticated with gemini-api-key" without asking. So the first file of [`Harness::identity`]
657/// is where an API key lands too, and `store_login` finds it there. The dialog also wrote
658/// `security.auth.selectedType` into the user's settings, beside the template's.
659///
660/// MCP servers, from `geminicli.com/docs/tools/mcp-server` (`mcpServers` in `settings.json`,
661/// `trust` skips the confirmation of each call) and checked against 0.60.0: `gemini mcp list`
662/// starts the server and prints `✓ qcode: node … (stdio) - Connected`. It prints `Disabled`
663/// instead in a folder nobody trusted, because the harness then suppresses user-level servers
664/// too; the `recommended` template's settings turn folder trust off, and under `base` the
665/// person's own answer in the harness's trust dialog decides.
666static GEMINI_CLI: Harness = Harness {
667    id: "gemini-cli",
668    display_name: "Gemini CLI",
669    accounts: &[AccountKind::ApiKey],
670    withdrawn: &[AccountKind::Subscription],
671    install: &["npm install -g @google/gemini-cli"],
672    command: "gemini",
673    auto_run: &["--approval-mode=yolo"],
674    environment: &[("GEMINI_FORCE_FILE_STORAGE", "true")],
675    identity: &[".gemini/gemini-credentials.json", ".gemini/google_accounts.json"],
676    // The update check and usage statistics off: keys of 0.61.0's own settings schema
677    // (`general.enableAutoUpdate`, `general.enableAutoUpdateNotification`,
678    // `privacy.usageStatisticsEnabled`, each on by default). A rebuild is how an update arrives.
679    settings: Some(ConfigFile {
680        path: ".gemini/settings.json",
681        contents: "{\n  \"general\": {\n    \"enableAutoUpdate\": false,\n    \"enableAutoUpdateNotification\": false\n  },\n  \"privacy\": {\n    \"usageStatisticsEnabled\": false\n  },\n  \"security\": {\n    \"folderTrust\": {\n      \"enabled\": false\n    }\n  }\n}\n",
682    }),
683    first_start: None,
684    resume: Some(Resume::Option("--resume")),
685    surface: Surface::Terminal,
686    mcp: Some(McpSettings { path: ".gemini/settings.json", shape: McpShape::Gemini }),
687    key_only: Some(SystemFile {
688        path: "/etc/gemini-cli/settings.json",
689        contents: "{\n  \"security\": {\n    \"auth\": {\n      \"enforcedType\": \"gemini-api-key\"\n    }\n  }\n}\n",
690    }),
691};
692
693/// Codex CLI. Install and start command from the workspace's readme, the argument from the source
694/// (`codex-rs/cli`), and the login file from the source as well: `login/src/auth/storage.rs`
695/// reads and writes `auth.json` under `CODEX_HOME`, and `config/src/types.rs` makes the file the
696/// default store. `core/src/config` documents `CODEX_HOME` as `~/.codex` unless it is set. The
697/// settings keys are from the Codex configuration documentation.
698///
699/// Checked against 0.155.0 in the image, where `codex --version` prints `codex-cli 0.155.0`.
700/// The program is one binary (`node_modules/@openai/codex-linux-x64/vendor/
701/// x86_64-unknown-linux-musl/bin/codex`); its text holds `auth.json`, `CODEX_HOME`,
702/// `approval_policy`, `sandbox_mode` and `danger-full-access`, and its `--help` says the
703/// configuration is "loaded from `~/.codex/config.toml`". `codex login status` answers `Not
704/// logged in` and, with a file placed at `~/.codex/auth.json`, `Logged in using an API key`;
705/// `codex doctor` prints `auth file ~/.codex/auth.json`. The argument parser refuses unknown
706/// arguments, so `--version` behind the one below shows it is known. The file below is read by
707/// every command: a wrong value in it is answered with `Error loading configuration`, and with
708/// it in place `codex doctor` reports `unrestricted fs + enabled network · approval Never`
709/// where it reported `restricted fs + restricted network · approval OnRequest` before.
710///
711/// Resuming, from the `resume` subcommand in `codex-rs/cli` of `github.com/openai/codex`
712/// (`codex resume [OPTIONS] [SESSION_ID] [PROMPT]`) and checked against 0.155.0. `codex resume --help` lists
713/// `--dangerously-bypass-approvals-and-sandbox` among the subcommand's own options, so the
714/// argument follows `resume` rather than going before it, where it would be the top-level
715/// command's and reach the resumed session only through however that version hands it down.
716/// `codex resume --dangerously-bypass-approvals-and-sandbox <id>` is parsed and stops at
717/// `Error: stdin is not a terminal`; an unknown argument in the same place is refused with
718/// `error: unexpected argument`. Given a terminal the interface waits for the terminal's answers
719/// before it looks the id up, so the lookup was checked through the same resume code without
720/// one: `codex exec resume <id> hi` answers `no rollout found for thread id <id>` for an unknown
721/// id, and for the id [`history`](super::history) lists it prints `session id: <id>` and goes
722/// on to the model.
723///
724/// MCP servers, from `developers.openai.com/codex/mcp` (`[mcp_servers.<name>]` in
725/// `config.toml`) and checked against 0.155.1: `codex mcp get qcode` reads the table back as
726/// `enabled: true, transport: stdio`, and `codex exec` starts the server even before it finds
727/// there is no login. Codex hands a server only a short list of variables, so the server finds
728/// its tab's token in the harness's own process instead (see the server's `token`).
729///
730/// A provider of one's own is a `[model_providers.<id>]` table of its configuration, given for
731/// one run with `-c` (see `ProviderChoice::arguments`). Checked against 0.156.1: the program
732/// answers `wire_api = "chat"` with "`wire_api = "chat"` is no longer supported", so it speaks
733/// only OpenAI's Responses shape (`/v1/responses`) to a provider. Through the relay, from a
734/// container with no network, it answered on Xiaomi MiMo (`mimo-v2.6-flash`), Kimi Code
735/// (`kimi-for-coding`) and OpenRouter, each of which serves that path; MiMo refused the first
736/// request for naming Codex's web search, a tool only OpenAI's own servers run, so it is turned
737/// off for such a profile.
738///
739/// First start, checked against 0.156.1 on a terminal at `/work` with a provider: before its
740/// prompt it asks "Trust this folder?", and the answer lands in the same `config.toml` as
741/// `[projects."/work"] trust_level = "trusted"`. So the template's settings carry that answer;
742/// with them the prompt comes up with no key pressed.
743///
744/// Hooks, checked against 0.156.1 on a terminal at `/work` under QCode basic, whose image carries
745/// graphify's `PreToolUse` hook in `~/.codex/hooks.json`: the harness runs no hook until someone has
746/// trusted it, keeps that trust as `hooks.state."<hook>".trusted_hash` in `config.toml`, and opens
747/// saying "1 hook needs review before it can run". Given a task, it never ran the hook and the
748/// person's next keys went to the review list instead ("t trust all · enter review"). `codex
749/// --help` lists `--dangerously-bypass-hook-trust`, "Run enabled hooks without requiring persisted
750/// hook trust for this invocation", on the command, on `resume` and on `exec`; with it the notice
751/// is gone and the hook runs. The only hooks in an image are the ones its template installed, so
752/// there is nothing for the person to review.
753static CODEX: Harness = Harness {
754    id: "codex",
755    display_name: "Codex",
756    accounts: &[AccountKind::Subscription, AccountKind::ApiKey, AccountKind::Provider],
757    withdrawn: &[],
758    install: &["npm install -g @openai/codex"],
759    command: "codex",
760    auto_run: &["--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust"],
761    environment: &[],
762    identity: &[".codex/auth.json"],
763    // `check_for_update_on_startup` and `[analytics] enabled` are keys of 0.156.1's configuration,
764    // read in its binary, which documents the second as `[analytics] enabled = false`.
765    settings: Some(ConfigFile {
766        path: ".codex/config.toml",
767        contents: "approval_policy = \"never\"\nsandbox_mode = \"danger-full-access\"\ncheck_for_update_on_startup = false\n\n[analytics]\nenabled = false\n\n[projects.\"/work\"]\ntrust_level = \"trusted\"\n",
768    }),
769    first_start: None,
770    resume: Some(Resume::Subcommand("resume")),
771    surface: Surface::Terminal,
772    mcp: Some(McpSettings { path: ".codex/config.toml", shape: McpShape::Codex }),
773    key_only: None,
774};
775
776/// Kimi Code CLI, Moonshot's coding agent. Install, start command, data folder and sign-in from
777/// its documentation (`moonshotai.github.io/kimi-code`, whose `llms-full.txt` holds every page):
778/// "npm install -g @moonshot-ai/kimi-code", `kimi` to start, everything under `~/.kimi-code/`
779/// (`KIMI_CODE_HOME` moves it), `/login` inside the interface or `kimi login` for Kimi Code's
780/// device-code sign-in, and "Managed provider credentials are stored as `credentials/<name>.json`".
781/// The TypeScript CLI replaced an older Python one of the same name; this is the npm package.
782///
783/// Checked against 2.1.0 in the image, whose program is one bundle (`dist/main.mjs`). `kimi
784/// --help` lists `--auto`, "Start in Never Ask mode: never interrupts you; everything runs and is
785/// decided automatically", beside `-y, --yolo`, which still asks for "risky actions", so `--auto`
786/// is the unattended argument; it refuses to be combined with `-p`, which is why a one-shot run
787/// leaves it out.
788///
789/// The login: the bundle's `resolveKimiTokenStorageName` turns the default slot `oauth/kimi-code`
790/// into `kimi-code`, and its `FileTokenStorage` writes `<name>.json` under `credentials/`, so a
791/// sign-in lands in `.kimi-code/credentials/kimi-code.json`. That file is not the whole login:
792/// `applyManagedKimiCodeConfig` writes the provider `managed:kimi-code`, which names that slot,
793/// its models and the default model into `.kimi-code/config.toml`, and without that entry the
794/// token is never looked for (a fresh home answers "LLM not set, send /login to login"). So the
795/// config file is carried beside the token, and no template writes it. The default slot is the
796/// mainland (`auth.kimi.com`) one, which is what `/login` picks unless the person chooses the
797/// global region; a global sign-in goes into a slot named after a hash of its hosts
798/// (`kimi-code-env-<hash>`), which this record does not carry, and QCode then finds no login
799/// rather than half of one. The key sign-in the same dialog offers (a Kimi Platform key) is
800/// written into `config.toml` alone and is not offered here: a Kimi Code key is used through the
801/// Providers page instead, where it never enters the container.
802///
803/// A provider is handed over in the `KIMI_MODEL_*` variables, which the documentation's
804/// "Define a model from environment variables" describes and the bundle reads at start: with
805/// `KIMI_MODEL_NAME` set it makes a provider of `KIMI_MODEL_PROVIDER_TYPE` at
806/// `KIMI_MODEL_BASE_URL` with `KIMI_MODEL_API_KEY`, in memory, and nothing is written. Pointed at
807/// an ollama server that way it sent its request there and printed the server's own answer.
808///
809/// First start, checked against 2.1.0 on a terminal at `/work`: a fresh home asks "Trust this
810/// folder?" with "Trust this folder" highlighted and "Don't trust" leaving. The answer is a file
811/// of its own, `.kimi-code/workspace-trust/<key>` holding `{"root":"/work","trustedAt":…}`, where
812/// the key is the bundle's `encodeWorkDirKey`: `wd_`, the folder's last name, and the first twelve
813/// hexadecimal digits of the SHA-256 of its path. With that file in place and no key pressed the
814/// prompt comes up at once.
815///
816/// Resuming, from `kimi --help` (`-S, --session [id]`) and checked against 2.1.0: `kimi --auto
817/// --session <id>` with an id no session has answers `Session "<id>" not found`, and with the id
818/// [`history`](super::history) lists it opens that session.
819///
820/// MCP servers, from the documentation's MCP page (`~/.kimi-code/mcp.json`, `mcpServers`, "Entries
821/// with a `command` field are stdio servers") and checked against 2.1.0: with the entry the bridge
822/// writes, `/mcp` in the interface lists `qcode connected stdio 2 tools`.
823///
824/// Instructions: the documentation's agents page names the workspace's `AGENTS.md` and the
825/// home's `~/.agents/skills/` for skills shared between tools, which is where graphify's `agents`
826/// installer puts its section and its skill. graphify's own `kimi` platform (0.9.66) was tried and
827/// does not fit: there is no `graphify kimi install` for the workspace, and `graphify install
828/// --platform kimi` writes the skill to `~/.kimi/skills`, the folder of the older Python CLI.
829/// Kimi Code CLI does not read it there; instead the folder's mere existence makes it open on a
830/// "Migrate from kimi-cli" dialog in place of its prompt. `kimi migrate --run` would move the skill
831/// to `~/.kimi-code/skills` and silence the dialog, but the workspace section would still need the
832/// `agents` installer, which leaves a second copy of the same skill in `~/.agents/skills`.
833static KIMI_CODE: Harness = Harness {
834    id: "kimi-code",
835    display_name: "Kimi Code CLI",
836    accounts: &[AccountKind::Subscription, AccountKind::Provider],
837    withdrawn: &[],
838    install: &["npm install -g @moonshot-ai/kimi-code"],
839    command: "kimi",
840    auto_run: &["--auto"],
841    environment: &[],
842    identity: &[".kimi-code/credentials/kimi-code.json", ".kimi-code/config.toml"],
843    settings: None,
844    first_start: Some(ConfigFile {
845        path: ".kimi-code/workspace-trust/wd_work_0c9a453fad61",
846        contents: "{\"root\":\"/work\",\"trustedAt\":0}\n",
847    }),
848    resume: Some(Resume::Option("--session")),
849    surface: Surface::Terminal,
850    mcp: Some(McpSettings { path: ".kimi-code/mcp.json", shape: McpShape::Kimi }),
851    key_only: None,
852};
853
854/// Qwen Code, Alibaba's fork of Gemini CLI. Install and start command from its readme
855/// ("npm install -g @qwen-code/qwen-code@latest", `qwen`), the argument from `qwen --help`
856/// (`--approval-mode` with `yolo`, "Automatically approve all tools"), and the rest from its
857/// authentication guide (`qwenlm.github.io/qwen-code-docs/en/users/configuration/auth/`).
858///
859/// There is no sign-in to carry. The guide says "The Qwen OAuth free tier was discontinued on
860/// 2026-04-15", and the 0.24.4 help lists `qwen auth` as "(removed)". What remains is a key typed
861/// into `/auth`, which the harness stores in `~/.qwen/settings.json` beside everything else, the
862/// very file the template writes; that is not a login QCode could carry without carrying the
863/// settings, so a key goes through the Providers page instead, and the key never enters the
864/// container. A provider is handed over in `OPENAI_BASE_URL`, `OPENAI_API_KEY` and `OPENAI_MODEL`,
865/// which the guide lists: with them set, a fresh home starts on its prompt saying "API Key |
866/// <model>" and asks for nothing.
867///
868/// Checked against 0.24.4 in the image. The folder trust of its parent is still there
869/// (`packages/cli/src/config/config.ts`: "Approval mode overridden to "default" because the current
870/// folder is not trusted"), but `isFolderTrustEnabled` reads `settings.security?.folderTrust?.enabled
871/// ?? false`, so it is off unless turned on; the template writes it off all the same, so a later
872/// default cannot quietly undo the argument. The template also turns off the usage statistics,
873/// `privacy.usageStatisticsEnabled`, which the settings schema of the same build defaults to
874/// `true` and which the bundle sends to Alibaba Cloud's `gb4w8c3ygj-default-sea.rum.aliyuncs.com`.
875///
876/// Resuming, from `qwen --help` (`-r, --resume`, "Resume a specific session by its ID") and checked
877/// against 0.24.4: with an id no session has it answers "No saved session found with ID <id>", and
878/// with the id [`history`](super::history) lists it goes on to ask the model.
879///
880/// MCP servers, as its parent keeps them (`mcpServers` in `settings.json`, `trust` to skip the
881/// confirmation of each call) and checked against 0.24.4: `qwen mcp list` starts the bridge's
882/// server and prints `✓ qcode: node … (stdio) - Connected`.
883///
884/// Instructions: `memory-constants.ts` in the bundle reads `QWEN.md` and `AGENTS.md`, and its skill
885/// folders are `~/.qwen/skills` and `~/.agents/skills`. graphify has no platform of Qwen Code's
886/// own; its `agents` installer (0.9.66) writes its section into `AGENTS.md` and its skill into
887/// `~/.agents/skills/graphify/`, both of which Qwen Code reads.
888static QWEN_CODE: Harness = Harness {
889    id: "qwen-code",
890    display_name: "Qwen Code",
891    accounts: &[AccountKind::Provider],
892    withdrawn: &[],
893    install: &["npm install -g @qwen-code/qwen-code"],
894    command: "qwen",
895    auto_run: &["--approval-mode=yolo"],
896    environment: &[],
897    identity: &[],
898    // `general.enableAutoUpdate`, on by default in 0.24.4's settings schema, off like Gemini CLI's.
899    settings: Some(ConfigFile {
900        path: ".qwen/settings.json",
901        contents: "{\n  \"general\": {\n    \"enableAutoUpdate\": false\n  },\n  \"security\": {\n    \"folderTrust\": {\n      \"enabled\": false\n    }\n  },\n  \"privacy\": {\n    \"usageStatisticsEnabled\": false\n  }\n}\n",
902    }),
903    first_start: None,
904    resume: Some(Resume::Option("--resume")),
905    surface: Surface::Terminal,
906    mcp: Some(McpSettings { path: ".qwen/settings.json", shape: McpShape::Gemini }),
907    key_only: None,
908};
909
910/// Antigravity IDE, the one harness here that opens a window instead of drawing in a terminal.
911///
912/// Everything below was measured in a throwaway container on a Wayland desktop, and the paragraphs
913/// that follow say what each field was read from and what was seen.
914///
915/// The archive: the address, the version and the length are from the maker's own download page
916/// (`antigravity.google/download`), whose Linux x64 link for the IDE is the one below; the server
917/// answers it with `content-length: 240837095` and `last-modified` of 2026-09-13. The digest was
918/// taken of that download. The address carries the version, so a new version is a new record and
919/// a new image, the way a command-line harness is updated by installing it again.
920///
921/// The program: the archive holds one directory, `Antigravity IDE/`, and `antigravity-ide` inside
922/// it is the real program. Its `bin/antigravity-ide` launcher is a shell script, and a container
923/// whose first process is that shell swallowed the stop signal: `stop` waited its ten seconds
924/// and killed. Started directly, with `--init` above it, the window closed on the signal in
925/// under two and a half seconds with an exit code of 0.
926///
927/// The one flag: `--ozone-platform=wayland` was enough for a native Wayland window (the
928/// compositor listed the client with `xwayland: false`). `--enable-features=UseOzonePlatform` was
929/// not needed, the application draws its own title bar, and `--ignore-gpu-blocklist` must never
930/// be added: with it the window came up empty and the graphics process restarted four times.
931/// `--no-sandbox` is not here either, and is not to be added: the application's own sandbox comes
932/// up inside the container on both engines (see `crate::desktop` for what docker needs for that).
933///
934/// The packages: the application is Electron 39 with Chromium 142 inside, so it wants GTK 3, NSS,
935/// ALSA, GBM, libsecret, the X and Wayland client libraries and a font; Mesa, so that the
936/// graphics process can fall back to drawing in software, which is what it did on the virtual
937/// card it was measured on; `dbus` and `procps`, which it looks for on startup; and `curl`, which
938/// the base image does not carry and the install step downloads with. Recommended packages stay
939/// out, as everywhere in these images.
940///
941/// The login: the application offers nothing but "Continue with Google" on its first screen. The
942/// person signs in once, in the profile wizard, in a window opened for that alone: the page goes
943/// to their own browser and the way back is carried into the container (`crate::desktop::callback`).
944/// The login is two rows of the application's database, read in its own code (where, and why no
945/// keyring or `--password-store` flag is involved, is written in `crate::desktop::login`); QCode
946/// takes those rows into the profile's credentials volume and puts them into each workspace's
947/// database before its window opens, where the workspace has no login of its own.
948///
949/// The settings: both QCode templates turn the maker's telemetry and the application's own updater
950/// off, and turn workspace trust off, so the window never opens on "Do you trust the authors of the
951/// files in this folder?" nor keeps the workspace in restricted mode, where the application's own
952/// agent extension runs with less. The container is what keeps the work apart from the machine, as
953/// it is for every harness. `security.workspace.trust.enabled` is read in 2.5.5's
954/// `out/vs/workbench/workbench.desktop.main.js`: declared `{type:"boolean",default:!0}` in the
955/// configuration registry, and `isWorkspaceTrustEnabled(){return
956/// this.environmentService.disableWorkspaceTrust?!1:!!this.configurationService.getValue(…)}` reads
957/// it. The file is written into the image's home directory like every template's file, which means
958/// the workspace's home volume gets it when the volume is first filled and never again, so an edit
959/// the person makes afterwards stays. Workspace trust was left alone at first, as a question that
960/// was not QCode's to answer; the owner's rule since 2026-09-24 is that no harness asks under a
961/// QCode template, and under `base` the question is still the application's own.
962///
963/// What the settings file cannot hold: whether the agent runs a terminal command, proceeds past a
964/// plan without a review and runs JavaScript in its browser without asking. In 2.5.5 none of the
965/// three is a setting; each is a row of the application's own state database
966/// (`~/.config/Antigravity IDE/User/globalStorage/state.vscdb`, key
967/// `antigravityUnifiedStateSync.agentPreferences` with `terminalAutoExecutionPolicySentinelKey` and
968/// `artifactReviewPolicySentinelKey`, and `antigravityUnifiedStateSync.browserPreferences` with
969/// `browser_js_execution_config_sentinel_key`), and the onboarding of its first start writes all
970/// three from the mode the person picks there, "Agent-driven development" being the one that asks
971/// nothing. Its last page writes them over whatever an image put there, and it is skipped only once
972/// the database says the onboarding is done, so they are not written here: seeding them would take
973/// a build step that writes that database and passes over the page the sign-in starts from.
974///
975/// The servers: the application reads user-level MCP servers from `~/.gemini/config/mcp_config.json`,
976/// which is where its own code joins that path, and its schema takes no field it does not name.
977/// This was measured in a throwaway container: a server written there was started, it was handed
978/// the variable the entry's `env` named, and the application opened the newer era of the protocol
979/// and asked for the tools. So the window's agent reaches the other tabs of the workspace and can
980/// give them work; nothing can be given back to it, because it draws no prompt to type into.
981static ANTIGRAVITY_IDE: Harness = Harness {
982    id: "antigravity-ide",
983    display_name: "Antigravity IDE",
984    accounts: &[AccountKind::InApp],
985    withdrawn: &[],
986    install: &[],
987    command: "/opt/antigravity-ide/antigravity-ide",
988    auto_run: &[],
989    environment: &[],
990    identity: &[".config/Antigravity IDE/User/globalStorage/state.vscdb"],
991    settings: Some(ConfigFile {
992        path: ".config/Antigravity IDE/User/settings.json",
993        contents: "{\n  \"telemetry.telemetryLevel\": \"off\",\n  \"update.mode\": \"none\",\n  \"security.workspace.trust.enabled\": false\n}\n",
994    }),
995    first_start: None,
996    resume: None,
997    surface: Surface::Desktop(&ANTIGRAVITY),
998    mcp: Some(McpSettings { path: ".gemini/config/mcp_config.json", shape: McpShape::Antigravity }),
999    key_only: None,
1000};
1001
1002/// The window Antigravity IDE opens, as the trial measured it.
1003static ANTIGRAVITY: Desktop = Desktop {
1004    version: "2.5.5",
1005    archives: &[
1006        Archive {
1007            machine: "x86_64",
1008            address: "https://edgedl.me.gvt1.com/edgedl/release2/j0qc3/antigravity/stable/\
1009                      2.5.5-4923483625488384/linux-x64/Antigravity%20IDE.tar.gz",
1010            bytes: 240_837_095,
1011            sha256: "0c5233b297d2b3aebb61af49f8944012c2953d361a5ebb16978490636917f831",
1012        },
1013        // Downloaded and summed 2026-09-25: the same single directory with the program at its top,
1014        // built for aarch64.
1015        Archive {
1016            machine: "aarch64",
1017            address: "https://edgedl.me.gvt1.com/edgedl/release2/j0qc3/antigravity/stable/\
1018                      2.5.5-4923483625488384/linux-arm/Antigravity%20IDE.tar.gz",
1019            bytes: 236_865_493,
1020            sha256: "88c167108980c33a223a8d7f0aa6aaf4dec61f0cc3950235a2698e9ffc38a49e",
1021        },
1022    ],
1023    install_dir: "/opt/antigravity-ide",
1024    program: "antigravity-ide",
1025    flags: &["--ozone-platform=wayland"],
1026    packages: &[
1027        "curl",
1028        "dbus",
1029        "fonts-dejavu-core",
1030        "libasound2t64",
1031        "libegl1",
1032        "libgbm1",
1033        "libgl1-mesa-dri",
1034        "libgtk-3-0t64",
1035        "libnss3",
1036        "libsecret-1-0",
1037        "libxkbfile1",
1038        "libxss1",
1039        "libxtst6",
1040        "mesa-vulkan-drivers",
1041        "procps",
1042        // Without it the application's own "open this in a browser" call returns success and does
1043        // nothing at all, which is how signing in stayed silent; measured 2026-09-20.
1044        "xdg-utils",
1045    ],
1046    // Measured 2026-09-20 on top of qcode/base: base 517 MB, the packages 351 MB, the
1047    // application 769 MB, which is 2295 MiB in all.
1048    image_mib: 2_295,
1049};
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054
1055    #[test]
1056    fn every_harness_is_described_completely() {
1057        for harness in HarnessKind::ALL {
1058            let record = harness.record();
1059            assert!(!record.id.is_empty() && !record.display_name.is_empty(), "{harness:?}");
1060            assert!(!record.accounts.is_empty(), "{harness:?} must support an account type");
1061            assert!(!record.command.is_empty(), "{harness:?} must say how it is started");
1062        }
1063    }
1064
1065    #[test]
1066    fn every_command_line_harness_says_how_it_is_installed_run_and_resumed() {
1067        // The four fields below are what a terminal tab needs and a window has no use for: a
1068        // window is installed from an archive named in its own record, asks the person in its own
1069        // interface instead of taking an unattended flag, keeps a login QCode never carries, and
1070        // has no conversation id to be handed back.
1071        for harness in HarnessKind::TERMINAL {
1072            let record = harness.record();
1073            assert!(!record.install.is_empty(), "{harness:?} must say how it is installed");
1074            assert!(!record.auto_run.is_empty(), "{harness:?} must say how unattended mode is turned on");
1075            // A harness with nothing to sign in to has no login to carry; one with a sign-in must
1076            // say where it lands.
1077            let signs_in = record.accounts.iter().chain(record.withdrawn).any(|account| account.needs_login());
1078            assert_eq!(!record.identity.is_empty(), signs_in, "{harness:?} must say where its identity lives");
1079            assert!(record.resume.is_some(), "{harness:?} must say how a conversation is opened again");
1080            assert_eq!(harness.desktop(), None, "{harness:?} draws in the terminal");
1081        }
1082        assert_eq!(HarnessKind::TERMINAL.len() + 1, HarnessKind::ALL.len(), "every harness is one or the other");
1083    }
1084
1085    #[test]
1086    fn identity_paths_stay_inside_the_home_directory() {
1087        for harness in HarnessKind::TERMINAL {
1088            for path in harness.record().identity {
1089                assert!(!path.starts_with('/') && !path.starts_with('~'), "{harness:?}: {path}");
1090                assert!(!path.split('/').any(|part| part == ".." || part.is_empty()), "{harness:?}: {path}");
1091            }
1092        }
1093    }
1094
1095    #[test]
1096    fn configuration_a_template_writes_also_stays_inside_the_home_directory() {
1097        for harness in HarnessKind::ALL {
1098            let record = harness.record();
1099            for file in record.settings.into_iter().chain(record.first_start) {
1100                assert!(!file.path.starts_with('/') && !file.path.starts_with('~'), "{harness:?}");
1101                assert!(!file.contents.is_empty(), "{harness:?}");
1102            }
1103        }
1104    }
1105
1106    #[test]
1107    fn every_harness_reads_its_servers_from_its_home_and_never_from_its_login() {
1108        for harness in HarnessKind::ALL {
1109            let record = harness.record();
1110            let path = record.mcp.expect("every harness starts servers of its own").path;
1111            assert!(!path.starts_with('/') && !path.starts_with('~'), "{harness:?}: {path}");
1112            assert!(!record.identity.contains(&path), "{harness:?}: registering a server would touch the login");
1113        }
1114        assert_eq!(HarnessKind::ClaudeCode.record().mcp.expect("it has one").path, ".claude.json");
1115        assert_eq!(HarnessKind::Codex.record().mcp.expect("it has one").shape, McpShape::Codex);
1116        let window = HarnessKind::AntigravityIde.record().mcp.expect("a window reads servers too");
1117        assert_eq!(window.path, ".gemini/config/mcp_config.json");
1118        assert_eq!(window.shape, McpShape::Antigravity);
1119    }
1120
1121    #[test]
1122    fn where_a_template_writes_settings_the_servers_go_into_the_same_file() {
1123        // Otherwise the harness would read two files, and the one the template wrote could hide
1124        // the servers.
1125        for harness in [HarnessKind::OpenCode, HarnessKind::GeminiCli, HarnessKind::Codex, HarnessKind::QwenCode] {
1126            let record = harness.record();
1127            assert_eq!(record.settings.map(|file| file.path), record.mcp.map(|mcp| mcp.path), "{harness:?}");
1128        }
1129    }
1130
1131    #[test]
1132    fn identity_is_never_the_file_a_template_writes() {
1133        // A profile's settings are generated; overwriting them with a copied identity would
1134        // silently undo the template.
1135        for harness in HarnessKind::ALL {
1136            let record = harness.record();
1137            for file in record.settings.into_iter().chain(record.first_start) {
1138                assert!(!record.identity.contains(&file.path), "{harness:?}: {}", file.path);
1139            }
1140        }
1141    }
1142
1143    #[test]
1144    fn identifiers_are_unique_and_read_back_as_the_same_harness() {
1145        let mut seen = Vec::new();
1146        for harness in HarnessKind::ALL {
1147            let id = harness.record().id;
1148            assert!(!seen.contains(&id), "{id} twice");
1149            seen.push(id);
1150            assert_eq!(HarnessKind::parse(id), Some(harness));
1151        }
1152        assert_eq!(
1153            seen,
1154            ["claude-code", "opencode", "gemini-cli", "codex", "kimi-code", "qwen-code", "antigravity-ide"]
1155        );
1156        assert_eq!(HarnessKind::parse("Claude-Code"), None, "identifiers are written one way only");
1157        assert_eq!(HarnessKind::parse("cursor"), None);
1158        assert_eq!(HarnessKind::parse("antigravity-ide"), Some(HarnessKind::AntigravityIde));
1159    }
1160
1161    #[test]
1162    fn account_types_read_back_as_the_same_type() {
1163        for account in AccountKind::ALL {
1164            assert_eq!(AccountKind::parse(account.id()), Some(account));
1165        }
1166        assert_eq!(AccountKind::parse("none"), None);
1167        assert_eq!(AccountKind::parse("free"), Some(AccountKind::Free));
1168        assert!(HarnessKind::ClaudeCode.supports(AccountKind::Subscription));
1169        assert!(HarnessKind::ClaudeCode.supports(AccountKind::ApiKey));
1170    }
1171
1172    #[test]
1173    fn the_harnesses_proven_through_the_relay_are_offered_a_provider_of_ones_own_and_nothing_else_is() {
1174        for harness in [
1175            HarnessKind::ClaudeCode,
1176            HarnessKind::OpenCode,
1177            HarnessKind::Codex,
1178            HarnessKind::KimiCode,
1179            HarnessKind::QwenCode,
1180        ] {
1181            assert!(harness.supports(AccountKind::Provider), "{harness:?}");
1182        }
1183        for harness in [HarnessKind::GeminiCli, HarnessKind::AntigravityIde] {
1184            assert!(!harness.supports(AccountKind::Provider), "{harness:?}");
1185        }
1186        assert!(!AccountKind::Provider.needs_login(), "the key already lives in providers.toml");
1187    }
1188
1189    #[test]
1190    fn only_opencode_is_offered_for_free_and_offers_it_first() {
1191        assert_eq!(HarnessKind::OpenCode.record().accounts.first(), Some(&AccountKind::Free));
1192        for harness in [
1193            HarnessKind::ClaudeCode,
1194            HarnessKind::GeminiCli,
1195            HarnessKind::Codex,
1196            HarnessKind::KimiCode,
1197            HarnessKind::QwenCode,
1198        ] {
1199            assert!(!harness.supports(AccountKind::Free), "{harness:?}");
1200        }
1201        assert!(!AccountKind::Free.needs_login());
1202        assert!(AccountKind::Subscription.needs_login() && AccountKind::ApiKey.needs_login());
1203        // A window is signed in to once, in the wizard, and QCode stores that login.
1204        assert!(AccountKind::InApp.needs_login());
1205        assert_eq!(HarnessKind::AntigravityIde.record().accounts, [AccountKind::InApp]);
1206        for harness in HarnessKind::TERMINAL {
1207            assert!(!harness.supports(AccountKind::InApp), "{harness:?}");
1208        }
1209    }
1210
1211    #[test]
1212    fn a_new_conversation_is_the_program_in_unattended_mode() {
1213        assert_eq!(
1214            HarnessKind::ClaudeCode.command_line(None),
1215            ["claude", "--dangerously-skip-permissions", "--settings", CLAUDE_NO_MODE_WARNING]
1216        );
1217        assert_eq!(HarnessKind::OpenCode.command_line(None), ["opencode", "--auto"]);
1218        assert_eq!(HarnessKind::GeminiCli.command_line(None), ["gemini", "--approval-mode=yolo"]);
1219        assert_eq!(
1220            HarnessKind::Codex.command_line(None),
1221            ["codex", "--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust"]
1222        );
1223        assert_eq!(HarnessKind::KimiCode.command_line(None), ["kimi", "--auto"]);
1224        assert_eq!(HarnessKind::QwenCode.command_line(None), ["qwen", "--approval-mode=yolo"]);
1225    }
1226
1227    #[test]
1228    fn an_earlier_conversation_is_opened_the_way_each_harness_takes_it() {
1229        let id = "2afe99eb-008a-4542-b160-1aa5b29bb95f";
1230        assert_eq!(
1231            HarnessKind::ClaudeCode.command_line(Some(id)),
1232            ["claude", "--dangerously-skip-permissions", "--settings", CLAUDE_NO_MODE_WARNING, "--resume", id]
1233        );
1234        assert_eq!(
1235            HarnessKind::OpenCode.command_line(Some("ses_f4acc7e75ffeEArYIV9UJooqnn")),
1236            ["opencode", "--auto", "--session", "ses_f4acc7e75ffeEArYIV9UJooqnn"]
1237        );
1238        assert_eq!(HarnessKind::GeminiCli.command_line(Some(id)), ["gemini", "--approval-mode=yolo", "--resume", id]);
1239        // The subcommand comes first, so the unattended-mode argument is the subcommand's own.
1240        assert_eq!(
1241            HarnessKind::Codex.command_line(Some(id)),
1242            ["codex", "resume", "--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust", id]
1243        );
1244        let kimi = "session_e3f864de-1a92-4498-a9f7-5fd551a34703";
1245        assert_eq!(HarnessKind::KimiCode.command_line(Some(kimi)), ["kimi", "--auto", "--session", kimi]);
1246        assert_eq!(HarnessKind::QwenCode.command_line(Some(id)), ["qwen", "--approval-mode=yolo", "--resume", id]);
1247    }
1248
1249    #[test]
1250    fn an_id_that_could_be_taken_for_an_option_opens_a_new_conversation_instead() {
1251        for harness in HarnessKind::ALL {
1252            for id in ["--help", "-c", "", "two words", "a;b"] {
1253                assert_eq!(harness.command_line(Some(id)), harness.command_line(None), "{harness:?}: {id:?}");
1254            }
1255        }
1256    }
1257
1258    #[test]
1259    fn verified_claude_code_facts() {
1260        let record = HarnessKind::ClaudeCode.record();
1261        assert_eq!(record.command, "claude");
1262        assert_eq!(record.auto_run, ["--dangerously-skip-permissions", "--settings", CLAUDE_NO_MODE_WARNING]);
1263        let settings: serde_json::Value = serde_json::from_str(CLAUDE_NO_MODE_WARNING).expect("the settings are JSON");
1264        assert_eq!(settings["skipDangerousModePermissionPrompt"], true);
1265        assert_eq!(record.identity, [".claude/.credentials.json"]);
1266        assert!(record.install.iter().any(|step| step.contains("@anthropic-ai/claude-code")));
1267    }
1268
1269    #[test]
1270    fn verified_opencode_facts() {
1271        let record = HarnessKind::OpenCode.record();
1272        assert_eq!(record.command, "opencode");
1273        assert_eq!(record.auto_run, ["--auto"]);
1274        assert_eq!(record.identity, [".local/share/opencode/auth.json"]);
1275        assert!(record.install.iter().any(|step| step.contains("opencode-ai")));
1276    }
1277
1278    #[test]
1279    fn opencode_keeps_the_binary_its_install_linked_and_no_other_platform_package() {
1280        // npm brings a package for every processor that fits the machine and the postinstall picks
1281        // one of them; the rest are read by nothing and are 177 MB each on x86_64, so the step
1282        // takes them away — and then asks the program, so an install that lost the binary fails
1283        // the build rather than leaving an image opencode cannot be started in.
1284        let record = HarnessKind::OpenCode.record();
1285        assert_eq!(record.install.len(), 1, "one `RUN` step of its own");
1286        let step = record.install[0];
1287        let installed = step.find("npm install -g opencode-ai").expect("opencode is installed");
1288        let removed = step
1289            .find("rm -rf /usr/local/npm/lib/node_modules/opencode-ai/node_modules/opencode-*")
1290            .expect("the platform packages are taken away");
1291        let asked = step.find("opencode --version").expect("the program is asked after them");
1292        assert!(installed < removed && removed < asked, "{step}");
1293
1294        // The whole of it is one step, and the cache npm downloaded into goes with it.
1295        let steps = record.image_steps();
1296        assert_eq!(steps.len(), 1, "{steps:?}");
1297        assert!(steps[0].contains(step), "{steps:?}");
1298        assert!(steps[0].ends_with("&& rm -rf /tmp/qcode-npm-cache"), "{steps:?}");
1299        // No other harness installs a metapackage of platform packages.
1300        for harness in HarnessKind::ALL.into_iter().filter(|harness| *harness != HarnessKind::OpenCode) {
1301            for step in harness.record().install {
1302                assert!(!step.contains("opencode-*"), "{harness:?}: {step}");
1303            }
1304        }
1305    }
1306
1307    #[test]
1308    fn gemini_cli_offers_only_an_api_key_to_new_profiles() {
1309        // Google stopped personal "Login with Google" for Gemini CLI on 2026-06-18.
1310        assert_eq!(HarnessKind::GeminiCli.record().accounts, [AccountKind::ApiKey]);
1311        assert!(HarnessKind::GeminiCli.supports(AccountKind::Subscription), "existing profiles keep loading");
1312        assert!(HarnessKind::GeminiCli.withdrawn(AccountKind::Subscription));
1313        for harness in [HarnessKind::ClaudeCode, HarnessKind::OpenCode, HarnessKind::Codex] {
1314            assert!(!harness.withdrawn(AccountKind::Subscription), "{harness:?}");
1315        }
1316    }
1317
1318    #[test]
1319    fn gemini_cli_is_kept_to_a_key_by_its_system_settings_and_no_other_harness_is() {
1320        let file = HarnessKind::GeminiCli.record().key_only.expect("Gemini CLI can be kept to a key");
1321        assert_eq!(file.path, "/etc/gemini-cli/settings.json");
1322        let settings: serde_json::Value = serde_json::from_str(file.contents).expect("the file is JSON");
1323        assert_eq!(settings, serde_json::json!({ "security": { "auth": { "enforcedType": "gemini-api-key" } } }));
1324        for harness in HarnessKind::ALL.into_iter().filter(|harness| *harness != HarnessKind::GeminiCli) {
1325            assert_eq!(harness.record().key_only, None, "{harness:?}");
1326        }
1327    }
1328
1329    #[test]
1330    fn verified_gemini_cli_facts() {
1331        let record = HarnessKind::GeminiCli.record();
1332        assert_eq!(record.command, "gemini");
1333        assert_eq!(record.auto_run, ["--approval-mode=yolo"]);
1334        assert_eq!(record.identity, [".gemini/gemini-credentials.json", ".gemini/google_accounts.json"]);
1335        // Without this variable the credentials may go to an OS keyring the copy cannot reach.
1336        assert_eq!(record.environment, [("GEMINI_FORCE_FILE_STORAGE", "true")]);
1337        assert!(record.install.iter().any(|step| step.contains("@google/gemini-cli")));
1338        // Without this file the harness puts the approval mode back to "default" in a folder
1339        // nobody has trusted, which every fresh container's workspace directory is.
1340        let settings = record.settings.expect("the folder trust has to be turned off");
1341        assert_eq!(settings.path, ".gemini/settings.json");
1342        assert!(settings.contents.contains("\"folderTrust\""), "{}", settings.contents);
1343        assert!(settings.contents.contains("\"enabled\": false"), "{}", settings.contents);
1344    }
1345
1346    #[test]
1347    fn verified_antigravity_ide_facts() {
1348        let record = HarnessKind::AntigravityIde.record();
1349        let desktop = HarnessKind::AntigravityIde.desktop().expect("it opens a window");
1350        assert_eq!(record.command, desktop.command());
1351        assert_eq!(desktop.command(), "/opt/antigravity-ide/antigravity-ide");
1352        // One archive for each processor, each the maker's own, of the version this record
1353        // describes, and of the IDE rather than of the other product on the same page, with the
1354        // length and the digest measured on the download.
1355        let archives: Vec<(&str, &str, u64, &str)> =
1356            desktop.archives.iter().map(|a| (a.machine, a.address, a.bytes, a.sha256)).collect();
1357        assert_eq!(
1358            archives,
1359            [
1360                (
1361                    "x86_64",
1362                    "https://edgedl.me.gvt1.com/edgedl/release2/j0qc3/antigravity/stable/2.5.5-4923483625488384/linux-x64/Antigravity%20IDE.tar.gz",
1363                    240_837_095,
1364                    "0c5233b297d2b3aebb61af49f8944012c2953d361a5ebb16978490636917f831",
1365                ),
1366                (
1367                    "aarch64",
1368                    "https://edgedl.me.gvt1.com/edgedl/release2/j0qc3/antigravity/stable/2.5.5-4923483625488384/linux-arm/Antigravity%20IDE.tar.gz",
1369                    236_865_493,
1370                    "88c167108980c33a223a8d7f0aa6aaf4dec61f0cc3950235a2698e9ffc38a49e",
1371                ),
1372            ]
1373        );
1374        for archive in desktop.archives {
1375            assert!(archive.address.contains(desktop.version), "{}", archive.address);
1376            assert!(!archive.address.contains(['\'', ' ', '\n']), "{}", archive.address);
1377            assert!(archive.sha256.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
1378        }
1379        // The one flag the window needs, and the two that must never be added: one blanked the
1380        // window, the other would give up the application's own sandbox.
1381        assert_eq!(desktop.flags, ["--ozone-platform=wayland"]);
1382        assert!(!desktop.flags.contains(&"--no-sandbox"), "the sandbox comes up on both engines");
1383        assert!(!desktop.flags.contains(&"--ignore-gpu-blocklist"), "it left the window empty");
1384        // Nothing is installed from a registry, and nothing of the archive is carried here.
1385        assert!(record.install.is_empty() && record.auto_run.is_empty() && record.resume.is_none());
1386        // The login is kept in the application's own database, whose two rows QCode carries.
1387        assert_eq!(record.identity, [crate::desktop::login::DATABASE]);
1388        let settings = record.settings.expect("the template turns telemetry and the updater off");
1389        assert!(settings.contents.contains("\"telemetry.telemetryLevel\": \"off\""), "{}", settings.contents);
1390        assert!(settings.contents.contains("\"update.mode\": \"none\""), "{}", settings.contents);
1391        // No question about the folder under a QCode template, the owner's rule of 2026-09-24.
1392        assert!(settings.contents.contains("\"security.workspace.trust.enabled\": false"), "{}", settings.contents);
1393        assert!(desktop.image_mib > 1_000, "the person is told how large it is: {}", desktop.image_mib);
1394    }
1395
1396    #[test]
1397    fn a_window_is_opened_on_the_workspace_with_the_flags_it_always_takes() {
1398        let desktop = HarnessKind::AntigravityIde.desktop().expect("it opens a window");
1399        assert_eq!(
1400            desktop.command_line("/work"),
1401            ["/opt/antigravity-ide/antigravity-ide", "--ozone-platform=wayland", "/work"]
1402        );
1403    }
1404
1405    #[test]
1406    fn the_packages_a_window_needs_are_named_once_and_installable() {
1407        let desktop = HarnessKind::AntigravityIde.desktop().expect("it opens a window");
1408        let mut sorted = desktop.packages.to_vec();
1409        sorted.sort_unstable();
1410        sorted.dedup();
1411        assert_eq!(sorted, desktop.packages, "the list is sorted and says each package once");
1412        for package in desktop.packages {
1413            // A word apt takes as a package name, never an option and never a shell word.
1414            assert!(package.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()), "{package}");
1415            assert!(package.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "+-.".contains(c)));
1416        }
1417        // The archive is downloaded by the install step, and the base image carries no client.
1418        assert!(desktop.packages.contains(&"curl"), "nothing would fetch the archive");
1419    }
1420
1421    #[test]
1422    fn verified_codex_facts() {
1423        let record = HarnessKind::Codex.record();
1424        assert_eq!(record.command, "codex");
1425        assert_eq!(record.auto_run, ["--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust"]);
1426        assert_eq!(record.identity, [".codex/auth.json"]);
1427        assert!(record.install.iter().any(|step| step.contains("@openai/codex")));
1428        // The folder's trust is answered where Codex keeps the answer, so a tab opens on its prompt.
1429        let settings = record.settings.expect("the template writes its settings");
1430        assert!(settings.contents.contains("[projects.\"/work\"]\ntrust_level = \"trusted\""), "{}", settings.contents);
1431    }
1432
1433    #[test]
1434    fn verified_kimi_code_facts() {
1435        let record = HarnessKind::KimiCode.record();
1436        assert_eq!(record.command, "kimi");
1437        // `--yolo` still asks before what it counts as risky; only `--auto` never asks.
1438        assert_eq!(record.auto_run, ["--auto"]);
1439        assert!(record.install.iter().any(|step| step.contains("@moonshot-ai/kimi-code")));
1440        // The token alone is never looked for: the provider entry that names its slot is in the
1441        // configuration, so both go, the token first because it is the proof of a sign-in.
1442        assert_eq!(record.identity, [".kimi-code/credentials/kimi-code.json", ".kimi-code/config.toml"]);
1443        assert_eq!(record.settings, None, "the configuration is part of the login and no template writes it");
1444        assert_eq!(record.accounts, [AccountKind::Subscription, AccountKind::Provider]);
1445        let mcp = record.mcp.expect("it reads servers");
1446        assert_eq!((mcp.path, mcp.shape), (".kimi-code/mcp.json", McpShape::Kimi));
1447    }
1448
1449    #[test]
1450    fn kimi_code_opens_on_its_prompt_in_the_workspace_it_trusts() {
1451        let trust = HarnessKind::KimiCode.record().first_start.expect("the trust question is answered");
1452        // The file's name is Kimi Code's own key for the folder: `wd_`, the folder's last name, and
1453        // the first twelve hexadecimal digits of the SHA-256 of `/work`. The live test has the
1454        // installed harness answer the question and compares the name it wrote.
1455        assert_eq!(crate::base::paths::CODE_DIR, "/work");
1456        assert_eq!(trust.path, ".kimi-code/workspace-trust/wd_work_0c9a453fad61");
1457        let answer: serde_json::Value = serde_json::from_str(trust.contents).expect("the answer is JSON");
1458        assert_eq!(answer["root"], crate::base::paths::CODE_DIR);
1459    }
1460
1461    #[test]
1462    fn verified_qwen_code_facts() {
1463        let record = HarnessKind::QwenCode.record();
1464        assert_eq!(record.command, "qwen");
1465        assert_eq!(record.auto_run, ["--approval-mode=yolo"]);
1466        assert!(record.install.iter().any(|step| step.contains("@qwen-code/qwen-code")));
1467        // Its own sign-in was discontinued and a key it keeps lives in its settings, so a provider
1468        // of one's own is the one way in, and there is no login to carry.
1469        assert_eq!(record.accounts, [AccountKind::Provider]);
1470        assert!(record.identity.is_empty());
1471        let settings: serde_json::Value =
1472            serde_json::from_str(record.settings.expect("the template writes settings").contents).expect("JSON");
1473        assert_eq!(settings["security"]["folderTrust"]["enabled"], false);
1474        assert_eq!(settings["privacy"]["usageStatisticsEnabled"], false);
1475        assert_eq!(record.mcp.expect("it reads servers").shape, McpShape::Gemini);
1476    }
1477}