| 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,6 +18,7 @@ import { describe, expect } from "vite-plus/test"; |
| 18 | 18 | ||
| 19 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 19 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 20 | import type * as EffectAcpProtocol from "effect-acp/protocol"; | 20 | import type * as EffectAcpProtocol from "effect-acp/protocol"; |
| 21 | import * as EffectAcpErrors from "effect-acp/errors"; | ||
| 21 | 22 | ||
| 22 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 23 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| 23 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | 24 | const 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 | ); |
| 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 | 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.onAuthorizationUrl | 76 | 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 | }); |
| 392 | 392 | ||
| 393 | describe("Antigravity stderr compatibility", () => { | 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 | 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`); | ||
| 441 | 483 | ||
| 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 = |
| 25 | 25 | ||
| 26 | const maxAuthorizationUrlLength = 16_384; | 26 | const maxAuthorizationUrlLength = 16_384; |
| 27 | const maxBrowserHelperLineLength = | 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 | const maxStdoutLineBytes = 16 * 1024 * 1024; | 31 | const maxStdoutLineBytes = 16 * 1024 * 1024; |
| 30 | const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); | 32 | const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX); |
| 31 | const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); | 33 | const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString); |
| ... | @@ -458,25 +460,35 @@ export function makeAntigravityStdoutTransform( | ... | @@ -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. */ | 463 | /** Receives native 1.1.1 sign-in URLs and T3 browser-helper URLs without logging stderr. */ |
| 462 | export function makeAntigravityStderrHandler(input: { | 464 | export 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 | }; |
| 482 | 494 |
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 |
| 104 | Electron-as-Node helper prevents the official agent from opening a browser on the environment. | 104 | Electron-as-Node helper prevents the official agent from opening a browser on the environment. |
| 105 | The same launch factory serves setup, health checks, chat, and text generation. | 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 | 107 | The official agent prints a non-JSON OAuth line on stderr in version 1.1.1. Earlier versions |
| 108 | filtered before ACP decoding. Fragmented lines are joined and bounded. Other malformed | 108 | print it on stdout. T3 accepts the exact native prefix on either stream and its browser-helper |
| 109 | protocol output remains fatal. Authorization URLs are validated before use. Native stderr is | 109 | marker on stderr. Fragmented lines are joined and bounded. Other malformed protocol output |
| 110 | drained without logging because it can contain OAuth data. Normal work rejects an interactive | 110 | remains fatal. Authorization URLs are validated before use. Other stderr is discarded because |
| 111 | login request with a sign-in-required error instead of waiting for consent. | 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 | [`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance | 115 | [`AntigravityAuth`][antigravity-auth] owns each sign-in process and deadline in the instance |
| 114 | scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or | 116 | scope. Only the initiating T3 auth session receives its URL and flow ID or can complete or |