1import * as Sink from "effect/Sink";
2import * as Deferred from "effect/Deferred";
3import * as Fiber from "effect/Fiber";
4import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
5import * as TestClock from "effect/testing/TestClock";
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
7import * as NodePath from "@effect/platform-node/NodePath";
8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { ProviderInstanceId } from "@t3tools/contracts";
10import * as HostProcess from "@t3tools/shared/HostProcess";
11import { describe, expect, it } from "@effect/vitest";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Path from "effect/Path";
16import * as Result from "effect/Result";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import * as Ndjson from "effect/encoding/Ndjson";
20import * as AcpErrors from "effect-acp/errors";
21import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
22
23import {
24 ANTIGRAVITY_AUTH_BROWSER_MARKER,
25 ANTIGRAVITY_AUTH_STDOUT_PREFIX,
26 ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
27 type AntigravityAuthConfig,
28 type AntigravityProfile,
29 buildAntigravityAcpSpawnInput,
30 isAntigravitySignInRequiredError,
31 makeAntigravityStderrHandler,
32 makeAntigravityStdoutTransform,
33 parseAntigravityAuthorizationUrl,
34 prepareAntigravityProfile,
35 resolveAntigravityInstanceDirectories,
36} from "./antigravityAuthSupport.ts";
37
38const authorizationUrl =
39 "https://accounts.google.com/o/oauth2/v2/auth?response_type=code" +
40 "&client_id=test-client&redirect_uri=http%3A%2F%2F127.0.0.1%3A46353%2F" +
41 "&state=test-opaque-state&code_challenge=test-challenge&code_challenge_method=S256";
42const authLine = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`;
43const encode = (text: string) => new TextEncoder().encode(text);
44const encodeUnknownJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
45
46const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
47
48describe("Antigravity process environment", () => {
49 const profile: AntigravityProfile = {
50 platform: "linux",
51 geminiHome: "/t3/userdata/providers/antigravity/profile",
52 acpDirectory: "/t3/userdata/providers/antigravity/profile/antigravity-acp",
53 tokenPath: "/t3/userdata/providers/antigravity/profile/antigravity-acp/acp_token.json",
54 tempDirectory: "/t3/userdata/providers/antigravity/profile/antigravity-acp/tmp",
55 browserCommand: "managed-browser-helper",
56 };
57
58 it("isolates the profile and harness after merging overrides without changing the base environment", () => {
59 const baseEnv = {
60 HOME: "/home/developer",
61 PATH: "/usr/bin",
62 GEMINI_API_KEY: "do-not-use-api-billing",
63 google_api_key: "case-insensitive-api-key",
64 GOOGLE_CLOUD_PROJECT: "do-not-use-project",
65 GOOGLE_CLOUD_LOCATION: "do-not-use-location",
66 GOOGLE_APPLICATION_CREDENTIALS: "/credentials.json",
67 GOOGLE_CLOUD_QUOTA_PROJECT: "do-not-use-quota-project",
68 GOOGLE_GENAI_USE_VERTEXAI: "true",
69 AGY_ACP_CCPA_PROJECT: "do-not-use-consumer-project",
70 AGY_ACP_ENABLE_OAUTH: "1",
71 GEMINI_HOME: "/shared-gemini-home",
72 gemini_home: "/alias-shared-home",
73 AGY_ACP_FORCE_FILE_STORAGE: "0",
74 ANTIGRAVITY_HARNESS_PATH: "/wrong-version/harness",
75 BROWSER: "open-real-browser",
76 browser: "another-real-browser",
77 PYTHONUNBUFFERED: "0",
78 ELECTRON_RUN_AS_NODE: "0",
79 CHROME_CRASHPAD_PIPE_NAME: "\\\\.\\pipe\\crashpad_50144_ZUJNJELFHHXPBIRT",
80 chrome_crashpad_pipe_name: "\\\\.\\pipe\\crashpad_stale_alias",
81 CUSTOM_SETTING: "keep-this",
82 };
83 const original = { ...baseEnv };
84 const spawn = buildAntigravityAcpSpawnInput({
85 installation: { executablePath: "/release/acp", harnessPath: "/release/harness" },
86 profile,
87 cwd: "/project",
88 baseEnv,
89 });
90
91 expect(baseEnv).toEqual(original);
92 expect(spawn).toEqual({
93 command: "/release/acp",
94 args: ["--uid="],
95 cwd: "/project",
96 extendEnv: false,
97 env: {
98 HOME: "/home/developer",
99 PATH: "/usr/bin",
100 CUSTOM_SETTING: "keep-this",
101 GEMINI_HOME: profile.geminiHome,
102 AGY_ACP_FORCE_FILE_STORAGE: "1",
103 ANTIGRAVITY_HARNESS_PATH: "/release/harness",
104 BROWSER: profile.browserCommand,
105 PYTHONUNBUFFERED: "1",
106 ELECTRON_RUN_AS_NODE: "1",
107 TMPDIR: profile.tempDirectory,
108 },
109 });
110 });
111
112 it("runs the agy CLI as `agy acp` without a harness", () => {
113 const spawn = buildAntigravityAcpSpawnInput({
114 installation: { executablePath: "/usr/bin/agy", harnessPath: null },
115 profile,
116 cwd: "/project",
117 baseEnv: { PATH: "/usr/bin", ANTIGRAVITY_HARNESS_PATH: "/stale/harness" },
118 });
119
120 expect(spawn.command).toBe("/usr/bin/agy");
121 expect(spawn.args).toEqual([
122 "acp",
123 "--gemini-dir",
124 profile.geminiHome,
125 "--app-data-dir",
126 "antigravity-acp",
127 ]);
128 expect(spawn.env).not.toHaveProperty("ANTIGRAVITY_HARNESS_PATH");
129 expect(spawn.env).toMatchObject({ GEMINI_HOME: profile.geminiHome, PATH: "/usr/bin" });
130 });
131
132 it("passes only the configured method's credential and keeps the GCP pair out of the environment", () => {
133 const baseEnv = { PATH: "/usr/bin", GOOGLE_API_KEY: "ambient-key" };
134 const spawnFor = (auth: AntigravityAuthConfig) =>
135 buildAntigravityAcpSpawnInput({
136 installation: { executablePath: "/release/acp", harnessPath: "/release/harness" },
137 profile,
138 cwd: "/project",
139 baseEnv,
140 auth,
141 }).env ?? {};
142
143 const geminiKey = spawnFor({
144 authMethod: "gemini-api-key",
145 apiKey: "gemini-secret",
146 gcpProject: "proj",
147 gcpLocation: "us-central1",
148 });
149 expect(geminiKey.GEMINI_API_KEY).toBe("gemini-secret");
150 expect(geminiKey.GOOGLE_API_KEY).toBeUndefined();
151 expect(geminiKey.GOOGLE_CLOUD_PROJECT).toBeUndefined();
152
153 const vertexKey = spawnFor({
154 authMethod: "agent-platform",
155 apiKey: "vertex-secret",
156 gcpProject: "",
157 gcpLocation: "",
158 });
159 expect(vertexKey.GOOGLE_API_KEY).toBe("vertex-secret");
160 expect(vertexKey.GEMINI_API_KEY).toBeUndefined();
161
162 const business = spawnFor({
163 authMethod: "oauth-business",
164 apiKey: "ignored",
165 gcpProject: "proj",
166 gcpLocation: "us-central1",
167 });
168 expect(business.GEMINI_API_KEY).toBeUndefined();
169 expect(business.GOOGLE_API_KEY).toBeUndefined();
170 });
171
172 it("isolates TEMP and TMP to the profile directory on Windows", () => {
173 const windowsProfile: AntigravityProfile = {
174 platform: "win32",
175 geminiHome: "C:\\state\\providers\\antigravity\\profile",
176 acpDirectory: "C:\\state\\providers\\antigravity\\profile\\antigravity-acp",
177 tokenPath: "C:\\state\\providers\\antigravity\\profile\\antigravity-acp\\acp_token.json",
178 tempDirectory: "C:\\state\\providers\\antigravity\\profile\\antigravity-acp\\tmp",
179 browserCommand: "managed-browser-helper",
180 };
181 const input = {
182 installation: {
183 executablePath: "C:\\release\\agy_acp_server.exe",
184 harnessPath: "C:\\release\\localharness_external.exe",
185 },
186 profile: windowsProfile,
187 cwd: "C:\\project",
188 baseEnv: { PATH: "C:\\Windows\\system32", TEMP: "C:\\Users\\user\\AppData\\Local\\Temp" },
189 };
190 const shared = buildAntigravityAcpSpawnInput(input);
191 expect(shared.env?.TEMP).toBe(windowsProfile.tempDirectory);
192 expect(shared.env?.TMP).toBe(windowsProfile.tempDirectory);
193 const perRun = buildAntigravityAcpSpawnInput({
194 ...input,
195 runtimeTempDirectory: `${windowsProfile.tempDirectory}\\run-1`,
196 });
197 expect(perRun.env?.TEMP).toBe(`${windowsProfile.tempDirectory}\\run-1`);
198 expect(perRun.env?.TMP).toBe(`${windowsProfile.tempDirectory}\\run-1`);
199 });
200
201 it.effect("keeps accounts separate even when instance IDs differ only by case", () =>
202 Effect.gen(function* () {
203 const first = yield* resolveAntigravityInstanceDirectories(
204 "/userdata",
205 ProviderInstanceId.make("antigravity"),
206 );
207 const second = yield* resolveAntigravityInstanceDirectories(
208 "/userdata",
209 ProviderInstanceId.make("Antigravity"),
210 );
211 expect(first.profile).toBe(
212 "/userdata/providers/antigravity/ac0a3dfd6dddb20962cecff6ee5fe65e19d3923be20e52c5ab52ff877f7e4c32",
213 );
214 expect(first.profile.toLowerCase()).not.toBe(second.profile.toLowerCase());
215 expect(first.runtimeTemp.toLowerCase()).not.toBe(second.runtimeTemp.toLowerCase());
216 }).pipe(Effect.provide(Layer.mergeAll(NodeCrypto.layer, NodePath.layerPosix))),
217 );
218
219 it.effect("keeps the unpacked Windows runtime under MAX_PATH for long user names", () =>
220 Effect.gen(function* () {
221 const path = yield* Path.Path;
222 // Deepest member of the official agy_acp_server_1.1.1 windows-x86_64 bundle.
223 const deepestMember =
224 "google3\\cloud\\developer_experience\\antigravity_extensions\\acp_server\\_private__agy_acp_server_bin.lazy_imports_info.json";
225 const directories = yield* resolveAntigravityInstanceDirectories(
226 "C:\\Users\\a-twenty-char-person\\.t3\\userdata",
227 ProviderInstanceId.make("antigravity"),
228 );
229 const extracted = (tempDirectory: string) =>
230 path.join(tempDirectory, "run-AbC123", "_MEI000012ab2", deepestMember);
231 // MAX_PATH is 260 including the terminating NUL.
232 expect(extracted(directories.runtimeTemp).length).toBeLessThan(260);
233 expect(
234 extracted(path.join(directories.profile, "antigravity-acp", "tmp")).length,
235 ).toBeGreaterThanOrEqual(260);
236 }).pipe(Effect.provide(Layer.mergeAll(NodeCrypto.layer, NodePath.layerWin32))),
237 );
238});
239
240describe("Antigravity authorization URL", () => {
241 it.effect(
242 "rejects other origins, ambiguous state, and non-loopback redirects without retaining them",
243 () =>
244 Effect.gen(function* () {
245 const invalidUrls = [
246 authorizationUrl.replace("https:", "http:"),
247 authorizationUrl.replace("accounts.google.com", "accounts.google.com.example.invalid"),
248 authorizationUrl.replace("accounts.google.com", "secret@accounts.google.com"),
249 authorizationUrl.replace("/o/oauth2/v2/auth", "/another-path"),
250 `${authorizationUrl}#secret-fragment`,
251 `${authorizationUrl}&state=another-state`,
252 authorizationUrl.replace("test-opaque-state", ""),
253 authorizationUrl.replace("test-opaque-state", "opaque%0astate"),
254 authorizationUrl.replace("127.0.0.1", "localhost"),
255 authorizationUrl.replace("127.0.0.1", "169.254.169.254"),
256 authorizationUrl.replace("46353", "80"),
257 authorizationUrl.replace("46353", "70000"),
258 authorizationUrl.replace("46353%2F", "46353%2Fother"),
259 authorizationUrl.replace("response_type=code", "response_type=token"),
260 "not a URL containing secret-code",
261 ];
262 for (const invalidUrl of invalidUrls) {
263 const result = yield* parseAntigravityAuthorizationUrl(invalidUrl).pipe(Effect.result);
264 expect(Result.isFailure(result)).toBe(true);
265 if (Result.isSuccess(result)) continue;
266 expect(result.failure._tag).toBe("AcpTransportError");
267 expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state");
268 expect(encodeUnknownJson(result.failure)).not.toContain("secret-code");
269 expect(encodeUnknownJson(result.failure)).not.toContain(invalidUrl);
270 }
271 }),
272 );
273});
274
275describe("Antigravity sign-in errors", () => {
276 it("recognizes native auth-required errors and the blocked-login transport error", () => {
277 expect(isAntigravitySignInRequiredError(AcpErrors.AcpRequestError.authRequired())).toBe(true);
278 expect(
279 isAntigravitySignInRequiredError(
280 new AcpErrors.AcpTransportError({
281 detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
282 cause: undefined,
283 }),
284 ),
285 ).toBe(true);
286 });
287
288 it("does not treat other failures or arbitrary text as missing authentication", () => {
289 const otherErrors = [
290 new AcpErrors.AcpTransportError({ detail: "The process stopped.", cause: undefined }),
291 AcpErrors.AcpRequestError.internalError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE),
292 new Error(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE),
293 { detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE },
294 ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
295 undefined,
296 ];
297 for (const error of otherErrors) expect(isAntigravitySignInRequiredError(error)).toBe(false);
298 });
299});
300
301describe("Antigravity stdout compatibility", () => {
302 it.effect(
303 "handles fragmented login lines between JSON messages without changing protocol bytes",
304 () =>
305 Effect.gen(function* () {
306 const urls: string[] = [];
307 const jsonBefore = '{"jsonrpc":"2.0","id":1,"result":{}}\r\n';
308 const jsonAfter = '{"jsonrpc":"2.0","id":2,"result":{"text":"café"}}\n';
309 const chunks = [
310 encode(`${jsonBefore}${ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(0, 7)}`),
311 encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX.slice(7)),
312 encode(authorizationUrl.slice(0, 40)),
313 encode(`${authorizationUrl.slice(40)}\r`),
314 encode(`\n${jsonAfter}`),
315 ];
316 const result = yield* makeAntigravityStdoutTransform({
317 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
318 })(Stream.fromIterable(chunks)).pipe(Stream.decodeText(), Stream.mkString);
319 expect(result).toBe(`${jsonBefore}${jsonAfter}`);
320 expect(urls).toEqual([authorizationUrl]);
321 }),
322 );
323
324 it.effect("ends normal work with a safe sign-in error instead of waiting for OAuth", () =>
325 Effect.gen(function* () {
326 const result = yield* makeAntigravityStdoutTransform()(Stream.make(encode(authLine))).pipe(
327 Stream.runDrain,
328 Effect.result,
329 );
330 expect(Result.isFailure(result)).toBe(true);
331 if (Result.isSuccess(result)) return;
332 expect(result.failure).toMatchObject({
333 _tag: "AcpTransportError",
334 detail: ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE,
335 });
336 expect(encodeUnknownJson(result.failure)).not.toContain(authorizationUrl);
337 expect(encodeUnknownJson(result.failure)).not.toContain("test-opaque-state");
338 }),
339 );
340
341 it.effect("preserves typed errors from the flow owner", () =>
342 Effect.gen(function* () {
343 const failure = new AcpErrors.AcpTransportError({
344 detail: "This sign-in flow has expired.",
345 cause: undefined,
346 });
347 const result = yield* makeAntigravityStdoutTransform({
348 onAuthorizationUrl: () => Effect.fail(failure),
349 })(Stream.make(encode(authLine))).pipe(Stream.runDrain, Effect.result);
350 expect(Result.isFailure(result)).toBe(true);
351 if (Result.isSuccess(result)) return;
352 expect(result.failure).toBe(failure);
353 }),
354 );
355
356 it.effect("does not suppress malformed protocol data or similar login messages", () =>
357 Effect.gen(function* () {
358 const unrelated = [
359 "this is not JSON\n",
360 `${ANTIGRAVITY_AUTH_STDOUT_PREFIX.toLowerCase()}${authorizationUrl}\n`,
361 ` ${authLine}`,
362 ];
363 for (const line of unrelated) {
364 const transform = makeAntigravityStdoutTransform();
365 const output = yield* transform(Stream.make(encode(line))).pipe(
366 Stream.decodeText(),
367 Stream.mkString,
368 );
369 expect(output).toBe(line);
370 const decoded = yield* transform(Stream.make(encode(line))).pipe(
371 Stream.pipeThroughChannel(Ndjson.decode()),
372 Stream.runDrain,
373 Effect.result,
374 );
375 expect(Result.isFailure(decoded)).toBe(true);
376 }
377 }),
378 );
379
380 it.effect("bounds unfinished protocol lines", () =>
381 Effect.gen(function* () {
382 const result = yield* makeAntigravityStdoutTransform()(
383 Stream.make(new Uint8Array(16 * 1024 * 1024), encode("x")),
384 ).pipe(Stream.runDrain, Effect.result);
385 expect(Result.isFailure(result)).toBe(true);
386 if (Result.isSuccess(result)) return;
387 expect(result.failure).toMatchObject({
388 _tag: "AcpTransportError",
389 detail: "Antigravity sent a protocol line that is too large.",
390 });
391 }),
392 );
393});
394
395describe("Antigravity stderr compatibility", () => {
396 it.effect("forwards fragmented native sign-in URLs from runtime 1.1.1", () =>
397 Effect.gen(function* () {
398 const urls: string[] = [];
399 const line = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\r\n`;
400 const handleStderr = makeAntigravityStderrHandler({
401 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
402 });
403 yield* handleStderr(`native log\n${line.slice(0, 40)}`);
404 yield* handleStderr(line.slice(40, 90));
405 yield* handleStderr(`${line.slice(90)}another native log\n`);
406 expect(urls).toEqual([authorizationUrl]);
407 }),
408 );
409
410 it.effect("rejects interactive sign-in during normal work", () =>
411 Effect.gen(function* () {
412 const handleStderr = makeAntigravityStderrHandler();
413 const error = yield* handleStderr(
414 `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`,
415 ).pipe(Effect.flip);
416 expect(isAntigravitySignInRequiredError(error)).toBe(true);
417 }),
418 );
419
420 it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () =>
421 Effect.gen(function* () {
422 const urls: string[] = [];
423 const longAuthorizationUrl = `${authorizationUrl}&scope=${"a".repeat(9_000)}`;
424 const handleStderr = makeAntigravityStderrHandler({
425 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
426 });
427
428 expect(longAuthorizationUrl.length).toBeGreaterThan(8_192);
429 yield* handleStderr(
430 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(longAuthorizationUrl)}\n`,
431 );
432
433 expect(urls).toEqual([longAuthorizationUrl]);
434 }),
435 );
436
437 it.effect("forwards a fragmented browser-helper URL without exposing other stderr", () =>
438 Effect.gen(function* () {
439 const urls: string[] = [];
440 const markerLine = `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`;
441 const handleStderr = makeAntigravityStderrHandler({
442 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
443 });
444
445 yield* handleStderr(`native log\n${markerLine.slice(0, 12)}`);
446 yield* handleStderr(markerLine.slice(12, 70));
447 yield* handleStderr(`${markerLine.slice(70)}another native log\n`);
448
449 expect(urls).toEqual([authorizationUrl]);
450 }),
451 );
452
453 it.effect("ignores malformed and similar browser-helper messages", () =>
454 Effect.gen(function* () {
455 const urls: string[] = [];
456 const handleStderr = makeAntigravityStderrHandler({
457 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
458 });
459
460 yield* handleStderr(
461 ` ${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`,
462 );
463 yield* handleStderr(`${ANTIGRAVITY_AUTH_BROWSER_MARKER}${authorizationUrl}\n`);
464 yield* handleStderr(
465 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson("https://example.com")}\n`,
466 );
467 yield* handleStderr(`${ANTIGRAVITY_AUTH_STDOUT_PREFIX}https://example.com\n`);
468 yield* handleStderr(` ${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`);
469
470 expect(urls).toEqual([]);
471 }),
472 );
473});
474
475it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {
476 const prepareSpawn = Effect.fn(function* (
477 bundles: ReadonlyArray<string>,
478 baseEnv: NodeJS.ProcessEnv = {},
479 platform: NodeJS.Platform = "linux",
480 ) {
481 const fs = yield* FileSystem.FileSystem;
482 const path = yield* Path.Path;
483 const directory = yield* fs.makeTempDirectoryScoped();
484 const profile = yield* prepareAntigravityProfile({
485 profileDirectory: path.join(directory, "profile"),
486 userHome: path.join(directory, "home"),
487 runtimeExecutablePath: process.execPath,
488 platform,
489 baseEnv,
490 }).pipe(
491 Effect.provideService(FileSystem.FileSystem, {
492 ...fs,
493 exists: (candidate) => Effect.succeed(bundles.includes(candidate)),
494 }),
495 );
496 return buildAntigravityAcpSpawnInput({
497 installation: { executablePath: "/release/acp", harnessPath: "/release/harness" },
498 profile,
499 cwd: directory,
500 baseEnv,
501 }).env;
502 });
503
504 // A Windows Node path cannot preflight the browser helper as a POSIX host.
505 const caTest = it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32");
506
507 caTest(
508 "launches Linux agents with the first available CA bundle, preferring absolute Nix paths",
509 () =>
510 Effect.gen(function* () {
511 const systemBundles = ["/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"];
512 const nixBundle = "/nix/store/nss-cacert/etc/ssl/certs/ca-bundle.crt";
513 expect((yield* prepareSpawn(systemBundles))?.SSL_CERT_FILE).toBe(systemBundles[0]);
514 expect(
515 (yield* prepareSpawn([nixBundle, ...systemBundles], { NIX_SSL_CERT_FILE: nixBundle }))
516 ?.SSL_CERT_FILE,
517 ).toBe(nixBundle);
518 expect(
519 (yield* prepareSpawn(systemBundles, { NIX_SSL_CERT_FILE: nixBundle }))?.SSL_CERT_FILE,
520 ).toBe(systemBundles[0]);
521 expect(
522 (yield* prepareSpawn(["certs/local.pem", ...systemBundles], {
523 NIX_SSL_CERT_FILE: "certs/local.pem",
524 }))?.SSL_CERT_FILE,
525 ).toBe(systemBundles[0]);
526 expect((yield* prepareSpawn([]))?.SSL_CERT_FILE).toBeUndefined();
527 }),
528 );
529
530 caTest("preserves explicit certificate files and directories in Linux launches", () =>
531 Effect.gen(function* () {
532 const bundles = ["/etc/ssl/certs/ca-certificates.crt"];
533 expect(
534 (yield* prepareSpawn(bundles, { SSL_CERT_FILE: "/custom/ca.pem" }))?.SSL_CERT_FILE,
535 ).toBe("/custom/ca.pem");
536 const directoryEnv = yield* prepareSpawn(bundles, { SSL_CERT_DIR: "/custom/certs" });
537 expect(directoryEnv?.SSL_CERT_DIR).toBe("/custom/certs");
538 expect(directoryEnv?.SSL_CERT_FILE).toBeUndefined();
539 }),
540 );
541
542 it.effect("runs the browser helper with installed Node in standalone builds", () =>
543 Effect.gen(function* () {
544 const fs = yield* FileSystem.FileSystem;
545 const path = yield* Path.Path;
546 const directory = yield* fs.makeTempDirectoryScoped();
547 const profile = yield* prepareAntigravityProfile({
548 profileDirectory: path.join(directory, "profile"),
549 baseEnv: { PATH: path.dirname(process.execPath) },
550 });
551 expect(profile.browserCommand).not.toContain("/packaged/t3");
552 expect(yield* fs.exists(profile.acpDirectory)).toBe(true);
553 }).pipe(
554 Effect.provideService(HostProcess.IsExecutable, true),
555 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
556 ),
557 );
558
559 it.effect("reports missing Node before creating the standalone sign-in profile", () =>
560 Effect.gen(function* () {
561 const fs = yield* FileSystem.FileSystem;
562 const path = yield* Path.Path;
563 const directory = yield* fs.makeTempDirectoryScoped();
564 const profileDirectory = path.join(directory, "profile");
565 const result = yield* prepareAntigravityProfile({
566 profileDirectory,
567 baseEnv: { PATH: "" },
568 }).pipe(Effect.result);
569 expect(Result.isFailure(result)).toBe(true);
570 if (Result.isFailure(result)) {
571 expect(result.failure).toMatchObject({
572 _tag: "AcpTransportError",
573 detail: expect.stringContaining("Install Node.js"),
574 cause: {
575 _tag: "NodeRuntimeUnavailableError",
576 },
577 });
578 }
579 expect(yield* fs.exists(profileDirectory)).toBe(false);
580 }).pipe(Effect.provideService(HostProcess.IsExecutable, true)),
581 );
582
583 it.effect("preflights the no-browser helper and creates private directories only", () =>
584 Effect.gen(function* () {
585 const fs = yield* FileSystem.FileSystem;
586 const path = yield* Path.Path;
587 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
588 const profile = yield* prepareAntigravityProfile({
589 profileDirectory: path.join(temporaryDirectory, "profile"),
590 });
591
592 expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile"));
593 expect(yield* fs.exists(profile.acpDirectory)).toBe(true);
594 expect(yield* fs.exists(profile.tokenPath)).toBe(false);
595 if ((yield* HostProcess.Platform) !== "win32") {
596 expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700);
597 expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700);
598 }
599
600 yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture");
601 yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome });
602 expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture");
603 }),
604 );
605
606 it.effect.skipIf(!symlinksSupported)(
607 "links only the user's existing global skill directories without touching real content",
608 () =>
609 Effect.gen(function* () {
610 const fs = yield* FileSystem.FileSystem;
611 const path = yield* Path.Path;
612 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
613 const userHome = path.join(temporaryDirectory, "home");
614 const profileDirectory = path.join(temporaryDirectory, "profile");
615 const configSkills = path.join(userHome, ".gemini", "config", "skills");
616 const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills");
617 const isLink = (link: string) =>
618 fs.readLink(link).pipe(
619 Effect.as(true),
620 Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)),
621 );
622 yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true });
623
624 yield* prepareAntigravityProfile({ profileDirectory, userHome });
625 const configLink = path.join(profileDirectory, "config", "skills");
626 const cliLink = path.join(profileDirectory, "antigravity-cli", "skills");
627 expect(yield* fs.readLink(configLink)).toBe(configSkills);
628 expect(yield* isLink(cliLink)).toBe(false);
629 expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true);
630 expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe(
631 false,
632 );
633
634 // The harness leaves an empty folder where the link was skipped.
635 yield* fs.makeDirectory(cliLink, { recursive: true });
636 yield* prepareAntigravityProfile({ profileDirectory, userHome });
637 expect((yield* fs.stat(cliLink)).type).toBe("Directory");
638
639 yield* fs.makeDirectory(cliSkills, { recursive: true });
640 yield* fs.remove(configLink);
641 yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true });
642 yield* prepareAntigravityProfile({ profileDirectory, userHome });
643 expect(yield* fs.readLink(cliLink)).toBe(cliSkills);
644 expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true);
645 expect((yield* fs.stat(configLink)).type).toBe("Directory");
646
647 yield* fs.remove(cliLink);
648 yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink);
649 yield* prepareAntigravityProfile({ profileDirectory, userHome });
650 expect(yield* fs.readLink(cliLink)).toBe(cliSkills);
651
652 yield* fs.remove(cliSkills, { recursive: true });
653 yield* prepareAntigravityProfile({ profileDirectory, userHome });
654 expect(yield* isLink(cliLink)).toBe(false);
655 }),
656 );
657
658 it.effect("rewrites the GCP block on every launch and never stores the API key", () =>
659 Effect.gen(function* () {
660 const fs = yield* FileSystem.FileSystem;
661 const path = yield* Path.Path;
662 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
663 const profile = yield* prepareAntigravityProfile({
664 profileDirectory: temporaryDirectory,
665 auth: {
666 authMethod: "agent-platform",
667 apiKey: "vertex-secret",
668 gcpProject: "proj",
669 gcpLocation: "us-central1",
670 },
671 });
672 const settingsPath = path.join(profile.acpDirectory, "settings.json");
673 const first = yield* fs.readFileString(settingsPath);
674 expect(decodeJson(first)).toEqual({
675 auth: { type: "agent-platform" },
676 gcp: { project: "proj", location: "us-central1" },
677 });
678 expect(first).not.toContain("vertex-secret");
679
680 yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory });
681 expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({
682 auth: { type: "oauth-personal" },
683 });
684 }),
685 );
686
687 const preflightHelper = (exitDelay: Effect.Effect<void>) => {
688 const spawns = { count: 0 };
689 const started = Deferred.makeUnsafe<void>();
690 const spawner = ChildProcessSpawner.make(() =>
691 Effect.sync(() => {
692 spawns.count += 1;
693 return ChildProcessSpawner.makeHandle({
694 pid: ChildProcessSpawner.ProcessId(1),
695 exitCode: Deferred.succeed(started, undefined).pipe(
696 Effect.andThen(exitDelay),
697 Effect.as(ChildProcessSpawner.ExitCode(0)),
698 ),
699 isRunning: Effect.succeed(false),
700 kill: () => Effect.void,
701 unref: Effect.succeed(Effect.void),
702 stdin: Sink.drain,
703 stdout: Stream.empty,
704 stderr: Stream.encodeText(
705 Stream.make(
706 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"https://example.invalid/t3-antigravity-browser-preflight"\n`,
707 ),
708 ),
709 all: Stream.empty,
710 getInputFd: () => Sink.drain,
711 getOutputFd: () => Stream.empty,
712 });
713 }),
714 );
715 return { spawner, spawns, started };
716 };
717
718 const prepareWithHelper = Effect.fn("prepareWithHelper")(function* (
719 runtimeExecutablePath: string,
720 spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
721 ) {
722 const fs = yield* FileSystem.FileSystem;
723 const path = yield* Path.Path;
724 const directory = yield* fs.makeTempDirectoryScoped();
725 return yield* prepareAntigravityProfile({
726 profileDirectory: path.join(directory, "profile"),
727 userHome: path.join(directory, "home"),
728 runtimeExecutablePath,
729 platform: "linux",
730 baseEnv: {},
731 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
732 });
733
734 it.effect("allows a browser preflight that takes longer than five seconds", () =>
735 Effect.gen(function* () {
736 const helper = preflightHelper(Effect.sleep("20 seconds"));
737 const fiber = yield* prepareWithHelper("/fake/slow-node", helper.spawner).pipe(
738 Effect.forkChild,
739 );
740 yield* Deferred.await(helper.started);
741 yield* TestClock.adjust("20 seconds");
742 yield* Fiber.join(fiber);
743
744 expect(helper.spawns.count).toBe(1);
745 }),
746 );
747
748 it.effect("fails a hung preflight after 30 seconds and verifies again on the next open", () =>
749 Effect.gen(function* () {
750 const hung = preflightHelper(Effect.never);
751 const fiber = yield* prepareWithHelper("/fake/hung-node", hung.spawner).pipe(
752 Effect.result,
753 Effect.forkChild,
754 );
755 yield* Deferred.await(hung.started);
756 yield* TestClock.adjust("29 seconds");
757 expect(fiber.pollUnsafe()).toBeUndefined();
758 yield* TestClock.adjust("1 second");
759 const result = yield* Fiber.join(fiber);
760 expect(Result.isFailure(result)).toBe(true);
761 if (Result.isFailure(result)) {
762 expect(result.failure).toMatchObject({
763 _tag: "AcpTransportError",
764 detail: "Antigravity browser suppression verification timed out.",
765 });
766 }
767
768 const recovered = preflightHelper(Effect.void);
769 yield* prepareWithHelper("/fake/hung-node", recovered.spawner);
770 expect(recovered.spawns.count).toBe(1);
771 }),
772 );
773
774 it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () =>
775 Effect.gen(function* () {
776 const fs = yield* FileSystem.FileSystem;
777 const path = yield* Path.Path;
778 const temporaryDirectory = yield* fs.makeTempDirectoryScoped();
779 for (const platform of ["linux", "win32"] as const) {
780 const profileDirectory = path.join(temporaryDirectory, platform);
781 const result = yield* prepareAntigravityProfile({
782 profileDirectory,
783 platform,
784 runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node",
785 }).pipe(Effect.result);
786 expect(Result.isFailure(result)).toBe(true);
787 expect(yield* fs.exists(profileDirectory)).toBe(false);
788 }
789 }),
790 );
791});