authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-03 16:39:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 16:39:17-07:00
logeb334ca57448742139fb8ec38fb397c1e51c45c5
tree81c65b7aef5e777e0ff4163740b4e56005b7b038
parentbaf67b6e3accd48d510fd0d39a4e6b978a1eea80
signature Signed by PGP key B5690EEEBB952194

fix(antigravity): handle native sign-in URLs on stderr (#9514)


6 files changed, 120 insertions(+), 30 deletions(-)

apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+23
......@@ -18,6 +18,7 @@ import { describe, expect } from "vite-plus/test";
1818
1919import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
2020import type * as EffectAcpProtocol from "effect-acp/protocol";
21import * as EffectAcpErrors from "effect-acp/errors";
2122
2223const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
2324const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
......@@ -345,6 +346,28 @@ describe("AcpSessionRuntime", () => {
345346 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
346347 );
347348
349 it.effect("fails a pending request when the stderr handler rejects the runtime", () =>
350 Effect.gen(function* () {
351 const failure = new EffectAcpErrors.AcpTransportError({
352 detail: "Sign in before continuing.",
353 cause: undefined,
354 });
355 const runtime = yield* AcpSessionRuntime.make({
356 ...mockRuntimeOptions,
357 spawn: { ...mockRuntimeOptions.spawn, env: { T3_ACP_FLOOD_STDERR: "1" } },
358 onStderr: () => Effect.fail(failure),
359 });
360 expect(yield* runtime.start().pipe(Effect.flip)).toBe(failure);
361 const events = yield* runtime.getEvents().pipe(
362 Stream.filter((event) => event._tag === "ConnectionTerminated"),
363 Stream.take(1),
364 Stream.runCollect,
365 );
366 expect(events[0]?.error).toBe(failure);
367 expect(yield* runtime.initialize().pipe(Effect.flip)).toBe(failure);
368 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
369 );
370
348371 it.effect("drains large stderr output and keeps auth-sized logging chunks", () =>
349372 Effect.gen(function* () {
350373 const lengths: Array<number> = [];
apps/server/src/provider/acp/AcpSessionRuntime.ts+19-4
......@@ -102,8 +102,8 @@ export interface AcpSessionRuntimeOptions {
102102 readonly transformSessionUpdate?: (
103103 notification: EffectAcpSchema.SessionNotification,
104104 ) => EffectAcpSchema.SessionNotification;
105 /** Receives bounded stderr chunks. The provider must redact any secrets before logging. */
106 readonly onStderr?: (text: string) => Effect.Effect<void, never>;
105 /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */
106 readonly onStderr?: (text: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
107107 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
108108 readonly protocolLogging?: {
109109 readonly logIncoming?: boolean;
......@@ -353,6 +353,7 @@ export const make = (
353353 Option.none(),
354354 );
355355 const stoppingRef = yield* Ref.make(false);
356 const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>();
356357 const runtimeClosed = yield* Deferred.make<void>();
357358 const promptSerializationSemaphore = yield* Semaphore.make(1);
358359 const promptDispatchSemaphore = yield* Semaphore.make(1);
......@@ -400,7 +401,10 @@ export const make = (
400401 ): Effect.Effect<A, EffectAcpErrors.AcpError> =>
401402 logRequest({ method, payload, status: "started" }).pipe(
402403 Effect.flatMap(() =>
403 effect.pipe(
404 (options.onStderr
405 ? Effect.raceFirst(effect, Deferred.await(stderrFailure))
406 : effect
407 ).pipe(
404408 Effect.tap((result) =>
405409 logRequest({
406410 method,
......@@ -448,7 +452,18 @@ export const make = (
448452 yield* child.stderr.pipe(
449453 Stream.decodeText(),
450454 Stream.runForEach((chunk) =>
451 options.onStderr ? options.onStderr(chunk.slice(-maxStderrChunkLength)) : Effect.void,
455 (options.onStderr
456 ? options.onStderr(chunk.slice(-maxStderrChunkLength))
457 : Effect.void
458 ).pipe(
459 Effect.catch((error) =>
460 Effect.gen(function* () {
461 yield* Deferred.fail(stderrFailure, error);
462 yield* recordTermination(error);
463 yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore);
464 }),
465 ),
466 ),
452467 ),
453468 Effect.ignore,
454469 Effect.forkIn(runtimeScope),
apps/server/src/provider/acp/AntigravityAcpSupport.ts+3-7
......@@ -73,13 +73,9 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(
7373 transformStdout: makeAntigravityStdoutTransform(
7474 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},
7575 ),
76 ...(input.onAuthorizationUrl
77 ? {
78 onStderr: makeAntigravityStderrHandler({
79 onAuthorizationUrl: input.onAuthorizationUrl,
80 }),
81 }
82 : {}),
76 onStderr: makeAntigravityStderrHandler(
77 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},
78 ),
8379 transformSessionUpdate: normalizeAntigravitySessionUpdate,
8480 }).pipe(
8581 Layer.provide(
apps/server/src/provider/antigravityAuthSupport.test.ts+42
......@@ -391,6 +391,46 @@ describe("Antigravity stdout compatibility", () => {
391391});
392392
393393describe("Antigravity stderr compatibility", () => {
394 it.effect("forwards fragmented native sign-in URLs from runtime 1.1.1", () =>
395 Effect.gen(function* () {
396 const urls: string[] = [];
397 const line = `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\r\n`;
398 const handleStderr = makeAntigravityStderrHandler({
399 onAuthorizationUrl: (url) => Effect.sync(() => void urls.push(url)),
400 });
401 yield* handleStderr(`native log\n${line.slice(0, 40)}`);
402 yield* handleStderr(line.slice(40, 90));
403 yield* handleStderr(`${line.slice(90)}another native log\n`);
404 expect(urls).toEqual([authorizationUrl]);
405 }),
406 );
407
408 it.effect("rejects interactive sign-in during normal work", () =>
409 Effect.gen(function* () {
410 const handleStderr = makeAntigravityStderrHandler();
411 const error = yield* handleStderr(
412 `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`,
413 ).pipe(Effect.flip);
414 expect(isAntigravitySignInRequiredError(error)).toBe(true);
415 }),
416 );
417
418 it.effect("preserves failures from the sign-in flow owner", () =>
419 Effect.gen(function* () {
420 const failure = new AcpErrors.AcpTransportError({
421 detail: "The sign-in flow stopped.",
422 cause: undefined,
423 });
424 const handleStderr = makeAntigravityStderrHandler({
425 onAuthorizationUrl: () => Effect.fail(failure),
426 });
427 const error = yield* handleStderr(
428 `${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`,
429 ).pipe(Effect.flip);
430 expect(error).toBe(failure);
431 }),
432 );
433
394434 it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () =>
395435 Effect.gen(function* () {
396436 const urls: string[] = [];
......@@ -438,6 +478,8 @@ describe("Antigravity stderr compatibility", () => {
438478 yield* handleStderr(
439479 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson("https://example.com")}\n`,
440480 );
481 yield* handleStderr(`${ANTIGRAVITY_AUTH_STDOUT_PREFIX}https://example.com\n`);
482 yield* handleStderr(` ${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`);
441483
442484 expect(urls).toEqual([]);
443485 }),
apps/server/src/provider/antigravityAuthSupport.ts+26-14
......@@ -25,7 +25,9 @@ export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE =
2525
2626const maxAuthorizationUrlLength = 16_384;
2727const maxBrowserHelperLineLength =
28 ANTIGRAVITY_AUTH_BROWSER_MARKER.length + maxAuthorizationUrlLength + 2;
28 Math.max(ANTIGRAVITY_AUTH_BROWSER_MARKER.length, ANTIGRAVITY_AUTH_STDOUT_PREFIX.length) +
29 maxAuthorizationUrlLength +
30 2;
2931const maxStdoutLineBytes = 16 * 1024 * 1024;
3032const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX);
3133const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString);
......@@ -458,25 +460,35 @@ export function makeAntigravityStdoutTransform(
458460 });
459461}
460462
461/** Receives the URL emitted by T3's no-browser helper on stderr. */
462export function makeAntigravityStderrHandler(input: {
463 readonly onAuthorizationUrl: (
464 authorizationUrl: string,
465 ) => Effect.Effect<void, AcpErrors.AcpError>;
466}) {
463/** Receives native 1.1.1 sign-in URLs and T3 browser-helper URLs without logging stderr. */
464export function makeAntigravityStderrHandler(
465 input: {
466 readonly onAuthorizationUrl?: (
467 authorizationUrl: string,
468 ) => Effect.Effect<void, AcpErrors.AcpError>;
469 } = {},
470) {
467471 let pending = "";
468472 const handleLine = (line: string) => {
469473 const message = line.endsWith("\r") ? line.slice(0, -1) : line;
470 if (
471 message.length > maxBrowserHelperLineLength ||
472 !message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER)
473 ) {
474 if (message.length > maxBrowserHelperLineLength) {
474475 return Effect.void;
475476 }
476 return decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length)).pipe(
477 const url = message.startsWith(ANTIGRAVITY_AUTH_STDOUT_PREFIX)
478 ? Effect.succeed(message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length))
479 : message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER)
480 ? decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length))
481 : undefined;
482 if (url === undefined) return Effect.void;
483 return url.pipe(
477484 Effect.flatMap(parseAntigravityAuthorizationUrl),
478 Effect.flatMap((request) => input.onAuthorizationUrl(request.authorizationUrl)),
479 Effect.ignore,
485 Effect.matchEffect({
486 onFailure: () => Effect.void,
487 onSuccess: (request) =>
488 input.onAuthorizationUrl
489 ? input.onAuthorizationUrl(request.authorizationUrl)
490 : Effect.fail(authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)),
491 }),
480492 );
481493 };
482494
docs/internals/providers.md+7-5
......@@ -104,11 +104,13 @@ environment extension, sets `PYTHONUNBUFFERED=1`, and controls `BROWSER`. A test
104104Electron-as-Node helper prevents the official agent from opening a browser on the environment.
105105The same launch factory serves setup, health checks, chat, and text generation.
106106
107The official agent prints one non-JSON OAuth line on stdout. Only the exact known prefix is
108filtered before ACP decoding. Fragmented lines are joined and bounded. Other malformed
109protocol output remains fatal. Authorization URLs are validated before use. Native stderr is
110drained without logging because it can contain OAuth data. Normal work rejects an interactive
111login request with a sign-in-required error instead of waiting for consent.
107The official agent prints a non-JSON OAuth line on stderr in version 1.1.1. Earlier versions
108print it on stdout. T3 accepts the exact native prefix on either stream and its browser-helper
109marker on stderr. Fragmented lines are joined and bounded. Other malformed protocol output
110remains fatal. Authorization URLs are validated before use. Other stderr is discarded because
111it can contain OAuth data. Normal work rejects an interactive login request with a
112sign-in-required error instead of waiting for consent. A rejected stderr callback fails pending
113ACP requests and closes the owned process.
112114
113115[`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance
114116scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or