1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Crypto from "effect/Crypto";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as Hex from "effect/encoding/Hex";
8import * as Exit from "effect/Exit";
9import * as Fiber from "effect/Fiber";
10import * as FileSystem from "effect/FileSystem";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as PlatformError from "effect/PlatformError";
14import * as Queue from "effect/Queue";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Sink from "effect/Sink";
18import * as Stream from "effect/Stream";
19import { HttpClient, HttpClientResponse } from "effect/http";
20import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
21
22import * as AntigravityInstallation from "./AntigravityInstallation.ts";
23import { ANTIGRAVITY_AUTH_BROWSER_MARKER } from "./antigravityAuthSupport.ts";
24import {
25 resolveAntigravityReleaseAsset,
26 type AntigravityReleaseAsset,
27} from "./antigravityRelease.ts";
28
29import antigravityInitialize from "../../../../packages/effect-acp/test/fixtures/antigravity-initialize.json" with { type: "json" };
30
31const serverContents = "antigravity runtime\n";
32const harnessContents = "local harness\n";
33const previousReleaseId = "1".repeat(64);
34const previousVersion = "fixture-old";
35const encodeJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
36
37// Small ZIPs made with Python's zipfile module. The unsafe entries are intentional.
38const zipFixtures = {
39 complete:
40 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA",
41 missingHarness:
42 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YEAAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAABAAEAQAAAAEQAAAAAAA==",
43 duplicate:
44 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAABhZ3lfYWNwX3NlcnZlci5wYXJLzCvJTC9KLMssqVQoKgVyclO5AFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdcxMv6BQAAAAUAAAAEgAAAAAAAAAAAAAA7YFEAAAAYWd5X2FjcF9zZXJ2ZXIucGFyUEsFBgAAAAACAAIAgAAAAIgAAAAAAA==",
45 traversal:
46 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAAVAAAALi5cYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABUAAAAAAAAAAAAAAO2BAAAAAC4uXGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAADtgUcAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCGAAAAigAAAAAA",
47 symlink:
48 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABUAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWzLyU9OzFHISCzKSy0u5gIAUEsBAhQDFAAAAAgAAAAiXXMTL+gUAAAAFAAAABIAAAAAAAAAAAAAAO2BAAAAAGFneV9hY3Bfc2VydmVyLnBhclBLAQIUAxQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAAAAAAAAAAAD/oUQAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWxQSwUGAAAAAAIAAgCDAAAAhwAAAAAA",
49 oversizedMember:
50 "UEsDBBQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAYWd5X2FjcF9zZXJ2ZXIucGFyS8wryUwvSizLLKlUKCoFcnJTuSoAUEsDBBQAAAAIAAAAIl1fcgMpEAAAAA4AAAAVAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsy8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl0WGThFFQAAABUAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5wYXJQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAFQAAAAAAAAAAAAAA7YFFAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsUEsFBgAAAAACAAIAgwAAAIgAAAAAAA==",
51 windows:
52 "UEsDBBQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAYWd5X2FjcF9zZXJ2ZXIuZXhlS8wryUwvSizLLKlUKCoFcnJTuQBQSwMEFAAAAAgAAAAiXV9yAykQAAAADgAAABkAAABsb2NhbGhhcm5lc3NfZXh0ZXJuYWwuZXhly8lPTsxRyEgsykstLuYCAFBLAQIUAxQAAAAIAAAAIl1zEy/oFAAAABQAAAASAAAAAAAAAAAAAADtgQAAAABhZ3lfYWNwX3NlcnZlci5leGVQSwECFAMUAAAACAAAACJdX3IDKRAAAAAOAAAAGQAAAAAAAAAAAAAA7YFEAAAAbG9jYWxoYXJuZXNzX2V4dGVybmFsLmV4ZVBLBQYAAAAAAgACAIcAAACLAAAAAAA=",
53};
54
55// The installation checks POSIX exec bits off the real filesystem unless the
56// platform is win32, so a linux platform mock cannot pass on NTFS. Default to
57// the host and let the fixture names follow; the suite is about install
58// mechanics, which are the same on every platform.
59const hostPlatform: NodeJS.Platform =
60 HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux";
61const completeArchive = Buffer.from(
62 hostPlatform === "win32" ? zipFixtures.windows : zipFixtures.complete,
63 "base64",
64);
65const executableName = hostPlatform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par";
66const harnessName =
67 hostPlatform === "win32" ? "localharness_external.exe" : "localharness_external";
68
69const releaseAsset = Effect.fn("test.antigravityReleaseAsset")(function* (
70 archive: Uint8Array = completeArchive,
71 platform: NodeJS.Platform = hostPlatform,
72) {
73 const crypto = yield* Crypto.Crypto;
74 return {
75 version: "fixture-new",
76 url: "https://dl.google.com/antigravity-test.zip",
77 sha256: Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie)),
78 archiveBytes: archive.byteLength,
79 executable: {
80 name: platform === "win32" ? "agy_acp_server.exe" : "agy_acp_server.par",
81 bytes: Buffer.byteLength(serverContents),
82 },
83 harness: {
84 name: platform === "win32" ? "localharness_external.exe" : "localharness_external",
85 bytes: Buffer.byteLength(harnessContents),
86 },
87 } satisfies AntigravityReleaseAsset;
88});
89
90const writeRelease = Effect.fn("test.writeAntigravityRelease")(function* (
91 managedDirectory: string,
92 asset: AntigravityReleaseAsset,
93 active = true,
94) {
95 const fs = yield* FileSystem.FileSystem;
96 const path = yield* Path.Path;
97 const directory = path.join(managedDirectory, "versions", asset.sha256);
98 yield* fs.makeDirectory(directory, { recursive: true });
99 yield* fs.writeFileString(path.join(directory, asset.executable.name), serverContents, {
100 mode: 0o755,
101 });
102 yield* fs.writeFileString(path.join(directory, asset.harness.name), harnessContents, {
103 mode: 0o755,
104 });
105 yield* fs.writeFileString(
106 path.join(directory, ".install-complete.json"),
107 encodeJsonString({
108 releaseId: asset.sha256,
109 version: asset.version,
110 executable: asset.executable,
111 harness: asset.harness,
112 }),
113 );
114 if (active) {
115 yield* fs.writeFileString(
116 path.join(managedDirectory, "active.json"),
117 encodeJsonString({ releaseId: asset.sha256 }),
118 );
119 }
120});
121
122interface HarnessOptions {
123 readonly baseDir?: string;
124 readonly asset?: AntigravityReleaseAsset | null;
125 readonly archive?: Buffer;
126 readonly body?: Stream.Stream<Uint8Array> | undefined;
127 readonly contentLength?: number;
128 readonly contentEncoding?: string;
129 readonly platform?: NodeJS.Platform;
130 readonly path?: string;
131 readonly previous?: boolean;
132 readonly fileSystem?: FileSystem.FileSystem;
133 readonly validate?: AntigravityInstallation.AntigravityInstallationOptions["validate"];
134 readonly useDefaultValidation?: boolean;
135}
136
137const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (
138 options: HarnessOptions = {},
139) {
140 const fs = yield* FileSystem.FileSystem;
141 const path = yield* Path.Path;
142 const baseDir =
143 options.baseDir ?? (yield* fs.makeTempDirectoryScoped({ prefix: "t3-agy-test-" }));
144 const platform = options.platform ?? hostPlatform;
145 const archive = options.archive ?? completeArchive;
146 const asset =
147 options.asset === undefined ? yield* releaseAsset(archive, platform) : options.asset;
148 const managedDirectory = path.join(baseDir, "tools", "antigravity-acp", `${platform}-x64`);
149 if (options.previous) {
150 yield* writeRelease(managedDirectory, {
151 ...(yield* releaseAsset(archive, platform)),
152 sha256: previousReleaseId,
153 version: previousVersion,
154 });
155 }
156 const stagingReleased = yield* Deferred.make<void>();
157 const requests: string[] = [];
158 const validations: Array<{
159 executable: AntigravityInstallation.AntigravityExecutable;
160 version: string;
161 }> = [];
162 const installationFs = options.fileSystem ?? fs;
163 const trackedFs = FileSystem.FileSystem.of({
164 ...installationFs,
165 makeTempDirectoryScoped: (settings) =>
166 settings?.prefix === ".install-"
167 ? Effect.acquireRelease(installationFs.makeTempDirectory(settings), (directory) =>
168 fs
169 .remove(directory, { recursive: true, force: true })
170 .pipe(Effect.orDie, Effect.andThen(Deferred.succeed(stagingReleased, undefined))),
171 )
172 : installationFs.makeTempDirectoryScoped(settings),
173 });
174 const installation = yield* AntigravityInstallation.makeAntigravityInstallation({
175 baseDir,
176 releaseAsset: asset,
177 ...(options.useDefaultValidation
178 ? {}
179 : {
180 validate: (executable: AntigravityInstallation.AntigravityExecutable, version: string) =>
181 Effect.sync(() => validations.push({ executable, version })).pipe(
182 Effect.andThen(options.validate?.(executable, version) ?? Effect.void),
183 ),
184 }),
185 }).pipe(
186 Effect.provideService(FileSystem.FileSystem, trackedFs),
187 Effect.provideService(HostProcess.Platform, platform),
188 Effect.provideService(HostProcess.Architecture, "x64"),
189 Effect.provideService(HostProcess.Environment, { PATH: options.path ?? "" }),
190 Effect.provideService(
191 HttpClient.HttpClient,
192 HttpClient.make((request) =>
193 Effect.sync(() => {
194 requests.push(request.url);
195 const response = HttpClientResponse.fromWeb(
196 request,
197 new Response(null, {
198 headers: {
199 ...(options.contentLength === undefined
200 ? {}
201 : { "content-length": String(options.contentLength) }),
202 ...(options.contentEncoding === undefined
203 ? {}
204 : { "content-encoding": options.contentEncoding }),
205 },
206 }),
207 );
208 return Object.defineProperty(response, "stream", {
209 value:
210 options.body ??
211 Stream.make(
212 archive.subarray(0, 31),
213 archive.subarray(31, 149),
214 archive.subarray(149),
215 ),
216 });
217 }),
218 ),
219 ),
220 );
221 return { installation, fs, path, baseDir, requests, validations, stagingReleased };
222});
223
224const terminalState = (installation: AntigravityInstallation.AntigravityInstallation["Service"]) =>
225 installation.changes.pipe(
226 Stream.filter((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)),
227 Stream.runHead,
228 Effect.map(Option.getOrThrow),
229 );
230
231const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")(function* (
232 installation: AntigravityInstallation.AntigravityInstallation["Service"],
233) {
234 const fs = yield* FileSystem.FileSystem;
235 const path = yield* Path.Path;
236 const resolved = yield* installation.resolve();
237 expect(resolved.version).toBe(previousVersion);
238 expect(resolved.managedVersionDirectory).toBe(
239 path.join(installation.managedDirectory, "versions", previousReleaseId),
240 );
241 expect(yield* fs.readFileString(resolved.executablePath)).toBe(serverContents);
242 expect(yield* fs.readFileString(resolved.harnessPath)).toBe(harnessContents);
243 expect((yield* installation.state).installedVersion).toBe(previousVersion);
244});
245
246it.layer(NodeServices.layer)("Antigravity installation", (it) => {
247 it.effect("reports missing Node before downloading the standalone provider runtime", () =>
248 Effect.gen(function* () {
249 const { installation, requests, validations } = yield* makeHarness();
250 yield* installation.start;
251 expect(yield* terminalState(installation)).toMatchObject({
252 phase: "failed",
253 message: expect.stringContaining("Install Node.js"),
254 });
255 expect(requests).toEqual([]);
256 expect(validations).toEqual([]);
257 }).pipe(Effect.provideService(HostProcess.IsExecutable, true)),
258 );
259
260 it.effect("verifies both files before activating a streamed download", () =>
261 Effect.gen(function* () {
262 const enteredValidation = yield* Deferred.make<void>();
263 const finishValidation = yield* Deferred.make<void>();
264 const { installation, fs, path, validations, requests, stagingReleased } = yield* makeHarness(
265 {
266 previous: true,
267 validate: () =>
268 Deferred.succeed(enteredValidation, undefined).pipe(
269 Effect.andThen(Deferred.await(finishValidation)),
270 ),
271 },
272 );
273 const initial = yield* installation.changes.pipe(
274 Stream.runHead,
275 Effect.map(Option.getOrThrow),
276 );
277 expect(initial).toMatchObject({ phase: "idle", installedVersion: previousVersion });
278 const started = yield* installation.start;
279 expect(started).toMatchObject({ phase: "downloading", downloadedBytes: 0 });
280 yield* Deferred.await(enteredValidation);
281 yield* expectPreviousRelease(installation);
282 const validation = validations[0];
283 expect(validation?.version).toBe("fixture-new");
284 if (!validation) return yield* Effect.die("Expected runtime validation.");
285 expect(yield* fs.readFileString(validation.executable.executablePath)).toBe(serverContents);
286 expect(yield* fs.readFileString(validation.executable.harnessPath)).toBe(harnessContents);
287
288 yield* Deferred.succeed(finishValidation, undefined);
289 expect(yield* terminalState(installation)).toMatchObject({
290 phase: "succeeded",
291 operationId: started.operationId,
292 downloadedBytes: completeArchive.byteLength,
293 installedVersion: "fixture-new",
294 });
295 yield* Deferred.await(stagingReleased);
296 const selected = yield* installation.resolve();
297 expect(selected).toMatchObject({ source: "managed", version: "fixture-new" });
298 expect(path.dirname(selected.harnessPath)).toBe(path.dirname(selected.executablePath));
299 expect(yield* fs.readFileString(selected.executablePath)).toBe(serverContents);
300 expect(yield* fs.readFileString(selected.harnessPath)).toBe(harnessContents);
301 expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual(
302 expect.arrayContaining([previousReleaseId, (yield* releaseAsset()).sha256]),
303 );
304 expect(requests).toEqual([(yield* releaseAsset()).url]);
305 }),
306 );
307
308 it.effect.each([
309 {
310 name: "the expected release with protocol 1",
311 protocolVersion: 1,
312 agentName: "antigravity-acp",
313 version: "fixture-new",
314 valid: true,
315 },
316 {
317 name: "the expected release with protocol 2 and legacy fields",
318 protocolVersion: 2,
319 agentName: "antigravity-acp",
320 version: "fixture-new",
321 valid: true,
322 },
323 {
324 name: "an unsupported protocol version",
325 protocolVersion: 3,
326 agentName: "antigravity-acp",
327 version: "fixture-new",
328 valid: false,
329 },
330 { name: "a different agent", agentName: "other-agent", version: "fixture-new", valid: false },
331 {
332 name: "a different version",
333 agentName: "antigravity-acp",
334 version: "other-version",
335 valid: false,
336 },
337 ])("validates $name with initialize only and removes the disposable profile", (testCase) =>
338 Effect.gen(function* () {
339 const fs = yield* FileSystem.FileSystem;
340 const encoder = new TextEncoder();
341 const decodeRequest = Schema.decodeUnknownEffect(
342 Schema.fromJsonString(
343 Schema.Struct({
344 id: Schema.Union([Schema.String, Schema.Number]),
345 method: Schema.String,
346 }),
347 ),
348 );
349 const methods: string[] = [];
350 const profiles = new Set<string>();
351 let closedRuntimes = 0;
352 const spawner = ChildProcessSpawner.make(
353 Effect.fn("test.spawnAntigravityValidator")(function* (command) {
354 if (command._tag !== "StandardCommand") {
355 return yield* Effect.die("Expected one validation process.");
356 }
357 const profile = command.options.env?.GEMINI_HOME;
358 if (!profile) return yield* Effect.die("Expected a disposable validation profile.");
359 profiles.add(profile);
360 const helper = command.args[0] === "-e";
361 // The runtime unpacks straight into the disposable profile.
362 if (!helper) expect(command.options.env?.TMPDIR).toBe(profile);
363 const output = yield* Queue.unbounded<Uint8Array>();
364 const exited = yield* Deferred.make<ChildProcessSpawner.ExitCode>();
365 const terminate = Deferred.succeed(exited, ChildProcessSpawner.ExitCode(0)).pipe(
366 Effect.asVoid,
367 );
368 yield* Effect.addFinalizer(() =>
369 terminate.pipe(
370 Effect.andThen(Queue.shutdown(output)),
371 Effect.andThen(
372 Effect.sync(() => {
373 if (!helper) closedRuntimes += 1;
374 }),
375 ),
376 ),
377 );
378 return ChildProcessSpawner.makeHandle({
379 pid: ChildProcessSpawner.ProcessId(helper ? 1 : 2),
380 exitCode: helper
381 ? Effect.succeed(ChildProcessSpawner.ExitCode(0))
382 : Deferred.await(exited),
383 isRunning: Deferred.isDone(exited).pipe(Effect.map((done) => !done)),
384 kill: () => terminate,
385 unref: Effect.succeed(Effect.void),
386 stdin: Sink.forEach((bytes: Uint8Array) =>
387 Effect.gen(function* () {
388 const request = yield* decodeRequest(new TextDecoder().decode(bytes)).pipe(
389 Effect.orDie,
390 );
391 methods.push(request.method);
392 yield* Queue.offer(
393 output,
394 encoder.encode(
395 `${encodeJsonString({
396 jsonrpc: "2.0",
397 id: request.id,
398 ...(request.method === "initialize"
399 ? {
400 result: {
401 ...antigravityInitialize,
402 protocolVersion: testCase.protocolVersion ?? 2,
403 agentInfo: { name: testCase.agentName, version: testCase.version },
404 },
405 }
406 : {
407 error: {
408 code: -32601,
409 message: "Validation must not sign in or create sessions.",
410 },
411 }),
412 })}\n`,
413 ),
414 );
415 }),
416 ),
417 stdout: helper ? Stream.empty : Stream.fromQueue(output),
418 stderr: helper
419 ? Stream.make(
420 encoder.encode(
421 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeJsonString(command.args.at(-1))}\n`,
422 ),
423 )
424 : Stream.empty,
425 all: Stream.empty,
426 getInputFd: () => Sink.drain,
427 getOutputFd: () => Stream.empty,
428 });
429 }),
430 );
431 const { installation, stagingReleased } = yield* makeHarness({
432 previous: true,
433 useDefaultValidation: true,
434 }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner));
435 yield* installation.start;
436 expect((yield* terminalState(installation)).phase).toBe(
437 testCase.valid ? "succeeded" : "failed",
438 );
439 yield* Deferred.await(stagingReleased);
440 expect(methods).toEqual(["initialize"]);
441 expect(closedRuntimes).toBe(1);
442 expect(profiles.size).toBe(1);
443 for (const profile of profiles) {
444 expect(yield* fs.exists(profile)).toBe(false);
445 }
446 if (testCase.valid) {
447 expect((yield* installation.resolve()).version).toBe("fixture-new");
448 } else {
449 yield* expectPreviousRelease(installation);
450 }
451 }),
452 );
453
454 it.effect("accepts an encoded Content-Length when the body is compressed", () =>
455 Effect.gen(function* () {
456 // dl.google.com gzips the archive and reports the encoded size. The decoded
457 // stream is still checked byte for byte and by hash.
458 const { installation, validations } = yield* makeHarness({
459 contentLength: completeArchive.byteLength - 1_000,
460 contentEncoding: "gzip",
461 });
462 yield* installation.start;
463 const state = yield* terminalState(installation);
464 expect(state.phase).toBe("succeeded");
465 expect(validations).toHaveLength(1);
466 }),
467 );
468
469 it.effect.each([
470 { name: "checksum mismatch", completeAsset: { sha256: "2".repeat(64) } },
471 { name: "short download", archive: completeArchive.subarray(0, -1), completeAsset: {} },
472 {
473 name: "oversized download",
474 archive: Buffer.concat([completeArchive, Buffer.from("extra")]),
475 completeAsset: {},
476 },
477 { name: "wrong Content-Length", contentLength: completeArchive.byteLength + 1 },
478 { name: "missing harness", archive: Buffer.from(zipFixtures.missingHarness, "base64") },
479 { name: "duplicate executable", archive: Buffer.from(zipFixtures.duplicate, "base64") },
480 { name: "path traversal", archive: Buffer.from(zipFixtures.traversal, "base64") },
481 { name: "symbolic link", archive: Buffer.from(zipFixtures.symlink, "base64") },
482 { name: "oversized member", archive: Buffer.from(zipFixtures.oversizedMember, "base64") },
483 ])("rejects $name before runtime validation", ({ completeAsset, ...options }) =>
484 Effect.gen(function* () {
485 // Pin the asset to the complete archive so the download itself is what disagrees.
486 const asset =
487 completeAsset === undefined ? undefined : { ...(yield* releaseAsset()), ...completeAsset };
488 const { installation, validations, stagingReleased, fs, path } = yield* makeHarness({
489 ...options,
490 ...(asset === undefined ? {} : { asset }),
491 previous: true,
492 });
493 yield* installation.start;
494 const state = yield* terminalState(installation);
495 expect(state.phase).toBe("failed");
496 expect(state.message).toBeTruthy();
497 expect(validations).toEqual([]);
498 yield* Deferred.await(stagingReleased);
499 yield* expectPreviousRelease(installation);
500 expect(yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).toEqual(
501 [previousReleaseId],
502 );
503 }),
504 );
505
506 it.effect.each(["download", "extract", "active pointer"] as const)(
507 "preserves the old runtime after an ENOSPC error during %s",
508 (stage) =>
509 Effect.gen(function* () {
510 const fs = yield* FileSystem.FileSystem;
511 const noSpace = PlatformError.systemError({
512 _tag: "Unknown",
513 module: "FileSystem",
514 method: "write",
515 description: "ENOSPC: no space left on device",
516 });
517 const fileSystem = FileSystem.FileSystem.of({
518 ...fs,
519 sink: (target, options) =>
520 (stage === "download" && target.endsWith("download.zip")) ||
521 (stage === "extract" && target.endsWith(executableName))
522 ? fs.sink(target, options).pipe(Sink.mapInputEffect(() => Effect.fail(noSpace)))
523 : fs.sink(target, options),
524 writeFileString: (target, content, options) =>
525 stage === "active pointer" && target.endsWith("contents.tmp")
526 ? Effect.fail(noSpace)
527 : fs.writeFileString(target, content, options),
528 });
529 const { installation, stagingReleased, path } = yield* makeHarness({
530 previous: true,
531 fileSystem,
532 });
533 yield* installation.start;
534 expect((yield* terminalState(installation)).phase).toBe("failed");
535 yield* Deferred.await(stagingReleased);
536 yield* expectPreviousRelease(installation);
537 expect(
538 (yield* fs.readDirectory(path.join(installation.managedDirectory, "versions"))).some(
539 (name) => name.startsWith(".install-"),
540 ),
541 ).toBe(false);
542 expect(yield* fs.readDirectory(installation.managedDirectory)).toEqual([
543 "active.json",
544 "versions",
545 ]);
546 }),
547 );
548
549 it.effect.each(["downloading", "extracting", "verifying"] as const)(
550 "cancels during %s and waits for open resources to close",
551 (phase) =>
552 Effect.gen(function* () {
553 const fs = yield* FileSystem.FileSystem;
554 const entered = yield* Deferred.make<void>();
555 const release = yield* Deferred.make<void>();
556 const interrupted = yield* Deferred.make<void>();
557 const barrier = Deferred.succeed(entered, undefined).pipe(
558 Effect.andThen(Deferred.await(release)),
559 Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
560 );
561 const { installation, stagingReleased, path, validations } = yield* makeHarness({
562 previous: true,
563 body:
564 phase === "downloading"
565 ? Stream.concat(
566 Stream.make(completeArchive.subarray(0, 31)),
567 Stream.fromEffect(barrier.pipe(Effect.as(completeArchive.subarray(31)))),
568 )
569 : undefined,
570 validate: phase === "verifying" ? () => barrier : undefined,
571 fileSystem: FileSystem.FileSystem.of({
572 ...fs,
573 sink: (target, options) =>
574 phase === "extracting" && target.endsWith(executableName)
575 ? fs
576 .sink(target, options)
577 .pipe(
578 Sink.mapInputEffect((chunk: Uint8Array) => barrier.pipe(Effect.as(chunk))),
579 )
580 : fs.sink(target, options),
581 }),
582 });
583 const started = yield* installation.start;
584 yield* Deferred.await(entered);
585 expect((yield* installation.state).phase).toBe(phase);
586 expect(
587 (yield* installation.cancel(started.operationId ?? "missing-operation-id")).phase,
588 ).toBe("cancelled");
589 yield* Deferred.await(interrupted);
590 yield* Deferred.await(stagingReleased);
591 yield* expectPreviousRelease(installation);
592 expect(validations).toHaveLength(phase === "verifying" ? 1 : 0);
593 expect(
594 yield* fs.readDirectory(path.join(installation.managedDirectory, "versions")),
595 ).toEqual([previousReleaseId]);
596 }),
597 );
598
599 it.effect.each([
600 { name: "the committed install", restart: false },
601 { name: "a newer install", restart: true },
602 ])("does not fail $name when old pointer cleanup fails", (testCase) =>
603 Effect.gen(function* () {
604 const fs = yield* FileSystem.FileSystem;
605 const cleanupStarted = yield* Deferred.make<void>();
606 const releaseCleanup = yield* Deferred.make<void>();
607 const nextValidationStarted = yield* Deferred.make<void>();
608 const releaseNextValidation = yield* Deferred.make<void>();
609 const firstWorker = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>();
610 const cleanupError = PlatformError.systemError({
611 _tag: "PermissionDenied",
612 module: "FileSystem",
613 method: "remove",
614 description: "EPERM: pointer temp directory is in use",
615 });
616 let firstPointer = true;
617 let validationCount = 0;
618 const { installation, stagingReleased } = yield* makeHarness({
619 previous: true,
620 validate: () => {
621 validationCount += 1;
622 return validationCount === 2
623 ? Deferred.succeed(nextValidationStarted, undefined).pipe(
624 Effect.andThen(Deferred.await(releaseNextValidation)),
625 )
626 : Effect.void;
627 },
628 fileSystem: FileSystem.FileSystem.of({
629 ...fs,
630 makeTempDirectoryScoped: (settings) => {
631 if (settings?.prefix !== "active.json." || !firstPointer) {
632 return fs.makeTempDirectoryScoped(settings);
633 }
634 firstPointer = false;
635 return Effect.fiber.pipe(
636 Effect.tap((worker) => Deferred.succeed(firstWorker, worker)),
637 Effect.andThen(
638 Effect.acquireRelease(fs.makeTempDirectory(settings), () =>
639 Deferred.succeed(cleanupStarted, undefined).pipe(
640 Effect.andThen(Deferred.await(releaseCleanup)),
641 Effect.andThen(Effect.die(cleanupError)),
642 ),
643 ),
644 ),
645 );
646 },
647 }),
648 });
649 yield* Effect.gen(function* () {
650 const first = yield* installation.start;
651 yield* Deferred.await(cleanupStarted);
652 expect(yield* installation.state).toMatchObject({
653 operationId: first.operationId,
654 phase: "succeeded",
655 installedVersion: "fixture-new",
656 });
657 const current = testCase.restart ? yield* installation.start : first;
658 if (testCase.restart) yield* Deferred.await(nextValidationStarted);
659
660 yield* Deferred.succeed(releaseCleanup, undefined);
661 yield* Fiber.await(yield* Deferred.await(firstWorker));
662 yield* Deferred.await(stagingReleased);
663 expect(yield* installation.state).toMatchObject({
664 operationId: current.operationId,
665 phase: testCase.restart ? "verifying" : "succeeded",
666 installedVersion: "fixture-new",
667 });
668 expect((yield* installation.resolve()).version).toBe("fixture-new");
669
670 yield* Deferred.succeed(releaseNextValidation, undefined);
671 expect(yield* terminalState(installation)).toMatchObject({
672 operationId: current.operationId,
673 phase: "succeeded",
674 installedVersion: "fixture-new",
675 });
676 }).pipe(
677 Effect.ensuring(
678 Deferred.succeed(releaseCleanup, undefined).pipe(
679 Effect.andThen(Deferred.succeed(releaseNextValidation, undefined)),
680 ),
681 ),
682 );
683 }),
684 );
685
686 it.effect(
687 "shares one install across callers and keeps it alive after the caller scope closes",
688 () =>
689 Effect.gen(function* () {
690 const entered = yield* Deferred.make<void>();
691 const release = yield* Deferred.make<void>();
692 const { installation, requests, stagingReleased } = yield* makeHarness({
693 body: Stream.fromEffect(
694 Deferred.succeed(entered, undefined).pipe(
695 Effect.andThen(Deferred.await(release)),
696 Effect.as(completeArchive),
697 ),
698 ),
699 });
700 const callerScope = yield* Scope.make();
701 const started = yield* installation.start.pipe(Scope.provide(callerScope));
702 yield* Deferred.await(entered);
703 yield* Scope.close(callerScope, Exit.void);
704 const concurrent = yield* Effect.all([installation.start, installation.start], {
705 concurrency: "unbounded",
706 });
707 expect(concurrent.map((state) => state.operationId)).toEqual([
708 started.operationId,
709 started.operationId,
710 ]);
711 expect(
712 yield* installation.changes.pipe(Stream.runHead, Effect.map(Option.getOrThrow)),
713 ).toMatchObject({ phase: "downloading", operationId: started.operationId });
714 yield* Deferred.succeed(release, undefined);
715 expect((yield* terminalState(installation)).phase).toBe("succeeded");
716 yield* Deferred.await(stagingReleased);
717
718 const next = yield* installation.start;
719 expect(next.operationId).not.toBe(started.operationId);
720 expect(
721 yield* installation
722 .cancel(started.operationId ?? "missing-operation-id")
723 .pipe(Effect.flip),
724 ).toMatchObject({ operation: "cancel" });
725 expect((yield* terminalState(installation)).phase).toBe("succeeded");
726 expect(requests).toHaveLength(1);
727 }),
728 );
729
730 // Real posix executables in a real temp dir, resolved by a linux-mocked
731 // PATH walk; a Windows temp path cannot be split on `:`.
732 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
733 "honors explicit paths and reports invalid overrides without falling back",
734 () =>
735 Effect.gen(function* () {
736 const fs = yield* FileSystem.FileSystem;
737 const path = yield* Path.Path;
738 const baseDir = yield* fs
739 .makeTempDirectoryScoped({ prefix: "t3-agy-path-test-" })
740 .pipe(Effect.flatMap((directory) => fs.realPath(directory)));
741 const externalDirectory = path.join(baseDir, "external");
742 const externalExecutable = path.join(externalDirectory, executableName);
743 const externalHarness = path.join(externalDirectory, harnessName);
744 yield* fs.makeDirectory(externalDirectory);
745 yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 });
746 yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 });
747 const { installation } = yield* makeHarness({
748 baseDir,
749 path: externalDirectory,
750 previous: true,
751 });
752 yield* expectPreviousRelease(installation);
753 expect(yield* installation.resolve(undefined, { PATH: externalDirectory })).toMatchObject({
754 source: "managed",
755 version: previousVersion,
756 });
757 expect(yield* installation.resolve(externalExecutable)).toMatchObject({
758 executablePath: externalExecutable,
759 source: "override",
760 managedVersionDirectory: null,
761 });
762 expect(yield* installation.resolve(executableName)).toMatchObject({
763 source: "override",
764 });
765 yield* fs.remove(externalHarness);
766 expect(yield* installation.resolve(externalExecutable).pipe(Effect.flip)).toMatchObject({
767 operation: "resolve",
768 });
769 expect(
770 yield* installation.resolve(path.join(baseDir, "missing")).pipe(Effect.flip),
771 ).toMatchObject({
772 operation: "resolve",
773 });
774 yield* expectPreviousRelease(installation);
775 yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 });
776 yield* installation.remove();
777 expect(yield* installation.resolve()).toMatchObject({
778 source: "path",
779 executablePath: externalExecutable,
780 });
781 const isolated = yield* makeHarness({ baseDir });
782 expect(yield* isolated.installation.resolve().pipe(Effect.flip)).toMatchObject({
783 operation: "resolve",
784 });
785 expect(
786 yield* isolated.installation.resolve(undefined, { PATH: externalDirectory }),
787 ).toMatchObject({
788 source: "path",
789 executablePath: externalExecutable,
790 });
791 expect(
792 yield* isolated.installation.resolve(executableName, { PATH: externalDirectory }),
793 ).toMatchObject({ source: "override", executablePath: externalExecutable });
794 }),
795 );
796
797 it.effect("keeps leased releases available while new sessions resolve the new release", () =>
798 Effect.gen(function* () {
799 const { installation, fs, stagingReleased } = yield* makeHarness({ previous: true });
800 const processScope = yield* Scope.make();
801 const oldExecutable = yield* installation.acquire().pipe(Scope.provide(processScope));
802 yield* installation.start;
803 expect((yield* terminalState(installation)).phase).toBe("succeeded");
804 yield* Deferred.await(stagingReleased);
805 const current = yield* installation.resolve();
806 expect(current.version).toBe("fixture-new");
807 expect(current.executablePath).not.toBe(oldExecutable.executablePath);
808 expect(yield* fs.readFileString(oldExecutable.executablePath)).toBe(serverContents);
809 expect(yield* installation.remove().pipe(Effect.flip)).toMatchObject({ operation: "remove" });
810 yield* Scope.close(processScope, Exit.void);
811 yield* installation.remove();
812 expect(yield* fs.exists(installation.managedDirectory)).toBe(false);
813 }),
814 );
815
816 it.effect(
817 "removes an incomplete active release before reinstalling without a PATH fallback",
818 () =>
819 Effect.gen(function* () {
820 const { installation, baseDir, fs, path } = yield* makeHarness({ previous: true });
821 const previous = yield* installation.resolve();
822 yield* fs.remove(previous.harnessPath);
823 const externalDirectory = path.join(baseDir, "external");
824 yield* fs.makeDirectory(externalDirectory);
825 yield* fs.writeFileString(path.join(externalDirectory, executableName), "external server", {
826 mode: 0o755,
827 });
828 yield* fs.writeFileString(path.join(externalDirectory, harnessName), "external harness", {
829 mode: 0o755,
830 });
831 const restarted = yield* makeHarness({ baseDir, path: externalDirectory });
832 expect(yield* restarted.installation.state).toMatchObject({
833 phase: "failed",
834 installedVersion: null,
835 canRemove: true,
836 });
837 expect(yield* restarted.installation.resolve().pipe(Effect.flip)).toMatchObject({
838 operation: "resolve",
839 });
840 yield* restarted.installation.remove();
841 expect(yield* restarted.installation.state).toMatchObject({
842 phase: "idle",
843 canRemove: false,
844 });
845 expect(yield* fs.exists(installation.managedDirectory)).toBe(false);
846 yield* restarted.installation.start;
847 expect(yield* terminalState(restarted.installation)).toMatchObject({
848 phase: "succeeded",
849 installedVersion: "fixture-new",
850 canRemove: true,
851 });
852 yield* Deferred.await(restarted.stagingReleased);
853 expect((yield* restarted.installation.resolve()).source).toBe("managed");
854 }),
855 );
856
857 it.effect(
858 "blocks removal of custom managed paths and leaves external executables and profiles intact",
859 () =>
860 Effect.gen(function* () {
861 const { installation, fs, path, baseDir } = yield* makeHarness({ previous: true });
862 const managed = yield* installation.resolve();
863 const externalDirectory = path.join(baseDir, "external");
864 const profileDirectory = path.join(baseDir, "providers", "antigravity", "profile");
865 yield* fs.makeDirectory(externalDirectory);
866 yield* fs.makeDirectory(profileDirectory, { recursive: true });
867 const externalExecutable = path.join(externalDirectory, executableName);
868 const externalHarness = path.join(externalDirectory, harnessName);
869 const profilePath = path.join(profileDirectory, "preferences.json");
870 yield* fs.writeFileString(externalExecutable, "external server", { mode: 0o755 });
871 yield* fs.writeFileString(externalHarness, "external harness", { mode: 0o755 });
872 yield* fs.writeFileString(profilePath, "{}");
873 expect(
874 yield* installation.remove([managed.executablePath]).pipe(Effect.flip),
875 ).toMatchObject({
876 operation: "remove",
877 });
878 yield* expectPreviousRelease(installation);
879 yield* installation.remove([externalExecutable]);
880 expect(yield* installation.state).toMatchObject({
881 phase: "idle",
882 operationId: null,
883 installedVersion: null,
884 });
885 expect(yield* fs.readFileString(externalExecutable)).toBe("external server");
886 expect(yield* fs.readFileString(externalHarness)).toBe("external harness");
887 expect(yield* fs.readFileString(profilePath)).toBe("{}");
888 }),
889 );
890
891 it.effect(
892 "reuses an immutable Windows release and preserves the pointer when rename is denied",
893 () =>
894 Effect.gen(function* () {
895 const fs = yield* FileSystem.FileSystem;
896 const path = yield* Path.Path;
897 const archive = Buffer.from(zipFixtures.windows, "base64");
898 const asset = yield* releaseAsset(archive, "win32");
899 let denyPointerRename = true;
900 const renameTargets: string[] = [];
901 const { installation, requests, validations } = yield* makeHarness({
902 previous: true,
903 platform: "win32",
904 archive,
905 fileSystem: FileSystem.FileSystem.of({
906 ...fs,
907 rename: (source, target) => {
908 renameTargets.push(target);
909 return denyPointerRename
910 ? Effect.fail(
911 PlatformError.systemError({
912 _tag: "PermissionDenied",
913 module: "FileSystem",
914 method: "rename",
915 pathOrDescriptor: target,
916 description: "EPERM: file is in use",
917 }),
918 )
919 : fs.rename(source, target);
920 },
921 }),
922 });
923 yield* writeRelease(installation.managedDirectory, asset, false);
924 yield* installation.start;
925 expect((yield* terminalState(installation)).phase).toBe("failed");
926 yield* expectPreviousRelease(installation);
927 expect(renameTargets).toEqual([path.join(installation.managedDirectory, "active.json")]);
928
929 denyPointerRename = false;
930 yield* installation.start;
931 expect((yield* terminalState(installation)).phase).toBe("succeeded");
932 expect((yield* installation.resolve()).version).toBe("fixture-new");
933 expect(requests).toEqual([]);
934 expect(validations).toHaveLength(2);
935 expect(renameTargets).toEqual([
936 path.join(installation.managedDirectory, "active.json"),
937 path.join(installation.managedDirectory, "active.json"),
938 ]);
939 }),
940 );
941
942 it.effect("reports unsupported hosts without downloading or changing state", () =>
943 Effect.gen(function* () {
944 const { installation, requests } = yield* makeHarness({ asset: null, platform: "darwin" });
945 expect(yield* installation.start.pipe(Effect.flip)).toMatchObject({ operation: "start" });
946 expect(yield* installation.resolve().pipe(Effect.flip)).toMatchObject({
947 operation: "resolve",
948 });
949 expect(yield* installation.state).toMatchObject({ phase: "idle", operationId: null });
950 expect(requests).toEqual([]);
951 }),
952 );
953
954 it("resolves all supported platform release assets including Intel Mac", () => {
955 const supportedPlatforms: Array<{ readonly platform: NodeJS.Platform; readonly arch: string }> =
956 [
957 { platform: "darwin", arch: "arm64" },
958 { platform: "darwin", arch: "x64" },
959 { platform: "linux", arch: "x64" },
960 { platform: "linux", arch: "arm64" },
961 { platform: "win32", arch: "x64" },
962 { platform: "win32", arch: "arm64" },
963 ];
964
965 for (const { platform, arch } of supportedPlatforms) {
966 const asset = resolveAntigravityReleaseAsset(platform, arch);
967 expect(asset).not.toBeNull();
968 expect(asset?.version).toBe("1.3.0");
969 expect(asset?.url).toContain("1.3.0");
970 expect(asset?.archiveBytes).toBeGreaterThan(0);
971 expect(asset?.executable.bytes).toBeGreaterThan(0);
972 expect(asset?.harness.bytes).toBeGreaterThan(0);
973 }
974
975 expect(resolveAntigravityReleaseAsset("freebsd", "x64")).toBeNull();
976 });
977});