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";...@@ -18,6 +18,7 @@ import { describe, expect } from "vite-plus/test";
1818
19import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";19import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
20import type * as EffectAcpProtocol from "effect-acp/protocol";20import type * as EffectAcpProtocol from "effect-acp/protocol";
21import * as EffectAcpErrors from "effect-acp/errors";
2122
22const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));23const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
23const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");24const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts");
...@@ -345,6 +346,28 @@ describe("AcpSessionRuntime", () => {...@@ -345,6 +346,28 @@ describe("AcpSessionRuntime", () => {
345 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),346 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
346 );347 );
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
348 it.effect("drains large stderr output and keeps auth-sized logging chunks", () =>371 it.effect("drains large stderr output and keeps auth-sized logging chunks", () =>
349 Effect.gen(function* () {372 Effect.gen(function* () {
350 const lengths: Array<number> = [];373 const lengths: Array<number> = [];
apps/server/src/provider/acp/AcpSessionRuntime.ts+19-4
...@@ -102,8 +102,8 @@ export interface AcpSessionRuntimeOptions {...@@ -102,8 +102,8 @@ export interface AcpSessionRuntimeOptions {
102 readonly transformSessionUpdate?: (102 readonly transformSessionUpdate?: (
103 notification: EffectAcpSchema.SessionNotification,103 notification: EffectAcpSchema.SessionNotification,
104 ) => EffectAcpSchema.SessionNotification;104 ) => EffectAcpSchema.SessionNotification;
105 /** Receives bounded stderr chunks. The provider must redact any secrets before logging. */105 /** Receives bounded stderr chunks. Redact secrets before logging. A failure closes the runtime. */
106 readonly onStderr?: (text: string) => Effect.Effect<void, never>;106 readonly onStderr?: (text: string) => Effect.Effect<void, EffectAcpErrors.AcpError>;
107 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;107 readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>;
108 readonly protocolLogging?: {108 readonly protocolLogging?: {
109 readonly logIncoming?: boolean;109 readonly logIncoming?: boolean;
...@@ -353,6 +353,7 @@ export const make = (...@@ -353,6 +353,7 @@ export const make = (
353 Option.none(),353 Option.none(),
354 );354 );
355 const stoppingRef = yield* Ref.make(false);355 const stoppingRef = yield* Ref.make(false);
356 const stderrFailure = yield* Deferred.make<never, EffectAcpErrors.AcpError>();
356 const runtimeClosed = yield* Deferred.make<void>();357 const runtimeClosed = yield* Deferred.make<void>();
357 const promptSerializationSemaphore = yield* Semaphore.make(1);358 const promptSerializationSemaphore = yield* Semaphore.make(1);
358 const promptDispatchSemaphore = yield* Semaphore.make(1);359 const promptDispatchSemaphore = yield* Semaphore.make(1);
...@@ -400,7 +401,10 @@ export const make = (...@@ -400,7 +401,10 @@ export const make = (
400 ): Effect.Effect<A, EffectAcpErrors.AcpError> =>401 ): Effect.Effect<A, EffectAcpErrors.AcpError> =>
401 logRequest({ method, payload, status: "started" }).pipe(402 logRequest({ method, payload, status: "started" }).pipe(
402 Effect.flatMap(() =>403 Effect.flatMap(() =>
403 effect.pipe(404 (options.onStderr
405 ? Effect.raceFirst(effect, Deferred.await(stderrFailure))
406 : effect
407 ).pipe(
404 Effect.tap((result) =>408 Effect.tap((result) =>
405 logRequest({409 logRequest({
406 method,410 method,
...@@ -448,7 +452,18 @@ export const make = (...@@ -448,7 +452,18 @@ export const make = (
448 yield* child.stderr.pipe(452 yield* child.stderr.pipe(
449 Stream.decodeText(),453 Stream.decodeText(),
450 Stream.runForEach((chunk) =>454 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 ),
452 ),467 ),
453 Effect.ignore,468 Effect.ignore,
454 Effect.forkIn(runtimeScope),469 Effect.forkIn(runtimeScope),
apps/server/src/provider/acp/AntigravityAcpSupport.ts+3-7
...@@ -73,13 +73,9 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(...@@ -73,13 +73,9 @@ export const makeAntigravityAcpRuntime = Effect.fn("makeAntigravityAcpRuntime")(
73 transformStdout: makeAntigravityStdoutTransform(73 transformStdout: makeAntigravityStdoutTransform(
74 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},74 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},
75 ),75 ),
76 ...(input.onAuthorizationUrl76 onStderr: makeAntigravityStderrHandler(
77 ? {77 input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {},
78 onStderr: makeAntigravityStderrHandler({78 ),
79 onAuthorizationUrl: input.onAuthorizationUrl,
80 }),
81 }
82 : {}),
83 transformSessionUpdate: normalizeAntigravitySessionUpdate,79 transformSessionUpdate: normalizeAntigravitySessionUpdate,
84 }).pipe(80 }).pipe(
85 Layer.provide(81 Layer.provide(
apps/server/src/provider/antigravityAuthSupport.test.ts+42
...@@ -391,6 +391,46 @@ describe("Antigravity stdout compatibility", () => {...@@ -391,6 +391,46 @@ describe("Antigravity stdout compatibility", () => {
391});391});
392392
393describe("Antigravity stderr compatibility", () => {393describe("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
394 it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () =>434 it.effect("forwards an accepted browser-helper URL larger than 8 KiB", () =>
395 Effect.gen(function* () {435 Effect.gen(function* () {
396 const urls: string[] = [];436 const urls: string[] = [];
...@@ -438,6 +478,8 @@ describe("Antigravity stderr compatibility", () => {...@@ -438,6 +478,8 @@ describe("Antigravity stderr compatibility", () => {
438 yield* handleStderr(478 yield* handleStderr(
439 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson("https://example.com")}\n`,479 `${ANTIGRAVITY_AUTH_BROWSER_MARKER}${encodeUnknownJson("https://example.com")}\n`,
440 );480 );
481 yield* handleStderr(`${ANTIGRAVITY_AUTH_STDOUT_PREFIX}https://example.com\n`);
482 yield* handleStderr(` ${ANTIGRAVITY_AUTH_STDOUT_PREFIX}${authorizationUrl}\n`);
441483
442 expect(urls).toEqual([]);484 expect(urls).toEqual([]);
443 }),485 }),
apps/server/src/provider/antigravityAuthSupport.ts+26-14
...@@ -25,7 +25,9 @@ export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE =...@@ -25,7 +25,9 @@ export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE =
2525
26const maxAuthorizationUrlLength = 16_384;26const maxAuthorizationUrlLength = 16_384;
27const maxBrowserHelperLineLength =27const 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;
29const maxStdoutLineBytes = 16 * 1024 * 1024;31const maxStdoutLineBytes = 16 * 1024 * 1024;
30const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX);32const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX);
31const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString);33const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString);
...@@ -458,25 +460,35 @@ export function makeAntigravityStdoutTransform(...@@ -458,25 +460,35 @@ export function makeAntigravityStdoutTransform(
458 });460 });
459}461}
460462
461/** Receives the URL emitted by T3's no-browser helper on stderr. */463/** Receives native 1.1.1 sign-in URLs and T3 browser-helper URLs without logging stderr. */
462export function makeAntigravityStderrHandler(input: {464export function makeAntigravityStderrHandler(
463 readonly onAuthorizationUrl: (465 input: {
464 authorizationUrl: string,466 readonly onAuthorizationUrl?: (
465 ) => Effect.Effect<void, AcpErrors.AcpError>;467 authorizationUrl: string,
466}) {468 ) => Effect.Effect<void, AcpErrors.AcpError>;
469 } = {},
470) {
467 let pending = "";471 let pending = "";
468 const handleLine = (line: string) => {472 const handleLine = (line: string) => {
469 const message = line.endsWith("\r") ? line.slice(0, -1) : line;473 const message = line.endsWith("\r") ? line.slice(0, -1) : line;
470 if (474 if (message.length > maxBrowserHelperLineLength) {
471 message.length > maxBrowserHelperLineLength ||
472 !message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER)
473 ) {
474 return Effect.void;475 return Effect.void;
475 }476 }
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(
477 Effect.flatMap(parseAntigravityAuthorizationUrl),484 Effect.flatMap(parseAntigravityAuthorizationUrl),
478 Effect.flatMap((request) => input.onAuthorizationUrl(request.authorizationUrl)),485 Effect.matchEffect({
479 Effect.ignore,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 }),
480 );492 );
481 };493 };
482494
docs/internals/providers.md+7-5
...@@ -104,11 +104,13 @@ environment extension, sets `PYTHONUNBUFFERED=1`, and controls `BROWSER`. A test...@@ -104,11 +104,13 @@ environment extension, sets `PYTHONUNBUFFERED=1`, and controls `BROWSER`. A test
104Electron-as-Node helper prevents the official agent from opening a browser on the environment.104Electron-as-Node helper prevents the official agent from opening a browser on the environment.
105The same launch factory serves setup, health checks, chat, and text generation.105The 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 is107The official agent prints a non-JSON OAuth line on stderr in version 1.1.1. Earlier versions
108filtered before ACP decoding. Fragmented lines are joined and bounded. Other malformed108print it on stdout. T3 accepts the exact native prefix on either stream and its browser-helper
109protocol output remains fatal. Authorization URLs are validated before use. Native stderr is109marker on stderr. Fragmented lines are joined and bounded. Other malformed protocol output
110drained without logging because it can contain OAuth data. Normal work rejects an interactive110remains fatal. Authorization URLs are validated before use. Other stderr is discarded because
111login request with a sign-in-required error instead of waiting for consent.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
113[`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance115[`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance
114scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or116scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or