diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index 02e8b691a1334..21663e2b6f215 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -270,6 +270,56 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect("shows Antigravity's sanitized sign-in rejection only to the owner", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Deferred.fail( + harness.authenticated, + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: + "Onboarding failed: User is ineligible for free-tier.\n Reason: Your current account is not eligible for Antigravity. Try signing in with another personal Google account.\u0007 ", + method: "authenticate", + }), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.equal( + failed.message, + "Onboarding failed: User is ineligible for free-tier. Reason: Your current account is not eligible for Antigravity. Try signing in with another personal Google account.", + ); + const other = yield* phase(harness.auth, "failed", otherOwner); + assert.equal(other.message, "Sign-in failed in another client."); + yield* Deferred.await(harness.closed); + }), + ); + + it.effect("keeps sensitive authenticate rejection details out of the sign-in status", () => + Effect.gen(function* () { + for (const errorMessage of [ + `Sign-in failed at ${callbackUrl}`, + "Redirect to http%3A%2F%2F127.0.0.1 failed", + "Rejected key sk-123456789012345678901234567890", + "Account person@example.com is not eligible", + "Onboarding failed. Visit support.google.com for help.", + "Onboarding failed: See https:example.com for help.", + "Authentication failed: token sk-live-abcd-efgh", + ]) { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Deferred.fail( + harness.authenticated, + new AcpErrors.AcpRequestError({ code: -32603, errorMessage, method: "authenticate" }), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.equal(failed.message, "Google sign-in failed. Start sign-in again.", errorMessage); + yield* Deferred.await(harness.closed); + } + }), + ); + it.effect("accepts direct local or cached completion without a callback RPC", () => Effect.gen(function* () { const harness = yield* makeHarness({ interactive: false }); @@ -282,7 +332,7 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); - it.effect("rejects mismatched callbacks without sending any HTTP request", () => + it.effect("retains callback errors only for the owner until a valid callback is accepted", () => Effect.gen(function* () { const harness = yield* makeHarness(); const state = yield* harness.auth.controller.start(owner); @@ -297,9 +347,26 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { .complete(owner, { flowId: state.flowId!, callbackUrl: invalidUrl }) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(result)); + const waiting = yield* phase(harness.auth, "waiting"); + assert.equal( + waiting.inputError, + "This redirect URL does not belong to the current sign-in.", + ); + assert.equal(waiting.flowId, state.flowId); + assert.equal(waiting.authorizationUrl, authorizationUrl); + const other = yield* phase(harness.auth, "waiting", otherOwner); + assert.notExists(other.inputError); + assert.equal(other.message, "Sign-in is in progress in another client."); } assert.equal(harness.forwarded(), 0); - assert.equal((yield* phase(harness.auth, "waiting")).authorizationUrl, authorizationUrl); + const verifying = yield* harness.auth.controller.complete(owner, { + flowId: state.flowId!, + callbackUrl, + }); + assert.equal(verifying.phase, "verifying"); + assert.notExists(verifying.inputError); + assert.equal(harness.forwarded(), 1); + assert.notExists((yield* phase(harness.auth, "verifying")).inputError); yield* harness.auth.controller.cancel(owner, state.flowId!); }), ); @@ -343,9 +410,19 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { .cancel(otherOwner, state.flowId!) .pipe(Effect.exit); assert.isTrue(Exit.isFailure(wrongOwner)); + const rejected = yield* harness.auth.controller + .complete(owner, { flowId: state.flowId!, callbackUrl: "not a URL" }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(rejected)); + assert.equal( + (yield* phase(harness.auth, "waiting")).inputError, + "Paste the complete redirect URL from the Google sign-in page.", + ); const cancelled = yield* harness.auth.controller.cancel(owner, state.flowId!); assert.equal(cancelled.phase, "cancelled"); assert.isNull(cancelled.authorizationUrl); + assert.notExists(cancelled.inputError); + assert.notExists((yield* phase(harness.auth, "cancelled")).inputError); assert.equal(harness.forwarded(), 0); yield* Deferred.await(harness.closed); assert.deepEqual(harness.catalog(), ["previous-account-model"]); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index ea1c8f7448cc2..602944dbf3fb1 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -96,10 +96,29 @@ function visibleSnapshot(snapshot: AuthSnapshot, ownerSessionId: string): Provid authorizationUrl: null, interaction: null, expiresAt: null, - ...(busy ? { message: "Sign-in is in progress in another client." } : {}), + inputError: null, + ...(busy + ? { message: "Sign-in is in progress in another client." } + : snapshot.state.phase === "failed" + ? { message: "Sign-in failed in another client." } + : {}), }; } +// Antigravity's authenticate rejection is the only text that says why Google turned the +// account down, but the same channel can echo callback URLs, tokens, paths, or emails. +function safeRejectionReason(message: string): string | undefined { + const text = message.replace(/[\p{Cc}\s]+/gu, " ").trim(); + const plainProse = + text.length > 0 && + text.length <= 300 && + /\p{L}/u.test(text) && + /^[\p{L}\p{N} .,;:!?'"()-]+$/u.test(text) && + !/[\p{L}\p{N}][.:][\p{L}\p{N}]/u.test(text) && + !/\S{20,}|\d{6,}|\bsk-[a-z0-9-]+/i.test(text); + return plainProse ? text : undefined; +} + function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): string { const error = Cause.findErrorOption(cause); if (Option.isSome(error)) { @@ -119,6 +138,10 @@ function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): str if (!usesBrowser && error.value.code === -32602) { return "Antigravity rejected the configured credentials. Check the provider settings."; } + if (error.value.method === "authenticate") { + const reason = safeRejectionReason(error.value.errorMessage); + if (reason) return reason; + } } } return usesBrowser @@ -143,6 +166,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < authorizationUrl: null, expiresAt: null, message: null, + inputError: null, }; const snapshot = yield* SubscriptionRef.make({ ownerSessionId: null, @@ -226,6 +250,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < phase: Exit.isSuccess(result) ? "succeeded" : "failed", authorizationUrl: null, expiresAt: null, + inputError: null, message: Exit.isSuccess(result) ? usesBrowser ? "Signed in with Google." @@ -276,6 +301,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < ...flow.state, phase: "verifying", authorizationUrl: null, + inputError: null, message: "Checking Antigravity access and models.", }); }), @@ -306,6 +332,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < phase, authorizationUrl: null, expiresAt: null, + inputError: null, message, }); return true; @@ -398,12 +425,17 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < options.instanceId, flow.pending, input.callbackUrl, + ).pipe( + Effect.tapError((error) => + publishFlow(flow, { ...flow.state, inputError: error.detail }), + ), ); flow.callbackSent = true; yield* publishFlow(flow, { ...flow.state, phase: "verifying", authorizationUrl: null, + inputError: null, message: "Waiting for Google to finish sign-in.", }); const forwarding = yield* ( @@ -452,6 +484,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < phase: "cancelled", authorizationUrl: null, expiresAt: null, + inputError: null, message: "Google sign-in was cancelled by sign-out.", }); } diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts index 69394bed3c146..821d34c4275c6 100644 --- a/packages/contracts/src/providerSetup.ts +++ b/packages/contracts/src/providerSetup.ts @@ -130,6 +130,8 @@ export const ProviderAuthState = Schema.Struct({ authorizationUrl: Schema.NullOr(Schema.String), expiresAt: Schema.NullOr(IsoDateTime), message: Schema.NullOr(Schema.String), + // Callback validation feedback while the sign-in flow keeps waiting. + inputError: Schema.optionalKey(Schema.NullOr(Schema.String)), // Newer servers may add method types, interactions, or owners; older // clients drop what they cannot decode instead of rejecting the state. methods: Schema.optionalKey(