Skip to content
117 changes: 117 additions & 0 deletions apps/server/src/provider/AntigravityAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,22 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* (
const discovered = yield* Deferred.make<void, AcpErrors.AcpError>();
const closed = yield* Deferred.make<void>();
const authenticateCalled = yield* Deferred.make<void>();
const sessionRequested = yield* Deferred.make<void>();
const events: string[] = [];
let receiveAuthorizationUrl:
| ((url: string) => Effect.Effect<void, AcpErrors.AcpError>)
| undefined;
let logRequest:
| ((event: AcpSessionRuntime.AcpSessionRequestLogEvent) => Effect.Effect<void>)
| undefined;
let forwarded = 0;
let catalog = ["previous-account-model"];
const auth = yield* makeAntigravityAuth({
instanceId,
makeRuntime: (input) =>
Effect.gen(function* () {
receiveAuthorizationUrl = input.onAuthorizationUrl;
logRequest = input.requestLogger;
events.push("process-open");
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
Expand All @@ -94,14 +99,30 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* (
start: () =>
Effect.gen(function* () {
events.push("authenticate");
yield* (
input.requestLogger?.({
method: "authenticate",
payload: { methodId: "oauth-personal" },
status: "started",
}) ?? Effect.void
);
yield* Deferred.succeed(authenticateCalled, undefined);
if (options.interactive !== false && input.onAuthorizationUrl) {
for (const url of options.authorizationUrls ?? [authorizationUrl]) {
yield* input.onAuthorizationUrl(url);
}
}
yield* Deferred.await(authenticated);
yield* (
input.requestLogger?.({
method: "authenticate",
payload: { methodId: "oauth-personal" },
status: "succeeded",
result: {},
}) ?? Effect.void
);
events.push("session-new");
yield* Deferred.succeed(sessionRequested, undefined);
yield* Deferred.await(discovered);
return started;
}),
Expand Down Expand Up @@ -133,6 +154,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* (
discovered,
closed,
authenticateCalled,
sessionRequested,
events,
catalog: () => catalog,
forwarded: () => forwarded,
Expand All @@ -142,6 +164,10 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* (
? receiveAuthorizationUrl(url)
: Effect.die("Authorization URL receiver is not ready."),
),
logRequest: (event: AcpSessionRuntime.AcpSessionRequestLogEvent) =>
Effect.suspend(() =>
logRequest ? logRequest(event) : Effect.die("Request logger is not ready."),
),
};
});

Expand Down Expand Up @@ -290,6 +316,97 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => {
}),
);

it.effect("does not blame the network when stored sign-in succeeds but no session opens", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({ interactive: false });
yield* harness.auth.controller.start(owner);
yield* Deferred.succeed(harness.authenticated, undefined);
yield* Deferred.await(harness.sessionRequested);

yield* TestClock.adjust("300 seconds");
const failed = yield* phase(harness.auth, "failed");
assert.notInclude(failed.message ?? "", "could not reach Google");
assert.match(failed.message ?? "", /session/i);
}),
);

it.effect("shows verification as soon as authenticate succeeds", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({ interactive: false });
yield* harness.auth.controller.start(owner);
yield* Deferred.succeed(harness.authenticated, undefined);
yield* Deferred.await(harness.sessionRequested);

const current = yield* harness.auth.controller
.subscribe(owner)
.pipe(Stream.runHead, Effect.map(Option.getOrThrow));
assert.equal(current.phase, "verifying");
assert.equal(current.message, "Checking Antigravity access and models.");
}),
);

it.effect("ignores a late duplicate Google link once sign-in authenticates", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
yield* harness.auth.controller.start(owner);
yield* phase(harness.auth, "waiting");
yield* Deferred.succeed(harness.authenticated, undefined);
yield* Deferred.await(harness.sessionRequested);

yield* harness.receiveAuthorizationUrl(authorizationUrl);
const current = yield* harness.auth.controller
.subscribe(owner)
.pipe(Stream.runHead, Effect.map(Option.getOrThrow));
assert.equal(current.phase, "verifying");
assert.isNull(current.authorizationUrl);
}),
);

it.effect("accepts the new Google link and redirect when the agent authenticates again", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
const state = yield* harness.auth.controller.start(owner);
yield* phase(harness.auth, "waiting");
yield* harness.auth.controller.complete(owner, { flowId: state.flowId!, callbackUrl });
yield* Deferred.succeed(harness.authenticated, undefined);
yield* Deferred.await(harness.sessionRequested);

const retryUrl = authorizationUrl.replace("state=test-state", "state=retry-state");
yield* harness.logRequest({
method: "authenticate",
payload: { methodId: "oauth-personal" },
status: "started",
});
yield* harness.receiveAuthorizationUrl(retryUrl);
const current = yield* harness.auth.controller
.subscribe(owner)
.pipe(Stream.runHead, Effect.map(Option.getOrThrow));
assert.equal(current.phase, "waiting");
assert.equal(current.authorizationUrl, retryUrl);

yield* harness.auth.controller.complete(owner, {
flowId: state.flowId!,
callbackUrl: callbackUrl.replace("state=test-state", "state=retry-state"),
});
assert.equal(harness.forwarded(), 2);
}),
);

it.effect("does not call a completed Google sign-in expired when no session opens", () =>
Effect.gen(function* () {
const harness = yield* makeHarness();
yield* harness.auth.controller.start(owner);
yield* phase(harness.auth, "waiting");
yield* Deferred.succeed(harness.authenticated, undefined);
yield* Deferred.await(harness.sessionRequested);

yield* TestClock.adjust("300 seconds");
const failed = yield* phase(harness.auth, "failed");
assert.notInclude(failed.message ?? "", "expired");
assert.match(failed.message ?? "", /session/i);
}),
);

it.effect("accepts direct local or cached completion without a callback RPC", () =>
Effect.gen(function* () {
const harness = yield* makeHarness({ interactive: false });
Expand Down
59 changes: 43 additions & 16 deletions apps/server/src/provider/AntigravityAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const NO_RESPONSE_MESSAGE =
const UNREACHABLE_MESSAGE =
"Antigravity could not reach Google. Check this machine's network connection, then start sign-in again.";
const FORWARDING_FAILED_MESSAGE = "Could not deliver the sign-in response. Start sign-in again.";
const NO_SESSION_MESSAGE =
"Antigravity signed in, but did not finish loading its session and models. Start sign-in again.";
const isSetupError = Schema.is(ProviderSetupError);
const isAcpRequestError = Schema.is(AcpErrors.AcpRequestError);

Expand All @@ -54,6 +56,7 @@ interface AuthFlow {
state: ProviderAuthState;
pending: AntigravityAuthorizationUrl | undefined;
callbackSent: boolean;
authenticated: boolean;
fiber: Fiber.Fiber<void> | undefined;
forwarding: Fiber.Fiber<void, ProviderSetupError> | undefined;
}
Expand Down Expand Up @@ -82,6 +85,9 @@ export interface AntigravityAuthOptions<
readonly instanceId: ProviderInstanceId;
readonly makeRuntime: (input: {
readonly onAuthorizationUrl?: (url: string) => Effect.Effect<void, AcpErrors.AcpError>;
readonly requestLogger?: (
event: AcpSessionRuntime.AcpSessionRequestLogEvent,
) => Effect.Effect<void>;
}) => Effect.Effect<Runtime, AcpErrors.AcpError | ProviderSetupError, Scope.Scope>;
readonly onAuthenticated: (
result: AcpSessionRuntime.AcpSessionRuntimeStartResult,
Expand Down Expand Up @@ -247,7 +253,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
Effect.flatMap((authorization) =>
lock.withPermits(1)(
Effect.gen(function* () {
if (activeFlow !== flow || operation !== "auth") return;
if (activeFlow !== flow || operation !== "auth" || flow.authenticated) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (flow.pending) {
if (flow.pending.authorizationUrl === authorization.authorizationUrl) return;
return yield* new AcpErrors.AcpTransportError({
Expand All @@ -268,6 +274,20 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
),
);

const publishVerifying = (flow: AuthFlow) =>
lock.withPermits(1)(
Effect.gen(function* () {
if (activeFlow !== flow) return;
flow.pending = undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
yield* publishFlow(flow, {
...flow.state,
phase: "verifying",
authorizationUrl: null,
message: "Checking Antigravity access and models.",
});
}),
);

const runSignIn = Effect.fn("AntigravityAuth.runSignIn")(
function* (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) {
yield* stopSessions.pipe(Effect.ensuring(stopOwnedProcesses));
Expand All @@ -284,20 +304,24 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
);
const runtime = yield* options.makeRuntime({
onAuthorizationUrl: (url) => receiveAuthorizationUrl(flow, url),
// start() also opens the session, which can hang after authentication succeeds.
// A retry after auth-required starts a new attempt that may show a new Google link.
requestLogger: (event) => {
if (event.method !== "authenticate" || event.status === "failed") return Effect.void;
if (event.status === "started") {
return lock.withPermits(1)(
Effect.sync(() => {
flow.authenticated = false;
flow.callbackSent = false;
}),
);
}
flow.authenticated = true;
return publishVerifying(flow);
},
});
const started = yield* runtime.start();
yield* lock.withPermits(1)(
Effect.gen(function* () {
if (activeFlow !== flow) return;
flow.pending = undefined;
yield* publishFlow(flow, {
...flow.state,
phase: "verifying",
authorizationUrl: null,
message: "Checking Antigravity access and models.",
});
}),
);
yield* publishVerifying(flow);
yield* options.onAuthenticated(started, runtime);
},
(effect, flow, _stopSessions) =>
Expand All @@ -309,9 +333,11 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
Effect.fail(
setupError(
"start",
flow.state.phase === "starting"
? UNREACHABLE_MESSAGE
: "Google sign-in expired. Start sign-in again.",
flow.authenticated
? NO_SESSION_MESSAGE
: flow.state.phase === "starting"
? UNREACHABLE_MESSAGE
: "Google sign-in expired. Start sign-in again.",
),
),
}),
Expand Down Expand Up @@ -399,6 +425,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* <
state,
pending: undefined,
callbackSent: false,
authenticated: false,
fiber: undefined,
forwarding: undefined,
};
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/provider/Drivers/AntigravityDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export const AntigravityDriver: ProviderDriver<
});

const makeDisposableRuntime = Effect.fn("AntigravityDriver.makeDisposableRuntime")(function* (
input: Pick<AntigravityAcpRuntimeInput, "onAuthorizationUrl">,
input: Pick<AntigravityAcpRuntimeInput, "onAuthorizationUrl" | "requestLogger">,
) {
const cwd = yield* fileSystem
.makeTempDirectoryScoped({ prefix: "t3-antigravity-setup-" })
Expand Down Expand Up @@ -300,7 +300,7 @@ export const AntigravityDriver: ProviderDriver<
const runtime = yield* makeRuntime({
cwd,
mcpServers: [],
...(input.onAuthorizationUrl ? { onAuthorizationUrl: input.onAuthorizationUrl } : {}),
...input,
});
return {
...runtime,
Expand Down
Loading