diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a10f9855cbbbf..bab7babe6bb27 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -520,12 +520,13 @@ const program = Effect.gen(function* () { }), ); - // Antigravity requires GEMINI_API_KEY for API key authentication. + // Antigravity requires GEMINI_API_KEY for API key authentication. Agent Platform checks credentials on prompt. yield* agent.handleAuthenticate((request) => Effect.gen(function* () { if (antigravityProfile) { if ( request.methodId !== "oauth-personal" && + request.methodId !== "agent-platform" && !(request.methodId === "gemini-api-key" && process.env.GEMINI_API_KEY) ) { return yield* AcpError.AcpRequestError.invalidParams( @@ -1773,6 +1774,13 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { + // Antigravity's sign-out is the raw v1 method, sent through the runtime's generic request. + if (antigravityProfile && method === "logout") { + return Effect.sync(() => { + authenticated = false; + return {}; + }); + } if (method === "_test/environment") { return Effect.succeed({ inherited: process.env.T3_ACP_RUNTIME_AMBIENT === "sentinel", diff --git a/apps/server/src/outboundHttp.ts b/apps/server/src/outboundHttp.ts index ead14cb5d2035..749ff2a2e7f2e 100644 --- a/apps/server/src/outboundHttp.ts +++ b/apps/server/src/outboundHttp.ts @@ -26,6 +26,15 @@ export function installHttp1FetchDispatcher(): void { ); } +/** A fetch on its own Agent, so neither the global dispatcher nor an env proxy applies. */ +export function makeDirectFetch() { + const undici = requireUndici("undici") as typeof Undici; + const dispatcher = new undici.Agent(); + const fetch: typeof Undici.fetch = (input, init) => undici.fetch(input, { ...init, dispatcher }); + // The same WHATWG fetch, typed by npm undici rather than the copy behind Node's globals. + return { fetch: fetch as typeof globalThis.fetch, close: () => dispatcher.destroy() }; +} + /** Avoids uncaught idle-timeout errors on h2 streams (https://github.com/nodejs/undici/issues/5936). */ export function keepOutboundFetchOnHttp1(): void { if (builtinFetchUsesHttp2(process.versions.undici)) installHttp1FetchDispatcher(); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index 67f25681a1a4b..6c79faa9c300f 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -82,6 +82,7 @@ export interface AntigravityAuthOptions< readonly instanceId: ProviderInstanceId; readonly makeRuntime: (input: { readonly onAuthorizationUrl?: (url: string) => Effect.Effect; + readonly signOut?: boolean; }) => Effect.Effect; readonly onAuthenticated: ( result: AcpSessionRuntime.AcpSessionRuntimeStartResult, @@ -497,7 +498,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < const result = yield* restore( Effect.gen(function* () { yield* stopSessions.pipe(Effect.ensuring(stopRemaining)); - const runtime = yield* options.makeRuntime({}); + const runtime = yield* options.makeRuntime({ signOut: true }); const initialized = yield* runtime.initialize(); if (!initialized.agentCapabilities?.auth?.logout) { return yield* setupError( diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 74f4daa1323af..7fdb77e2ea045 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -10,9 +10,10 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import { HttpClient } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import * as ServerConfig from "../../config.ts"; @@ -52,7 +53,13 @@ function shellQuote(value: string): string { } const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( - options: { readonly config?: Partial; readonly enabled?: boolean } = {}, + options: { + readonly config?: Partial; + readonly enabled?: boolean; + readonly environment?: ReadonlyArray<{ readonly name: string; readonly value: string }>; + readonly metadataServer?: boolean; + readonly metadataHost?: string; + } = {}, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -154,6 +161,25 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( return selected; }), }); + const metadataRequests: Array = []; + const httpClient = HttpClient.make((request) => { + if (!request.url.startsWith(`http://${options.metadataHost ?? "169.254.169.254"}`)) { + return Effect.die("Unexpected quota request"); + } + metadataRequests.push(request.url); + return options.metadataServer + ? Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(null, { headers: { "Metadata-Flavor": "Google" } }), + ), + ) + : Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request }), + }), + ); + }); const observedSpawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { if (command._tag !== "StandardCommand") @@ -195,10 +221,14 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( { name: "GEMINI_HOME", value: "/must-not-be-used" }, { name: "ANTIGRAVITY_HARNESS_PATH", value: "/must-not-be-used" }, { name: "BROWSER", value: "must-not-run" }, + { name: "CLOUDSDK_CONFIG", value: "" }, + { name: "GCE_METADATA_HOST", value: "" }, + ...(options.environment ?? []), ].map((variable) => ({ ...variable, sensitive: false })), }).pipe( Effect.provide(layerInstallation), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, observedSpawner), + Effect.provideService(HttpClient.HttpClient, httpClient), ); const refresh = instance.refreshModels; if (!refresh) return yield* Effect.die("Antigravity does not expose model refresh."); @@ -232,6 +262,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( acquisitions, releases, launches, + metadataRequests, readRequests, assertClosed, }; @@ -353,6 +384,96 @@ it.layer(layerTest)("AntigravityDriver", (it) => { }).pipe(Effect.scoped), ); + it.effect.skipIf(windowsHost)( + "reports missing Google Cloud credentials before Agent Platform starts", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-home-" }); + const h = yield* makeHarness({ + config: { authMethod: "agent-platform", gcpProject: "proj", gcpLocation: "us-central1" }, + environment: [{ name: "HOME", value: home }], + }); + const result = yield* h.refresh().pipe(Effect.result); + expect(Result.isFailure(result) ? result.failure.detail : "refresh succeeded").toContain( + "gcloud auth application-default login", + ); + expect((yield* h.instance.snapshot.getSnapshot).auth.status).not.toBe("authenticated"); + expect(h.launches).toEqual([]); + + const gcloudDirectory = path.join(home, ".config", "gcloud"); + yield* fs.makeDirectory(gcloudDirectory, { recursive: true }); + yield* fs.writeFileString( + path.join(gcloudDirectory, "application_default_credentials.json"), + "{}", + ); + yield* h.refresh(); + expect((yield* h.instance.snapshot.getSnapshot).auth.status).toBe("authenticated"); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)( + "starts Agent Platform with metadata server credentials and no local credentials file", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-home-" }); + const h = yield* makeHarness({ + config: { authMethod: "agent-platform", gcpProject: "proj", gcpLocation: "us-central1" }, + environment: [ + { name: "HOME", value: home }, + { name: "GCE_METADATA_HOST", value: "metadata.internal:8080" }, + ], + metadataHost: "metadata.internal:8080", + metadataServer: true, + }); + yield* h.refresh(); + yield* h.refresh(); + expect((yield* h.instance.snapshot.getSnapshot).auth.status).toBe("authenticated"); + expect(h.metadataRequests).toEqual(["http://metadata.internal:8080"]); + yield* h.assertClosed; + }).pipe(Effect.scoped), + ); + + it.effect.skipIf(windowsHost)( + "signs out of Agent Platform after its Google Cloud credentials are removed", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-antigravity-home-" }); + const credentials = path.join( + home, + ".config", + "gcloud", + "application_default_credentials.json", + ); + yield* fs.makeDirectory(path.dirname(credentials), { recursive: true }); + yield* fs.writeFileString(credentials, "{}"); + const h = yield* makeHarness({ + config: { authMethod: "agent-platform", gcpProject: "proj", gcpLocation: "us-central1" }, + // The mock advertises sign-out only when it lists an auth method. + environment: [ + { name: "HOME", value: home }, + { name: "T3_ACP_AUTH_METHOD_ID", value: "agent-platform" }, + ], + }); + const auth = h.instance.auth; + if (!auth) return yield* Effect.die("Antigravity does not expose sign-in."); + yield* h.refresh(); + expect((yield* h.instance.snapshot.getSnapshot).auth.status).toBe("authenticated"); + + yield* fs.remove(credentials); + const state = yield* auth.logout(Effect.void); + expect(state.message).toBe("Signed out of Google."); + expect((yield* h.instance.snapshot.getSnapshot).auth.status).toBe("unauthenticated"); + expect((yield* h.readRequests).map((request) => request.method)).toContain("logout"); + 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(); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index f1f39e104b44c..e8d7c188b7ca3 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -29,11 +29,13 @@ import * as AntigravityInstallation from "../AntigravityInstallation.ts"; import * as AntigravityUsage from "./AntigravityUsage.ts"; import { antigravityAuthConfigIssue, + antigravityAuthIssue, antigravityAuthLabel, antigravityAuthUsesBrowser, buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, prepareAntigravityProfile, + probeGoogleMetadataServer, resolveAntigravityInstanceDirectories, type AntigravityAuthConfig, } from "../antigravityAuthSupport.ts"; @@ -122,13 +124,29 @@ export const AntigravityDriver: ProviderDriver< gcpProject: settings.gcpProject, gcpLocation: settings.gcpLocation, }; - const authConfigIssue = antigravityAuthConfigIssue(auth); const processEnvironment = yield* mergeProviderInstanceEnvironment(environment); + const platform = yield* HostProcess.Platform; const userHome = resolveAntigravityUserHome( - yield* HostProcess.Platform, + platform, processEnvironment, yield* HostProcess.HomeDirectory, ); + // Probe the metadata server at most once per instance, as the Go client does per process. + const onGoogleCloud = yield* Effect.cached( + probeGoogleMetadataServer(processEnvironment.GCE_METADATA_HOST || undefined).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ), + ); + // Recheck on every use so credentials created after startup take effect. + const authIssue = antigravityAuthIssue(auth, { + platform, + environment: processEnvironment, + userHome, + onGoogleCloud, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const directories = yield* resolveAntigravityInstanceDirectories( host.paths.stateDir, instanceId, @@ -172,11 +190,14 @@ export const AntigravityDriver: ProviderDriver< const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* ( input: Omit, + options: { readonly signOut?: boolean } = {}, ): Effect.fn.Return< AcpSessionRuntime.AcpSessionRuntime["Service"], AcpError | ProviderSetupError, Scope.Scope > { + // Sign-out needs no Google Cloud credentials, so removing them must not block it. + const authConfigIssue = options.signOut ? antigravityAuthConfigIssue(auth) : yield* authIssue; if (authConfigIssue !== null) { return yield* new ProviderSetupError({ instanceId, @@ -269,7 +290,9 @@ export const AntigravityDriver: ProviderDriver< }); const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* ( - input: Pick, + input: Pick & { + readonly signOut?: boolean; + }, ) { const cwd = yield* fileSystem .makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" }) @@ -294,11 +317,14 @@ export const AntigravityDriver: ProviderDriver< Effect.provideService(Path.Path, path), ), ); - const runtime = yield* makeRuntime({ - cwd, - mcpServers: [], - ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), - }); + const runtime = yield* makeRuntime( + { + cwd, + mcpServers: [], + ...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}), + }, + { signOut: input.signOut === true }, + ); return { ...runtime, start: () => @@ -355,6 +381,7 @@ export const AntigravityDriver: ProviderDriver< // minute. const probe = Effect.gen(function* () { yield* modelCatalog.refreshInBackground; + const authConfigIssue = yield* authIssue; if (authConfigIssue !== null) { return yield* new ProviderSetupError({ instanceId, diff --git a/apps/server/src/provider/antigravityAuthSupport.test.ts b/apps/server/src/provider/antigravityAuthSupport.test.ts index 9138a28dab5b3..88f37c904a3f9 100644 --- a/apps/server/src/provider/antigravityAuthSupport.test.ts +++ b/apps/server/src/provider/antigravityAuthSupport.test.ts @@ -1,3 +1,7 @@ +// @effect-diagnostics nodeBuiltinImport:off - real HTTP servers stand in for an outbound proxy and the metadata server. +import * as NodeHttp from "node:http"; +import type * as NodeNet from "node:net"; + import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -5,14 +9,18 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; +import { FetchHttpClient, HttpClient, HttpClientError, HttpClientResponse } from "effect/http"; import * as Layer from "effect/Layer"; 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 TestClock from "effect/testing/TestClock"; import * as Ndjson from "effect/encoding/Ndjson"; import * as AcpErrors from "effect-acp/errors"; +import * as Undici from "undici"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import { @@ -21,12 +29,14 @@ import { ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE, type AntigravityAuthConfig, type AntigravityProfile, + antigravityAuthIssue, buildAntigravityAcpSpawnInput, isAntigravitySignInRequiredError, makeAntigravityStderrHandler, makeAntigravityStdoutTransform, parseAntigravityAuthorizationUrl, prepareAntigravityProfile, + probeGoogleMetadataServer, resolveAntigravityInstanceDirectories, } from "./antigravityAuthSupport.ts"; @@ -657,3 +667,159 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => { }), ); }); + +it.layer(NodeServices.layer)("Antigravity Agent Platform credentials", (it) => { + it.effect("finds application default credentials where the runtime looks for them", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fs.makeTempDirectoryScoped(); + const sdkConfig = path.join(userHome, "sdk-config"); + yield* fs.makeDirectory(sdkConfig); + yield* fs.makeDirectory(path.join(userHome, ".config", "gcloud"), { recursive: true }); + const adcPath = path.join( + userHome, + ".config", + "gcloud", + "application_default_credentials.json", + ); + yield* fs.writeFileString(adcPath, "{}"); + const auth: AntigravityAuthConfig = { + authMethod: "agent-platform", + apiKey: "", + gcpProject: "proj", + gcpLocation: "us-central1", + }; + const issueWith = ( + environment: NodeJS.ProcessEnv, + platform: NodeJS.Platform = "darwin", + onGoogleCloud = false, + ) => + antigravityAuthIssue(auth, { + platform, + environment, + userHome, + onGoogleCloud: Effect.succeed(onGoogleCloud), + }); + + // The bundled Go harness ignores CLOUDSDK_CONFIG when finding ADC. + expect(yield* issueWith({ CLOUDSDK_CONFIG: sdkConfig })).toBeNull(); + yield* fs.writeFileString(path.join(sdkConfig, "application_default_credentials.json"), "{}"); + yield* fs.remove(adcPath); + expect(yield* issueWith({ CLOUDSDK_CONFIG: sdkConfig })).toContain( + "gcloud auth application-default login", + ); + expect( + yield* issueWith({ + CLOUDSDK_CONFIG: path.join(userHome, "missing"), + GCE_METADATA_HOST: "metadata.google.internal", + }), + ).toContain("gcloud auth application-default login"); + expect( + yield* issueWith( + { + CLOUDSDK_CONFIG: path.join(userHome, "missing"), + GCE_METADATA_HOST: "metadata.google.internal", + }, + "linux", + true, + ), + ).toBeNull(); + + yield* fs.writeFileString(adcPath, "{}"); + // Windows reads %APPDATA%\gcloud and never the POSIX path. + expect(yield* issueWith({ APPDATA: userHome }, "win32")).toContain( + "gcloud auth application-default login", + ); + yield* fs.makeDirectory(path.join(userHome, "gcloud")); + yield* fs.writeFileString( + path.join(userHome, "gcloud", "application_default_credentials.json"), + "{}", + ); + expect(yield* issueWith({ APPDATA: userHome }, "win32")).toBeNull(); + + yield* fs.remove(adcPath); + // A metadata server supplies credentials without any local file. + const missing = { CLOUDSDK_CONFIG: path.join(userHome, "missing") }; + expect(yield* issueWith(missing, "linux")).toContain("gcloud auth application-default login"); + expect(yield* issueWith(missing, "linux", true)).toBeNull(); + expect( + yield* issueWith({ APPDATA: path.join(userHome, "missing") }, "win32", true), + ).toBeNull(); + }), + ); + + it.effect("detects the metadata server the way the Go client does", () => + Effect.gen(function* () { + const probeWith = (respond: Parameters[0]) => + probeGoogleMetadataServer().pipe( + Effect.provideService(HttpClient.HttpClient, HttpClient.make(respond)), + ); + const respondWith = (headers: Record) => + probeWith((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { headers }))), + ); + + expect(yield* respondWith({ "Metadata-Flavor": "Google" })).toBe(true); + expect(yield* respondWith({})).toBe(false); + expect( + yield* probeWith((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request }), + }), + ), + ), + ).toBe(false); + const unanswered = yield* probeWith(() => Effect.never).pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + expect(yield* Fiber.join(unanswered)).toBe(false); + }), + ); +}); + +describe("Google metadata server probe", () => { + it.live("reaches the metadata server directly when outbound fetch uses an env proxy", () => + Effect.gen(function* () { + const listen = (handler: NodeHttp.RequestListener) => + Effect.acquireRelease( + Effect.promise(async () => { + const server = NodeHttp.createServer(handler); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + return server; + }), + (server) => Effect.promise(() => new Promise((resolve) => server.close(resolve))), + ).pipe( + Effect.map( + (server) => `http://127.0.0.1:${(server.address() as NodeNet.AddressInfo).port}`, + ), + ); + const metadataServer = yield* listen((_request, response) => + response.writeHead(200, { "Metadata-Flavor": "Google" }).end(), + ); + const proxied: Array = []; + const proxy = yield* listen((request, response) => { + proxied.push(request.url); + response.writeHead(403).end(); + }); + yield* Effect.acquireRelease( + Effect.sync(() => { + const previous = Undici.getGlobalDispatcher(); + const envProxy = new Undici.EnvHttpProxyAgent({ httpProxy: proxy, noProxy: "" }); + Undici.setGlobalDispatcher(envProxy); + return { previous, envProxy }; + }), + ({ previous, envProxy }) => + Effect.promise(() => { + Undici.setGlobalDispatcher(previous); + return envProxy.destroy(); + }), + ); + + const onGoogleCloud = yield* probeGoogleMetadataServer(new URL(metadataServer).host).pipe( + Effect.provide(FetchHttpClient.layer), + ); + expect({ onGoogleCloud, proxied }).toEqual({ onGoogleCloud: true, proxied: [] }); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index 5030b53005e89..08d500182d643 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -8,6 +8,7 @@ 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 { FetchHttpClient, HttpClient } from "effect/http"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; @@ -22,6 +23,7 @@ import { antigravityUserSkillDirectories, resolveAntigravityUserHome, } from "./Drivers/AntigravitySkills.ts"; +import { makeDirectFetch } from "../outboundHttp.ts"; export const ANTIGRAVITY_AUTH_STDOUT_PREFIX = "Open the following link to authenticate the ACP server: "; @@ -136,6 +138,58 @@ export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string } } +/** + * The runtime starts sessions without credentials and fails on the first prompt. Mirror its Go + * credential lookup, minus GOOGLE_APPLICATION_CREDENTIALS, which the launch environment drops. + */ +export const antigravityAuthIssue = Effect.fn("antigravityAuthIssue")(function* ( + auth: AntigravityAuthConfig, + host: { + readonly platform: NodeJS.Platform; + readonly environment: NodeJS.ProcessEnv; + readonly userHome: string; + readonly onGoogleCloud: Effect.Effect; + }, +) { + const configIssue = antigravityAuthConfigIssue(auth); + if (configIssue !== null || auth.authMethod !== "agent-platform" || auth.apiKey) { + return configIssue; + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const gcloudDirectory = + host.platform === "win32" + ? host.environment.APPDATA && path.join(host.environment.APPDATA, "gcloud") + : path.join(host.userHome, ".config", "gcloud"); + if ( + gcloudDirectory && + (yield* fs + .exists(path.join(gcloudDirectory, "application_default_credentials.json")) + .pipe(Effect.orElseSucceed(() => false))) + ) { + return null; + } + return (yield* host.onGoogleCloud) + ? null + : "Agent Platform without an API key needs Google Cloud application default credentials. Run `gcloud auth application-default login`, or enter an API key in the Antigravity provider settings."; +}); + +// The Go client's metadata server check. Like Go's transport it never uses a proxy, which cannot +// reach this host's link-local address. Unreachable link-local addresses can hang, so bound it. +export const probeGoogleMetadataServer = (host = "169.254.169.254") => + Effect.acquireUseRelease( + Effect.try(makeDirectFetch), + (direct) => + HttpClient.get(`http://${host}`).pipe( + Effect.map((response) => response.headers["metadata-flavor"] === "Google"), + Effect.provideService(FetchHttpClient.Fetch, direct.fetch), + ), + (direct) => Effect.promise(direct.close), + ).pipe( + Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(false) }), + Effect.orElseSucceed(() => false), + ); + /** * `auth.type` names the selected method so a native logout clears only that method's credentials * instead of every stored token. Never holds a credential.