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