1import {
2 ProviderSetupError,
3 type ProviderAuthState,
4 type ProviderInstanceId,
5} from "@t3tools/contracts";
6import * as Cause from "effect/Cause";
7import * as Clock from "effect/Clock";
8import * as SecureRandom from "effect/crypto/SecureRandom";
9import * as DateTime from "effect/DateTime";
10import * as Deferred from "effect/Deferred";
11import * as Effect from "effect/Effect";
12import * as Exit from "effect/Exit";
13import * as Fiber from "effect/Fiber";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16import * as Scope from "effect/Scope";
17import * as Semaphore from "effect/Semaphore";
18import * as Stream from "effect/Stream";
19import * as SubscriptionRef from "effect/SubscriptionRef";
20import * as AcpErrors from "effect-acp/errors";
21
22import type * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
23import {
24 parseAntigravityAuthorizationUrl,
25 type AntigravityAuthorizationUrl,
26} from "./antigravityAuthSupport.ts";
27import {
28 forwardAntigravityCallback,
29 validateAntigravityCallbackUrl,
30} from "./antigravityCallback.ts";
31import type { ProviderAuthController } from "./ProviderAuthService.ts";
32
33const AUTH_TIMEOUT_MS = 300_000;
34// Once initialized, a healthy agent shows the Google link within seconds; its bundled Python has no
35// IPv4 fallback, so broken IPv6 stalls each Google request. Startup is timed separately: the
36// one-file runtime unpacks on every launch (about a minute on Windows) before it answers initialize.
37const NO_RESPONSE_HINT_MS = 30_000;
38const STARTUP_HINT_MS = 90_000;
39const NOT_STARTED_MESSAGE =
40 "Antigravity has not started after 90 seconds. Its runtime may be stuck starting on this machine.";
41const NOT_RESPONDING_MESSAGE =
42 "Antigravity did not respond. Its runtime may not run on this machine.";
43const NO_RESPONSE_MESSAGE =
44 "Antigravity has not reached Google after 30 seconds. Check this machine's network connection. Broken IPv6 is a common cause.";
45const UNREACHABLE_MESSAGE =
46 "Antigravity could not reach Google. Check this machine's network connection, then start sign-in again.";
47const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again.";
48const isSetupError = Schema.is(ProviderSetupError);
49const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError);
50
51interface AuthSnapshot {
52 readonly ownerSessionId: string | null;
53 readonly state: ProviderAuthState;
54}
55
56interface AuthFlow {
57 readonly id: string;
58 readonly ownerSessionId: string;
59 readonly expiresAtMillis: number;
60 state: ProviderAuthState;
61 pending: AntigravityAuthorizationUrl | undefined;
62 initialized: boolean;
63 callbackSent: boolean;
64 fiber: Fiber.Fiber<void> | undefined;
65 forwarding: Fiber.Fiber<void, ProviderSetupError> | undefined;
66}
67
68interface OwnedProcess {
69 readonly stop: Effect.Effect<void>;
70 startup: Fiber.Fiber<unknown, unknown> | undefined;
71}
72
73export interface AntigravityAuth {
74 readonly controller: ProviderAuthController;
75 readonly withProcess: <A, E, R>(
76 stop: Effect.Effect<void>,
77 task: Effect.Effect<A, E, R>,
78 ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>;
79}
80
81export type AntigravityAuthRuntime = Pick<
82 AcpSessionRuntime.AcpSessionRuntime["Service"],
83 "initialize" | "start" | "request"
84>;
85
86export interface AntigravityAuthOptions<
87 Runtime extends AntigravityAuthRuntime = AcpSessionRuntime.AcpSessionRuntime["Service"],
88> {
89 readonly instanceId: ProviderInstanceId;
90 readonly makeRuntime: (input: {
91 readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, AcpErrors.AcpError>;
92 }) => Effect.Effect<Runtime, AcpErrors.AcpError | ProviderSetupError, Scope.Scope>;
93 readonly onAuthenticated: (
94 result: AcpSessionRuntime.AcpSessionRuntimeStartResult,
95 runtime: Runtime,
96 ) => Effect.Effect<void>;
97 readonly onSignedOut: Effect.Effect<void>;
98 readonly forwardCallback?: (callback: URL) => Effect.Effect<void, ProviderSetupError>;
99 readonly usesBrowser?: boolean;
100}
101
102function visibleSnapshot(snapshot: AuthSnapshot, ownerSessionId: string): ProviderAuthState {
103 if (snapshot.ownerSessionId === null || snapshot.ownerSessionId === ownerSessionId) {
104 return snapshot.state;
105 }
106 const busy = ["starting", "waiting", "verifying"].includes(snapshot.state.phase);
107 return {
108 ...snapshot.state,
109 flowId: null,
110 authorizationUrl: null,
111 interaction: null,
112 expiresAt: null,
113 ...(busy ? { message: "Sign-in is in progress in another client." } : {}),
114 };
115}
116
117function safeAuthFailure(cause: Cause.Cause<unknown>, usesBrowser: boolean): string {
118 const error = Cause.findErrorOption(cause);
119 if (Option.isSome(error)) {
120 if (isSetupError(error.value)) {
121 return error.value.detail;
122 }
123 if (isAcpRequestError(error.value)) {
124 if (error.value.errorMessage.includes("SUBSCRIPTION_REQUIRED")) {
125 return "Google requires an eligible Antigravity subscription for this account.";
126 }
127 if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) {
128 return "Google sign-in was not approved. Start sign-in again.";
129 }
130 if (error.value.method === "session/new" && error.value.code === -32603) {
131 return "Antigravity authenticated, but could not initialize a session or load models.";
132 }
133 if (!usesBrowser && error.value.code === -32602) {
134 return "Antigravity rejected the configured credentials. Check the provider settings.";
135 }
136 }
137 }
138 return usesBrowser
139 ? "Google sign-in failed. Start sign-in again."
140 : "Antigravity could not authenticate with the configured credentials.";
141}
142
143export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
144 Runtime extends AntigravityAuthRuntime,
145>(
146 options: AntigravityAuthOptions<Runtime>,
147): Effect.fn.Return<AntigravityAuth, never, SecureRandom.SecureRandom | Scope.Scope> {
148 const random = yield* SecureRandom.SecureRandom;
149 const instanceScope = yield* Scope.Scope;
150 const usesBrowser = options.usesBrowser ?? true;
151 const lock = yield* Semaphore.make(1);
152 const closed = yield* Deferred.make<void>();
153 const emptyState: ProviderAuthState = {
154 instanceId: options.instanceId,
155 phase: "idle",
156 flowId: null,
157 authorizationUrl: null,
158 expiresAt: null,
159 message: null,
160 };
161 const snapshot = yield* SubscriptionRef.make<AuthSnapshot>({
162 ownerSessionId: null,
163 state: emptyState,
164 });
165 const processes = new Set<OwnedProcess>();
166 let activeFlow: AuthFlow | undefined;
167 let operation: "idle" | "auth" | "logout" | "cancel" | "closed" = "idle";
168
169 const setupError = (name: string, detail: string) =>
170 new ProviderSetupError({ instanceId: options.instanceId, operation: name, detail });
171 const currentState = (ownerSessionId: string) =>
172 SubscriptionRef.get(snapshot).pipe(
173 Effect.map((value) => visibleSnapshot(value, ownerSessionId)),
174 );
175 const publishFlow = (flow: AuthFlow, state: ProviderAuthState) => {
176 flow.state = state;
177 return SubscriptionRef.set(snapshot, { ownerSessionId: flow.ownerSessionId, state });
178 };
179 const stopOwnedProcesses = Effect.suspend(() =>
180 Effect.forEach(
181 Array.from(processes),
182 (owned) =>
183 Effect.gen(function* () {
184 if (owned.startup) {
185 yield* Fiber.interrupt(owned.startup);
186 }
187 yield* owned.stop;
188 }),
189 { discard: true, concurrency: "unbounded" },
190 ),
191 );
192
193 const withProcess: AntigravityAuth["withProcess"] = (stop, task) =>
194 Effect.uninterruptibleMask((restore) =>
195 Effect.gen(function* () {
196 const scope = yield* Scope.Scope;
197 const owned: OwnedProcess = { stop, startup: undefined };
198 const fiber = yield* lock.withPermits(1)(
199 Effect.gen(function* () {
200 if (operation !== "idle") {
201 return yield* setupError(
202 "startProcess",
203 "Antigravity sign-in or sign-out is in progress. Try again after it finishes.",
204 );
205 }
206 processes.add(owned);
207 yield* Scope.addFinalizer(
208 scope,
209 Effect.sync(() => {
210 processes.delete(owned);
211 }),
212 );
213 const child = yield* restore(task).pipe(Effect.forkIn(scope));
214 owned.startup = child;
215 return child;
216 }),
217 );
218 // Propagate interruption after the exit wait so concurrent stop waiters stay attached.
219 return yield* restore(Fiber.await(fiber)).pipe(
220 Effect.flatten,
221 Effect.ensuring(Fiber.interrupt(fiber)),
222 Effect.ensuring(
223 Effect.sync(() => {
224 owned.startup = undefined;
225 }),
226 ),
227 );
228 }),
229 );
230
231 const finishFlow = (flow: AuthFlow, result: Exit.Exit<void, unknown>) =>
232 lock.withPermits(1)(
233 Effect.gen(function* () {
234 if (activeFlow !== flow) return;
235 activeFlow = undefined;
236 operation = "idle";
237 flow.pending = undefined;
238 yield* publishFlow(flow, {
239 ...flow.state,
240 phase: Exit.isSuccess(result) ? "succeeded" : "failed",
241 authorizationUrl: null,
242 expiresAt: null,
243 message: Exit.isSuccess(result)
244 ? usesBrowser
245 ? "Signed in with Google."
246 : "Connected to Antigravity."
247 : safeAuthFailure(result.cause, usesBrowser),
248 });
249 }),
250 );
251
252 const receiveAuthorizationUrl = (flow: AuthFlow, url: string) =>
253 parseAntigravityAuthorizationUrl(url).pipe(
254 Effect.flatMap((authorization) =>
255 lock.withPermits(1)(
256 Effect.gen(function* () {
257 if (activeFlow !== flow || operation !== "auth") return;
258 if (flow.pending) {
259 if (flow.pending.authorizationUrl === authorization.authorizationUrl) return;
260 return yield* new AcpErrors.AcpTransportError({
261 detail: "Antigravity started more than one Google sign-in request.",
262 cause: undefined,
263 });
264 }
265 flow.pending = authorization;
266 yield* publishFlow(flow, {
267 ...flow.state,
268 phase: "waiting",
269 authorizationUrl: authorization.authorizationUrl,
270 message:
271 "Open the Google sign-in link. If you are remote, paste the redirect URL here.",
272 });
273 }),
274 ),
275 ),
276 );
277
278 const publishStartingHint = (flow: AuthFlow, delayMs: number, message: string) =>
279 Effect.sleep(delayMs).pipe(
280 Effect.andThen(
281 lock.withPermits(1)(
282 Effect.gen(function* () {
283 if (activeFlow !== flow || flow.state.phase !== "starting") return;
284 yield* publishFlow(flow, { ...flow.state, message });
285 }),
286 ),
287 ),
288 Effect.forkScoped,
289 );
290
291 const runSignIn = Effect.fn("AntigravityAuth.runSignIn")(
292 function* (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) {
293 yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses));
294 const runtime = yield* options.makeRuntime({
295 onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url),
296 });
297 const startupHint = yield* publishStartingHint(flow, STARTUP_HINT_MS, NOT_STARTED_MESSAGE);
298 // `start` reuses this memoized handshake.
299 yield* runtime.initialize();
300 flow.initialized = true;
301 yield* Fiber.interrupt(startupHint);
302 yield* publishStartingHint(flow, NO_RESPONSE_HINT_MS, NO_RESPONSE_MESSAGE);
303 const started = yield* runtime.start();
304 yield* lock.withPermits(1)(
305 Effect.gen(function* () {
306 if (activeFlow !== flow) return;
307 flow.pending = undefined;
308 yield* publishFlow(flow, {
309 ...flow.state,
310 phase: "verifying",
311 authorizationUrl: null,
312 message: "Checking Antigravity access and models.",
313 });
314 }),
315 );
316 yield* options.onAuthenticated(started, runtime);
317 },
318 (effect, flow, _stopSessions) =>
319 effect.pipe(
320 Effect.scoped,
321 Effect.timeoutOrElse({
322 duration: AUTH_TIMEOUT_MS,
323 orElse: () =>
324 Effect.fail(
325 setupError(
326 "start",
327 flow.state.phase !== "starting"
328 ? "Google sign-in expired. Start sign-in again."
329 : flow.initialized
330 ? UNREACHABLE_MESSAGE
331 : NOT_RESPONDING_MESSAGE,
332 ),
333 ),
334 }),
335 Effect.exit,
336 Effect.flatMap((result) => finishFlow(flow, result)),
337 ),
338 );
339
340 const stopFlow = (flow: AuthFlow, phase: "cancelled" | "failed", message: string) =>
341 Effect.uninterruptible(
342 Effect.gen(function* () {
343 const detached = yield* lock.withPermits(1)(
344 Effect.gen(function* () {
345 if (activeFlow !== flow) return false;
346 activeFlow = undefined;
347 operation = "cancel";
348 flow.pending = undefined;
349 yield* publishFlow(flow, {
350 ...flow.state,
351 phase,
352 authorizationUrl: null,
353 expiresAt: null,
354 message,
355 });
356 return true;
357 }),
358 );
359 if (!detached) return;
360 if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding);
361 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
362 yield* lock.withPermits(1)(
363 Effect.sync(() => {
364 if (operation === "cancel") operation = "idle";
365 }),
366 );
367 }),
368 );
369
370 const requireFlow = Effect.fn("AntigravityAuth.requireFlow")(function* (
371 ownerSessionId: string,
372 flowId: string,
373 name: string,
374 ) {
375 const flow = activeFlow;
376 if (!flow || flow.id !== flowId || flow.ownerSessionId !== ownerSessionId) {
377 return yield* setupError(name, "This sign-in is no longer active in this client.");
378 }
379 const now = yield* Clock.currentTimeMillis;
380 if (now >= flow.expiresAtMillis) {
381 return yield* setupError(name, "Google sign-in expired. Start sign-in again.");
382 }
383 return flow;
384 });
385
386 const controller: ProviderAuthController = {
387 start: (ownerSessionId, stopSessions = Effect.void) =>
388 lock.withPermits(1)(
389 Effect.uninterruptible(
390 Effect.gen(function* () {
391 if (activeFlow?.ownerSessionId === ownerSessionId && operation === "auth") {
392 return activeFlow.state;
393 }
394 if (operation !== "idle") {
395 return yield* setupError("start", "Antigravity setup is already in progress.");
396 }
397 const flowId = yield* random
398 .uuidV4()
399 .pipe(
400 Effect.mapError(() =>
401 setupError("start", "Could not start Google sign-in. Try again."),
402 ),
403 );
404 const expiresAtMillis = (yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS;
405 const state: ProviderAuthState = {
406 ...emptyState,
407 phase: "starting",
408 flowId,
409 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)),
410 message: usesBrowser ? "Starting Google sign-in." : "Checking credentials.",
411 };
412 const flow: AuthFlow = {
413 id: flowId,
414 ownerSessionId,
415 expiresAtMillis,
416 state,
417 pending: undefined,
418 initialized: false,
419 callbackSent: false,
420 fiber: undefined,
421 forwarding: undefined,
422 };
423 activeFlow = flow;
424 operation = "auth";
425 yield* publishFlow(flow, state);
426 flow.fiber = yield* runSignIn(flow, stopSessions).pipe(
427 Effect.interruptible,
428 Effect.forkIn(instanceScope),
429 );
430 return state;
431 }),
432 ),
433 ),
434 complete: Effect.fn("AntigravityAuth.complete")(function* (ownerSessionId, input) {
435 const pending = yield* lock.withPermits(1)(
436 Effect.gen(function* () {
437 const flow = yield* requireFlow(ownerSessionId, input.flowId, "complete");
438 if (!flow.pending || flow.callbackSent) {
439 return yield* setupError(
440 "complete",
441 flow.callbackSent
442 ? "The sign-in response was already sent. Wait for Google to finish."
443 : "Wait for the Google sign-in link before you send a redirect URL.",
444 );
445 }
446 const callback = yield* validateAntigravityCallbackUrl(
447 options.instanceId,
448 flow.pending,
449 input.callbackUrl,
450 );
451 flow.callbackSent = true;
452 yield* publishFlow(flow, {
453 ...flow.state,
454 phase: "verifying",
455 authorizationUrl: null,
456 message: "Waiting for Google to finish sign-in.",
457 });
458 const forwarding = yield* (
459 options.forwardCallback?.(callback) ??
460 forwardAntigravityCallback(options.instanceId, callback)
461 ).pipe(
462 // stopFlow interrupts this fiber, so it runs from a sibling fiber.
463 Effect.tapError(() =>
464 stopFlow(flow, "failed", FORWARDING_FAILED_MESSAGE).pipe(
465 Effect.forkIn(instanceScope),
466 ),
467 ),
468 Effect.interruptible,
469 Effect.forkIn(instanceScope),
470 );
471 flow.forwarding = forwarding;
472 return { flow, forwarding };
473 }),
474 );
475 const forwarded = yield* Fiber.await(pending.forwarding);
476 if (Exit.isFailure(forwarded)) {
477 return yield* setupError("complete", FORWARDING_FAILED_MESSAGE);
478 }
479 return pending.flow.state;
480 }),
481 cancel: Effect.fn("AntigravityAuth.cancel")(function* (ownerSessionId, flowId) {
482 const flow = yield* lock.withPermits(1)(requireFlow(ownerSessionId, flowId, "cancel"));
483 yield* stopFlow(flow, "cancelled", "Google sign-in was cancelled.");
484 return flow.state;
485 }),
486 logout: Effect.fn("AntigravityAuth.logout")(function* (stopSessions) {
487 const task = Effect.uninterruptibleMask((restore) =>
488 Effect.gen(function* () {
489 const flow = yield* lock.withPermits(1)(
490 Effect.gen(function* () {
491 if (operation !== "idle" && operation !== "auth") {
492 return yield* setupError("logout", "Antigravity setup is already stopping.");
493 }
494 operation = "logout";
495 const currentFlow = activeFlow;
496 activeFlow = undefined;
497 if (currentFlow) {
498 currentFlow.pending = undefined;
499 yield* publishFlow(currentFlow, {
500 ...currentFlow.state,
501 phase: "cancelled",
502 authorizationUrl: null,
503 expiresAt: null,
504 message: "Google sign-in was cancelled by sign-out.",
505 });
506 }
507 return currentFlow;
508 }),
509 );
510 const stopRemaining = Effect.gen(function* () {
511 if (flow?.forwarding) yield* Fiber.interrupt(flow.forwarding);
512 if (flow?.fiber) yield* Fiber.interrupt(flow.fiber);
513 yield* stopOwnedProcesses;
514 });
515 const result = yield* restore(
516 Effect.gen(function* () {
517 yield* stopSessions.pipe(Effect.ensuring(stopRemaining));
518 const runtime = yield* options.makeRuntime({});
519 const initialized = yield* runtime.initialize();
520 if (!initialized.agentCapabilities?.auth?.logout) {
521 return yield* setupError(
522 "logout",
523 "This Antigravity version does not support sign-out. Update the provider.",
524 );
525 }
526 yield* runtime.request("logout", {});
527 yield* options.onSignedOut;
528 }).pipe(
529 Effect.scoped,
530 Effect.timeoutOrElse({
531 duration: "90 seconds",
532 orElse: () => Effect.fail(setupError("logout", "Antigravity sign-out timed out.")),
533 }),
534 ),
535 ).pipe(Effect.exit);
536 yield* lock.withPermits(1)(
537 Effect.gen(function* () {
538 operation = "idle";
539 yield* SubscriptionRef.set(snapshot, {
540 ownerSessionId: null,
541 state: {
542 ...emptyState,
543 phase: Exit.isSuccess(result) ? "idle" : "failed",
544 message: Exit.isSuccess(result)
545 ? "Signed out of Google."
546 : "Antigravity sign-out failed. Try again.",
547 },
548 });
549 }),
550 );
551 if (Exit.isFailure(result)) {
552 const failure = Cause.findErrorOption(result.cause);
553 return yield* Option.isSome(failure) && isSetupError(failure.value)
554 ? failure.value
555 : setupError("logout", "Antigravity sign-out failed. Try again.");
556 }
557 return yield* currentState("");
558 }),
559 );
560 const worker = yield* task.pipe(Effect.forkIn(instanceScope));
561 return yield* Fiber.await(worker).pipe(Effect.flatten);
562 }),
563 subscribe: (ownerSessionId) =>
564 SubscriptionRef.changes(snapshot).pipe(
565 Stream.map((value) => {
566 const state = visibleSnapshot(value, ownerSessionId);
567 return {
568 ...state,
569 credentialOwner: "provider" as const,
570 interaction:
571 state.phase === "waiting" && state.authorizationUrl && state.flowId
572 ? {
573 type: "browser" as const,
574 id: state.flowId,
575 url: state.authorizationUrl,
576 requiresConsent: false,
577 acceptsCallback: true,
578 }
579 : null,
580 };
581 }),
582 Stream.interruptWhen(Deferred.await(closed)),
583 ),
584 isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout",
585 };
586
587 yield* Effect.addFinalizer(() =>
588 Effect.gen(function* () {
589 operation = "closed";
590 const flow = activeFlow;
591 activeFlow = undefined;
592 if (flow) {
593 flow.pending = undefined;
594 if (flow.forwarding) yield* Fiber.interrupt(flow.forwarding);
595 if (flow.fiber) yield* Fiber.interrupt(flow.fiber);
596 }
597 yield* stopOwnedProcesses;
598 yield* Deferred.succeed(closed, undefined);
599 }),
600 );
601
602 return { controller, withProcess };
603});