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