Skip to main content

qcode/desktop/
login.rs

1//! Signing a window in once, in the profile wizard, and giving that login to every workspace.
2//!
3//! What the login is made of, read in Antigravity IDE 2.5.5's own code (the archive the record
4//! names, unpacked outside any installation):
5//!
6//! - The Google sign-in is the IDE's main process, `antigravityAuthMainService` in
7//!   `resources/app/out/main.js`. The extension's authentication provider
8//!   (`resources/app/extensions/antigravity/dist/extension.js`, `ExternalAuthProvider.getSessions`)
9//!   answers a session only when two things are both there: the OAuth token
10//!   (`OAuthPreferences.getOAuthTokenInfo`) and the user status with a non-empty e-mail
11//!   (`UserStatus.getUserStatus`). With either missing it answers none, and the window asks to
12//!   sign in.
13//! - Both are kept by the application's storage service at application scope, under the keys
14//!   `antigravityUnifiedStateSync.oauthToken` (`oauthLifecycle.js`: access token, refresh token,
15//!   expiry, token type) and `antigravityUnifiedStateSync.userStatus` (`userStatusLifecycle.js`:
16//!   name, e-mail, tier). Each value is a protocol buffer written as base64 text
17//!   (`out-build/vs/base/common/proto.js`, `Qo` and `qo`).
18//! - Application-scope storage is the `ItemTable` of `state.vscdb` in the default profile's
19//!   `globalStorage` (`aAt.STORAGE_NAME = "state.vscdb"`, under `globalStorageHome`), which is
20//!   `~/.config/Antigravity IDE/User/globalStorage/state.vscdb` for the product name
21//!   `Antigravity IDE` in `product.json`. The table is
22//!   `ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)`.
23//! - Nothing of the sign-in goes through Electron's `safeStorage`: its one use in `main.js` is the
24//!   encryption service behind extensions' secret storage, and the token is written as plain
25//!   base64 into the table above. So a login taken from one container is read in another with no
26//!   keyring and no `--password-store=basic`; the window's flags stay as they were.
27//! - `~/.gemini/` holds no part of it: the language server's own paths there are artifacts,
28//!   transcripts, its settings and the MCP servers' own tokens (`language_server_linux_x64`).
29//!
30//! So the smallest thing that carries the login is those two rows, not the database. The database
31//! holds everything else the application remembers about a home — recent folders, panels, what
32//! each extension stored — and copying it would carry the sign-in window's state into every
33//! workspace, and would overwrite a workspace's own on the way. The two rows are taken out into a
34//! file of QCode's ([`STORED`]) and put into a workspace's database, created when it is not there,
35//! by a program of QCode's run inside a container ([`PROGRAM`]): the image carries Node, and Node 24
36//! reads and writes SQLite by itself, so nothing is added to the image for it.
37//!
38//! The copy is made only where the workspace has no login of its own ([`Fill::Keep`]): a
39//! workspace whose window was signed in to another account, or signed in again, keeps it. A person
40//! who asks for the profile's login to be given to every workspace again gets [`Fill::Replace`].
41
42use std::path::{Path, PathBuf};
43
44use crate::base::paths::{HOME_DIR, KEEP_ALIVE};
45use crate::engine::names;
46use crate::engine::run::capture;
47use crate::engine::scratch::Scratch;
48use crate::engine::{
49    Access, ContainerCreate, ContainerState, Engine, Exec, HostUser, Mount, MountSource, Network, RunOnce,
50};
51use crate::profile::identity::STORE_DIR;
52use crate::profile::{Profile, SafeName};
53use crate::ui::profiles::Problem;
54use crate::ui::profiles::recipe::CAPTURE_DIR;
55use crate::ui::profiles::work::{self, LoginContainer};
56
57use super::{Display, signin};
58
59/// The database the login lives in, relative to the home directory.
60pub const DATABASE: &str = ".config/Antigravity IDE/User/globalStorage/state.vscdb";
61
62/// The rows of [`DATABASE`] that are the login: the token first, then the account it belongs to.
63pub const KEYS: [&str; 2] = ["antigravityUnifiedStateSync.oauthToken", "antigravityUnifiedStateSync.userStatus"];
64
65/// The file the two rows are kept in, in the profile's credentials volume and in the folder they
66/// are taken out into. Its presence in a credentials volume is what says the login is a window's.
67pub const STORED: &str = "antigravity-login.json";
68
69/// How long the sign-in window is given to quit, writing what it holds, before it is ended.
70pub const QUIT_WITHIN: u32 = 30;
71
72/// How long the wizard waits between two looks for the login while the sign-in window is open.
73/// Each look runs a program in the window's container, so it is not made more often than a person
74/// would notice.
75pub const LOOK_EVERY: std::time::Duration = std::time::Duration::from_secs(2);
76
77/// How long the wizard waits between two looks for a page the window wants opened. That look only
78/// reads a folder of this machine, and the person has just pressed a button in the window.
79pub const ASK_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
80
81/// The program that reads and writes the two rows, run by Node inside a container.
82///
83/// A row being there is not a login: the application writes the token's row as soon as it has
84/// looked whether it is signed in, holding only "signed out" when it is not (measured: a window
85/// that never signed in had the row, with `authStateWithContextSentinelKey` in it and no token).
86/// So the rows are read the way the application reads them. Each is a `Topic` of the application's
87/// state sync, `data` (1) a map of `key` (1) to a `Row` whose `value` (1) is the entry, all written
88/// as base64 of the protocol buffer (`exa.unified_state_sync_pb`, read out of the descriptors in
89/// `main.js`). A login is the token row's `oauthTokenInfoSentinelKey` entry, an `OAuthTokenInfo`
90/// with an access (1) or refresh (3) token, beside the account row's `userStatusSentinelKey`
91/// entry, a `UserStatus` with an e-mail (7) — the two things the extension asks for.
92///
93/// With a third word `approve`, the agent's own approvals are merged into the database: what makes
94/// the window's agent run a command, write a file, go past a plan and act in its browser without
95/// asking, and what the language server is told it may do on its own — see [`ANSWERED`] for the
96/// rows and [`approve_command`] for where the program is run. A database that is not there is made
97/// with them, as `keep` and `replace` make it for a login. `approvals <database>` prints every one
98/// of those settings as the application would read them, one `name=value` line each, empty for one
99/// that is not there.
100///
101/// `seen <database>` answers 0 when there is a login; `take <database> <file>` writes the two rows
102/// into the file and fails when there is none; `keep <file> <database>` puts them into the database
103/// unless it holds a token of its own, and `replace <file> <database>` puts them in whatever it
104/// holds. A database that is not there is made, with the table the application makes. Written with
105/// double quotes only, so that a shell can carry it inside single ones.
106pub const PROGRAM: &str = r#""use strict";
107const fs = require("node:fs");
108const path = require("node:path");
109const { DatabaseSync } = require("node:sqlite");
110const KEYS = ["antigravityUnifiedStateSync.oauthToken", "antigravityUnifiedStateSync.userStatus"];
111const [mode, from, to] = process.argv.slice(1);
112const text = (value) => (typeof value === "string" ? value : Buffer.from(value).toString("utf8"));
113const fields = (bytes) => {
114  const out = [];
115  let at = 0;
116  const varint = () => {
117    let value = 0n;
118    let shift = 0n;
119    for (;;) {
120      const byte = bytes[at++];
121      if (byte === undefined) throw new Error("short");
122      value |= BigInt(byte & 127) << shift;
123      shift += 7n;
124      if (byte < 128) return value;
125    }
126  };
127  while (at < bytes.length) {
128    const tag = Number(varint());
129    const kind = tag & 7;
130    if (kind === 0) out.push([tag >> 3, varint()]);
131    else if (kind === 2) {
132      const length = Number(varint());
133      if (at + length > bytes.length) throw new Error("short");
134      out.push([tag >> 3, bytes.subarray(at, at + length)]);
135      at += length;
136    } else if (kind === 1) at += 8;
137    else if (kind === 5) at += 4;
138    else throw new Error("wire type");
139  }
140  return out;
141};
142const field = (message, wanted) => {
143  try {
144    for (const [number, value] of fields(Buffer.from(message, "base64"))) {
145      if (number === wanted && typeof value !== "bigint") return text(value);
146    }
147  } catch (error) {
148    return "";
149  }
150  return "";
151};
152const entry = (topic, wanted) => {
153  try {
154    for (const [number, data] of fields(Buffer.from(topic, "base64"))) {
155      if (number !== 1 || typeof data === "bigint") continue;
156      let key = "";
157      let row = null;
158      for (const [part, value] of fields(data)) {
159        if (part === 1 && typeof value !== "bigint") key = text(value);
160        if (part === 2 && typeof value !== "bigint") row = value;
161      }
162      if (key === wanted && row) {
163        for (const [part, value] of fields(row)) if (part === 1 && typeof value !== "bigint") return text(value);
164      }
165    }
166  } catch (error) {
167    return "";
168  }
169  return "";
170};
171const entries = (topic) => {
172  const out = [];
173  try {
174    for (const [number, data] of fields(Buffer.from(topic, "base64"))) {
175      if (number !== 1 || typeof data === "bigint") continue;
176      let key = "";
177      let value = "";
178      for (const [part, piece] of fields(data)) {
179        if (part === 1 && typeof piece !== "bigint") key = text(piece);
180        if (part === 2 && typeof piece !== "bigint") {
181          for (const [held, row] of fields(piece)) if (held === 1 && typeof row !== "bigint") value = text(row);
182        }
183      }
184      if (key) out.push([key, value]);
185    }
186  } catch (error) {
187    return [];
188  }
189  return out;
190};
191const token = (login) => entry(login[KEYS[0]] || "", "oauthTokenInfoSentinelKey");
192const signed = (login) => field(token(login), 1).length > 0 || field(token(login), 3).length > 0;
193const account = (login) => field(entry(login[KEYS[1]] || "", "userStatusSentinelKey"), 7);
194const whole = (login) => signed(login) && account(login).length > 0;
195const read = (file) => {
196  const found = {};
197  if (!fs.existsSync(file)) return found;
198  const db = new DatabaseSync(file, { readOnly: true });
199  try {
200    const row = db.prepare("SELECT value FROM ItemTable WHERE key = ?");
201    for (const key of KEYS) {
202      const hit = row.get(key);
203      if (hit && hit.value !== null && hit.value !== undefined) found[key] = text(hit.value);
204    }
205  } catch (error) {
206    return {};
207  } finally {
208    db.close();
209  }
210  return found;
211};
212const varint = (number) => {
213  const out = [];
214  while (number > 127) {
215    out.push((number & 127) | 128);
216    number = Math.floor(number / 128);
217  }
218  out.push(number);
219  return out;
220};
221const bytes = (number, value) => [...varint((number << 3) | 2), ...varint(value.length), ...value];
222const utf8 = (value) => [...Buffer.from(value, "utf8")];
223const base64 = (value) => Buffer.from(value).toString("base64");
224const topic = (entries) => base64(entries.flatMap(([key, value]) => bytes(1, [...bytes(1, utf8(key)), ...bytes(2, bytes(1, utf8(value)))])));
225const whole_number = (number, value) => base64([...varint(number << 3), ...varint(value)]);
226const AGENT = "antigravityUnifiedStateSync.agentPreferences";
227const BROWSER = "antigravityUnifiedStateSync.browserPreferences";
228const ONBOARDING = "antigravityOnboarding";
229// What the agent panel asks of the agent: `Primitive.int32_value` (2) 3 is EAGER, 2 is TURBO,
230// `bool_value` (1) true lets it at the files outside the workspace, and
231// `PermissionGrantsConfig.allow` (1) is what the language server reads for everything.
232const ANSWERS = {
233  terminalAutoExecutionPolicySentinelKey: whole_number(2, 3),
234  artifactReviewPolicySentinelKey: whole_number(2, 2),
235  allowAgentAccessNonWorkspaceFilesSentinelKey: base64([8, 1]),
236};
237// What the language server itself asks for to be told "everything": each action with the `*`
238// target. One of them is `execute_url(localhost)`, the address of the workspace itself.
239const EVERY = ["read_file(*)", "write_file(*)", "command(*)", "unsandboxed(*)", "mcp(*)", "read_url(*)", "execute_url(*)"];
240const number = (message, wanted) => {
241  try {
242    for (const [at, value] of fields(Buffer.from(message, "base64"))) if (at === wanted && typeof value === "bigint") return String(value);
243  } catch (error) {
244    return "";
245  }
246  return "";
247};
248const listed = (message, wanted) => {
249  const out = [];
250  try {
251    for (const [at, value] of fields(Buffer.from(message, "base64"))) if (at === wanted && typeof value !== "bigint") out.push(text(value));
252  } catch (error) {
253    return [];
254  }
255  return out;
256};
257// The grants as the application reads them: what is allowed, then what is refused, and never an
258// ask, since an ask is a question the person would have to be there for.
259const grants = (allow, deny) => base64([
260  ...allow.flatMap((one) => bytes(1, utf8(one))),
261  ...deny.flatMap((one) => bytes(2, utf8(one))),
262]);
263// A topic with `wanted` in it and every other entry of the home exactly as it was: the
264// settings the application keeps beside the approvals, such as the planning mode, are not ours to
265// touch and would be lost by a row written from scratch.
266const merged = (held, wanted) => {
267  const kept = entries(held);
268  const out = [];
269  for (const [key, value] of kept) out.push([key, wanted[key] === undefined ? value : wanted[key]]);
270  for (const [key, value] of Object.entries(wanted)) {
271    if (!kept.some(([was]) => was === key)) out.push([key, value]);
272  }
273  return topic(out);
274};
275const prepared = (db) => {
276  db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
277  return db.prepare("INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)");
278};
279const row = (file, key) => {
280  if (!fs.existsSync(file)) return "";
281  const db = new DatabaseSync(file, { readOnly: true });
282  try {
283    const hit = db.prepare("SELECT value FROM ItemTable WHERE key = ?").get(key);
284    return hit && hit.value !== null && hit.value !== undefined ? text(hit.value) : "";
285  } catch (error) {
286    return "";
287  } finally {
288    db.close();
289  }
290};
291if (mode === "approvals") {
292  const agent = row(from, AGENT);
293  const browser = row(from, BROWSER);
294  const granted = entry(agent, "permission_grants_global");
295  console.log("terminal=" + number(entry(agent, "terminalAutoExecutionPolicySentinelKey"), 2));
296  console.log("review=" + number(entry(agent, "artifactReviewPolicySentinelKey"), 2));
297  console.log("javascript=" + number(entry(browser, "browser_js_execution_config_sentinel_key"), 1));
298  console.log("files=" + number(entry(agent, "allowAgentAccessNonWorkspaceFilesSentinelKey"), 1));
299  console.log("allow=" + listed(granted, 1).join(","));
300  console.log("ask=" + listed(granted, 3).join(","));
301  console.log("onboarding=" + row(from, ONBOARDING));
302  process.exit(0);
303}
304if (mode === "seen") process.exit(whole(read(from)) ? 0 : 1);
305if (mode === "take") {
306  const login = read(from);
307  if (!whole(login)) process.exit(1);
308  fs.mkdirSync(path.dirname(to), { recursive: true });
309  fs.writeFileSync(to + ".part", JSON.stringify(login), { mode: 0o600 });
310  fs.renameSync(to + ".part", to);
311  process.exit(0);
312}
313if (mode === "keep" || mode === "replace") {
314  const login = JSON.parse(fs.readFileSync(from, "utf8"));
315  if (!whole(login)) process.exit(1);
316  if (mode === "keep" && signed(read(to))) process.exit(0);
317  fs.mkdirSync(path.dirname(to), { recursive: true });
318  const db = new DatabaseSync(to);
319  const put = prepared(db);
320  for (const key of KEYS) put.run(key, login[key]);
321  db.close();
322  process.exit(0);
323}
324if (mode === "approve") {
325  fs.mkdirSync(path.dirname(from), { recursive: true });
326  const db = new DatabaseSync(from);
327  const put = prepared(db);
328  const agent = row(from, AGENT);
329  const granted = entry(agent, "permission_grants_global");
330  const allow = listed(granted, 1);
331  put.run(AGENT, merged(agent, {
332    ...ANSWERS,
333    permission_grants_global: grants([...allow, ...EVERY.filter((one) => !allow.includes(one))], listed(granted, 2)),
334  }));
335  put.run(BROWSER, merged(row(from, BROWSER), { browser_js_execution_config_sentinel_key: whole_number(1, 4) }));
336  put.run(ONBOARDING, "true");
337  db.close();
338  process.exit(0);
339}
340process.exit(2);
341"#;
342
343/// The file a profile image on a QCode template carries as the mark of a QCode-made image, and the
344/// place a recipe on a QCode template puts it.
345///
346/// Nothing reads it any more: the courier carries the login alone, and the agent's approvals are
347/// merged into every window's home as it is prepared, under every template and into a home that
348/// has a login of its own. The recipe is left writing it, so that no image is rebuilt over a file
349/// nothing opens; an image on `base` carries no such file, as it always did.
350pub const APPROVALS: &str = "/usr/share/qcode/antigravity-approvals";
351
352/// The rows a home is given its approvals in, as the application stores them, so a test can hold
353/// the program to them without running it. They are what a home with nothing in its database ends
354/// up with; a home that holds more keeps the rest, as [`PROGRAM`] merges rather than writes.
355pub const ANSWERED: [(&str, &str); 3] = [
356    (
357        "antigravityUnifiedStateSync.agentPreferences",
358        "CjAKJnRlcm1pbmFsQXV0b0V4ZWN1dGlvblBvbGljeVNlbnRpbmVsS2V5EgYKBEVBTT0KKQofYXJ0aWZhY3RSZXZpZXdQb2xpY3lTZW50aW5lbEtleRIGCgRFQUk9CjYKLGFsbG93QWdlbnRBY2Nlc3NOb25Xb3Jrc3BhY2VGaWxlc1NlbnRpbmVsS2V5EgYKBENBRT0KoAEKGHBlcm1pc3Npb25fZ3JhbnRzX2dsb2JhbBKDAQqAAUNneHlaV0ZrWDJacGJHVW9LaWtLRFhkeWFYUmxYMlpwYkdVb0tpa0tDbU52YlcxaGJtUW9LaWtLRG5WdWMyRnVaR0p2ZUdWa0tDb3BDZ1p0WTNBb0tpa0tDM0psWVdSZmRYSnNLQ29wQ2c1bGVHVmpkWFJsWDNWeWJDZ3FLUT09",
359    ),
360    (
361        "antigravityUnifiedStateSync.browserPreferences",
362        "CjIKKGJyb3dzZXJfanNfZXhlY3V0aW9uX2NvbmZpZ19zZW50aW5lbF9rZXkSBgoEQ0FRPQ==",
363    ),
364    ("antigravityOnboarding", "true"),
365];
366
367/// Whether a login given to a home may replace one the home already has.
368#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum Fill {
370    /// Only a home with no login of its own is given one: a workspace's first window, or one that
371    /// was never signed in.
372    Keep,
373    /// Every home is given it, whatever it had: the person asked for the profile's login again.
374    Replace,
375}
376
377impl Fill {
378    fn word(self) -> &'static str {
379        match self {
380            Self::Keep => "keep",
381            Self::Replace => "replace",
382        }
383    }
384}
385
386/// The command that gives a home its profile's stored login, run in a container that mounts the
387/// credentials volume at [`STORE_DIR`] and the home at [`HOME_DIR`].
388///
389/// What the store holds decides how: a window's login is the two rows, put into the home's
390/// database; every other harness's is files, copied over. So the one courier serves both, and a
391/// home never needs to be told which harness it belongs to. What the agent may do without asking
392/// is not the courier's business: it is merged into every window's home by [`approve_command`],
393/// whichever template the image was built from and whatever the home holds already.
394#[must_use]
395pub fn give(fill: Fill) -> Vec<String> {
396    let script = format!(
397        "if [ -f '{STORE_DIR}/{STORED}' ]; then exec node -e \"$1\" \"$2\" '{STORE_DIR}/{STORED}' '{HOME_DIR}/{DATABASE}'; \
398         else cp -R {STORE_DIR}/. {HOME_DIR}/; fi"
399    );
400    ["sh", "-c", &script, "sh", PROGRAM, fill.word()].map(str::to_owned).to_vec()
401}
402
403/// The shell that takes a window's login out of the home it was made in, into the capture folder;
404/// it fails when the login is not whole, so a sign-in that never happened is never stored.
405#[must_use]
406pub fn capture_script() -> String {
407    format!("set -e\nnode -e '{PROGRAM}' take '{HOME_DIR}/{DATABASE}' '{CAPTURE_DIR}/{STORED}'")
408}
409
410/// The command that answers, inside a running container, whether its home holds a whole login.
411#[must_use]
412pub fn seen_command() -> Vec<String> {
413    ["node", "-e", PROGRAM, "seen", &format!("{HOME_DIR}/{DATABASE}")].map(str::to_owned).to_vec()
414}
415
416/// The command that answers the window's agent for itself inside a running container, run before
417/// the window opens, from a container that has the home volume and nothing else.
418///
419/// A window's own container cannot be written to from the inside: it is created and started in one
420/// go with the application as its only program, so by the time it is there the application has read
421/// its database. This runs on the same volume and from the same image beforehand instead, so what
422/// it merges is what the application finds. It merges rather than writes, so the settings the
423/// person made in the window are kept, and a home that was signed in to by hand gets the same
424/// answers as one that was given the profile's login.
425#[must_use]
426pub fn approve_command() -> Vec<String> {
427    ["node", "-e", PROGRAM, "approve", &format!("{HOME_DIR}/{DATABASE}")].map(str::to_owned).to_vec()
428}
429
430/// The window a profile is signed in from, and everything made for it.
431#[derive(Debug, PartialEq, Eq)]
432pub struct SignIn {
433    /// The profile being signed in.
434    pub profile: SafeName,
435    /// The container the window is open in.
436    pub window: String,
437    /// The volume that is the window's home while it is open, and nothing afterwards.
438    pub volume: String,
439    /// The short-lived container the login is taken out of the home by.
440    pub courier: String,
441    /// The folder of this machine the login is taken out into.
442    pub capture: PathBuf,
443    /// The folder of this machine the window leaves the addresses it wants opened in.
444    pub browser: PathBuf,
445    /// The private folder both of those are in. The login passes through it, so it is the
446    /// person's alone, and it goes with the sign-in however that ends.
447    folder: Scratch,
448}
449
450impl SignIn {
451    /// What signing `profile` in is called, and the private folder of this machine it uses; the
452    /// containers and the volume are not made yet.
453    ///
454    /// # Errors
455    ///
456    /// When no private folder can be made.
457    pub fn named(profile: &SafeName) -> std::io::Result<Self> {
458        let folder = Scratch::new(&format!("signin-{profile}"))?;
459        Ok(Self {
460            profile: profile.clone(),
461            window: format!("qcode-signin-{profile}"),
462            volume: format!("qcode-signin-{profile}"),
463            courier: format!("qcode-signin-take-{profile}"),
464            capture: folder.path().join("capture"),
465            browser: folder.path().join("browser"),
466            folder,
467        })
468    }
469
470    /// The command that opens the window: the profile's image, a home of its own in
471    /// [`SignIn::volume`], the folder it leaves addresses in, and the network, which a sign-in
472    /// cannot do without. No workspace is mounted and none is opened: the window is only there to
473    /// be signed in.
474    #[must_use]
475    pub fn open_command(
476        &self,
477        engine: &Engine,
478        profile: &Profile,
479        user: HostUser,
480        display: &Display,
481        seccomp: Option<&Path>,
482    ) -> Option<crate::engine::EngineCommand> {
483        let desktop = profile.harness.desktop()?;
484        let program = desktop.command();
485        let mut command = vec![program.as_str()];
486        command.extend(desktop.flags);
487        let image = profile.image();
488        let mounts = [
489            Mount { source: MountSource::Volume(&self.volume), target: Path::new(HOME_DIR), access: Access::ReadWrite },
490            Mount {
491                source: MountSource::Path(&self.browser),
492                target: Path::new(signin::OPEN_DIR),
493                access: Access::ReadWrite,
494            },
495        ];
496        let once =
497            RunOnce { image: &image, mounts: &mounts, network: Network::Full, user, workdir: None, command: &command };
498        Some(super::run_command(engine, &self.window, once, display, seccomp))
499    }
500}
501
502/// Whether the window's home holds a login, looked at once.
503#[derive(Debug, Clone, Copy, PartialEq, Eq)]
504pub enum Seen {
505    /// Both rows are there.
506    SignedIn,
507    /// The window is open and not signed in yet.
508    NotYet,
509    /// The window is gone: the person closed it, or it never came up.
510    Closed,
511}
512
513/// Opens the sign-in window of `profile` on `display`.
514///
515/// Whatever an interrupted sign-in left under the same names is taken away first: its window
516/// would stand in the way of this one, and its home could hold a login that is not this one.
517///
518/// # Errors
519///
520/// When a folder cannot be made, the seccomp profile cannot be written, or the engine refuses.
521pub fn open(engine: &Engine, profile: &Profile, display: &Display) -> Result<SignIn, Problem> {
522    let sign_in = SignIn::named(&profile.name).map_err(|error| Problem::Machine(error.to_string()))?;
523    clear(engine, &sign_in);
524    let user = HostUser::current().map_err(|error| Problem::Machine(error.to_string()))?;
525    for folder in [&sign_in.capture, &sign_in.browser] {
526        std::fs::create_dir_all(folder).map_err(|error| Problem::Machine(error.to_string()))?;
527    }
528    let seccomp = if engine.needs_sandbox_profile() {
529        Some(super::seccomp::file().map_err(|error| Problem::Machine(error.to_string()))?)
530    } else {
531        None
532    };
533    let Some(command) = sign_in.open_command(engine, profile, user, display, seccomp.as_deref()) else {
534        close(engine, &sign_in);
535        return Err(Problem::NoLogin);
536    };
537    match capture(&command) {
538        Ok(_) => Ok(sign_in),
539        Err(error) => {
540            close(engine, &sign_in);
541            Err(error.into())
542        }
543    }
544}
545
546/// Looks once whether the window's home holds a login.
547///
548/// The look runs inside the window's own container. When it cannot, the container is asked
549/// whether it still runs: a window that is gone is said as such, so the wizard stops looking and
550/// takes whatever the home has.
551pub fn seen(engine: &Engine, sign_in: &SignIn) -> Seen {
552    let command = seen_command();
553    let words: Vec<&str> = command.iter().map(String::as_str).collect();
554    match capture(&engine.exec_without_terminal(&Exec { container: &sign_in.window, command: &words })) {
555        Ok(_) => Seen::SignedIn,
556        Err(_) => {
557            let state = capture(&engine.container_state(&sign_in.window)).map(|word| ContainerState::parse(&word));
558            if state.is_ok_and(|state| state.is_running()) { Seen::NotYet } else { Seen::Closed }
559        }
560    }
561}
562
563/// Takes the login out of the window's home and stores it with the profile, and answers how many
564/// files it stored.
565///
566/// It is taken by a short-lived container of its own that mounts the same home, rather than from
567/// the window's: the window may be closed already, and a closed container cannot be run in. The
568/// storing itself is the one every harness's login goes through.
569///
570/// # Errors
571///
572/// [`Problem::NoLogin`] when the home holds no whole login, or the engine's words when it refuses.
573pub fn take(engine: &Engine, profile: &Profile, sign_in: &SignIn) -> Result<usize, Problem> {
574    let user = HostUser::current().map_err(|error| Problem::Machine(error.to_string()))?;
575    // The application keeps a new login in memory and writes it to its home only when it quits
576    // (measured with a real Google sign-in: signed in at 20:14:13, the two rows on disk at
577    // 20:20:49, the second the window closed). So the window is asked to quit, and given the time
578    // to, before anything is read; removing it outright would lose the login it had not written.
579    // A window that is gone already makes the engine refuse, which changes nothing here.
580    let _ = capture(&engine.stop_container_within(&sign_in.window, QUIT_WITHIN));
581    let _ = capture(&engine.remove_container(&sign_in.courier));
582    let mounts = [
583        Mount { source: MountSource::Volume(&sign_in.volume), target: Path::new(HOME_DIR), access: Access::ReadWrite },
584        Mount {
585            source: MountSource::Path(&sign_in.capture),
586            target: Path::new(CAPTURE_DIR),
587            access: Access::ReadWrite,
588        },
589    ];
590    let image = profile.image();
591    let create = engine.create_container(&ContainerCreate {
592        name: &sign_in.courier,
593        hostname: names::HOSTNAME,
594        labels: &[],
595        image: &image,
596        mounts: &mounts,
597        network: Network::None,
598        user,
599        workdir: None,
600        command: KEEP_ALIVE,
601    });
602    let up = capture(&create).and_then(|_| capture(&engine.start_container(&sign_in.courier)));
603    let stored = match up {
604        Ok(_) => {
605            let courier = LoginContainer::into_folder(sign_in.courier.clone(), sign_in.capture.clone());
606            work::store_login(engine, profile, &courier)
607        }
608        Err(error) => Err(error.into()),
609    };
610    let _ = capture(&engine.remove_container(&sign_in.courier));
611    stored
612}
613
614/// Takes away everything a sign-in made: the window, the courier, the window's home and this
615/// machine's folders. What cannot be removed is left rather than reported; the next sign-in of the
616/// profile starts by clearing it.
617pub fn close(engine: &Engine, sign_in: &SignIn) {
618    clear(engine, sign_in);
619    let _ = std::fs::remove_dir_all(sign_in.folder.path());
620}
621
622/// Removes the containers and the volume of a sign-in, the window first since it holds the volume.
623fn clear(engine: &Engine, sign_in: &SignIn) {
624    for container in [&sign_in.window, &sign_in.courier] {
625        let _ = capture(&engine.remove_container(container));
626    }
627    let _ = capture(&engine.remove_volume(&sign_in.volume));
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633    use crate::engine::EngineKind;
634
635    fn words(command: &crate::engine::EngineCommand) -> Vec<String> {
636        command.args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect()
637    }
638
639    #[test]
640    fn the_program_names_the_two_rows_and_travels_inside_single_quotes() {
641        for key in KEYS {
642            assert!(PROGRAM.contains(&format!("\"{key}\"")), "{key}");
643        }
644        assert!(!PROGRAM.contains('\''), "a shell carries the program inside single quotes");
645        assert!(DATABASE.ends_with("/User/globalStorage/state.vscdb"), "{DATABASE}");
646        // The same table the application makes, so a database QCode starts is one it reads.
647        assert!(PROGRAM.contains("ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"));
648    }
649
650    #[test]
651    fn a_window_login_is_put_into_the_database_and_any_other_is_copied() {
652        let keep = give(Fill::Keep);
653        assert_eq!(&keep[..2], ["sh", "-c"]);
654        assert_eq!(&keep[3..], ["sh", PROGRAM, "keep"]);
655        let script = &keep[2];
656        assert!(script.contains(&format!("[ -f '{STORE_DIR}/{STORED}' ]")), "{script}");
657        assert!(script.contains(&format!("'{HOME_DIR}/{DATABASE}'")), "{script}");
658        assert!(script.contains(&format!("else cp -R {STORE_DIR}/. {HOME_DIR}/; fi")), "{script}");
659        assert_eq!(give(Fill::Replace).last().map(String::as_str), Some("replace"));
660    }
661
662    #[test]
663    fn the_courier_carries_the_login_alone_and_the_answers_come_from_a_command_of_their_own() {
664        // The mark file is still what a recipe on a QCode template writes, so no image is rebuilt
665        // over it; what is read of it is nothing, and the answers are written where the home is
666        // prepared, under every template.
667        let script = &give(Fill::Keep)[2];
668        assert!(!script.contains("approve"), "{script}");
669        assert!(!PROGRAM.contains("\"$approve\""), "the courier does not ask for them");
670        let approve = approve_command();
671        assert_eq!(&approve[..2], ["node", "-e"]);
672        assert_eq!(approve[0], "node");
673        assert_eq!(&approve[1..3], ["-e", PROGRAM]);
674        assert_eq!(&approve[3..], ["approve".to_owned(), format!("{HOME_DIR}/{DATABASE}")]);
675    }
676
677    /// A state-sync `Topic` decoded down to each key's `Primitive` fields.
678    type Decoded = Vec<(String, Vec<(u64, Vec<u8>)>)>;
679
680    /// `text` decoded from standard base64.
681    fn unbase64(text: &str) -> Vec<u8> {
682        const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
683        let mut out = Vec::new();
684        let (mut bits, mut held) = (0_u32, 0_u32);
685        for byte in text.bytes().filter(|&byte| byte != b'=') {
686            let at = ALPHABET.iter().position(|&letter| letter == byte).expect("base64");
687            bits = (bits << 6) | u32::try_from(at).expect("small");
688            held += 6;
689            if held >= 8 {
690                held -= 8;
691                out.push(u8::try_from((bits >> held) & 0xff).expect("a byte"));
692            }
693        }
694        out
695    }
696
697    /// The fields of one protobuf message, by number: a length-delimited field as its bytes, a
698    /// varint as its value's bytes spelled out in decimal.
699    fn message(bytes: &[u8]) -> Vec<(u64, Vec<u8>)> {
700        let varint = |at: &mut usize| {
701            let (mut value, mut shift) = (0_u64, 0);
702            loop {
703                let byte = bytes[*at];
704                *at += 1;
705                value |= u64::from(byte & 127) << shift;
706                shift += 7;
707                if byte < 128 {
708                    return value;
709                }
710            }
711        };
712        let (mut out, mut at) = (Vec::new(), 0);
713        while at < bytes.len() {
714            let tag = varint(&mut at);
715            let value = match tag & 7 {
716                0 => varint(&mut at).to_string().into_bytes(),
717                2 => {
718                    let length = usize::try_from(varint(&mut at)).expect("a length");
719                    at += length;
720                    bytes[at - length..at].to_vec()
721                }
722                kind => panic!("wire type {kind}"),
723            };
724            out.push((tag >> 3, value));
725        }
726        out
727    }
728
729    /// A state-sync `Topic` row, as `(key, the Row's value decoded from base64)` pairs.
730    fn topic(row: &str) -> Vec<(String, Vec<u8>)> {
731        message(&unbase64(row))
732            .into_iter()
733            .map(|(number, entry)| {
734                assert_eq!(number, 1, "Topic.data");
735                let entry = message(&entry);
736                assert_eq!(entry.iter().map(|(number, _)| *number).collect::<Vec<_>>(), [1, 2], "a map entry");
737                let row = message(&entry[1].1);
738                assert_eq!(row.len(), 1, "Row.value alone");
739                assert_eq!(row[0].0, 1, "Row.value");
740                (
741                    String::from_utf8(entry[0].1.clone()).expect("a key"),
742                    unbase64(std::str::from_utf8(&row[0].1).expect("text")),
743                )
744            })
745            .collect()
746    }
747
748    /// The `PermissionGrantsConfig` of an `agentPreferences` row, as `(allow, deny, ask)`.
749    fn grants(row: &str) -> (Vec<String>, Vec<String>, Vec<String>) {
750        let value = topic(row).into_iter().find(|(key, _)| key == "permission_grants_global").map(|(_, value)| value);
751        grants_of(&value.expect("the grants are there"))
752    }
753
754    /// The lists of a decoded `PermissionGrantsConfig`, by the field each is read from.
755    fn grants_of(value: &[u8]) -> (Vec<String>, Vec<String>, Vec<String>) {
756        let list = |number: u64| {
757            message(value)
758                .into_iter()
759                .filter(|(at, _)| *at == number)
760                .map(|(_, held)| String::from_utf8(held).expect("a grant"))
761                .collect::<Vec<String>>()
762        };
763        (list(1), list(2), list(3))
764    }
765
766    #[test]
767    fn the_answered_rows_say_always_proceed_as_the_application_reads_them() {
768        // Decoded here without the program, so the rows are held to what the application's own
769        // reading of them means: `Primitive.int32_value` (2) 3 is EAGER and 2 is TURBO,
770        // `bool_value` (1) true is the files outside the workspace, and
771        // `browser_js_execution_policy` (1) 4 is TURBO.
772        let [(agent_key, agent), (browser_key, browser), (onboarding_key, onboarding)] = ANSWERED;
773        assert_eq!(agent_key, "antigravityUnifiedStateSync.agentPreferences");
774        let agent: Decoded = topic(agent).into_iter().map(|(key, value)| (key, message(&value))).collect();
775        for (key, field, wanted) in [
776            ("terminalAutoExecutionPolicySentinelKey", 2, b"3"),
777            ("artifactReviewPolicySentinelKey", 2, b"2"),
778            ("allowAgentAccessNonWorkspaceFilesSentinelKey", 1, b"1"),
779        ] {
780            let found =
781                agent.iter().find(|(held, _)| held == key).unwrap_or_else(|| panic!("{key} is there: {agent:?}"));
782            assert_eq!(found.1, vec![(field, wanted.to_vec())], "{key}");
783        }
784        let (allow, deny, ask) = grants(ANSWERED[0].1);
785        assert_eq!(deny, Vec::<String>::new());
786        assert_eq!(ask, Vec::<String>::new(), "an ask is a question, and there is nobody to answer it");
787        assert_eq!(
788            allow,
789            [
790                "read_file(*)",
791                "write_file(*)",
792                "command(*)",
793                "unsandboxed(*)",
794                "mcp(*)",
795                "read_url(*)",
796                "execute_url(*)"
797            ]
798        );
799        assert_eq!(browser_key, "antigravityUnifiedStateSync.browserPreferences");
800        let browser: Decoded = topic(browser).into_iter().map(|(key, value)| (key, message(&value))).collect();
801        assert_eq!(browser, [("browser_js_execution_config_sentinel_key".to_owned(), vec![(1, b"4".to_vec())])]);
802        assert_eq!((onboarding_key, onboarding), ("antigravityOnboarding", "true"));
803    }
804
805    /// Node, when this machine has one: the program runs in the image, which always has it, and a
806    /// machine without one fails these tests unless it says it has none knowingly.
807    fn node() -> Option<PathBuf> {
808        crate::testing::node("taking the login out")
809    }
810
811    /// A login as the application writes one after a Google sign-in: the token for `access-417`
812    /// and the account `owner@example.com`, in the shape the take-out stores.
813    const LOGIN: &str = r#"{"antigravityUnifiedStateSync.oauthToken":"Cl8KGW9hdXRoVG9rZW5JbmZvU2VudGluZWxLZXkSQgpAQ2dwaFkyTmxjM010TkRFM0VnWkNaV0Z5WlhJYUVuSmxabkpsYzJndFlXTmpaWE56TFRReE55SUdDSUQzeE5VRw==","antigravityUnifiedStateSync.userStatus":"Cj8KFXVzZXJTdGF0dXNTZW50aW5lbEtleRImCiRHZ1ZQZDI1bGNqb1JiM2R1WlhKQVpYaGhiWEJzWlM1amIyMD0="}"#;
814
815    /// Runs the program with `words` and answers what it printed.
816    fn program(node: &Path, words: &[&str]) -> String {
817        let out = std::process::Command::new(node).args(["-e", PROGRAM]).args(words).output().expect("node runs");
818        assert!(out.status.success(), "{words:?}: {}", String::from_utf8_lossy(&out.stderr));
819        String::from_utf8_lossy(&out.stdout).into_owned()
820    }
821
822    /// One row of `database`, as text, read without the program.
823    fn raw(node: &Path, database: &Path, key: &str) -> String {
824        let read = "const { DatabaseSync } = require('node:sqlite'); \
825                    const row = new DatabaseSync(process.argv[1], { readOnly: true }).prepare('SELECT value FROM ItemTable WHERE key = ?').get(process.argv[2]); \
826                    process.stdout.write(row ? String(row.value) : '');";
827        let out =
828            std::process::Command::new(node).args(["-e", read]).arg(database).arg(key).output().expect("node runs");
829        String::from_utf8_lossy(&out.stdout).into_owned()
830    }
831
832    /// Writes into the database at `argv[1]` the values the first-start onboarding leaves behind:
833    /// the "Review-driven" mode, which is what a person who changes nothing gets, a planning mode
834    /// of their own, and the permission grants the language server asks for one at a time.
835    const REVIEW_DRIVEN: &str = r#"const { DatabaseSync } = require("node:sqlite");
836const [file] = process.argv.slice(1);
837const v = (n) => { const o = []; while (n > 127) { o.push((n & 127) | 128); n = Math.floor(n / 128); } o.push(n); return o; };
838const f = (no, bytes) => [...v((no << 3) | 2), ...v(bytes.length), ...bytes];
839const s = (t) => [...Buffer.from(t, "utf8")];
840const b64 = (a) => Buffer.from(a).toString("base64");
841const topic = (pairs) => b64(pairs.flatMap(([key, value]) => f(1, [...f(1, s(key)), ...f(2, f(1, s(value)))])));
842const whole = (no, n) => b64([...v(no << 3), ...v(n)]);
843const list = (no, items) => items.flatMap((one) => f(no, s(one)));
844const db = new DatabaseSync(file);
845db.exec("CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)");
846const put = db.prepare("INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)");
847put.run("antigravityUnifiedStateSync.agentPreferences", topic([
848  ["terminalAutoExecutionPolicySentinelKey", whole(2, 1)],
849  ["artifactReviewPolicySentinelKey", whole(2, 1)],
850  ["planningModeSentinelKey", whole(2, 2)],
851  ["permission_grants_global", b64([...list(1, ["execute_url(localhost)"]), ...list(2, ["read_file(/secret)"]), ...list(3, ["command(rm)"])])],
852]));
853put.run("antigravityUnifiedStateSync.browserPreferences", topic([["browser_js_execution_config_sentinel_key", whole(1, 1)]]));
854put.run("antigravityOnboarding", "true");
855db.close();"#;
856
857    /// The rows a home is given, as `key=value` lines, in the order the application reads them.
858    fn rows_of(node: &Path, database: &Path) -> Vec<String> {
859        let mut rows: Vec<String> =
860            ANSWERED.iter().map(|(key, _)| format!("{key}={}", raw(node, database, key))).collect();
861        rows.sort();
862        rows
863    }
864
865    #[test]
866    fn the_answers_are_merged_into_a_home_that_asks_as_it_comes_and_keep_what_it_holds() {
867        let Some(node) = node() else { return };
868        let folder = Scratch::new("approvals").expect("a folder");
869        let spelled = |path: &Path| path.to_str().expect("a path").to_owned();
870        let home = folder.path().join("state.vscdb");
871        let written = |node: &Path| {
872            std::process::Command::new(node)
873                .args(["-e", REVIEW_DRIVEN])
874                .arg(&home)
875                .output()
876                .expect("node runs")
877                .status
878                .success()
879        };
880        assert!(written(&node), "the first start's own values are in the database");
881        let planning = raw(&node, &home, "antigravityUnifiedStateSync.agentPreferences");
882        let theirs =
883            topic(&planning).into_iter().find(|(key, _)| key == "planningModeSentinelKey").map(|(_, value)| value);
884
885        // What the application asked of the agent, and what it asked the language server.
886        assert_eq!(
887            program(&node, &["approvals", &spelled(&home)]),
888            "terminal=1\nreview=1\njavascript=1\nfiles=\n\
889             allow=execute_url(localhost)\nask=command(rm)\nonboarding=true\n"
890        );
891
892        program(&node, &["approve", &spelled(&home)]);
893        assert_eq!(
894            program(&node, &["approvals", &spelled(&home)]),
895            "terminal=3\nreview=2\njavascript=4\nfiles=1\n\
896             allow=execute_url(localhost),read_file(*),write_file(*),command(*),unsandboxed(*),mcp(*),read_url(*),execute_url(*)\n\
897             ask=\nonboarding=true\n"
898        );
899        // The planning mode is the person's and is kept byte for byte, beside the answers.
900        let after = topic(&raw(&node, &home, "antigravityUnifiedStateSync.agentPreferences"));
901        assert_eq!(
902            after.iter().find(|(key, _)| key == "planningModeSentinelKey").map(|(_, value)| value.clone()),
903            theirs
904        );
905        assert!(after.iter().any(|(key, _)| key == "permission_grants_global"), "{after:?}");
906        let (_, deny, ask) = grants(&raw(&node, &home, "antigravityUnifiedStateSync.agentPreferences"));
907        assert_eq!(deny, ["read_file(/secret)"], "a refusal is not a question, and is kept");
908        assert!(ask.is_empty(), "an ask is a question: {ask:?}");
909
910        // Answered once, a home that is answered again changes nothing at all.
911        let rows = rows_of(&node, &home);
912        program(&node, &["approve", &spelled(&home)]);
913        assert_eq!(rows_of(&node, &home), rows, "the same bytes a second time");
914        let _ = std::fs::remove_dir_all(folder.path());
915    }
916
917    #[test]
918    fn a_home_with_no_database_is_given_the_answers_and_no_login_at_all() {
919        let Some(node) = node() else { return };
920        let folder = Scratch::new("approvals-fresh").expect("a folder");
921        let home = folder.path().join("deep/state.vscdb");
922        let spelled = |path: &Path| path.to_str().expect("a path").to_owned();
923        program(&node, &["approve", &spelled(&home)]);
924        for (key, value) in ANSWERED {
925            assert_eq!(raw(&node, &home, key), value, "{key}");
926        }
927        for key in KEYS {
928            assert_eq!(raw(&node, &home, key), "", "{key}: the answer never makes a login");
929        }
930        assert!(
931            !std::process::Command::new(&node)
932                .args(["-e", PROGRAM, "seen"])
933                .arg(&home)
934                .status()
935                .expect("runs")
936                .success(),
937            "and it is not signed in"
938        );
939        let _ = std::fs::remove_dir_all(folder.path());
940    }
941
942    #[test]
943    fn the_courier_writes_a_login_and_never_the_answers() {
944        let Some(node) = node() else { return };
945        let folder = Scratch::new("approvals-courier").expect("a folder");
946        let spelled = |path: &Path| path.to_str().expect("a path").to_owned();
947        let login = folder.path().join(STORED);
948        std::fs::write(&login, LOGIN).expect("a stored login");
949
950        // A home the profile's login is given, and one the person signed in to by hand: neither
951        // is told what the agent may do, which is what the approve command is for.
952        let given = folder.path().join("given/state.vscdb");
953        program(&node, &["keep", &spelled(&login), &spelled(&given)]);
954        assert!(
955            std::process::Command::new(&node)
956                .args(["-e", PROGRAM, "seen"])
957                .arg(&given)
958                .status()
959                .expect("runs")
960                .success(),
961            "the login is there"
962        );
963        for (key, _) in ANSWERED {
964            assert_eq!(raw(&node, &given, key), "", "{key}");
965        }
966        // A home that has a login of its own keeps it, and asked for again it is written anyway.
967        let own = folder.path().join("own/state.vscdb");
968        program(&node, &["keep", &spelled(&login), &spelled(&own)]);
969        let before = raw(&node, &own, KEYS[0]);
970        program(&node, &["replace", &spelled(&login), &spelled(&own)]);
971        assert_eq!(raw(&node, &own, KEYS[0]), before);
972        for (key, _) in ANSWERED {
973            assert_eq!(raw(&node, &own, key), "", "{key}");
974        }
975        let _ = std::fs::remove_dir_all(folder.path());
976    }
977
978    #[cfg(unix)]
979    #[test]
980    fn the_login_taken_out_of_a_window_is_readable_by_its_owner_alone() {
981        use std::os::unix::fs::PermissionsExt as _;
982        let Some(node) = node() else { return };
983        let folder = Scratch::new("token-mode").expect("a folder");
984        let spelled = |path: &Path| path.to_str().expect("a path").to_owned();
985        let login = folder.path().join(STORED);
986        std::fs::write(&login, LOGIN).expect("a stored login");
987        let home = folder.path().join("home/state.vscdb");
988        program(&node, &["keep", &spelled(&login), &spelled(&home)]);
989        // Taken out the way the courier does it, into a folder the program makes itself.
990        let taken = folder.path().join("capture/out").join(STORED);
991        program(&node, &["take", &spelled(&home), &spelled(&taken)]);
992        let mode = std::fs::metadata(&taken).expect("the login was taken out").permissions().mode() & 0o777;
993        assert_eq!(mode, 0o600, "the token is the person's alone");
994        assert_eq!(std::fs::read_to_string(&taken).expect("readable"), LOGIN);
995    }
996
997    #[test]
998    fn taking_the_login_fails_when_it_is_not_there() {
999        let script = capture_script();
1000        assert!(script.starts_with("set -e\nnode -e '"), "{script}");
1001        assert!(script.ends_with(&format!("' take '{HOME_DIR}/{DATABASE}' '{CAPTURE_DIR}/{STORED}'")), "{script}");
1002        assert_eq!(seen_command()[3..], ["seen".to_owned(), format!("{HOME_DIR}/{DATABASE}")]);
1003    }
1004
1005    #[test]
1006    fn the_sign_in_window_has_a_home_of_its_own_and_opens_no_workspace() {
1007        let engine = Engine::new(EngineKind::Podman, "/usr/bin/podman");
1008        let profile = Profile {
1009            name: SafeName::parse("anti").expect("safe"),
1010            harness: crate::profile::HarnessKind::AntigravityIde,
1011            template: crate::profile::Template::Recommended,
1012            account: crate::profile::AccountKind::InApp,
1013            provider: None,
1014            assets: crate::profile::MountAccess::ReadOnly,
1015            network: crate::profile::NetworkMode::None,
1016            without: Vec::new(),
1017            os: crate::base::Os::Debian,
1018        };
1019        let sign_in = SignIn::named(&profile.name).expect("a private folder");
1020        let display =
1021            Display { socket: PathBuf::from("/run/user/1000/wayland-1"), name: "wayland-1".to_owned(), device: None };
1022        let command = sign_in
1023            .open_command(&engine, &profile, HostUser::Ids { uid: 1000, gid: 1000 }, &display, None)
1024            .expect("a window harness opens a window");
1025        let words = words(&command);
1026        let spelled = words.join(" ");
1027        assert!(spelled.starts_with("run --detach --init --shm-size=1g --name qcode-signin-anti "), "{spelled}");
1028        assert!(spelled.contains("qcode-signin-anti:/home/qcode:rw"), "{spelled}");
1029        assert!(spelled.contains(&format!(":{}:rw", signin::OPEN_DIR)), "{spelled}");
1030        assert!(spelled.contains(&format!("BROWSER={}", signin::OPEN_PROGRAM)), "{spelled}");
1031        assert!(spelled.contains("WAYLAND_DISPLAY=wayland-1"), "{spelled}");
1032        // Signing in needs the network whatever the profile's own setting is.
1033        assert!(!spelled.contains("--network=none"), "{spelled}");
1034        assert!(!spelled.contains("/work"), "no workspace is mounted or opened: {spelled}");
1035        assert_eq!(
1036            words.iter().rev().take(2).collect::<Vec<_>>(),
1037            ["--ozone-platform=wayland", "/opt/antigravity-ide/antigravity-ide"]
1038        );
1039    }
1040}