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