1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import {
5 ANTIGRAVITY_DEFAULT_MODEL,
6 ProviderInstanceId,
7 type AntigravitySettings,
8} from "@t3tools/contracts";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import * as Effect from "effect/Effect";
11import * as Deferred from "effect/Deferred";
12import * as Fiber from "effect/Fiber";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";
17import * as TestClock from "effect/testing/TestClock";
18import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
19import { HttpClient } from "effect/http";
20
21import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
22import * as ServerConfig from "../../config.ts";
23import * as ServerSettings from "../../serverSettings.ts";
24import * as AntigravityInstallation from "../AntigravityInstallation.ts";
25import {
26 ANTIGRAVITY_AUTH_STDOUT_PREFIX,
27 resolveAntigravityInstanceDirectories,
28} from "../antigravityAuthSupport.ts";
29import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
30import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
31import * as ModelManifest from "../ModelManifest.ts";
32import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
33import { AntigravityDriver } from "./AntigravityDriver.ts";
34import * as ProviderHostLive from "../ProviderHostLive.ts";
35import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
36
37const hostPlatform = HostProcess.Platform.defaultValue();
38const windowsHost = hostPlatform === "win32";
39const decodeRequest = Schema.decodeEffect(
40 Schema.fromJsonString(
41 Schema.Struct({
42 method: Schema.optional(Schema.String),
43 params: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
44 }),
45 ),
46);
47const blockedCredentialKeys = new Set([
48 "GEMINI_API_KEY",
49 "GOOGLE_API_KEY",
50 "GOOGLE_APPLICATION_CREDENTIALS",
51 "GOOGLE_GENAI_USE_VERTEXAI",
52]);
53
54function shellQuote(value: string): string {
55 return `'${value.replaceAll("'", `'"'"'`)}'`;
56}
57
58const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
59 options: { readonly config?: Partial<AntigravitySettings>; readonly enabled?: boolean } = {},
60) {
61 const fs = yield* FileSystem.FileSystem;
62 const path = yield* Path.Path;
63 const config = yield* ServerConfig.ServerConfig;
64 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
65 const nodePath = yield* HostProcess.ExecutablePath;
66 const baseEnv = yield* HostProcess.Environment;
67 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-driver-" });
68 const instanceId = ProviderInstanceId.make(path.basename(root));
69 const mockAgentPath = yield* path.fromFileUrl(
70 new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
71 );
72 const requestLog = path.join(root, "requests.jsonl");
73 const directories = yield* resolveAntigravityInstanceDirectories(config.stateDir, instanceId);
74 const profileDirectory = directories.profile;
75 const instancePath = `${path.join(root, "instance-bin")}:${baseEnv.PATH ?? ""}`;
76
77 const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* (
78 name: string,
79 loginRequired = false,
80 ) {
81 const directory = path.join(root, name);
82 const executablePath = path.join(directory, "agy_acp_server.par");
83 const harnessPath = path.join(directory, "localharness_external");
84 yield* fs.makeDirectory(directory, { recursive: true });
85 const authorizationUrl =
86 "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A51234%2F&state=fixture-state";
87 yield* fs.writeFileString(
88 executablePath,
89 [
90 "#!/bin/sh",
91 ...(loginRequired
92 ? [`printf '%s\\n' ${shellQuote(ANTIGRAVITY_AUTH_STDOUT_PREFIX + authorizationUrl)}`]
93 : []),
94 `exec ${shellQuote(nodePath)} ${shellQuote(mockAgentPath)} "$@"`,
95 "",
96 ].join("\n"),
97 );
98 yield* fs.writeFileString(harnessPath, "#!/bin/sh\nexit 99\n");
99 yield* fs.chmod(executablePath, 0o755);
100 yield* fs.chmod(harnessPath, 0o755);
101 return {
102 executablePath,
103 harnessPath,
104 source: "managed",
105 version: name,
106 managedVersionDirectory: directory,
107 } satisfies AntigravityInstallation.AntigravityExecutable;
108 });
109
110 const first = yield* makeExecutable("runtime 'one");
111 const second = yield* makeExecutable("runtime two");
112 const signedOut = yield* makeExecutable("runtime signed-out", true);
113 const controls = { selected: first, failResolution: false, beforeAcquire: Effect.void };
114 const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = [];
115 const releases: Array<string | null> = [];
116 const launches: Array<{
117 command: string;
118 args: ReadonlyArray<string>;
119 cwd: string | undefined;
120 extendEnv: boolean | undefined;
121 profileDirectory: string | undefined;
122 harnessPath: string | undefined;
123 forceFileStorage: string | undefined;
124 credentialKeys: ReadonlyArray<string>;
125 geminiApiKey: string | undefined;
126 tempDirectory: string | undefined;
127 handle: ChildProcessSpawner.ChildProcessHandle;
128 }> = [];
129
130 const layerInstallation = Layer.mock(AntigravityInstallation.AntigravityInstallation)({
131 managedDirectory: root,
132 resolve: () =>
133 Effect.gen(function* () {
134 if (controls.failResolution) {
135 return yield* new AntigravityInstallation.AntigravityInstallationError({
136 operation: "resolve",
137 detail: "Fixture resolution failed.",
138 });
139 }
140 return controls.selected;
141 }),
142 acquire: (binaryPath, environment) =>
143 Effect.gen(function* () {
144 acquisitions.push({ binaryPath, path: environment?.PATH });
145 yield* controls.beforeAcquire;
146 if (controls.failResolution) {
147 return yield* new AntigravityInstallation.AntigravityInstallationError({
148 operation: "resolve",
149 detail: "Fixture resolution failed.",
150 });
151 }
152 const selected = controls.selected;
153 yield* Effect.addFinalizer(() =>
154 Effect.sync(() => {
155 releases.push(selected.version);
156 }),
157 );
158 return selected;
159 }),
160 });
161 const observedSpawner = ChildProcessSpawner.make((command) =>
162 Effect.gen(function* () {
163 if (command._tag !== "StandardCommand")
164 return yield* Effect.die("Unexpected process pipeline.");
165 const handle = yield* spawner.spawn(command);
166 const environment = command.options.env ?? {};
167 launches.push({
168 command: command.command,
169 args: command.args,
170 cwd: command.options.cwd,
171 extendEnv: command.options.extendEnv,
172 profileDirectory: environment.GEMINI_HOME,
173 harnessPath: environment.ANTIGRAVITY_HARNESS_PATH,
174 forceFileStorage: environment.AGY_ACP_FORCE_FILE_STORAGE,
175 credentialKeys: Object.keys(environment).filter((key) =>
176 blockedCredentialKeys.has(key.toUpperCase()),
177 ),
178 geminiApiKey: environment.GEMINI_API_KEY,
179 // Only the agent gets a per-process temp directory. Other launches
180 // inherit the host TMPDIR.
181 tempDirectory:
182 environment.ANTIGRAVITY_HARNESS_PATH === undefined ? undefined : environment.TMPDIR,
183 handle,
184 });
185 return handle;
186 }),
187 );
188 const instance = yield* AntigravityDriver.create({
189 instanceId,
190 displayName: "Google test account",
191 enabled: options.enabled ?? true,
192 config: { ...AntigravityDriver.defaultConfig(), ...options.config },
193 environment: [
194 { name: "PATH", value: instancePath },
195 { name: "T3_ACP_ANTIGRAVITY", value: "1" },
196 { name: "T3_ACP_REQUEST_LOG_PATH", value: requestLog },
197 { name: "GEMINI_API_KEY", value: "must-not-be-used" },
198 { name: "google_api_key", value: "must-not-be-used" },
199 { name: "GOOGLE_APPLICATION_CREDENTIALS", value: "/must-not-be-used.json" },
200 { name: "GOOGLE_GENAI_USE_VERTEXAI", value: "true" },
201 { name: "GEMINI_HOME", value: "/must-not-be-used" },
202 { name: "ANTIGRAVITY_HARNESS_PATH", value: "/must-not-be-used" },
203 { name: "BROWSER", value: "must-not-run" },
204 ].map((variable) => ({ ...variable, sensitive: false })),
205 }).pipe(
206 Effect.provide(layerInstallation),
207 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, observedSpawner),
208 );
209 const refresh = instance.refreshModels;
210 if (!refresh) return yield* Effect.die("Antigravity does not expose model refresh.");
211 const readRequests = Effect.gen(function* () {
212 if (!(yield* fs.exists(requestLog))) return [];
213 const text = yield* fs.readFileString(requestLog);
214 return yield* Effect.forEach(text.split(/\r?\n/u).filter(Boolean), (line) =>
215 decodeRequest(line),
216 );
217 });
218 const assertClosed = Effect.gen(function* () {
219 for (const launch of launches) {
220 // Cancelled startup can report a signal instead of a numeric exit code.
221 yield* launch.handle.exitCode.pipe(Effect.ignore);
222 expect(yield* launch.handle.isRunning).toBe(false);
223 if (launch.cwd) expect(yield* fs.exists(launch.cwd)).toBe(false);
224 if (launch.tempDirectory) expect(yield* fs.exists(launch.tempDirectory)).toBe(false);
225 }
226 });
227 return {
228 instance,
229 refresh,
230 fs,
231 path,
232 profileDirectory,
233 directories,
234 instancePath,
235 first,
236 second,
237 signedOut,
238 controls,
239 acquisitions,
240 releases,
241 launches,
242 readRequests,
243 assertClosed,
244 };
245});
246
247const layerDeps = ServerConfig.layerTest(process.cwd(), {
248 prefix: "t3-antigravity-driver-config-",
249}).pipe(
250 Layer.provideMerge(NodeServices.layer),
251 Layer.provideMerge(
252 Layer.succeed(
253 HttpClient.HttpClient,
254 HttpClient.make(() => Effect.die("Unexpected quota request")),
255 ),
256 ),
257 Layer.provideMerge(ServerSettings.layerTest()),
258 Layer.provideMerge(
259 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
260 shouldRunScopeWork: () => Effect.succeed(false),
261 }),
262 ),
263 Layer.provideMerge(
264 Layer.succeed(
265 ProviderEventLoggers.ProviderEventLoggers,
266 ProviderEventLoggers.NoOpProviderEventLoggers,
267 ),
268 ),
269 Layer.provideMerge(ModelManifest.layerTest),
270 Layer.provideMerge(IdAllocator.layer),
271 Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
272);
273const layerTest = ProviderHostLive.layer.pipe(
274 Layer.provideMerge(ServerSecretStore.layer),
275 Layer.provideMerge(layerDeps),
276);
277
278it.layer(layerTest)("AntigravityDriver", (it) => {
279 it.effect.skipIf(windowsHost)(
280 "preserves the Node install message when starting a standalone provider",
281 () =>
282 Effect.gen(function* () {
283 const h = yield* makeHarness();
284 const error = yield* h.refresh().pipe(Effect.flip);
285 expect(error.detail).toContain("Install Node.js");
286 expect(h.launches).toEqual([]);
287 }).pipe(
288 Effect.scoped,
289 Effect.provideService(HostProcess.IsExecutable, true),
290 Effect.provideService(HostProcess.Environment, { PATH: "" }),
291 ),
292 );
293
294 it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () =>
295 Effect.gen(function* () {
296 const h = yield* makeHarness({ enabled: false });
297 const snapshot = yield* h.instance.snapshot.refresh;
298 expect(snapshot.status).toBe("disabled");
299 expect(h.acquisitions).toEqual([]);
300 expect(h.launches).toEqual([]);
301 expect(yield* h.fs.exists(h.profileDirectory)).toBe(false);
302 }).pipe(Effect.scoped),
303 );
304
305 it.effect.skipIf(windowsHost)("refreshes models after slow process startup", () =>
306 Effect.gen(function* () {
307 const h = yield* makeHarness();
308 const entered = yield* Deferred.make<void>();
309 const ready = yield* Deferred.make<void>();
310 h.controls.beforeAcquire = Deferred.succeed(entered, undefined).pipe(
311 Effect.andThen(Deferred.await(ready)),
312 );
313 const refresh = yield* h.refresh().pipe(Effect.forkScoped);
314 yield* Deferred.await(entered);
315 yield* TestClock.adjust("70 seconds");
316 yield* Deferred.succeed(ready, undefined);
317 yield* Fiber.join(refresh);
318 const snapshot = yield* h.instance.snapshot.getSnapshot;
319 expect(snapshot.auth.status).toBe("authenticated");
320 expect(snapshot.models.length).toBeGreaterThan(0);
321 yield* h.assertClosed;
322 }).pipe(Effect.scoped),
323 );
324
325 it.effect.skipIf(windowsHost)(
326 "refreshes through the selected executable and personal Google ACP",
327 () =>
328 Effect.gen(function* () {
329 const h = yield* makeHarness();
330 yield* h.refresh();
331 const snapshot = yield* h.instance.snapshot.getSnapshot;
332 expect(snapshot.status).toBe("ready");
333 expect(snapshot.auth.status).toBe("authenticated");
334 expect(snapshot.models.map((model) => model.slug)).toEqual([
335 "gemini-test-low",
336 "gemini-test-high",
337 ]);
338 expect(snapshot.models[0]?.aliases).toContain(ANTIGRAVITY_DEFAULT_MODEL);
339 expect(snapshot.models.every((model) => model.isLegacy !== true)).toBe(true);
340 expect(snapshot.slashCommands.map((command) => command.name)).toEqual(["plan", "logout"]);
341 expect(snapshot.supportsTextGeneration).toBe(true);
342 h.controls.selected = h.second;
343 yield* h.refresh();
344
345 expect(h.acquisitions).toEqual([
346 { binaryPath: "", path: h.instancePath },
347 { binaryPath: "", path: h.instancePath },
348 ]);
349 const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined);
350 expect(nativeLaunches.map((launch) => launch.command)).toEqual([
351 h.first.executablePath,
352 h.second.executablePath,
353 ]);
354 expect(nativeLaunches.map((launch) => launch.harnessPath)).toEqual([
355 h.first.harnessPath,
356 h.second.harnessPath,
357 ]);
358 for (const launch of nativeLaunches) {
359 expect(launch.args).toEqual(hostPlatform === "linux" ? ["--uid="] : []);
360 expect(launch.profileDirectory).toBe(h.profileDirectory);
361 expect(launch.forceFileStorage).toBe("1");
362 expect(launch.extendEnv).toBe(false);
363 }
364 for (const launch of h.launches) expect(launch.credentialKeys).toEqual([]);
365 expect(h.releases).toEqual([h.first.version, h.second.version]);
366 const requests = yield* h.readRequests;
367 expect(requests.map((request) => request.method)).toEqual([
368 "initialize",
369 "auth/login",
370 "session/new",
371 "initialize",
372 "auth/login",
373 "session/new",
374 ]);
375 expect(
376 requests
377 .filter((request) => request.method === "auth/login")
378 .map((request) => request.params?.methodId),
379 ).toEqual(["oauth-personal", "oauth-personal"]);
380 expect(
381 requests
382 .filter((request) => request.method === "session/new")
383 .map((request) => request.params?.mcpServers),
384 ).toEqual([[], []]);
385 yield* h.assertClosed;
386 }).pipe(Effect.scoped),
387 );
388
389 it.effect.skipIf(windowsHost)(
390 "authenticates with the configured API key method and labels the account by method",
391 () =>
392 Effect.gen(function* () {
393 const h = yield* makeHarness({
394 config: { authMethod: "gemini-api-key", apiKey: "fixture-gemini-key" },
395 });
396 yield* h.refresh();
397 const snapshot = yield* h.instance.snapshot.getSnapshot;
398 expect(snapshot.auth).toMatchObject({
399 status: "authenticated",
400 type: "gemini-api-key",
401 label: "Gemini API key",
402 });
403 expect(snapshot.models.length).toBeGreaterThan(0);
404 const nativeLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined);
405 expect(nativeLaunches.map((launch) => launch.geminiApiKey)).toEqual(["fixture-gemini-key"]);
406 const requests = yield* h.readRequests;
407 expect(
408 requests
409 .filter((request) => request.method === "auth/login")
410 .map((request) => request.params?.methodId),
411 ).toEqual(["gemini-api-key"]);
412 yield* h.assertClosed;
413 }).pipe(Effect.scoped),
414 );
415
416 it.effect.skipIf(windowsHost)("reports the missing credential before launching a process", () =>
417 Effect.gen(function* () {
418 const h = yield* makeHarness({ config: { authMethod: "gemini-api-key" } });
419 const error = yield* h.refresh().pipe(Effect.flip);
420 expect(error.detail).toContain("API key");
421 expect(h.launches.filter((launch) => launch.harnessPath !== undefined)).toEqual([]);
422 }).pipe(Effect.scoped),
423 );
424
425 it.effect.skipIf(windowsHost)(
426 "closes refresh processes and clears account metadata when Google sign-in is required",
427 () =>
428 Effect.gen(function* () {
429 const h = yield* makeHarness();
430 yield* h.refresh();
431 h.controls.selected = h.signedOut;
432 const error = yield* h.refresh().pipe(Effect.flip);
433 expect(error.detail).toContain("Sign in to Antigravity");
434 const snapshot = yield* h.instance.snapshot.getSnapshot;
435 expect(snapshot.auth.status).toBe("unauthenticated");
436 expect(snapshot.models).toEqual([]);
437 expect(snapshot.slashCommands).toEqual([]);
438 expect(snapshot.supportsTextGeneration).toBe(false);
439 expect(h.acquisitions).toHaveLength(2);
440 expect(h.releases).toEqual([h.first.version, h.signedOut.version]);
441 yield* h.assertClosed;
442 }).pipe(Effect.scoped),
443 );
444
445 it.effect.skipIf(windowsHost)(
446 "clears account metadata when a text helper needs Google sign-in",
447 () =>
448 Effect.gen(function* () {
449 const h = yield* makeHarness();
450 yield* h.refresh();
451 h.controls.selected = h.signedOut;
452 const error = yield* h.instance.textGeneration
453 .generateThreadTitle({
454 cwd: h.profileDirectory,
455 message: "Repair Google login",
456 modelSelection: { instanceId: h.instance.instanceId, model: "gemini-test-low" },
457 })
458 .pipe(Effect.flip);
459 expect(error._tag).toBe("TextGenerationError");
460 const snapshot = yield* h.instance.snapshot.getSnapshot;
461 expect(snapshot.auth.status).toBe("unauthenticated");
462 expect(snapshot.models).toEqual([]);
463 expect(snapshot.supportsTextGeneration).toBe(false);
464 expect(h.releases).toEqual([h.first.version, h.signedOut.version]);
465 yield* h.assertClosed;
466 }).pipe(Effect.scoped),
467 );
468
469 it.effect.skipIf(windowsHost)("keeps the previous catalog when executable resolution fails", () =>
470 Effect.gen(function* () {
471 const h = yield* makeHarness();
472 yield* h.refresh();
473 const before = yield* h.instance.snapshot.getSnapshot;
474 h.controls.failResolution = true;
475 const error = yield* h.refresh().pipe(Effect.flip);
476 expect(error.detail).toContain("previous model list is unchanged");
477 const after = yield* h.instance.snapshot.getSnapshot;
478 expect(after.models).toEqual(before.models);
479 expect(after.auth).toEqual(before.auth);
480 expect(h.acquisitions).toHaveLength(2);
481 expect(h.releases).toEqual([h.first.version]);
482 yield* h.assertClosed;
483 }).pipe(Effect.scoped),
484 );
485
486 it.effect.skipIf(windowsHost)(
487 "gives each process its own temp directory and removes it when the process closes",
488 () =>
489 Effect.gen(function* () {
490 const h = yield* makeHarness();
491 const tempRoot = h.directories.runtimeTemp;
492 yield* h.refresh();
493 yield* h.refresh();
494 const directories = h.launches.flatMap((launch) =>
495 launch.tempDirectory === undefined ? [] : [launch.tempDirectory],
496 );
497 expect(directories).toHaveLength(2);
498 for (const directory of directories) {
499 expect(h.path.dirname(directory)).toBe(tempRoot);
500 }
501 expect(new Set(directories).size).toBe(2);
502 yield* h.assertClosed;
503 }).pipe(Effect.scoped),
504 );
505
506 it.effect.skipIf(windowsHost)(
507 "removes runtime temp directories left by a previous server on create",
508 () =>
509 Effect.gen(function* () {
510 const fs = yield* FileSystem.FileSystem;
511 const path = yield* Path.Path;
512 const config = yield* ServerConfig.ServerConfig;
513 const instanceId = ProviderInstanceId.make("antigravity-orphan-sweep");
514 const directories = yield* resolveAntigravityInstanceDirectories(
515 config.stateDir,
516 instanceId,
517 );
518 // Older builds unpacked inside the profile.
519 const legacyRoot = path.join(directories.profile, "antigravity-acp", "tmp");
520 for (const root of [directories.runtimeTemp, legacyRoot]) {
521 const orphan = path.join(root, "run-orphan", "_MEI123", "google3");
522 yield* fs.makeDirectory(orphan, { recursive: true });
523 yield* fs.writeFileString(path.join(orphan, "payload.bin"), "stale");
524 }
525 yield* AntigravityDriver.create({
526 instanceId,
527 displayName: "Sweep",
528 enabled: false,
529 config: AntigravityDriver.defaultConfig(),
530 environment: [],
531 }).pipe(
532 Effect.provide(
533 Layer.mock(AntigravityInstallation.AntigravityInstallation)({
534 managedDirectory: config.stateDir,
535 resolve: () => Effect.die("unused"),
536 acquire: () => Effect.die("unused"),
537 }),
538 ),
539 );
540 expect(yield* fs.exists(directories.runtimeTemp)).toBe(false);
541 expect(yield* fs.exists(legacyRoot)).toBe(false);
542 }).pipe(Effect.scoped),
543 );
544
545 it.effect("probes through installation resolution without launching a process", () =>
546 Effect.gen(function* () {
547 const h = yield* makeHarness({ enabled: true });
548 const snapshot = yield* h.instance.snapshot.refresh;
549 expect(snapshot.installed).toBe(true);
550 expect(snapshot.version).toBe(h.first.version);
551 expect(h.launches).toEqual([]);
552 expect(h.acquisitions).toEqual([]);
553 }).pipe(Effect.scoped),
554 );
555});