Skip to content
10 changes: 9 additions & 1 deletion apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/outboundHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/AntigravityAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export interface AntigravityAuthOptions<
readonly instanceId: ProviderInstanceId;
readonly makeRuntime: (input: {
readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, AcpErrors.AcpError>;
readonly signOut?: boolean;
}) => Effect.Effect<Runtime, AcpErrors.AcpError | ProviderSetupError, Scope.Scope>;
readonly onAuthenticated: (
result: AcpSessionRuntime.AcpSessionRuntimeStartResult,
Expand Down Expand Up @@ -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(
Expand Down
125 changes: 123 additions & 2 deletions apps/server/src/provider/Drivers/AntigravityDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -52,7 +53,13 @@ function shellQuote(value: string): string {
}

const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
options: { readonly config?: Partial<AntigravitySettings>; readonly enabled?: boolean } = {},
options: {
readonly config?: Partial<AntigravitySettings>;
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;
Expand Down Expand Up @@ -154,6 +161,25 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
return selected;
}),
});
const metadataRequests: Array<string> = [];
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")
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -232,6 +262,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
acquisitions,
releases,
launches,
metadataRequests,
readRequests,
assertClosed,
};
Expand Down Expand Up @@ -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();
Expand Down
43 changes: 35 additions & 8 deletions apps/server/src/provider/Drivers/AntigravityDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -172,11 +190,14 @@ export const AntigravityDriver: ProviderDriver<

const makeRuntime = Effect.fn("AntigravityDriver.makeRuntime")(function* (
input: Omit<AntigravityAcpRuntimeInput, "spawn" | "childProcessSpawner">,
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,
Expand Down Expand Up @@ -269,7 +290,9 @@ export const AntigravityDriver: ProviderDriver<
});

const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* (
input: Pick<AntigravityAcpRuntimeInput, "onAuthorizationUrl">,
input: Pick<AntigravityAcpRuntimeInput, "onAuthorizationUrl"> & {
readonly signOut?: boolean;
},
) {
const cwd = yield* fileSystem
.makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" })
Expand All @@ -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: () =>
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading