Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions apps/server/src/provider/Drivers/AntigravityDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ 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 Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import { HttpClient } from "effect/http";

Expand Down Expand Up @@ -45,6 +48,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",
Expand Down Expand Up @@ -82,14 +87,13 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
/** `data` of the agent's "Internal error" response to `session/new`. */
readonly newSessionErrorData?: unknown;
readonly initialMode?: string;
readonly browserWithoutStdio?: boolean;
} = {},
) {
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,
[
Expand All @@ -105,6 +109,17 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
...(options.initialMode === undefined
? []
: [`export T3_ACP_INITIAL_MODE=${shellQuote(options.initialMode)}`]),
// 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.
...(options.browserWithoutStdio
? [
`url=${shellQuote(authorizationUrl)}`,
'eval "set -- $BROWSER"',
'for arg do shift; if [ "$arg" = "%s" ]; then set -- "$@" "$url"; else set -- "$@" "$arg"; fi; done',
'"$@" </dev/null >/dev/null 2>&1',
"exit 1",
]
: []),
`exec ${shellQuote(nodePath)} ${shellQuote(mockAgentPath)} "$@"`,
"",
].join("\n"),
Expand All @@ -124,6 +139,9 @@ 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", { loginRequired: true });
const browserWithoutStdio = yield* makeExecutable("runtime browser-without-stdio", {
browserWithoutStdio: true,
});
const controls: {
selected: AntigravityInstallation.AntigravityExecutable;
failResolution: boolean;
Expand Down Expand Up @@ -255,6 +273,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* (
first,
second,
signedOut,
browserWithoutStdio,
controls,
acquisitions,
releases,
Expand Down Expand Up @@ -461,6 +480,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<void>();
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();
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/provider/Drivers/AntigravityDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
isAntigravitySignInRequiredError,
prepareAntigravityProfile,
resolveAntigravityInstanceDirectories,
serveAntigravityAuthorizationUrlSink,
type AntigravityAuthConfig,
} from "../antigravityAuthSupport.ts";
import {
Expand Down Expand Up @@ -267,6 +268,18 @@ export const AntigravityDriver: ProviderDriver<
),
),
);
const authorizationUrlSink =
input.onAuthorizationUrl === undefined
? undefined
: yield* serveAntigravityAuthorizationUrlSink(input.onAuthorizationUrl).pipe(
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.", {
error,
}).pipe(Effect.as(undefined)),
),
);
const runtime = yield* makeAntigravityAcpRuntime({
...input,
runtimeVersion: executable.version,
Expand All @@ -279,6 +292,7 @@ export const AntigravityDriver: ProviderDriver<
baseEnv: processEnvironment,
auth,
runtimeTempDirectory,
...(authorizationUrlSink === undefined ? {} : { authorizationUrlSink }),
}),
}).pipe(Effect.provideService(SecureRandom.SecureRandom, random));
return {
Expand Down
76 changes: 75 additions & 1 deletion apps/server/src/provider/antigravityAuthSupport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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 Ndjson from "effect/encoding/Ndjson";
import * as AcpErrors from "effect-acp/errors";
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
Expand All @@ -33,6 +34,7 @@ import {
parseAntigravityAuthorizationUrl,
prepareAntigravityProfile,
resolveAntigravityInstanceDirectories,
serveAntigravityAuthorizationUrlSink,
} from "./antigravityAuthSupport.ts";

const authorizationUrl =
Expand Down Expand Up @@ -74,6 +76,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",
CHROME_CRASHPAD_PIPE_NAME: "\\\\.\\pipe\\crashpad_50144_ZUJNJELFHHXPBIRT",
Expand Down Expand Up @@ -466,7 +469,9 @@ describe("Antigravity stderr compatibility", () => {
);
});

it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {
const profilePreparationLayer = Layer.mergeAll(NodeServices.layer, NodeCrypto.layer);

it.layer(profilePreparationLayer)("Antigravity profile preparation", (it) => {
const prepareSpawn = Effect.fn(function* (
bundles: ReadonlyArray<string>,
baseEnv: NodeJS.ProcessEnv = {},
Expand Down Expand Up @@ -678,6 +683,75 @@ it.layer(NodeServices.layer)("Antigravity profile preparation", (it) => {
}),
);

const runBrowserHelper = Effect.fn("runBrowserHelper")(function* (env: Record<string, string>) {
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 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) };
});

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`,
});
}
}),
);

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 preflightHelper = (exitDelay: Effect.Effect<void>) => {
const spawns = { count: 0 };
const started = Deferred.makeUnsafe<void>();
Expand Down
77 changes: 74 additions & 3 deletions apps/server/src/provider/antigravityAuthSupport.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
// @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 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";
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";
Expand Down Expand Up @@ -51,11 +62,22 @@ 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.
// `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 =
`process.stderr.on("error",()=>process.exit(0)).write(` +
`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",` +
`()=>process.exit(0))`;
`()=>process.exit(0)))`;
const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight";

const removedEnvironmentKeys = new Set([
Expand All @@ -78,6 +100,7 @@ const removedEnvironmentKeys = new Set([
"ELECTRON_RUN_AS_NODE",
// An inherited pipe whose owner exited makes Electron-as-Node print Crashpad errors to stderr.
"CHROME_CRASHPAD_PIPE_NAME",
authorizationUrlSinkEnvironmentKey,
]);

export interface AntigravityProfile {
Expand Down Expand Up @@ -461,6 +484,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,
Expand All @@ -482,11 +506,58 @@ export function buildAntigravityAcpSpawnInput(input: {
...(input.installation.harnessPath === null
? {}
: { 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<void, AcpErrors.AcpError>,
) {
const token = yield* (yield* SecureRandom.SecureRandom).uuidV4();
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(
(cause) =>
new AcpErrors.AcpTransportError({
detail: "The Antigravity sign-in URL listener could not start.",
cause,
}),
),
);

export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")(
function* (
authorizationUrl: string,
Expand Down
Loading