From dfea55c72ed87a9eae9d677c3954e2a18961564f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 19:22:09 +0000 Subject: [PATCH 1/7] test(server): reproduce #9624, Antigravity sign-in URL lost when BROWSER has no stderr Adds a failing driver test that reproduces #9624. A fake agent runs the configured BROWSER the way CPython's webbrowser does on Windows (shlex-split, %s substituted, stdio not inherited). The helper's marker goes nowhere, so the sign-in flow never shows the Google URL. Co-Authored-By: Claude Opus 5.5 --- .../Drivers/AntigravityDriver.test.ts | 56 +++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 74f4daa1323af..1acba53eca6b7 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -6,11 +6,14 @@ import { type AntigravitySettings, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { HttpClient } from "effect/http"; @@ -40,6 +43,8 @@ const decodeRequest = Schema.decodeEffect( }), ), ); +const authorizationUrl = + "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"; const blockedCredentialKeys = new Set([ "GEMINI_API_KEY", "GOOGLE_API_KEY", @@ -72,21 +77,30 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const makeExecutable = Effect.fn("AntigravityDriverTest.makeExecutable")(function* ( name: string, - loginRequired = false, + signIn: "none" | "stdout" | "browser-without-stdio" = "none", ) { const directory = path.join(root, name); const executablePath = path.join(directory, "agy_acp_server.par"); const harnessPath = path.join(directory, "localharness_external"); yield* fs.makeDirectory(directory, { recursive: true }); - const authorizationUrl = - "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"; yield* fs.writeFileString( executablePath, [ "#!/bin/sh", - ...(loginRequired + ...(signIn === "stdout" ? [`printf '%s\\n' ${shellQuote(ANTIGRAVITY_AUTH_STDOUT_PREFIX + authorizationUrl)}`] : []), + // CPython's webbrowser on Windows shlex-splits BROWSER, substitutes %s, and starts it + // without inheriting the agent's stdio, then waits for the browser command to exit. + ...(signIn === "browser-without-stdio" + ? [ + `url=${shellQuote(authorizationUrl)}`, + 'eval "set -- $BROWSER"', + 'for arg do shift; if [ "$arg" = "%s" ]; then set -- "$@" "$url"; else set -- "$@" "$arg"; fi; done', + '"$@" /dev/null 2>&1', + "exit 1", + ] + : []), `exec ${shellQuote(nodePath)} ${shellQuote(mockAgentPath)} "$@"`, "", ].join("\n"), @@ -105,7 +119,11 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( const first = yield* makeExecutable("runtime 'one"); const second = yield* makeExecutable("runtime two"); - const signedOut = yield* makeExecutable("runtime signed-out", true); + const signedOut = yield* makeExecutable("runtime signed-out", "stdout"); + const browserWithoutStdio = yield* makeExecutable( + "runtime browser-without-stdio", + "browser-without-stdio", + ); const controls = { selected: first, failResolution: false, beforeAcquire: Effect.void }; const acquisitions: Array<{ binaryPath: string | undefined; path: string | undefined }> = []; const releases: Array = []; @@ -228,6 +246,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( first, second, signedOut, + browserWithoutStdio, controls, acquisitions, releases, @@ -353,6 +372,33 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); + it.effect.skipIf(windowsHost)( + "shows the Google sign-in URL when the agent opens BROWSER without sharing its stderr", + () => + Effect.gen(function* () { + const h = yield* makeHarness(); + h.controls.selected = h.browserWithoutStdio; + const auth = h.instance.auth; + if (!auth) return yield* Effect.die("Antigravity does not expose sign-in."); + const subscribed = yield* Deferred.make(); + const states = yield* auth.subscribe("owner").pipe( + Stream.tap(() => Deferred.succeed(subscribed, undefined)), + Stream.takeUntil((state) => ["succeeded", "failed", "cancelled"].includes(state.phase)), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Deferred.await(subscribed); + yield* auth.start("owner"); + const observed = yield* Fiber.join(states); + + expect( + observed.find((state) => state.phase === "waiting")?.authorizationUrl, + "the sign-in flow never received the URL the agent handed to BROWSER", + ).toBe(authorizationUrl); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + it.effect.skipIf(windowsHost)("keeps the previous catalog when executable resolution fails", () => Effect.gen(function* () { const h = yield* makeHarness(); From 55d9bb02cf4ed6c4182d87626f839cce46dd79fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 19:27:43 +0000 Subject: [PATCH 2/7] fix(server): Antigravity Google sign-in shows its URL on Windows On Windows, CPython's webbrowser starts BROWSER without the agent's stdio, so the helper's stderr marker never reached T3 and sign-in waited with no URL. Sign-in runtimes now serve a scoped 127.0.0.1 listener with a random path token and pass its URL to the agent in T3_ANTIGRAVITY_AUTH_SINK. The helper POSTs the URL there and exits once it is accepted, and it falls back to the stderr marker when the variable is absent or delivery fails. The listener validates with parseAntigravityAuthorizationUrl and hands off to the same onAuthorizationUrl. Inherited copies of the variable are stripped from every launch. Co-Authored-By: Claude Opus 5.5 --- .../src/provider/Drivers/AntigravityDriver.ts | 8 +++ .../provider/antigravityAuthSupport.test.ts | 47 +++++++++++++ .../src/provider/antigravityAuthSupport.ts | 68 ++++++++++++++++++- 3 files changed, 120 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fe974dee763ff..1137b53036fd0 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -36,6 +36,7 @@ import { isAntigravitySignInRequiredError, prepareAntigravityProfile, resolveAntigravityInstanceDirectories, + serveAntigravityAuthorizationUrlSink, type AntigravityAuthConfig, } from "../antigravityAuthSupport.ts"; import { @@ -239,6 +240,12 @@ export const AntigravityDriver: ProviderDriver< ), ), ); + const authorizationUrlSink = + input.onAuthorizationUrl === undefined + ? undefined + : yield* serveAntigravityAuthorizationUrlSink(input.onAuthorizationUrl).pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); const runtime = yield* makeAntigravityAcpRuntime({ ...input, authMethod: auth.authMethod, @@ -250,6 +257,7 @@ export const AntigravityDriver: ProviderDriver< baseEnv: withAgentDeviceEnvironment(processEnvironment, input), auth, runtimeTempDirectory, + ...(authorizationUrlSink === undefined ? {} : { authorizationUrlSink }), }), }).pipe(Effect.provideService(Crypto.Crypto, crypto)); return { diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 9138a28dab5b3..863e06e616ab3 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -11,6 +11,8 @@ import * as Path from "effect/Path"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as ChildProcess from "effect/process/ChildProcess"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Ndjson from "effect/encoding/Ndjson"; import * as AcpErrors from "effect-acp/errors"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -28,6 +30,7 @@ import { parseAntigravityAuthorizationUrl, prepareAntigravityProfile, resolveAntigravityInstanceDirectories, + serveAntigravityAuthorizationUrlSink, } from "./antigravityAuthSupport.ts"; const authorizationUrl = @@ -69,6 +72,7 @@ describe("Antigravity process environment", () => { ANTIGRAVITY_HARNESS_PATH: "/wrong-version/harness", BROWSER: "open-real-browser", browser: "another-real-browser", + t3_antigravity_auth_sink: "http://127.0.0.1:1/stale-sign-in", PYTHONUNBUFFERED: "0", ELECTRON_RUN_AS_NODE: "0", CUSTOM_SETTING: "keep-this", @@ -639,6 +643,49 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); + it.effect("falls back to stderr when the sign-in listener rejects the URL or is gone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let helper: ChildProcess.StandardCommand | undefined; + yield* prepareAntigravityProfile({ + profileDirectory: yield* fs.makeTempDirectoryScoped(), + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) helper = command; + return spawner.spawn(command); + }), + ), + ); + if (!helper) return yield* Effect.die("The browser helper preflight did not run."); + const command = helper; + const rejecting = yield* serveAntigravityAuthorizationUrlSink(() => + Effect.fail(AcpErrors.AcpRequestError.internalError("The flow stopped.")), + ); + const closed = yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe( + Effect.scoped, + ); + for (const sink of [rejecting, closed]) { + const child = yield* spawner.spawn( + ChildProcess.make(command.command, [...command.args.slice(0, -1), authorizationUrl], { + env: { ...command.options.env, T3_ANTIGRAVITY_AUTH_SINK: sink }, + extendEnv: false, + }), + ); + const [stderr, exitCode] = yield* Effect.all( + [child.stderr.pipe(Stream.decodeText(), Stream.mkString), child.exitCode], + { concurrency: "unbounded" }, + ); + expect(Number(exitCode)).toBe(0); + expect(stderr).toBe( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, + ); + } + }), + ); + it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 5030b53005e89..e6116b0d9f6c9 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -1,19 +1,29 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation. import * as NodeFSP from "node:fs/promises"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - NodeHttpServer.layer takes Node's server factory. +import * as NodeHttp from "node:http"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; +import * as ByteSize from "effect/ByteSize"; +import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as HttpIncomingMessage from "effect/http/HttpIncomingMessage"; +import * as HttpServer from "effect/http/HttpServer"; +import * as HttpServerRequest from "effect/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/http/HttpServerResponse"; import * as AcpErrors from "effect-acp/errors"; import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText"; @@ -51,11 +61,19 @@ const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSet const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError); const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); -// Python splits BROWSER on path separators before parsing quotes. Exit 0 on EPIPE to prevent browser fallback after cancellation. +const authorizationUrlSinkEnvironmentKey = "T3_ANTIGRAVITY_AUTH_SINK"; +// Python splits BROWSER on path separators before parsing quotes, so the source has no `:` or `;`. +// Windows starts BROWSER without the agent's stdio: post to the sign-in listener, else use stderr. +// Exit 0 on EPIPE to prevent browser fallback after cancellation. const browserHelperSource = - `process.stderr.on("error",()=>process.exit(0)).write(` + + `Promise.resolve(process.env.${authorizationUrlSinkEnvironmentKey})` + + `.then(sink=>sink&&fetch(sink,Object.fromEntries([["method","POST"],["body",process.argv[1]],` + + `["signal",AbortSignal.timeout(5000)]])))` + + `.then(response=>response&&response.ok&&process.exit(0))` + + `.catch(()=>0)` + + `.then(()=>process.stderr.on("error",()=>process.exit(0)).write(` + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + - `()=>process.exit(0))`; + `()=>process.exit(0)))`; const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight"; const removedEnvironmentKeys = new Set([ @@ -76,6 +94,7 @@ const removedEnvironmentKeys = new Set([ "BROWSER", "PYTHONUNBUFFERED", "ELECTRON_RUN_AS_NODE", + authorizationUrlSinkEnvironmentKey.toUpperCase(), ]); export interface AntigravityProfile { @@ -429,6 +448,7 @@ export function buildAntigravityAcpSpawnInput(input: { readonly baseEnv?: NodeJS.ProcessEnv; readonly auth?: AntigravityAuthConfig; readonly runtimeTempDirectory?: string; + readonly authorizationUrlSink?: string; }): AcpSessionRuntime.AcpSpawnInput { return { command: input.installation.executablePath, @@ -442,11 +462,53 @@ export function buildAntigravityAcpSpawnInput(input: { input.runtimeTempDirectory, ), ANTIGRAVITY_HARNESS_PATH: input.installation.harnessPath, + ...(input.authorizationUrlSink === undefined + ? {} + : { [authorizationUrlSinkEnvironmentKey]: input.authorizationUrlSink }), }, extendEnv: false, }; } +/** Receives the browser helper's URL for one sign-in launch, independent of inherited stdio. */ +export const serveAntigravityAuthorizationUrlSink = Effect.fn( + "serveAntigravityAuthorizationUrlSink", +)( + function* ( + onAuthorizationUrl: (authorizationUrl: string) => Effect.Effect, + ) { + const crypto = yield* Crypto.Crypto; + const token = yield* crypto.randomUUIDv4(); + const context = yield* Layer.build( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + disablePreemptiveShutdown: true, + }), + ); + const server = Context.get(context, HttpServer.HttpServer); + yield* server.serve( + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + if (request.method !== "POST" || request.url !== `/${token}`) { + return HttpServerResponse.empty({ status: 404 }); + } + const authorization = yield* request.text.pipe( + Effect.provideService( + HttpIncomingMessage.MaxBodySize, + ByteSize.bytes(maxAuthorizationUrlLength), + ), + Effect.flatMap(parseAntigravityAuthorizationUrl), + ); + yield* onAuthorizationUrl(authorization.authorizationUrl); + return HttpServerResponse.empty({ status: 204 }); + }).pipe(Effect.orElseSucceed(() => HttpServerResponse.empty({ status: 400 }))), + ); + return `${HttpServer.formatAddress(server.address)}/${token}`; + }, + Effect.mapError(() => authSupportError("The Antigravity sign-in URL listener could not start.")), +); + export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")( function* ( authorizationUrl: string, From 69bb6c426e3cdaa3b1886d1ad87fe20c27be21af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 19:40:01 +0000 Subject: [PATCH 3/7] fix(server): Antigravity sign-in URL bypasses environment proxies The browser helper posts to the loopback listener with node:http and agent: false, so NODE_USE_ENV_PROXY with HTTP_PROXY no longer routes it to a proxy and back to the lost stderr path. Co-Authored-By: Claude Opus 5.5 --- .../provider/antigravityAuthSupport.test.ts | 48 +++++++++++++++++++ .../src/provider/antigravityAuthSupport.ts | 13 +++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 863e06e616ab3..7a3a002c780ee 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -686,6 +686,54 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); + it.effect("posts the sign-in URL to the listener directly when an environment proxy is set", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let helper: ChildProcess.StandardCommand | undefined; + yield* prepareAntigravityProfile({ + profileDirectory: yield* fs.makeTempDirectoryScoped(), + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) helper = command; + return spawner.spawn(command); + }), + ), + ); + if (!helper) return yield* Effect.die("The browser helper preflight did not run."); + const received: string[] = []; + const sink = yield* serveAntigravityAuthorizationUrlSink((url) => + Effect.sync(() => { + received.push(url); + }), + ); + const unreachableProxy = new URL( + yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe(Effect.scoped), + ).origin; + const child = yield* spawner.spawn( + ChildProcess.make(helper.command, [...helper.args.slice(0, -1), authorizationUrl], { + env: { + ...helper.options.env, + T3_ANTIGRAVITY_AUTH_SINK: sink, + NODE_USE_ENV_PROXY: "1", + HTTP_PROXY: unreachableProxy, + http_proxy: unreachableProxy, + }, + extendEnv: false, + }), + ); + const [stderr, exitCode] = yield* Effect.all( + [child.stderr.pipe(Stream.decodeText(), Stream.mkString), child.exitCode], + { concurrency: "unbounded" }, + ); + expect(Number(exitCode)).toBe(0); + expect(stderr).toBe(""); + expect(received).toEqual([authorizationUrl]); + }), + ); + it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index e6116b0d9f6c9..ca174dd34433a 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -64,12 +64,15 @@ const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError); const authorizationUrlSinkEnvironmentKey = "T3_ANTIGRAVITY_AUTH_SINK"; // Python splits BROWSER on path separators before parsing quotes, so the source has no `:` or `;`. // Windows starts BROWSER without the agent's stdio: post to the sign-in listener, else use stderr. +// `agent: false` keeps the loopback request off NODE_USE_ENV_PROXY proxies, which fetch would use. // Exit 0 on EPIPE to prevent browser fallback after cancellation. const browserHelperSource = - `Promise.resolve(process.env.${authorizationUrlSinkEnvironmentKey})` + - `.then(sink=>sink&&fetch(sink,Object.fromEntries([["method","POST"],["body",process.argv[1]],` + - `["signal",AbortSignal.timeout(5000)]])))` + - `.then(response=>response&&response.ok&&process.exit(0))` + + `new Promise((delivered,failed)=>(process.env.${authorizationUrlSinkEnvironmentKey}||failed())&&` + + `require("http").request(process.env.${authorizationUrlSinkEnvironmentKey},` + + `Object.fromEntries([["method","POST"],["agent",false],["signal",AbortSignal.timeout(5000)]]),` + + `response=>response.resume().on("error",failed).on("end",()=>delivered(response.statusCode===204)))` + + `.on("error",failed).end(process.argv[1]))` + + `.then(ok=>ok&&process.exit(0))` + `.catch(()=>0)` + `.then(()=>process.stderr.on("error",()=>process.exit(0)).write(` + `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` + @@ -94,7 +97,7 @@ const removedEnvironmentKeys = new Set([ "BROWSER", "PYTHONUNBUFFERED", "ELECTRON_RUN_AS_NODE", - authorizationUrlSinkEnvironmentKey.toUpperCase(), + authorizationUrlSinkEnvironmentKey, ]); export interface AntigravityProfile { From 86bed64731800d0b705f2c68de1683b5197150ca Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 20:07:36 +0000 Subject: [PATCH 4/7] fix(server): Antigravity sign-in keeps the stderr path when its listener cannot start Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/Drivers/AntigravityDriver.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index 1137b53036fd0..16e9eef0d1c6c 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -245,6 +245,12 @@ export const AntigravityDriver: ProviderDriver< ? undefined : yield* serveAntigravityAuthorizationUrlSink(input.onAuthorizationUrl).pipe( Effect.provideService(Crypto.Crypto, crypto), + // The helper still reports the URL on stderr, which reaches T3 outside Windows. + Effect.catch((error) => + Effect.logWarning("The Antigravity sign-in URL listener could not start.", { + error, + }).pipe(Effect.as(undefined)), + ), ); const runtime = yield* makeAntigravityAcpRuntime({ ...input, From 686a1db3e375ab58f0b37ce13ce71a42268afe48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 20:28:12 +0000 Subject: [PATCH 5/7] fix(server): keep the Antigravity sign-in listener's start failure as its cause Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/antigravityAuthSupport.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index ca174dd34433a..5b20503abc531 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -509,7 +509,13 @@ export const serveAntigravityAuthorizationUrlSink = Effect.fn( ); return `${HttpServer.formatAddress(server.address)}/${token}`; }, - Effect.mapError(() => authSupportError("The Antigravity sign-in URL listener could not start.")), + Effect.mapError( + (cause) => + new AcpErrors.AcpTransportError({ + detail: "The Antigravity sign-in URL listener could not start.", + cause, + }), + ), ); export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")( From bd6b77ad34e771e10c7011429ca2a79eb93d216a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:50:09 +0000 Subject: [PATCH 6/7] fix(server): adapt the Antigravity sign-in listener to main's SecureRandom AI-assisted; verified with the tests and tools listed above. --- .../src/provider/Drivers/AntigravityDriver.ts | 2 +- .../provider/antigravityAuthSupport.test.ts | 690 +++++++++--------- .../src/provider/antigravityAuthSupport.ts | 5 +- 3 files changed, 339 insertions(+), 358 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index b189d5532c01a..e5b12f70c10d3 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -272,7 +272,7 @@ export const AntigravityDriver: ProviderDriver< input.onAuthorizationUrl === undefined ? undefined : yield* serveAntigravityAuthorizationUrlSink(input.onAuthorizationUrl).pipe( - Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(SecureRandom.SecureRandom, random), // The helper still reports the URL on stderr, which reaches T3 outside Windows. Effect.catch((error) => Effect.logWarning("The Antigravity sign-in URL listener could not start.", { diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 12cdece01e565..3626f30ee2326 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -17,7 +17,6 @@ import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Ndjson from "effect/encoding/Ndjson"; import * as AcpErrors from "effect-acp/errors"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -470,263 +469,221 @@ describe("Antigravity stderr compatibility", () => { ); }); -it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { - const prepareSpawn = Effect.fn(function* ( - bundles: ReadonlyArray, - baseEnv: NodeJS.ProcessEnv = {}, - platform: NodeJS.Platform = "linux", - ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - const profile = yield* prepareAntigravityProfile({ - profileDirectory: path.join(directory, "profile"), - userHome: path.join(directory, "home"), - runtimeExecutablePath: process.execPath, - platform, - baseEnv, - }).pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - exists: (candidate) => Effect.succeed(bundles.includes(candidate)), - }), - ); - return buildAntigravityAcpSpawnInput({ - installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, - profile, - cwd: directory, - baseEnv, - }).env; - }); - - // A Windows Node path cannot preflight the browser helper as a POSIX host. - const caTest = it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32"); - - caTest( - "launches Linux agents with the first available CA bundle, preferring absolute Nix paths", - () => - Effect.gen(function* () { - const systemBundles = ["/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"]; - const nixBundle = "/nix/store/nss-cacert/etc/ssl/certs/ca-bundle.crt"; - expect((yield* prepareSpawn(systemBundles))?.SSL_CERT_FILE).toBe(systemBundles[0]); - expect( - (yield* prepareSpawn([nixBundle, ...systemBundles], { NIX_SSL_CERT_FILE: nixBundle })) - ?.SSL_CERT_FILE, - ).toBe(nixBundle); - expect( - (yield* prepareSpawn(systemBundles, { NIX_SSL_CERT_FILE: nixBundle }))?.SSL_CERT_FILE, - ).toBe(systemBundles[0]); - expect( - (yield* prepareSpawn(["certs/local.pem", ...systemBundles], { - NIX_SSL_CERT_FILE: "certs/local.pem", - }))?.SSL_CERT_FILE, - ).toBe(systemBundles[0]); - expect((yield* prepareSpawn([]))?.SSL_CERT_FILE).toBeUndefined(); - }), - ); - - caTest("preserves explicit certificate files and directories in Linux launches", () => - Effect.gen(function* () { - const bundles = ["/etc/ssl/certs/ca-certificates.crt"]; - expect( - (yield* prepareSpawn(bundles, { SSL_CERT_FILE: "/custom/ca.pem" }))?.SSL_CERT_FILE, - ).toBe("/custom/ca.pem"); - const directoryEnv = yield* prepareSpawn(bundles, { SSL_CERT_DIR: "/custom/certs" }); - expect(directoryEnv?.SSL_CERT_DIR).toBe("/custom/certs"); - expect(directoryEnv?.SSL_CERT_FILE).toBeUndefined(); - }), - ); - - it.effect("runs the browser helper with installed Node in standalone builds", () => - Effect.gen(function* () { +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( + "Antigravity profile preparation", + (it) => { + const prepareSpawn = Effect.fn(function* ( + bundles: ReadonlyArray, + baseEnv: NodeJS.ProcessEnv = {}, + platform: NodeJS.Platform = "linux", + ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const profile = yield* prepareAntigravityProfile({ profileDirectory: path.join(directory, "profile"), - baseEnv: { PATH: path.dirname(process.execPath) }, - }); - expect(profile.browserCommand).not.toContain("/packaged/t3"); - expect(yield* fs.exists(profile.acpDirectory)).toBe(true); - }).pipe( - Effect.provideService(HostProcess.IsExecutable, true), - Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"), - ), - ); + userHome: path.join(directory, "home"), + runtimeExecutablePath: process.execPath, + platform, + baseEnv, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: (candidate) => Effect.succeed(bundles.includes(candidate)), + }), + ); + return buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile, + cwd: directory, + baseEnv, + }).env; + }); - it.effect("reports missing Node before creating the standalone sign-in profile", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - const profileDirectory = path.join(directory, "profile"); - const result = yield* prepareAntigravityProfile({ - profileDirectory, - baseEnv: { PATH: "" }, - }).pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toMatchObject({ - _tag: "AcpTransportError", - detail: expect.stringContaining("Install Node.js"), - cause: { - _tag: "NodeRuntimeUnavailableError", - }, - }); - } - expect(yield* fs.exists(profileDirectory)).toBe(false); - }).pipe(Effect.provideService(HostProcess.IsExecutable, true)), - ); + // A Windows Node path cannot preflight the browser helper as a POSIX host. + const caTest = it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32"); + + caTest( + "launches Linux agents with the first available CA bundle, preferring absolute Nix paths", + () => + Effect.gen(function* () { + const systemBundles = ["/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"]; + const nixBundle = "/nix/store/nss-cacert/etc/ssl/certs/ca-bundle.crt"; + expect((yield* prepareSpawn(systemBundles))?.SSL_CERT_FILE).toBe(systemBundles[0]); + expect( + (yield* prepareSpawn([nixBundle, ...systemBundles], { NIX_SSL_CERT_FILE: nixBundle })) + ?.SSL_CERT_FILE, + ).toBe(nixBundle); + expect( + (yield* prepareSpawn(systemBundles, { NIX_SSL_CERT_FILE: nixBundle }))?.SSL_CERT_FILE, + ).toBe(systemBundles[0]); + expect( + (yield* prepareSpawn(["certs/local.pem", ...systemBundles], { + NIX_SSL_CERT_FILE: "certs/local.pem", + }))?.SSL_CERT_FILE, + ).toBe(systemBundles[0]); + expect((yield* prepareSpawn([]))?.SSL_CERT_FILE).toBeUndefined(); + }), + ); - it.effect("preflights the no-browser helper and creates private directories only", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const profile = yield* prepareAntigravityProfile({ - profileDirectory: path.join(temporaryDirectory, "profile"), - }); + caTest("preserves explicit certificate files and directories in Linux launches", () => + Effect.gen(function* () { + const bundles = ["/etc/ssl/certs/ca-certificates.crt"]; + expect( + (yield* prepareSpawn(bundles, { SSL_CERT_FILE: "/custom/ca.pem" }))?.SSL_CERT_FILE, + ).toBe("/custom/ca.pem"); + const directoryEnv = yield* prepareSpawn(bundles, { SSL_CERT_DIR: "/custom/certs" }); + expect(directoryEnv?.SSL_CERT_DIR).toBe("/custom/certs"); + expect(directoryEnv?.SSL_CERT_FILE).toBeUndefined(); + }), + ); - expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile")); - expect(yield* fs.exists(profile.acpDirectory)).toBe(true); - expect(yield* fs.exists(profile.tokenPath)).toBe(false); - if ((yield* HostProcess.Platform) !== "win32") { - expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700); - expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700); - } + it.effect("runs the browser helper with installed Node in standalone builds", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(directory, "profile"), + baseEnv: { PATH: path.dirname(process.execPath) }, + }); + expect(profile.browserCommand).not.toContain("/packaged/t3"); + expect(yield* fs.exists(profile.acpDirectory)).toBe(true); + }).pipe( + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"), + ), + ); - yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture"); - yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome }); - expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture"); - }), - ); + it.effect("reports missing Node before creating the standalone sign-in profile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const profileDirectory = path.join(directory, "profile"); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + baseEnv: { PATH: "" }, + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: expect.stringContaining("Install Node.js"), + cause: { + _tag: "NodeRuntimeUnavailableError", + }, + }); + } + expect(yield* fs.exists(profileDirectory)).toBe(false); + }).pipe(Effect.provideService(HostProcess.IsExecutable, true)), + ); - it.effect.skipIf(!symlinksSupported)( - "links only the user's existing global skill directories without touching real content", - () => + it.effect("preflights the no-browser helper and creates private directories only", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const userHome = path.join(temporaryDirectory, "home"); - const profileDirectory = path.join(temporaryDirectory, "profile"); - const configSkills = path.join(userHome, ".gemini", "config", "skills"); - const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); - const isLink = (link: string) => - fs.readLink(link).pipe( - Effect.as(true), - Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), - ); - yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); - - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - const configLink = path.join(profileDirectory, "config", "skills"); - const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); - expect(yield* fs.readLink(configLink)).toBe(configSkills); - expect(yield* isLink(cliLink)).toBe(false); - expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); - expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( - false, - ); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(temporaryDirectory, "profile"), + }); + + expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile")); + expect(yield* fs.exists(profile.acpDirectory)).toBe(true); + expect(yield* fs.exists(profile.tokenPath)).toBe(false); + if ((yield* HostProcess.Platform) !== "win32") { + expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700); + expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700); + } - // The harness leaves an empty folder where the link was skipped. - yield* fs.makeDirectory(cliLink, { recursive: true }); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect((yield* fs.stat(cliLink)).type).toBe("Directory"); - - yield* fs.makeDirectory(cliSkills, { recursive: true }); - yield* fs.remove(configLink); - yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect(yield* fs.readLink(cliLink)).toBe(cliSkills); - expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); - expect((yield* fs.stat(configLink)).type).toBe("Directory"); - - yield* fs.remove(cliLink); - yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect(yield* fs.readLink(cliLink)).toBe(cliSkills); - - yield* fs.remove(cliSkills, { recursive: true }); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect(yield* isLink(cliLink)).toBe(false); + yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture"); + yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome }); + expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture"); }), - ); + ); - it.effect("rewrites the GCP block on every launch and never stores the API key", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const profile = yield* prepareAntigravityProfile({ - profileDirectory: temporaryDirectory, - auth: { - authMethod: "agent-platform", - apiKey: "vertex-secret", - gcpProject: "proj", - gcpLocation: "us-central1", - }, - }); - const settingsPath = path.join(profile.acpDirectory, "settings.json"); - const first = yield* fs.readFileString(settingsPath); - expect(decodeJson(first)).toEqual({ - auth: { type: "agent-platform" }, - gcp: { project: "proj", location: "us-central1" }, - }); - expect(first).not.toContain("vertex-secret"); + it.effect.skipIf(!symlinksSupported)( + "links only the user's existing global skill directories without touching real content", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const userHome = path.join(temporaryDirectory, "home"); + const profileDirectory = path.join(temporaryDirectory, "profile"); + const configSkills = path.join(userHome, ".gemini", "config", "skills"); + const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); + const isLink = (link: string) => + fs.readLink(link).pipe( + Effect.as(true), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ); + yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); + + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + const configLink = path.join(profileDirectory, "config", "skills"); + const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); + expect(yield* fs.readLink(configLink)).toBe(configSkills); + expect(yield* isLink(cliLink)).toBe(false); + expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); + expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( + false, + ); - yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }); - expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({ - auth: { type: "oauth-personal" }, - }); - }), - ); + // The harness leaves an empty folder where the link was skipped. + yield* fs.makeDirectory(cliLink, { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect((yield* fs.stat(cliLink)).type).toBe("Directory"); + + yield* fs.makeDirectory(cliSkills, { recursive: true }); + yield* fs.remove(configLink); + yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); + expect((yield* fs.stat(configLink)).type).toBe("Directory"); + + yield* fs.remove(cliLink); + yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + + yield* fs.remove(cliSkills, { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* isLink(cliLink)).toBe(false); + }), + ); - it.effect("falls back to stderr when the sign-in listener rejects the URL or is gone", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - let helper: ChildProcess.StandardCommand | undefined; - yield* prepareAntigravityProfile({ - profileDirectory: yield* fs.makeTempDirectoryScoped(), - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) helper = command; - return spawner.spawn(command); - }), - ), - ); - if (!helper) return yield* Effect.die("The browser helper preflight did not run."); - const command = helper; - const rejecting = yield* serveAntigravityAuthorizationUrlSink(() => - Effect.fail(AcpErrors.AcpRequestError.internalError("The flow stopped.")), - ); - const closed = yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe( - Effect.scoped, - ); - for (const sink of [rejecting, closed]) { - const child = yield* spawner.spawn( - ChildProcess.make(command.command, [...command.args.slice(0, -1), authorizationUrl], { - env: { ...command.options.env, T3_ANTIGRAVITY_AUTH_SINK: sink }, - extendEnv: false, - }), - ); - const [stderr, exitCode] = yield* Effect.all( - [child.stderr.pipe(Stream.decodeText(), Stream.mkString), child.exitCode], - { concurrency: "unbounded" }, - ); - expect(Number(exitCode)).toBe(0); - expect(stderr).toBe( - `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, - ); - } - }), - ); + it.effect("rewrites the GCP block on every launch and never stores the API key", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: temporaryDirectory, + auth: { + authMethod: "agent-platform", + apiKey: "vertex-secret", + gcpProject: "proj", + gcpLocation: "us-central1", + }, + }); + const settingsPath = path.join(profile.acpDirectory, "settings.json"); + const first = yield* fs.readFileString(settingsPath); + expect(decodeJson(first)).toEqual({ + auth: { type: "agent-platform" }, + gcp: { project: "proj", location: "us-central1" }, + }); + expect(first).not.toContain("vertex-secret"); - it.effect("posts the sign-in URL to the listener directly when an environment proxy is set", () => - Effect.gen(function* () { + yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }); + expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({ + auth: { type: "oauth-personal" }, + }); + }), + ); + + const runBrowserHelper = Effect.fn("runBrowserHelper")(function* (env: Record) { const fs = yield* FileSystem.FileSystem; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; let helper: ChildProcess.StandardCommand | undefined; @@ -742,24 +699,9 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { ), ); if (!helper) return yield* Effect.die("The browser helper preflight did not run."); - const received: string[] = []; - const sink = yield* serveAntigravityAuthorizationUrlSink((url) => - Effect.sync(() => { - received.push(url); - }), - ); - const unreachableProxy = new URL( - yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe(Effect.scoped), - ).origin; const child = yield* spawner.spawn( ChildProcess.make(helper.command, [...helper.args.slice(0, -1), authorizationUrl], { - env: { - ...helper.options.env, - T3_ANTIGRAVITY_AUTH_SINK: sink, - NODE_USE_ENV_PROXY: "1", - HTTP_PROXY: unreachableProxy, - http_proxy: unreachableProxy, - }, + env: { ...helper.options.env, ...env }, extendEnv: false, }), ); @@ -767,114 +709,154 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { [child.stderr.pipe(Stream.decodeText(), Stream.mkString), child.exitCode], { concurrency: "unbounded" }, ); - expect(Number(exitCode)).toBe(0); - expect(stderr).toBe(""); - expect(received).toEqual([authorizationUrl]); - }), - ); + return { stderr, exitCode: Number(exitCode) }; + }); - const preflightHelper = (exitDelay: Effect.Effect) => { - const spawns = { count: 0 }; - const started = Deferred.makeUnsafe(); - const spawner = ChildProcessSpawner.make(() => - Effect.sync(() => { - spawns.count += 1; - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Deferred.succeed(started, undefined).pipe( - Effect.andThen(exitDelay), - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.encodeText( - Stream.make( - `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"https://example.invalid/t3-antigravity-browser-preflight"\n`, - ), - ), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); + it.effect("falls back to stderr when the sign-in listener rejects the URL or is gone", () => + Effect.gen(function* () { + const rejecting = yield* serveAntigravityAuthorizationUrlSink(() => + Effect.fail(AcpErrors.AcpRequestError.internalError("The flow stopped.")), + ); + const closed = yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe( + Effect.scoped, + ); + for (const sink of [rejecting, closed]) { + expect(yield* runBrowserHelper({ T3_ANTIGRAVITY_AUTH_SINK: sink })).toEqual({ + exitCode: 0, + stderr: `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, + }); + } }), ); - return { spawner, spawns, started }; - }; - const prepareWithHelper = Effect.fn("prepareWithHelper")(function* ( - runtimeExecutablePath: string, - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - return yield* prepareAntigravityProfile({ - profileDirectory: path.join(directory, "profile"), - userHome: path.join(directory, "home"), - runtimeExecutablePath, - platform: "linux", - baseEnv: {}, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - }); + it.effect( + "posts the sign-in URL to the listener directly when an environment proxy is set", + () => + Effect.gen(function* () { + const received: string[] = []; + const sink = yield* serveAntigravityAuthorizationUrlSink((url) => + Effect.sync(() => { + received.push(url); + }), + ); + const unreachableProxy = new URL( + yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe(Effect.scoped), + ).origin; + expect( + yield* runBrowserHelper({ + T3_ANTIGRAVITY_AUTH_SINK: sink, + NODE_USE_ENV_PROXY: "1", + HTTP_PROXY: unreachableProxy, + http_proxy: unreachableProxy, + }), + ).toEqual({ exitCode: 0, stderr: "" }); + expect(received).toEqual([authorizationUrl]); + }), + ); - it.effect("allows a browser preflight that takes longer than five seconds", () => - Effect.gen(function* () { - const helper = preflightHelper(Effect.sleep("20 seconds")); - const fiber = yield* prepareWithHelper("/fake/slow-node", helper.spawner).pipe( - Effect.forkChild, + const preflightHelper = (exitDelay: Effect.Effect) => { + const spawns = { count: 0 }; + const started = Deferred.makeUnsafe(); + const spawner = ChildProcessSpawner.make(() => + Effect.sync(() => { + spawns.count += 1; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Deferred.succeed(started, undefined).pipe( + Effect.andThen(exitDelay), + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText( + Stream.make( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"https://example.invalid/t3-antigravity-browser-preflight"\n`, + ), + ), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); + }), ); - yield* Deferred.await(helper.started); - yield* TestClock.adjust("20 seconds"); - yield* Fiber.join(fiber); + return { spawner, spawns, started }; + }; - expect(helper.spawns.count).toBe(1); - }), - ); + const prepareWithHelper = Effect.fn("prepareWithHelper")(function* ( + runtimeExecutablePath: string, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + return yield* prepareAntigravityProfile({ + profileDirectory: path.join(directory, "profile"), + userHome: path.join(directory, "home"), + runtimeExecutablePath, + platform: "linux", + baseEnv: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + }); - it.effect("fails a hung preflight after 30 seconds and verifies again on the next open", () => - Effect.gen(function* () { - const hung = preflightHelper(Effect.never); - const fiber = yield* prepareWithHelper("/fake/hung-node", hung.spawner).pipe( - Effect.result, - Effect.forkChild, - ); - yield* Deferred.await(hung.started); - yield* TestClock.adjust("29 seconds"); - expect(fiber.pollUnsafe()).toBeUndefined(); - yield* TestClock.adjust("1 second"); - const result = yield* Fiber.join(fiber); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toMatchObject({ - _tag: "AcpTransportError", - detail: "Antigravity browser suppression verification timed out.", - }); - } + it.effect("allows a browser preflight that takes longer than five seconds", () => + Effect.gen(function* () { + const helper = preflightHelper(Effect.sleep("20 seconds")); + const fiber = yield* prepareWithHelper("/fake/slow-node", helper.spawner).pipe( + Effect.forkChild, + ); + yield* Deferred.await(helper.started); + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(fiber); - const recovered = preflightHelper(Effect.void); - yield* prepareWithHelper("/fake/hung-node", recovered.spawner); - expect(recovered.spawns.count).toBe(1); - }), - ); + expect(helper.spawns.count).toBe(1); + }), + ); - it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - for (const platform of ["linux", "win32"] as const) { - const profileDirectory = path.join(temporaryDirectory, platform); - const result = yield* prepareAntigravityProfile({ - profileDirectory, - platform, - runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node", - }).pipe(Effect.result); + it.effect("fails a hung preflight after 30 seconds and verifies again on the next open", () => + Effect.gen(function* () { + const hung = preflightHelper(Effect.never); + const fiber = yield* prepareWithHelper("/fake/hung-node", hung.spawner).pipe( + Effect.result, + Effect.forkChild, + ); + yield* Deferred.await(hung.started); + yield* TestClock.adjust("29 seconds"); + expect(fiber.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); expect(Result.isFailure(result)).toBe(true); - expect(yield* fs.exists(profileDirectory)).toBe(false); - } - }), - ); -}); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: "Antigravity browser suppression verification timed out.", + }); + } + + const recovered = preflightHelper(Effect.void); + yield* prepareWithHelper("/fake/hung-node", recovered.spawner); + expect(recovered.spawns.count).toBe(1); + }), + ); + + it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + for (const platform of ["linux", "win32"] as const) { + const profileDirectory = path.join(temporaryDirectory, platform); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + platform, + runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node", + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + expect(yield* fs.exists(profileDirectory)).toBe(false); + } + }), + ); + }, +); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 13a376ffb93ad..d512856162c24 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -9,8 +9,8 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as ByteSize from "effect/ByteSize"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; import * as Digest from "effect/crypto/Digest"; +import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as FileSystem from "effect/FileSystem"; @@ -521,8 +521,7 @@ export const serveAntigravityAuthorizationUrlSink = Effect.fn( function* ( onAuthorizationUrl: (authorizationUrl: string) => Effect.Effect, ) { - const crypto = yield* Crypto.Crypto; - const token = yield* crypto.randomUUIDv4(); + const token = yield* (yield* SecureRandom.SecureRandom).uuidV4(); const context = yield* Layer.build( NodeHttpServer.layer(NodeHttp.createServer, { host: "127.0.0.1", From cb5804200f1f65452253b8385890d1a3e6ece9ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:04:30 +0000 Subject: [PATCH 7/7] test(server): keep the Antigravity profile test block at its original indentation AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../provider/antigravityAuthSupport.test.ts | 705 +++++++++--------- 1 file changed, 351 insertions(+), 354 deletions(-) diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 3626f30ee2326..31eefb6932801 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -469,394 +469,391 @@ describe("Antigravity stderr compatibility", () => { ); }); -it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( - "Antigravity profile preparation", - (it) => { - const prepareSpawn = Effect.fn(function* ( - bundles: ReadonlyArray, - baseEnv: NodeJS.ProcessEnv = {}, - platform: NodeJS.Platform = "linux", - ) { +const profilePreparationLayer = Layer.mergeAll(NodeServices.layer, NodeCrypto.layer); + +it.layer(profilePreparationLayer)("Antigravity profile preparation", (it) => { + const prepareSpawn = Effect.fn(function* ( + bundles: ReadonlyArray, + baseEnv: NodeJS.ProcessEnv = {}, + platform: NodeJS.Platform = "linux", + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(directory, "profile"), + userHome: path.join(directory, "home"), + runtimeExecutablePath: process.execPath, + platform, + baseEnv, + }).pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + exists: (candidate) => Effect.succeed(bundles.includes(candidate)), + }), + ); + return buildAntigravityAcpSpawnInput({ + installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, + profile, + cwd: directory, + baseEnv, + }).env; + }); + + // A Windows Node path cannot preflight the browser helper as a POSIX host. + const caTest = it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32"); + + caTest( + "launches Linux agents with the first available CA bundle, preferring absolute Nix paths", + () => + Effect.gen(function* () { + const systemBundles = ["/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"]; + const nixBundle = "/nix/store/nss-cacert/etc/ssl/certs/ca-bundle.crt"; + expect((yield* prepareSpawn(systemBundles))?.SSL_CERT_FILE).toBe(systemBundles[0]); + expect( + (yield* prepareSpawn([nixBundle, ...systemBundles], { NIX_SSL_CERT_FILE: nixBundle })) + ?.SSL_CERT_FILE, + ).toBe(nixBundle); + expect( + (yield* prepareSpawn(systemBundles, { NIX_SSL_CERT_FILE: nixBundle }))?.SSL_CERT_FILE, + ).toBe(systemBundles[0]); + expect( + (yield* prepareSpawn(["certs/local.pem", ...systemBundles], { + NIX_SSL_CERT_FILE: "certs/local.pem", + }))?.SSL_CERT_FILE, + ).toBe(systemBundles[0]); + expect((yield* prepareSpawn([]))?.SSL_CERT_FILE).toBeUndefined(); + }), + ); + + caTest("preserves explicit certificate files and directories in Linux launches", () => + Effect.gen(function* () { + const bundles = ["/etc/ssl/certs/ca-certificates.crt"]; + expect( + (yield* prepareSpawn(bundles, { SSL_CERT_FILE: "/custom/ca.pem" }))?.SSL_CERT_FILE, + ).toBe("/custom/ca.pem"); + const directoryEnv = yield* prepareSpawn(bundles, { SSL_CERT_DIR: "/custom/certs" }); + expect(directoryEnv?.SSL_CERT_DIR).toBe("/custom/certs"); + expect(directoryEnv?.SSL_CERT_FILE).toBeUndefined(); + }), + ); + + it.effect("runs the browser helper with installed Node in standalone builds", () => + Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); const profile = yield* prepareAntigravityProfile({ profileDirectory: path.join(directory, "profile"), - userHome: path.join(directory, "home"), - runtimeExecutablePath: process.execPath, - platform, - baseEnv, - }).pipe( - Effect.provideService(FileSystem.FileSystem, { - ...fs, - exists: (candidate) => Effect.succeed(bundles.includes(candidate)), - }), - ); - return buildAntigravityAcpSpawnInput({ - installation: { executablePath: "/release/acp", harnessPath: "/release/harness" }, - profile, - cwd: directory, - baseEnv, - }).env; - }); + baseEnv: { PATH: path.dirname(process.execPath) }, + }); + expect(profile.browserCommand).not.toContain("/packaged/t3"); + expect(yield* fs.exists(profile.acpDirectory)).toBe(true); + }).pipe( + Effect.provideService(HostProcess.IsExecutable, true), + Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"), + ), + ); - // A Windows Node path cannot preflight the browser helper as a POSIX host. - const caTest = it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32"); - - caTest( - "launches Linux agents with the first available CA bundle, preferring absolute Nix paths", - () => - Effect.gen(function* () { - const systemBundles = ["/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"]; - const nixBundle = "/nix/store/nss-cacert/etc/ssl/certs/ca-bundle.crt"; - expect((yield* prepareSpawn(systemBundles))?.SSL_CERT_FILE).toBe(systemBundles[0]); - expect( - (yield* prepareSpawn([nixBundle, ...systemBundles], { NIX_SSL_CERT_FILE: nixBundle })) - ?.SSL_CERT_FILE, - ).toBe(nixBundle); - expect( - (yield* prepareSpawn(systemBundles, { NIX_SSL_CERT_FILE: nixBundle }))?.SSL_CERT_FILE, - ).toBe(systemBundles[0]); - expect( - (yield* prepareSpawn(["certs/local.pem", ...systemBundles], { - NIX_SSL_CERT_FILE: "certs/local.pem", - }))?.SSL_CERT_FILE, - ).toBe(systemBundles[0]); - expect((yield* prepareSpawn([]))?.SSL_CERT_FILE).toBeUndefined(); - }), - ); + it.effect("reports missing Node before creating the standalone sign-in profile", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + const profileDirectory = path.join(directory, "profile"); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + baseEnv: { PATH: "" }, + }).pipe(Effect.result); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: expect.stringContaining("Install Node.js"), + cause: { + _tag: "NodeRuntimeUnavailableError", + }, + }); + } + expect(yield* fs.exists(profileDirectory)).toBe(false); + }).pipe(Effect.provideService(HostProcess.IsExecutable, true)), + ); - caTest("preserves explicit certificate files and directories in Linux launches", () => - Effect.gen(function* () { - const bundles = ["/etc/ssl/certs/ca-certificates.crt"]; - expect( - (yield* prepareSpawn(bundles, { SSL_CERT_FILE: "/custom/ca.pem" }))?.SSL_CERT_FILE, - ).toBe("/custom/ca.pem"); - const directoryEnv = yield* prepareSpawn(bundles, { SSL_CERT_DIR: "/custom/certs" }); - expect(directoryEnv?.SSL_CERT_DIR).toBe("/custom/certs"); - expect(directoryEnv?.SSL_CERT_FILE).toBeUndefined(); - }), - ); + it.effect("preflights the no-browser helper and creates private directories only", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: path.join(temporaryDirectory, "profile"), + }); - it.effect("runs the browser helper with installed Node in standalone builds", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - const profile = yield* prepareAntigravityProfile({ - profileDirectory: path.join(directory, "profile"), - baseEnv: { PATH: path.dirname(process.execPath) }, - }); - expect(profile.browserCommand).not.toContain("/packaged/t3"); - expect(yield* fs.exists(profile.acpDirectory)).toBe(true); - }).pipe( - Effect.provideService(HostProcess.IsExecutable, true), - Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"), - ), - ); + expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile")); + expect(yield* fs.exists(profile.acpDirectory)).toBe(true); + expect(yield* fs.exists(profile.tokenPath)).toBe(false); + if ((yield* HostProcess.Platform) !== "win32") { + expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700); + expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700); + } - it.effect("reports missing Node before creating the standalone sign-in profile", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - const profileDirectory = path.join(directory, "profile"); - const result = yield* prepareAntigravityProfile({ - profileDirectory, - baseEnv: { PATH: "" }, - }).pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toMatchObject({ - _tag: "AcpTransportError", - detail: expect.stringContaining("Install Node.js"), - cause: { - _tag: "NodeRuntimeUnavailableError", - }, - }); - } - expect(yield* fs.exists(profileDirectory)).toBe(false); - }).pipe(Effect.provideService(HostProcess.IsExecutable, true)), - ); + yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture"); + yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome }); + expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture"); + }), + ); - it.effect("preflights the no-browser helper and creates private directories only", () => + it.effect.skipIf(!symlinksSupported)( + "links only the user's existing global skill directories without touching real content", + () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const profile = yield* prepareAntigravityProfile({ - profileDirectory: path.join(temporaryDirectory, "profile"), - }); - - expect(profile.geminiHome).toBe(path.join(temporaryDirectory, "profile")); - expect(yield* fs.exists(profile.acpDirectory)).toBe(true); - expect(yield* fs.exists(profile.tokenPath)).toBe(false); - if ((yield* HostProcess.Platform) !== "win32") { - expect((yield* fs.stat(profile.geminiHome)).mode & 0o777).toBe(0o700); - expect((yield* fs.stat(profile.acpDirectory)).mode & 0o777).toBe(0o700); - } + const userHome = path.join(temporaryDirectory, "home"); + const profileDirectory = path.join(temporaryDirectory, "profile"); + const configSkills = path.join(userHome, ".gemini", "config", "skills"); + const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); + const isLink = (link: string) => + fs.readLink(link).pipe( + Effect.as(true), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ); + yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); + + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + const configLink = path.join(profileDirectory, "config", "skills"); + const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); + expect(yield* fs.readLink(configLink)).toBe(configSkills); + expect(yield* isLink(cliLink)).toBe(false); + expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); + expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( + false, + ); - yield* fs.writeFileString(profile.tokenPath, "synthetic-token-fixture"); - yield* prepareAntigravityProfile({ profileDirectory: profile.geminiHome }); - expect(yield* fs.readFileString(profile.tokenPath)).toBe("synthetic-token-fixture"); + // The harness leaves an empty folder where the link was skipped. + yield* fs.makeDirectory(cliLink, { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect((yield* fs.stat(cliLink)).type).toBe("Directory"); + + yield* fs.makeDirectory(cliSkills, { recursive: true }); + yield* fs.remove(configLink); + yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); + expect((yield* fs.stat(configLink)).type).toBe("Directory"); + + yield* fs.remove(cliLink); + yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* fs.readLink(cliLink)).toBe(cliSkills); + + yield* fs.remove(cliSkills, { recursive: true }); + yield* prepareAntigravityProfile({ profileDirectory, userHome }); + expect(yield* isLink(cliLink)).toBe(false); }), - ); + ); - it.effect.skipIf(!symlinksSupported)( - "links only the user's existing global skill directories without touching real content", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const userHome = path.join(temporaryDirectory, "home"); - const profileDirectory = path.join(temporaryDirectory, "profile"); - const configSkills = path.join(userHome, ".gemini", "config", "skills"); - const cliSkills = path.join(userHome, ".gemini", "antigravity-cli", "skills"); - const isLink = (link: string) => - fs.readLink(link).pipe( - Effect.as(true), - Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), - ); - yield* fs.makeDirectory(path.join(configSkills, "review"), { recursive: true }); - - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - const configLink = path.join(profileDirectory, "config", "skills"); - const cliLink = path.join(profileDirectory, "antigravity-cli", "skills"); - expect(yield* fs.readLink(configLink)).toBe(configSkills); - expect(yield* isLink(cliLink)).toBe(false); - expect(yield* fs.exists(path.join(configLink, "review"))).toBe(true); - expect(yield* fs.exists(path.join(profileDirectory, "config", "mcp_config.json"))).toBe( - false, - ); + it.effect("rewrites the GCP block on every launch and never stores the API key", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + const profile = yield* prepareAntigravityProfile({ + profileDirectory: temporaryDirectory, + auth: { + authMethod: "agent-platform", + apiKey: "vertex-secret", + gcpProject: "proj", + gcpLocation: "us-central1", + }, + }); + const settingsPath = path.join(profile.acpDirectory, "settings.json"); + const first = yield* fs.readFileString(settingsPath); + expect(decodeJson(first)).toEqual({ + auth: { type: "agent-platform" }, + gcp: { project: "proj", location: "us-central1" }, + }); + expect(first).not.toContain("vertex-secret"); - // The harness leaves an empty folder where the link was skipped. - yield* fs.makeDirectory(cliLink, { recursive: true }); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect((yield* fs.stat(cliLink)).type).toBe("Directory"); - - yield* fs.makeDirectory(cliSkills, { recursive: true }); - yield* fs.remove(configLink); - yield* fs.makeDirectory(path.join(configLink, "own-skill"), { recursive: true }); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect(yield* fs.readLink(cliLink)).toBe(cliSkills); - expect(yield* fs.exists(path.join(configLink, "own-skill"))).toBe(true); - expect((yield* fs.stat(configLink)).type).toBe("Directory"); - - yield* fs.remove(cliLink); - yield* fs.symlink(path.join(temporaryDirectory, "elsewhere"), cliLink); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect(yield* fs.readLink(cliLink)).toBe(cliSkills); - - yield* fs.remove(cliSkills, { recursive: true }); - yield* prepareAntigravityProfile({ profileDirectory, userHome }); - expect(yield* isLink(cliLink)).toBe(false); + yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }); + expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({ + auth: { type: "oauth-personal" }, + }); + }), + ); + + const runBrowserHelper = Effect.fn("runBrowserHelper")(function* (env: Record) { + const fs = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + let helper: ChildProcess.StandardCommand | undefined; + yield* prepareAntigravityProfile({ + profileDirectory: yield* fs.makeTempDirectoryScoped(), + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (ChildProcess.isStandardCommand(command)) helper = command; + return spawner.spawn(command); }), + ), ); - - it.effect("rewrites the GCP block on every launch and never stores the API key", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - const profile = yield* prepareAntigravityProfile({ - profileDirectory: temporaryDirectory, - auth: { - authMethod: "agent-platform", - apiKey: "vertex-secret", - gcpProject: "proj", - gcpLocation: "us-central1", - }, - }); - const settingsPath = path.join(profile.acpDirectory, "settings.json"); - const first = yield* fs.readFileString(settingsPath); - expect(decodeJson(first)).toEqual({ - auth: { type: "agent-platform" }, - gcp: { project: "proj", location: "us-central1" }, - }); - expect(first).not.toContain("vertex-secret"); - - yield* prepareAntigravityProfile({ profileDirectory: temporaryDirectory }); - expect(decodeJson(yield* fs.readFileString(settingsPath))).toEqual({ - auth: { type: "oauth-personal" }, - }); + if (!helper) return yield* Effect.die("The browser helper preflight did not run."); + const child = yield* spawner.spawn( + ChildProcess.make(helper.command, [...helper.args.slice(0, -1), authorizationUrl], { + env: { ...helper.options.env, ...env }, + extendEnv: false, }), ); + const [stderr, exitCode] = yield* Effect.all( + [child.stderr.pipe(Stream.decodeText(), Stream.mkString), child.exitCode], + { concurrency: "unbounded" }, + ); + return { stderr, exitCode: Number(exitCode) }; + }); - const runBrowserHelper = Effect.fn("runBrowserHelper")(function* (env: Record) { - const fs = yield* FileSystem.FileSystem; - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - let helper: ChildProcess.StandardCommand | undefined; - yield* prepareAntigravityProfile({ - profileDirectory: yield* fs.makeTempDirectoryScoped(), - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make((command) => { - if (ChildProcess.isStandardCommand(command)) helper = command; - return spawner.spawn(command); - }), - ), + it.effect("falls back to stderr when the sign-in listener rejects the URL or is gone", () => + Effect.gen(function* () { + const rejecting = yield* serveAntigravityAuthorizationUrlSink(() => + Effect.fail(AcpErrors.AcpRequestError.internalError("The flow stopped.")), ); - if (!helper) return yield* Effect.die("The browser helper preflight did not run."); - const child = yield* spawner.spawn( - ChildProcess.make(helper.command, [...helper.args.slice(0, -1), authorizationUrl], { - env: { ...helper.options.env, ...env }, - extendEnv: false, - }), + const closed = yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe( + Effect.scoped, ); - const [stderr, exitCode] = yield* Effect.all( - [child.stderr.pipe(Stream.decodeText(), Stream.mkString), child.exitCode], - { concurrency: "unbounded" }, + for (const sink of [rejecting, closed]) { + expect(yield* runBrowserHelper({ T3_ANTIGRAVITY_AUTH_SINK: sink })).toEqual({ + exitCode: 0, + stderr: `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, + }); + } + }), + ); + + it.effect("posts the sign-in URL to the listener directly when an environment proxy is set", () => + Effect.gen(function* () { + const received: string[] = []; + const sink = yield* serveAntigravityAuthorizationUrlSink((url) => + Effect.sync(() => { + received.push(url); + }), ); - return { stderr, exitCode: Number(exitCode) }; - }); + const unreachableProxy = new URL( + yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe(Effect.scoped), + ).origin; + expect( + yield* runBrowserHelper({ + T3_ANTIGRAVITY_AUTH_SINK: sink, + NODE_USE_ENV_PROXY: "1", + HTTP_PROXY: unreachableProxy, + http_proxy: unreachableProxy, + }), + ).toEqual({ exitCode: 0, stderr: "" }); + expect(received).toEqual([authorizationUrl]); + }), + ); - it.effect("falls back to stderr when the sign-in listener rejects the URL or is gone", () => - Effect.gen(function* () { - const rejecting = yield* serveAntigravityAuthorizationUrlSink(() => - Effect.fail(AcpErrors.AcpRequestError.internalError("The flow stopped.")), - ); - const closed = yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe( - Effect.scoped, - ); - for (const sink of [rejecting, closed]) { - expect(yield* runBrowserHelper({ T3_ANTIGRAVITY_AUTH_SINK: sink })).toEqual({ - exitCode: 0, - stderr: `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson(authorizationUrl)}\n`, - }); - } + const preflightHelper = (exitDelay: Effect.Effect) => { + const spawns = { count: 0 }; + const started = Deferred.makeUnsafe(); + const spawner = ChildProcessSpawner.make(() => + Effect.sync(() => { + spawns.count += 1; + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Deferred.succeed(started, undefined).pipe( + Effect.andThen(exitDelay), + Effect.as(ChildProcessSpawner.ExitCode(0)), + ), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.empty, + stderr: Stream.encodeText( + Stream.make( + `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"https://example.invalid/t3-antigravity-browser-preflight"\n`, + ), + ), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); }), ); + return { spawner, spawns, started }; + }; - it.effect( - "posts the sign-in URL to the listener directly when an environment proxy is set", - () => - Effect.gen(function* () { - const received: string[] = []; - const sink = yield* serveAntigravityAuthorizationUrlSink((url) => - Effect.sync(() => { - received.push(url); - }), - ); - const unreachableProxy = new URL( - yield* serveAntigravityAuthorizationUrlSink(() => Effect.void).pipe(Effect.scoped), - ).origin; - expect( - yield* runBrowserHelper({ - T3_ANTIGRAVITY_AUTH_SINK: sink, - NODE_USE_ENV_PROXY: "1", - HTTP_PROXY: unreachableProxy, - http_proxy: unreachableProxy, - }), - ).toEqual({ exitCode: 0, stderr: "" }); - expect(received).toEqual([authorizationUrl]); - }), - ); + const prepareWithHelper = Effect.fn("prepareWithHelper")(function* ( + runtimeExecutablePath: string, + spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], + ) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + return yield* prepareAntigravityProfile({ + profileDirectory: path.join(directory, "profile"), + userHome: path.join(directory, "home"), + runtimeExecutablePath, + platform: "linux", + baseEnv: {}, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + }); - const preflightHelper = (exitDelay: Effect.Effect) => { - const spawns = { count: 0 }; - const started = Deferred.makeUnsafe(); - const spawner = ChildProcessSpawner.make(() => - Effect.sync(() => { - spawns.count += 1; - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Deferred.succeed(started, undefined).pipe( - Effect.andThen(exitDelay), - Effect.as(ChildProcessSpawner.ExitCode(0)), - ), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.empty, - stderr: Stream.encodeText( - Stream.make( - `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"https://example.invalid/t3-antigravity-browser-preflight"\n`, - ), - ), - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }); - }), + it.effect("allows a browser preflight that takes longer than five seconds", () => + Effect.gen(function* () { + const helper = preflightHelper(Effect.sleep("20 seconds")); + const fiber = yield* prepareWithHelper("/fake/slow-node", helper.spawner).pipe( + Effect.forkChild, ); - return { spawner, spawns, started }; - }; + yield* Deferred.await(helper.started); + yield* TestClock.adjust("20 seconds"); + yield* Fiber.join(fiber); - const prepareWithHelper = Effect.fn("prepareWithHelper")(function* ( - runtimeExecutablePath: string, - spawner: ChildProcessSpawner.ChildProcessSpawner["Service"], - ) { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - return yield* prepareAntigravityProfile({ - profileDirectory: path.join(directory, "profile"), - userHome: path.join(directory, "home"), - runtimeExecutablePath, - platform: "linux", - baseEnv: {}, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - }); + expect(helper.spawns.count).toBe(1); + }), + ); - it.effect("allows a browser preflight that takes longer than five seconds", () => - Effect.gen(function* () { - const helper = preflightHelper(Effect.sleep("20 seconds")); - const fiber = yield* prepareWithHelper("/fake/slow-node", helper.spawner).pipe( - Effect.forkChild, - ); - yield* Deferred.await(helper.started); - yield* TestClock.adjust("20 seconds"); - yield* Fiber.join(fiber); + it.effect("fails a hung preflight after 30 seconds and verifies again on the next open", () => + Effect.gen(function* () { + const hung = preflightHelper(Effect.never); + const fiber = yield* prepareWithHelper("/fake/hung-node", hung.spawner).pipe( + Effect.result, + Effect.forkChild, + ); + yield* Deferred.await(hung.started); + yield* TestClock.adjust("29 seconds"); + expect(fiber.pollUnsafe()).toBeUndefined(); + yield* TestClock.adjust("1 second"); + const result = yield* Fiber.join(fiber); + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toMatchObject({ + _tag: "AcpTransportError", + detail: "Antigravity browser suppression verification timed out.", + }); + } - expect(helper.spawns.count).toBe(1); - }), - ); + const recovered = preflightHelper(Effect.void); + yield* prepareWithHelper("/fake/hung-node", recovered.spawner); + expect(recovered.spawns.count).toBe(1); + }), + ); - it.effect("fails a hung preflight after 30 seconds and verifies again on the next open", () => - Effect.gen(function* () { - const hung = preflightHelper(Effect.never); - const fiber = yield* prepareWithHelper("/fake/hung-node", hung.spawner).pipe( - Effect.result, - Effect.forkChild, - ); - yield* Deferred.await(hung.started); - yield* TestClock.adjust("29 seconds"); - expect(fiber.pollUnsafe()).toBeUndefined(); - yield* TestClock.adjust("1 second"); - const result = yield* Fiber.join(fiber); + it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); + for (const platform of ["linux", "win32"] as const) { + const profileDirectory = path.join(temporaryDirectory, platform); + const result = yield* prepareAntigravityProfile({ + profileDirectory, + platform, + runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node", + }).pipe(Effect.result); expect(Result.isFailure(result)).toBe(true); - if (Result.isFailure(result)) { - expect(result.failure).toMatchObject({ - _tag: "AcpTransportError", - detail: "Antigravity browser suppression verification timed out.", - }); - } - - const recovered = preflightHelper(Effect.void); - yield* prepareWithHelper("/fake/hung-node", recovered.spawner); - expect(recovered.spawns.count).toBe(1); - }), - ); - - it.effect("rejects Python BROWSER delimiter collisions before starting a helper", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const temporaryDirectory = yield* fs.makeTempDirectoryScoped(); - for (const platform of ["linux", "win32"] as const) { - const profileDirectory = path.join(temporaryDirectory, platform); - const result = yield* prepareAntigravityProfile({ - profileDirectory, - platform, - runtimeExecutablePath: platform === "win32" ? "C:/bad;path/node.exe" : "/bad:path/node", - }).pipe(Effect.result); - expect(Result.isFailure(result)).toBe(true); - expect(yield* fs.exists(profileDirectory)).toBe(false); - } - }), - ); - }, -); + expect(yield* fs.exists(profileDirectory)).toBe(false); + } + }), + ); +});