1// @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's symlink has no type argument, and Windows needs a junction to link without elevation.
2import * as NodeFSP from "node:fs/promises";
3
4import type { AntigravityAuthMethod, ProviderInstanceId } from "@t3tools/contracts";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime";
7import * as Digest from "effect/crypto/Digest";
8import * as Effect from "effect/Effect";
9import * as Hex from "effect/encoding/Hex";
10import * as FileSystem from "effect/FileSystem";
11import * as Path from "effect/Path";
12import type * as PlatformError from "effect/PlatformError";
13import * as Schema from "effect/Schema";
14import * as Stream from "effect/Stream";
15import * as ChildProcess from "effect/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17import * as AcpErrors from "effect-acp/errors";
18
19import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
20import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
21import {
22 antigravityUserSkillDirectories,
23 resolveAntigravityUserHome,
24} from "./Drivers/AntigravitySkills.ts";
25
26export const ANTIGRAVITY_AUTH_STDOUT_PREFIX =
27 "Open the following link to authenticate the ACP server: ";
28export const ANTIGRAVITY_AUTH_BROWSER_MARKER = "__T3_ANTIGRAVITY_AUTH_URL__";
29export const ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE =
30 "Sign in to Antigravity in Settings before you continue.";
31
32const maxAuthorizationUrlLength = 16_384;
33const maxBrowserHelperLineLength =
34 Math.max(ANTIGRAVITY_AUTH_BROWSER_MARKER.length, ANTIGRAVITY_AUTH_STDOUT_PREFIX.length) +
35 maxAuthorizationUrlLength +
36 2;
37const maxStdoutLineBytes = 16 * 1024 * 1024;
38const authPrefixBytes = new TextEncoder().encode(ANTIGRAVITY_AUTH_STDOUT_PREFIX);
39const decodeUrl = Schema.decodeUnknownEffect(Schema.URLFromString);
40const decodeBrowserHelperUrl = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String));
41const ProfileSettingsFile = Schema.Struct({
42 auth: Schema.Struct({ type: Schema.String }),
43 gcp: Schema.optional(
44 Schema.Struct({
45 project: Schema.optional(Schema.String),
46 location: Schema.optional(Schema.String),
47 }),
48 ),
49});
50const encodeProfileSettings = Schema.encodeSync(Schema.fromJsonString(ProfileSettingsFile));
51const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError);
52const isAcpTransportError = Schema.is(AcpErrors.AcpTransportError);
53
54// Python splits BROWSER on path separators before parsing quotes. Exit 0 on EPIPE to prevent browser fallback after cancellation.
55const browserHelperSource =
56 `process.stderr.on("error",()=>process.exit(0)).write(` +
57 `"${ANTIGRAVITY_AUTH_BROWSER_MARKER}"+JSON.stringify(process.argv[1])+"\\n",` +
58 `()=>process.exit(0))`;
59const browserPreflightUrl = "https://example.invalid/t3-antigravity-browser-preflight";
60
61const removedEnvironmentKeys = new Set([
62 "GEMINI_API_KEY",
63 "GOOGLE_API_KEY",
64 "GOOGLE_APPLICATION_CREDENTIALS",
65 "GOOGLE_CLOUD_PROJECT",
66 "GOOGLE_CLOUD_LOCATION",
67 "GOOGLE_CLOUD_QUOTA_PROJECT",
68 "GOOGLE_GENAI_USE_VERTEXAI",
69 "GCLOUD_PROJECT",
70 "CLOUDSDK_CORE_PROJECT",
71 "AGY_ACP_CCPA_PROJECT",
72 "AGY_ACP_ENABLE_OAUTH",
73 "GEMINI_HOME",
74 "AGY_ACP_FORCE_FILE_STORAGE",
75 "ANTIGRAVITY_HARNESS_PATH",
76 "BROWSER",
77 "PYTHONUNBUFFERED",
78 "ELECTRON_RUN_AS_NODE",
79 // An inherited pipe whose owner exited makes Electron-as-Node print Crashpad errors to stderr.
80 "CHROME_CRASHPAD_PIPE_NAME",
81]);
82
83export interface AntigravityProfile {
84 readonly platform: NodeJS.Platform;
85 readonly geminiHome: string;
86 readonly acpDirectory: string;
87 readonly tokenPath: string;
88 readonly tempDirectory: string;
89 readonly browserCommand: string;
90 readonly caBundlePath?: string;
91}
92
93export interface AntigravityAuthConfig {
94 readonly authMethod: AntigravityAuthMethod;
95 readonly apiKey: string;
96 readonly gcpProject: string;
97 readonly gcpLocation: string;
98}
99
100const ANTIGRAVITY_PERSONAL_AUTH: AntigravityAuthConfig = {
101 authMethod: "oauth-personal",
102 apiKey: "",
103 gcpProject: "",
104 gcpLocation: "",
105};
106
107export function antigravityAuthUsesBrowser(authMethod: AntigravityAuthMethod): boolean {
108 return authMethod === "oauth-personal" || authMethod === "oauth-business";
109}
110
111export function antigravityAuthLabel(authMethod: AntigravityAuthMethod): string {
112 switch (authMethod) {
113 case "oauth-personal":
114 return "Google account";
115 case "oauth-business":
116 return "Gemini Enterprise";
117 case "gemini-api-key":
118 return "Gemini API key";
119 case "agent-platform":
120 return "Agent Platform";
121 }
122}
123
124export function antigravityAuthConfigIssue(auth: AntigravityAuthConfig): string | null {
125 switch (auth.authMethod) {
126 case "oauth-personal":
127 return null;
128 case "oauth-business":
129 return auth.gcpProject && auth.gcpLocation
130 ? null
131 : "Gemini Enterprise needs a GCP project and location in the Antigravity provider settings.";
132 case "gemini-api-key":
133 return auth.apiKey ? null : "Enter a Gemini API key in the Antigravity provider settings.";
134 case "agent-platform":
135 return auth.apiKey || (auth.gcpProject && auth.gcpLocation)
136 ? null
137 : "Agent Platform needs an API key, or a GCP project and location, in the Antigravity provider settings.";
138 }
139}
140
141/**
142 * `auth.type` names the selected method so a native logout clears only that method's credentials
143 * instead of every stored token. Never holds a credential.
144 */
145function antigravityProfileSettings(auth: AntigravityAuthConfig): string {
146 const gcp = {
147 ...(auth.gcpProject ? { project: auth.gcpProject } : {}),
148 ...(auth.gcpLocation ? { location: auth.gcpLocation } : {}),
149 };
150 return `${encodeProfileSettings({
151 auth: { type: auth.authMethod },
152 ...(Object.keys(gcp).length > 0 ? { gcp } : {}),
153 })}\n`;
154}
155
156export interface AntigravityAuthorizationUrl {
157 readonly authorizationUrl: string;
158 readonly redirectUri: string;
159 readonly state: string;
160}
161
162function authSupportError(detail: string) {
163 return new AcpErrors.AcpTransportError({ detail, cause: undefined });
164}
165
166export function isAntigravitySignInRequiredError(error: unknown): boolean {
167 return (
168 (isAcpRequestError(error) && error.code === -32000) ||
169 (isAcpTransportError(error) && error.detail === ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)
170 );
171}
172
173export interface AntigravityInstanceDirectories {
174 readonly profile: string;
175 /** Keep beside the profile: 120-character archive members under its longer name would exceed Windows' 260-character limit. */
176 readonly runtimeTemp: string;
177}
178
179/** Hashes the instance ID so case-only differences stay separate on case-insensitive filesystems. */
180export const resolveAntigravityInstanceDirectories = Effect.fn(
181 "resolveAntigravityInstanceDirectories",
182)(function* (stateDir: string, instanceId: ProviderInstanceId) {
183 const digest = yield* Digest.Digest;
184 const path = yield* Path.Path;
185 const key = Hex.encode(yield* digest.digest(Digest.sha256, new TextEncoder().encode(instanceId)));
186 return {
187 profile: path.join(stateDir, "providers", "antigravity", key),
188 runtimeTemp: path.join(stateDir, "antigravity-tmp", key.slice(0, 12)),
189 } satisfies AntigravityInstanceDirectories;
190});
191
192function quoteBrowserArgument(value: string): string {
193 return `'${value.replaceAll("'", `'"'"'`)}'`;
194}
195
196function antigravityEnvironment(
197 profile: AntigravityProfile,
198 baseEnv: NodeJS.ProcessEnv,
199 auth: AntigravityAuthConfig,
200 runtimeTempDirectory?: string,
201) {
202 const environment: NodeJS.ProcessEnv = {};
203 for (const [key, value] of Object.entries(baseEnv)) {
204 // Windows treats environment keys as case-insensitive. Remove aliases too.
205 if (!removedEnvironmentKeys.has(key.toUpperCase())) environment[key] = value;
206 }
207 // Put the GCP pair in settings.json because GOOGLE_API_KEY takes precedence in the environment.
208 const credential =
209 auth.authMethod === "gemini-api-key" && auth.apiKey
210 ? { GEMINI_API_KEY: auth.apiKey }
211 : auth.authMethod === "agent-platform" && auth.apiKey
212 ? { GOOGLE_API_KEY: auth.apiKey }
213 : {};
214 // Each launch unpacks about 1 GB; use T3-owned temp so the driver can reclaim files after force-kill.
215 const tempDirectory = runtimeTempDirectory ?? profile.tempDirectory;
216 return {
217 ...environment,
218 ...credential,
219 ...(profile.caBundlePath !== undefined &&
220 baseEnv.SSL_CERT_FILE === undefined &&
221 baseEnv.SSL_CERT_DIR === undefined
222 ? { SSL_CERT_FILE: profile.caBundlePath }
223 : {}),
224 GEMINI_HOME: profile.geminiHome,
225 AGY_ACP_FORCE_FILE_STORAGE: "1",
226 BROWSER: profile.browserCommand,
227 PYTHONUNBUFFERED: "1",
228 ELECTRON_RUN_AS_NODE: "1",
229 ...(profile.platform === "win32"
230 ? { TEMP: tempDirectory, TMP: tempDirectory }
231 : { TMPDIR: tempDirectory }),
232 };
233}
234
235class AntigravitySkillLinkError extends Schema.TaggedError<AntigravitySkillLinkError>()(
236 "AntigravitySkillLinkError",
237 { link: Schema.String, cause: Schema.Defect() },
238) {
239 override get message(): string {
240 return `Could not link Antigravity user skills at ${this.link}.`;
241 }
242}
243
244/** Private GEMINI_HOME isolates credentials/hooks/MCP; link global skills from the real ~/.gemini. */
245const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(function* (input: {
246 readonly profileDirectory: string;
247 readonly userHome: string;
248 readonly platform: NodeJS.Platform;
249}): Effect.fn.Return<void, never, FileSystem.FileSystem | Path.Path> {
250 const fs = yield* FileSystem.FileSystem;
251 const path = yield* Path.Path;
252 const links = antigravityUserSkillDirectories(path, input.profileDirectory);
253 const targets = antigravityUserSkillDirectories(path, path.join(input.userHome, ".gemini"));
254 for (const [link, target] of [
255 [links[0], targets[0]],
256 [links[1], targets[1]],
257 ] as const) {
258 yield* Effect.gen(function* () {
259 // The harness creates these folders itself, and mkdir fails on a dangling link.
260 if (!(yield* fs.exists(target))) {
261 const linked = yield* fs.readLink(link).pipe(
262 Effect.as(true),
263 Effect.orElseSucceed(() => false),
264 );
265 if (linked) yield* fs.remove(link);
266 return;
267 }
268 const existing = yield* fs.readLink(link).pipe(
269 Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)),
270 Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined),
271 // An empty folder is the harness's placeholder from a launch without the link.
272 // rmdir refuses a non-empty folder, so content added meanwhile is never deleted.
273 Effect.catch((error) =>
274 Effect.tryPromise({
275 try: () => NodeFSP.rmdir(link),
276 catch: (cause) => new AntigravitySkillLinkError({ link, cause }),
277 }).pipe(
278 Effect.as(undefined),
279 Effect.mapError(() => error),
280 ),
281 ),
282 );
283 if (existing === target) return;
284 if (existing !== undefined) {
285 yield* fs.remove(link);
286 }
287 yield* fs.makeDirectory(path.dirname(link), { recursive: true });
288 yield* Effect.tryPromise({
289 try: () => NodeFSP.symlink(target, link, input.platform === "win32" ? "junction" : "dir"),
290 catch: (cause) => new AntigravitySkillLinkError({ link, cause }),
291 });
292 }).pipe(
293 Effect.catch((error) =>
294 Effect.logWarning("Antigravity user skills are not linked into the profile.", {
295 link,
296 target,
297 error,
298 }),
299 ),
300 );
301 }
302});
303
304export const prepareAntigravityProfile = Effect.fn("prepareAntigravityProfile")(function* (input: {
305 readonly profileDirectory: string;
306 readonly baseEnv?: NodeJS.ProcessEnv;
307 readonly runtimeExecutablePath?: string;
308 readonly platform?: NodeJS.Platform;
309 readonly auth?: AntigravityAuthConfig;
310 readonly userHome?: string;
311 readonly tempDirectory?: string;
312}) {
313 const auth = input.auth ?? ANTIGRAVITY_PERSONAL_AUTH;
314 const fs = yield* FileSystem.FileSystem;
315 const path = yield* Path.Path;
316 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
317 const platform = input.platform ?? (yield* HostProcess.Platform);
318 const userHome =
319 input.userHome ??
320 resolveAntigravityUserHome(
321 platform,
322 input.baseEnv ?? process.env,
323 yield* HostProcess.HomeDirectory,
324 );
325 const runtimeExecutablePath =
326 input.runtimeExecutablePath ??
327 (yield* resolveNodeExecutable("Antigravity sign-in", input.baseEnv).pipe(
328 Effect.mapError(
329 (cause) =>
330 new AcpErrors.AcpTransportError({
331 detail: nodeRuntimeUnavailableMessage("Antigravity sign-in"),
332 cause,
333 }),
334 ),
335 ));
336 const helperExecutable =
337 platform === "win32" ? runtimeExecutablePath.replaceAll("\\", "/") : runtimeExecutablePath;
338 const browserArguments = [helperExecutable, "-e", browserHelperSource, "--", "%s"];
339 const browserCommand = browserArguments.map(quoteBrowserArgument).join(" ");
340 if (
341 browserCommand.includes(platform === "win32" ? ";" : ":") ||
342 helperExecutable.includes("\r") ||
343 helperExecutable.includes("\n") ||
344 helperExecutable.includes("\0") ||
345 helperExecutable.includes("%s")
346 ) {
347 return yield* authSupportError(
348 "The T3 runtime path cannot be used to suppress Antigravity browser launches.",
349 );
350 }
351
352 const geminiHome = path.resolve(input.profileDirectory);
353 const acpDirectory = path.join(geminiHome, "antigravity-acp");
354 const tempDirectory = input.tempDirectory ?? path.join(acpDirectory, "tmp");
355 let caBundlePath: string | undefined;
356 if (platform === "linux") {
357 // Bundled Python may look for certificates at paths absent on this distribution.
358 // Relative paths would resolve differently when the agent runs in the project directory.
359 for (const candidate of [
360 (input.baseEnv ?? process.env).NIX_SSL_CERT_FILE,
361 "/etc/ssl/certs/ca-bundle.crt",
362 "/etc/ssl/certs/ca-certificates.crt",
363 "/etc/pki/tls/certs/ca-bundle.crt",
364 "/etc/ssl/cert.pem",
365 ]) {
366 if (
367 candidate &&
368 path.isAbsolute(candidate) &&
369 (yield* fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)))
370 ) {
371 caBundlePath = candidate;
372 break;
373 }
374 }
375 }
376 const profile: AntigravityProfile = {
377 platform,
378 geminiHome,
379 acpDirectory,
380 tokenPath: path.join(acpDirectory, "acp_token.json"),
381 tempDirectory,
382 browserCommand,
383 ...(caBundlePath === undefined ? {} : { caBundlePath }),
384 };
385 const environment = antigravityEnvironment(profile, input.baseEnv ?? process.env, auth);
386 yield* Effect.gen(function* () {
387 const child = yield* spawner.spawn(
388 ChildProcess.make(helperExecutable, ["-e", browserHelperSource, "--", browserPreflightUrl], {
389 env: environment,
390 extendEnv: false,
391 shell: false,
392 }),
393 );
394 const [stdout, stderr, exitCode] = yield* Effect.all(
395 [
396 collectUint8StreamText({ stream: child.stdout, maxBytes: 4_096 }),
397 collectUint8StreamText({ stream: child.stderr, maxBytes: 4_096 }),
398 child.exitCode,
399 ],
400 { concurrency: "unbounded" },
401 );
402 if (
403 Number(exitCode) !== 0 ||
404 stdout.bytes !== 0 ||
405 stdout.truncated ||
406 stderr.truncated ||
407 stderr.text !== `${ANTIGRAVITY_AUTH_BROWSER_MARKER}"${browserPreflightUrl}"\n`
408 ) {
409 return yield* authSupportError("Antigravity browser suppression could not be verified.");
410 }
411 }).pipe(
412 Effect.scoped,
413 Effect.timeoutOrElse({
414 duration: "30 seconds",
415 orElse: () =>
416 Effect.fail(authSupportError("Antigravity browser suppression verification timed out.")),
417 }),
418 Effect.mapError((error) =>
419 error._tag === "AcpTransportError"
420 ? error
421 : authSupportError("Antigravity browser suppression could not be verified."),
422 ),
423 );
424
425 for (const directory of [geminiHome, acpDirectory, tempDirectory]) {
426 yield* fs
427 .makeDirectory(directory, { recursive: true, mode: 0o700 })
428 .pipe(
429 Effect.mapError(() =>
430 authSupportError("The Antigravity profile directory could not be created."),
431 ),
432 );
433 if (platform !== "win32") {
434 yield* fs
435 .chmod(directory, 0o700)
436 .pipe(
437 Effect.mapError(() =>
438 authSupportError("The Antigravity profile directory permissions could not be set."),
439 ),
440 );
441 }
442 }
443 yield* fs
444 .writeFileString(path.join(acpDirectory, "settings.json"), antigravityProfileSettings(auth))
445 .pipe(
446 Effect.mapError(() =>
447 authSupportError("The Antigravity profile settings could not be written."),
448 ),
449 );
450 yield* linkAntigravityUserSkills({ profileDirectory: geminiHome, userHome, platform });
451 return profile;
452});
453
454export function buildAntigravityAcpSpawnInput(input: {
455 readonly installation: {
456 readonly executablePath: string;
457 readonly harnessPath: string | null;
458 };
459 readonly profile: AntigravityProfile;
460 readonly cwd: string;
461 readonly baseEnv?: NodeJS.ProcessEnv;
462 readonly auth?: AntigravityAuthConfig;
463 readonly runtimeTempDirectory?: string;
464}): AcpSessionRuntime.AcpSpawnInput {
465 return {
466 command: input.installation.executablePath,
467 // The agy CLI has no harness sibling and serves ACP through its `acp` subcommand.
468 args:
469 input.installation.harnessPath === null
470 ? [
471 "acp",
472 "--gemini-dir",
473 input.profile.geminiHome,
474 "--app-data-dir",
475 // The CLI joins this beneath gemini-dir, including when the value is absolute.
476 "antigravity-acp",
477 ]
478 : input.profile.platform === "linux"
479 ? ["--uid="]
480 : [],
481 cwd: input.cwd,
482 env: {
483 ...antigravityEnvironment(
484 input.profile,
485 input.baseEnv ?? process.env,
486 input.auth ?? ANTIGRAVITY_PERSONAL_AUTH,
487 input.runtimeTempDirectory,
488 ),
489 ...(input.installation.harnessPath === null
490 ? {}
491 : { ANTIGRAVITY_HARNESS_PATH: input.installation.harnessPath }),
492 },
493 extendEnv: false,
494 };
495}
496
497export const parseAntigravityAuthorizationUrl = Effect.fn("parseAntigravityAuthorizationUrl")(
498 function* (
499 authorizationUrl: string,
500 ): Effect.fn.Return<AntigravityAuthorizationUrl, AcpErrors.AcpError> {
501 const invalidUrl = () =>
502 authSupportError("Antigravity returned an invalid Google sign-in URL.");
503 if (authorizationUrl.length > maxAuthorizationUrlLength || /\s/.test(authorizationUrl)) {
504 return yield* invalidUrl();
505 }
506 const url = yield* decodeUrl(authorizationUrl).pipe(Effect.mapError(invalidUrl));
507 const state = url.searchParams.get("state");
508 const redirectUri = url.searchParams.get("redirect_uri");
509 if (
510 url.origin !== "https://accounts.google.com" ||
511 url.pathname !== "/o/oauth2/v2/auth" ||
512 url.username !== "" ||
513 url.password !== "" ||
514 url.hash !== "" ||
515 url.searchParams.getAll("state").length !== 1 ||
516 url.searchParams.getAll("redirect_uri").length !== 1 ||
517 url.searchParams.getAll("response_type").length !== 1 ||
518 url.searchParams.get("response_type") !== "code" ||
519 state === null ||
520 state.length === 0 ||
521 state.length > 512 ||
522 /\s/.test(state) ||
523 redirectUri === null ||
524 !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}\/$/.test(redirectUri)
525 ) {
526 return yield* invalidUrl();
527 }
528 const redirect = yield* decodeUrl(redirectUri).pipe(Effect.mapError(invalidUrl));
529 if (Number(redirect.port) < 1_024) return yield* invalidUrl();
530 return { authorizationUrl, redirectUri, state };
531 },
532);
533
534export function makeAntigravityStdoutTransform(
535 input: {
536 readonly onAuthorizationUrl?: (
537 authorizationUrl: string,
538 ) => Effect.Effect<void, AcpErrors.AcpError>;
539 } = {},
540) {
541 const handleLine = Effect.fn("antigravityAuthSupport.handleStdoutLine")(function* (
542 line: Uint8Array,
543 ) {
544 if (!authPrefixBytes.every((byte, index) => line[index] === byte)) return [line];
545 const message = new TextDecoder().decode(line).replace(/\r?\n$/, "");
546 const request = yield* parseAntigravityAuthorizationUrl(
547 message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length),
548 );
549 if (!input.onAuthorizationUrl) {
550 return yield* authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE);
551 }
552 yield* input.onAuthorizationUrl(request.authorizationUrl);
553 return [];
554 });
555
556 return (
557 stdout: ChildProcessSpawner.ChildProcessHandle["stdout"],
558 ): Stream.Stream<Uint8Array, PlatformError.PlatformError | AcpErrors.AcpError> =>
559 Stream.suspend(() => {
560 let pending: Uint8Array[] = [];
561 let pendingBytes = 0;
562 const finishLine = () => {
563 const line = Buffer.concat(pending, pendingBytes);
564 pending = [];
565 pendingBytes = 0;
566 return line;
567 };
568 return stdout.pipe(
569 Stream.mapEffect(
570 Effect.fn("antigravityAuthSupport.splitStdoutLines")(function* (chunk: Uint8Array) {
571 const lines: Uint8Array[] = [];
572 let offset = 0;
573 while (offset < chunk.byteLength) {
574 const newline = chunk.indexOf(10, offset);
575 const end = newline === -1 ? chunk.byteLength : newline + 1;
576 const part = chunk.subarray(offset, end);
577 if (pendingBytes + part.byteLength > maxStdoutLineBytes) {
578 return yield* authSupportError(
579 "Antigravity sent a protocol line that is too large.",
580 );
581 }
582 pending.push(part);
583 pendingBytes += part.byteLength;
584 if (newline !== -1) lines.push(finishLine());
585 offset = end;
586 }
587 return lines;
588 }),
589 ),
590 Stream.flatMap(Stream.fromIterable),
591 Stream.concat(
592 Stream.suspend(() => (pendingBytes > 0 ? Stream.succeed(finishLine()) : Stream.empty)),
593 ),
594 Stream.mapEffect(handleLine),
595 Stream.flatMap(Stream.fromIterable),
596 );
597 });
598}
599
600export function makeAntigravityStderrHandler(
601 input: {
602 readonly onAuthorizationUrl?: (
603 authorizationUrl: string,
604 ) => Effect.Effect<void, AcpErrors.AcpError>;
605 } = {},
606) {
607 let pending = "";
608 const handleLine = (line: string) => {
609 const message = line.endsWith("\r") ? line.slice(0, -1) : line;
610 if (message.length > maxBrowserHelperLineLength) {
611 return Effect.void;
612 }
613 const url = message.startsWith(ANTIGRAVITY_AUTH_STDOUT_PREFIX)
614 ? Effect.succeed(message.slice(ANTIGRAVITY_AUTH_STDOUT_PREFIX.length))
615 : message.startsWith(ANTIGRAVITY_AUTH_BROWSER_MARKER)
616 ? decodeBrowserHelperUrl(message.slice(ANTIGRAVITY_AUTH_BROWSER_MARKER.length))
617 : undefined;
618 if (url === undefined) return Effect.void;
619 return url.pipe(
620 Effect.flatMap(parseAntigravityAuthorizationUrl),
621 Effect.matchEffect({
622 onFailure: () => Effect.void,
623 onSuccess: (request) =>
624 input.onAuthorizationUrl
625 ? input.onAuthorizationUrl(request.authorizationUrl)
626 : Effect.fail(authSupportError(ANTIGRAVITY_SIGN_IN_REQUIRED_MESSAGE)),
627 }),
628 );
629 };
630
631 return Effect.fn("antigravityAuthSupport.handleStderr")(function* (text: string) {
632 const lines = `${pending}${text}`.split("\n");
633 pending = lines.pop() ?? "";
634 if (pending.length > maxBrowserHelperLineLength) pending = "";
635 yield* Effect.forEach(lines, handleLine, { discard: true });
636 });
637}