1use 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
59pub const DATABASE: &str = ".config/Antigravity IDE/User/globalStorage/state.vscdb";
61
62pub const KEYS: [&str; 2] = ["antigravityUnifiedStateSync.oauthToken", "antigravityUnifiedStateSync.userStatus"];
64
65pub const STORED: &str = "antigravity-login.json";
68
69pub const QUIT_WITHIN: u32 = 30;
71
72pub const LOOK_EVERY: std::time::Duration = std::time::Duration::from_secs(2);
76
77pub const ASK_EVERY: std::time::Duration = std::time::Duration::from_millis(250);
80
81pub 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
343pub const APPROVALS: &str = "/usr/share/qcode/antigravity-approvals";
351
352pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
369pub enum Fill {
370 Keep,
373 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#[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#[must_use]
406pub fn capture_script() -> String {
407 format!("set -e\nnode -e '{PROGRAM}' take '{HOME_DIR}/{DATABASE}' '{CAPTURE_DIR}/{STORED}'")
408}
409
410#[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#[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#[derive(Debug, PartialEq, Eq)]
432pub struct SignIn {
433 pub profile: SafeName,
435 pub window: String,
437 pub volume: String,
439 pub courier: String,
441 pub capture: PathBuf,
443 pub browser: PathBuf,
445 folder: Scratch,
448}
449
450impl SignIn {
451 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
504pub enum Seen {
505 SignedIn,
507 NotYet,
509 Closed,
511}
512
513pub 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
546pub 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
563pub 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 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
614pub 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
622fn 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 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 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 type Decoded = Vec<(String, Vec<(u64, Vec<u8>)>)>;
679
680 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 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 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 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 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 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 fn node() -> Option<PathBuf> {
808 crate::testing::node("taking the login out")
809 }
810
811 const LOGIN: &str = r#"{"antigravityUnifiedStateSync.oauthToken":"Cl8KGW9hdXRoVG9rZW5JbmZvU2VudGluZWxLZXkSQgpAQ2dwaFkyTmxjM010TkRFM0VnWkNaV0Z5WlhJYUVuSmxabkpsYzJndFlXTmpaWE56TFRReE55SUdDSUQzeE5VRw==","antigravityUnifiedStateSync.userStatus":"Cj8KFXVzZXJTdGF0dXNTZW50aW5lbEtleRImCiRHZ1ZQZDI1bGNqb1JiM2R1WlhKQVpYaGhiWEJzWlM1amIyMD0="}"#;
814
815 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 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 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 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 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 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 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 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 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 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 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}