From 3aa487c82f0012ffbf13218166c8a2b6a360214a Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:13:27 +0900 Subject: [PATCH 1/3] fix(google): treat Retry-After as the earliest resend The Google-family retry wrapper clamped a provider Retry-After to its 2 s local backoff maximum and resent early. Honour it as a lower bound, and end with the upstream response when the instruction exceeds the shared 60 s wait deadline, matching fetchWithTransientRetry. Refs #7802 --- src/adapters/google-http.ts | 16 ++++- structure/transports/inventory.md | 2 +- .../google/google-vertex-http.test.ts | 64 ++++++++++++++++++- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index ddce944901a..cee4accea22 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -13,6 +13,7 @@ import { abortError, cancelResponseBodyBestEffort, fetchWithAttemptDeadline, + RETRY_AFTER_CEILING_MS, retryBackoffDelayMs, sleepWithAbort, SendBudgetExhaustedError, @@ -135,13 +136,22 @@ export async function fetchGoogleWithRetry( }); } } - pendingResponse = res; - recovery = res.status === 429 ? "rate-limit-429" : "transient-5xx"; - retryDelayMs = retryBackoffDelayMs(attempt, { + // Retry-After names the earliest send Google will serve (#7802). The local maximum bounds + // only our own backoff; it must not shorten the provider's instruction into a send it + // already said it would refuse. An instruction past the shared wait deadline ends with + // this upstream answer, Retry-After intact, exactly as fetchWithTransientRetry does. + const delayMs = retryBackoffDelayMs(attempt, { baseDelayMs: GOOGLE_RETRY_BASE_MS, maxDelayMs: GOOGLE_RETRY_MAX_MS, headers: res.headers, + retryAfterIsLowerBound: true, }); + if (delayMs > RETRY_AFTER_CEILING_MS) { + return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); + } + pendingResponse = res; + recovery = res.status === 429 ? "rate-limit-429" : "transient-5xx"; + retryDelayMs = delayMs; } catch (err) { if (ctx.abortSignal?.aborted) throw err; if (err instanceof SendBudgetExhaustedError) { diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 6a6cc614f6c..37699c0a913 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -38,7 +38,7 @@ surface is listed here so a maintainer can find the owner without grepping: | AWS eventstream | `src/lib/eventstream-decoder.ts` | Kiro's prelude and message checksums use `Bun.hash.crc32` over the supplied byte view, with unsigned IEEE CRC-32 results and incremental seed support. Corrupt or truncated frames fail decoding. The streaming decoder rejects advertised sizes above 16 MiB per frame or 128 KiB per header block once the corresponding prelude bytes are available. `tests/responses/eventstream-decoder.test.ts` covers fixed wire bytes, offset views, seeded checksums, corruption, and fragmented input. | | Responses custom-tool preview | `src/bridge/sse.ts`, `src/server/responses-custom-tool-repair.ts`, `src/responses/progressive-freeform-input.ts`, `src/responses/freeform-wrapper-scan.ts` | Direct adapter events and routed function restoration share one progressive wrapper decoder over one bounded JSON classification of the prefix, so property order and escaped key spellings preview as the wrapper completion unwraps them. Fence-shaped `exec`/`apply_patch` prefixes stay held until authoritative completion normalization, while each caller retains its own patch-envelope and byte-budget policy. | | Meta Muse Responses tool names | `src/responses/muse-tool-name-alias.ts`, `src/adapters/openai-responses.ts` | `api.meta.ai` only: function names over 64 characters or containing characters outside `[a-zA-Z0-9_-]` become collision-safe wire aliases and are restored before the client sees them. | -| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/adapters/google-wire-shape.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. Its transient ladder uses the shared `TRANSIENT_RETRY_MAX_ATTEMPTS` constant, also used to fund each eligible generic OAuth account in the [request send budget](responses-spend.md#credential-hop-reservations). Antigravity 403 normalization reads at most 4096 bytes for at most two seconds and emits a validation marker only for a complete structured `VALIDATION_REQUIRED` reason; an unsafe or incomplete body falls back to status-only text. The Google-family wrapper reuses shared abort/deadline helpers, upstream error normalization, and policy-aware wire-body repair: strict initial schema loss sends nothing, while strict repair withholding returns the original 400 without a changed send. The final compiler produces the [content-free tool-schema loss contract](../providers/google.md#google-tool-schema-loss-reporting). `google-wire-shape.ts` remains diagnostic-only. | +| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/adapters/google-wire-shape.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. Its transient ladder uses the shared `TRANSIENT_RETRY_MAX_ATTEMPTS` constant, also used to fund each eligible generic OAuth account in the [request send budget](responses-spend.md#credential-hop-reservations). A `Retry-After` on a retryable 429/503 is the earliest resend: the two-second local maximum bounds only the wrapper's own jittered backoff, and an instruction past the shared `RETRY_AFTER_CEILING_MS` (60 seconds) ends with that upstream response, `Retry-After` intact, instead of an early resend. Antigravity 403 normalization reads at most 4096 bytes for at most two seconds and emits a validation marker only for a complete structured `VALIDATION_REQUIRED` reason; an unsafe or incomplete body falls back to status-only text. The Google-family wrapper reuses shared abort/deadline helpers, upstream error normalization, and policy-aware wire-body repair: strict initial schema loss sends nothing, while strict repair withholding returns the original 400 without a changed send. The final compiler produces the [content-free tool-schema loss contract](../providers/google.md#google-tool-schema-loss-reporting). `google-wire-shape.ts` remains diagnostic-only. | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts`, `src/adapters/anthropic-image-codec.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. An image's ladder position is pinned to its own fixed-size digest of content and canonical media type rather than recomputed from recency each request (#4532); appending a newer image therefore cannot demote and re-encode older images and bust Anthropic's prompt prefix cache. Position bytes count toward the shared retained-memory budget but are pinned — the shared evictor clears normalization cache slots first and never drops a position mid-request; positions shrink only through the store's own entry-count cap at request end. Unseen images still take the age-tier pyramid's first position, the total byte budget still binds, and a 413 `tierBias` retry still applies. Recorded positions only move down the ladder, so the store is monotonic. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | diff --git a/tests/adapters/google/google-vertex-http.test.ts b/tests/adapters/google/google-vertex-http.test.ts index 412f47ac2f9..fede330c371 100644 --- a/tests/adapters/google/google-vertex-http.test.ts +++ b/tests/adapters/google/google-vertex-http.test.ts @@ -255,11 +255,14 @@ describe("vertex retry fetch", () => { value: () => neverSettles, }); const mock = mockFetch([first, new Response("ok", { status: 200 })]); + // Retry-After is a floor over the jittered backoff (#7802), so stub the backoff sleep: the + // race below measures only whether the resend waits on the discarded body's cancellation. + const waits = spyOn(retry, "sleepWithAbort").mockImplementation(async () => {}); const res = await Promise.race([ fetchVertexWithRetry(request, { timeoutMs: 5_000 }), Bun.sleep(250).then(() => { throw new Error("retry waited for response body cancellation"); }), - ]); + ]).finally(() => waits.mockRestore()); expect(res.status).toBe(200); expect(await res.text()).toBe("ok"); @@ -520,3 +523,62 @@ describe("adapter fetchResponse wiring", () => { } }); }); + +describe("Google Retry-After is a lower bound (#7802)", () => { + const wrappers = [ + ["Vertex", fetchVertexWithRetry], + ["Antigravity", fetchAntigravityWithRetry], + ["Gemini direct", fetchDirectGeminiWithRetry], + ] as const; + const transient = (status: 429 | 503, retryAfter: string) => new Response( + vertexError(status, status === 429 ? "RESOURCE_EXHAUSTED" : "UNAVAILABLE", "rate limit, try again"), + { status, headers: { "Retry-After": retryAfter } }, + ); + + for (const [name, fetchResponse] of wrappers) { + for (const status of [429, 503] as const) test(`${name} waits the full instructed delay before resending (${status})`, async () => { + const fixture = mockFetch([transient(status, "30"), new Response("ok", { status: 200 })]); + const waits: number[] = []; + const sleep = spyOn(retry, "sleepWithAbort").mockImplementation(async ms => { waits.push(ms); }); + try { + const res = await fetchResponse(request, { timeoutMs: 5_000 }); + expect(res.status).toBe(200); + expect(fixture.calls).toHaveLength(2); + expect(waits).toHaveLength(1); + expect(waits[0]).toBeGreaterThanOrEqual(30_000); + } finally { sleep.mockRestore(); } + }); + + test(`${name} honours an HTTP-date Retry-After as the same floor`, async () => { + const at = new Date(Date.now() + 45_000).toUTCString(); + const fixture = mockFetch([transient(503, at), new Response("ok", { status: 200 })]); + const waits: number[] = []; + const sleep = spyOn(retry, "sleepWithAbort").mockImplementation(async ms => { waits.push(ms); }); + try { + expect((await fetchResponse(request, { timeoutMs: 5_000 })).status).toBe(200); + expect(fixture.calls).toHaveLength(2); + expect(waits).toHaveLength(1); + expect(waits[0]).toBeGreaterThanOrEqual(43_000); + expect(waits[0]).toBeLessThanOrEqual(retry.RETRY_AFTER_CEILING_MS); + } finally { sleep.mockRestore(); } + }); + + for (const status of [429, 503] as const) test(`${name} ends with the upstream answer when the instruction outlives the wait deadline (${status})`, async () => { + const first = transient(status, "120"); + const fixture = mockFetch([first, new Response("unexpected early resend", { status: 200 })]); + const sleep = spyOn(retry, "sleepWithAbort").mockImplementation(async () => {}); + try { + const res = await fetchResponse(request, { timeoutMs: 5_000 }); + expect(res.status).toBe(status); + expect(fixture.calls).toHaveLength(1); + expect(sleep).not.toHaveBeenCalled(); + if (name === "Gemini direct") { + expect(res).toBe(first); + expect(res.headers.get("retry-after")).toBe("120"); + expect(await res.text()).toContain("rate limit, try again"); + } + } finally { sleep.mockRestore(); } + }); + } +}); + From 33c1f740f20969dd801e3d8e94b70f7aa7c66aa4 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:13:28 +0900 Subject: [PATCH 2/3] fix(oauth): recognize the Cloud Code Assist Terms of Service account ban Classify the exact 403 PERMISSION_DENIED envelope Google returns when it disables Cloud Code Assist for an account as an account-scoped permanent refusal for the opt-in auto-pause policy. Project-scoped CONSUMER_SUSPENDED, service-enablement and model errors stay excluded. Refs #7803 --- .../docs/reference/configuration/providers.md | 2 +- src/oauth/account-auto-pause-policy.ts | 7 +++ structure/account-auto-pause.md | 2 +- tests/oauth/oauth-account-auto-pause.test.ts | 46 +++++++++++++++++++ 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d712337eacb..ef636a181d9 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1968,7 +1968,7 @@ ocx config set providers.anthropic.accountAutoPause.consecutiveFailures 2 `consecutiveFailures` is an integer from 1 to 100; omitting it uses 2. Only exact, account-scoped HTTP 401/403 refusals qualify: an organization forbidding OAuth, an inactive -or expired subscription, an account denied API/Claude Code access, or a downgrade to a free +or expired subscription, an account denied API/Claude Code access or disabled by Google for a Terms of Service violation, or a downgrade to a free plan with no credits. A model permission error, expired access token, quota window, 429, 5xx, malformed response or network failure never pauses an account. diff --git a/src/oauth/account-auto-pause-policy.ts b/src/oauth/account-auto-pause-policy.ts index cbf2a2aab74..9de0d99f05b 100644 --- a/src/oauth/account-auto-pause-policy.ts +++ b/src/oauth/account-auto-pause-policy.ts @@ -17,6 +17,13 @@ export function classifyPermanentAccountRefusal(status: number, payload: unknown if (/^(?:(?:Your |The )?(?:Claude |ChatGPT )?subscription is inactive|Your account does not have an active subscription)[.!]?$/i.test(text)) return "subscription_inactive"; if (/^Your (?:account|plan) (?:has been |was )?downgraded to (?:the )?free(?: plan)? (?:and |with )?(?:has )?(?:no|without) (?:remaining )?credits[.!]?$/i.test(text)) return "plan_downgraded"; if (type === "permission_error" && /^Your account (?:does not have access to Claude Code|is (?:disabled|suspended)|is not permitted to use (?:the |this )?API)[.!]?$/i.test(text)) return "account_permission_denied"; + // Cloud Code Assist (Antigravity / Gemini CLI OAuth) account ban, in the envelope Google's + // backend returned in google-gemini/gemini-cli#19501 (#7803). Only that whole sentence on a + // consistent 403 / numeric 403 / PERMISSION_DENIED envelope qualifies. Project-scoped + // CONSUMER_SUSPENDED, SERVICE_DISABLED and model-permission errors name a project or a model, + // not the sending identity, and stay excluded. + if (status === 403 && code === 403 && googleStatus === "PERMISSION_DENIED" + && /^This service has been disabled in this account for violation of (?:the )?Terms of Service[.!]?$/i.test(text.replace(/\s+/g, " "))) return "account_permission_denied"; } export async function permanentAccountRefusal(response: Response, signal?: AbortSignal): Promise { diff --git a/structure/account-auto-pause.md b/structure/account-auto-pause.md index d9344617fde..a4c17c2c9db 100644 --- a/structure/account-auto-pause.md +++ b/structure/account-auto-pause.md @@ -48,7 +48,7 @@ account selectors and uploaded-file constraints continue to suppress alternate m `src/oauth/account-auto-pause.ts` captures the exact sending OAuth snapshot, Codex context or API-key selection and validates outgoing credential headers before observing a response. -`src/oauth/account-auto-pause-policy.ts` recognizes whole account-scoped 401/403 messages, +`src/oauth/account-auto-pause-policy.ts` recognizes whole account-scoped 401/403 messages (for Google, only the Cloud Code Assist Terms of Service account ban on a consistent 403 `PERMISSION_DENIED` envelope; project-scoped `CONSUMER_SUSPENDED` and service-enablement errors are excluded), serializes response-arrival decisions, resets on accepted HTTP responses, and excludes transient/quota/model/request failures. No provider body or account identifier enters its log. The opt-in counter defaults to two refusals; each process owns its counters. Durable pause diff --git a/tests/oauth/oauth-account-auto-pause.test.ts b/tests/oauth/oauth-account-auto-pause.test.ts index a228c7c8246..d81959f0b78 100644 --- a/tests/oauth/oauth-account-auto-pause.test.ts +++ b/tests/oauth/oauth-account-auto-pause.test.ts @@ -248,6 +248,52 @@ test("Google structured account refusals classify without accepting model permis expect(classifyPermanentAccountRefusal(403, { error: { code: 403, status: "PERMISSION_DENIED", message: "This model is not available in this project" } })).toBeUndefined(); }); +// #7803: the Cloud Code Assist account-ban envelope reported in google-gemini/gemini-cli#19501. +const ccaTosDisabled = "This service has been disabled in this account for violation of Terms of Service."; +const googleEnvelope = (message: string, extra: Record = {}) => + ({ error: { code: 403, status: "PERMISSION_DENIED", message, ...extra } }); + +test("the Cloud Code Assist Terms of Service account ban classifies as an account refusal", () => { + expect(classifyPermanentAccountRefusal(403, googleEnvelope(ccaTosDisabled))).toBe("account_permission_denied"); + expect(classifyPermanentAccountRefusal(403, googleEnvelope(` This service has been disabled in this account\n for violation of the Terms of Service `))) + .toBe("account_permission_denied"); +}); + +test("project, model and partial Google refusals never classify as an account ban", () => { + for (const message of [ + `Request failed: ${ccaTosDisabled}`, + "This service has been disabled in this account.", + "This service has been disabled in this project for violation of Terms of Service.", + "Permission denied: Consumer 'projects/123456' has been suspended.", + "Cloud Code Private API has not been used in project 123456 before or it is disabled.", + "Access to this model is denied.", + ]) expect(classifyPermanentAccountRefusal(403, googleEnvelope(message))).toBeUndefined(); + expect(classifyPermanentAccountRefusal(403, googleEnvelope("Permission denied: Consumer 'projects/123456' has been suspended.", + { details: [{ "@type": "type.googleapis.com/google.rpc.ErrorInfo", reason: "CONSUMER_SUSPENDED" }] }))).toBeUndefined(); + // The envelope must be the consistent 403 form Google returned; other statuses and codes do not qualify. + expect(classifyPermanentAccountRefusal(401, { error: { code: 401, status: "UNAUTHENTICATED", message: ccaTosDisabled } })).toBeUndefined(); + expect(classifyPermanentAccountRefusal(403, { error: { status: "PERMISSION_DENIED", message: ccaTosDisabled } })).toBeUndefined(); + expect(classifyPermanentAccountRefusal(403, { error: { code: 401, status: "PERMISSION_DENIED", message: ccaTosDisabled } })).toBeUndefined(); +}); + +test("an Antigravity Terms of Service ban pauses the sending account through normalization", async () => { + const { fetchGoogleWithRetry } = await import("../../src/adapters/google-http"); + const { wasPermanentAccountRefusal } = await import("../../src/oauth/account-auto-pause"); + const config = configFor("fixture", "key"); + config.providers.fixture!.apiKey = "synthetic-A"; + config.providers.fixture!.accountAutoPause!.consecutiveFailures = 1; + saveConfig(config); + const provider = routedProviderConfig("fixture", config.providers.fixture!); + const headers = { authorization: `Bearer ${provider.apiKey}` }; + const observe = captureAccountAutoPauseAttempt("fixture", provider, { config }, new Headers(headers))!; + const response = await fetchGoogleWithRetry("Antigravity", { url: "https://example.test", method: "POST", headers, body: "{}" }, { + executor: (async () => observe(Response.json(googleEnvelope(ccaTosDisabled), { status: 403 }))) as typeof fetch, + }); + expect(response.status).toBe(403); + expect(wasPermanentAccountRefusal(response)).toBe(true); + expect(wasAccountAutoPaused(response)).toBe(true); +}); + test("Antigravity normalization retains permanent refusal and auto-pause ownership", async () => { const { fetchGoogleWithRetry } = await import("../../src/adapters/google-http"); const { wasPermanentAccountRefusal } = await import("../../src/oauth/account-auto-pause"); From 9901a7ffe73df33fb5d606873479f78de2d9bb57 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:17:07 +0900 Subject: [PATCH 3/3] fix(oauth): accept the complete Cloud Code Assist ban messages Google returns the Terms of Service sentence alone and with a fixed contact or appeal sentence (gemini-cli #19501, #20813, #27199, #27388). Allowlist exactly those whole messages after whitespace normalization; prefixes, quotes and other suffixes still do not classify. --- src/oauth/account-auto-pause-policy.ts | 16 ++++++++++------ structure/account-auto-pause.md | 2 +- tests/oauth/oauth-account-auto-pause.test.ts | 19 ++++++++++++++----- 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/oauth/account-auto-pause-policy.ts b/src/oauth/account-auto-pause-policy.ts index 9de0d99f05b..35d3743470b 100644 --- a/src/oauth/account-auto-pause-policy.ts +++ b/src/oauth/account-auto-pause-policy.ts @@ -17,15 +17,19 @@ export function classifyPermanentAccountRefusal(status: number, payload: unknown if (/^(?:(?:Your |The )?(?:Claude |ChatGPT )?subscription is inactive|Your account does not have an active subscription)[.!]?$/i.test(text)) return "subscription_inactive"; if (/^Your (?:account|plan) (?:has been |was )?downgraded to (?:the )?free(?: plan)? (?:and |with )?(?:has )?(?:no|without) (?:remaining )?credits[.!]?$/i.test(text)) return "plan_downgraded"; if (type === "permission_error" && /^Your account (?:does not have access to Claude Code|is (?:disabled|suspended)|is not permitted to use (?:the |this )?API)[.!]?$/i.test(text)) return "account_permission_denied"; - // Cloud Code Assist (Antigravity / Gemini CLI OAuth) account ban, in the envelope Google's - // backend returned in google-gemini/gemini-cli#19501 (#7803). Only that whole sentence on a - // consistent 403 / numeric 403 / PERMISSION_DENIED envelope qualifies. Project-scoped - // CONSUMER_SUSPENDED, SERVICE_DISABLED and model-permission errors name a project or a model, - // not the sending identity, and stay excluded. + // Cloud Code Assist (Antigravity / Gemini CLI OAuth) account ban (#7803). Google's backend has + // returned the sentence alone and with exactly two closing sentences (google-gemini/gemini-cli + // #19501, #20813, #27199, #27388); only those whole messages, on a consistent 403 / numeric 403 + // / PERMISSION_DENIED envelope, qualify. Project-scoped CONSUMER_SUSPENDED, SERVICE_DISABLED + // and model-permission errors name a project or a model, not the sending identity. if (status === 403 && code === 403 && googleStatus === "PERMISSION_DENIED" - && /^This service has been disabled in this account for violation of (?:the )?Terms of Service[.!]?$/i.test(text.replace(/\s+/g, " "))) return "account_permission_denied"; + && CCA_TOS_ACCOUNT_BAN.test(text.replace(/\s+/g, " "))) return "account_permission_denied"; } +const CCA_TOS_ACCOUNT_BAN = new RegExp("^This service has been disabled in this account for violation of Terms of Service\\." + + "(?: If you believe this is an error, contact gemini-code-assist-user-feedback@google\\.com\\." + + "| Please submit an appeal to continue using this product\\.)?$"); + export async function permanentAccountRefusal(response: Response, signal?: AbortSignal): Promise { if (response.status !== 401 && response.status !== 403) return; try { diff --git a/structure/account-auto-pause.md b/structure/account-auto-pause.md index a4c17c2c9db..30b0ffcdd0d 100644 --- a/structure/account-auto-pause.md +++ b/structure/account-auto-pause.md @@ -48,7 +48,7 @@ account selectors and uploaded-file constraints continue to suppress alternate m `src/oauth/account-auto-pause.ts` captures the exact sending OAuth snapshot, Codex context or API-key selection and validates outgoing credential headers before observing a response. -`src/oauth/account-auto-pause-policy.ts` recognizes whole account-scoped 401/403 messages (for Google, only the Cloud Code Assist Terms of Service account ban on a consistent 403 `PERMISSION_DENIED` envelope; project-scoped `CONSUMER_SUSPENDED` and service-enablement errors are excluded), +`src/oauth/account-auto-pause-policy.ts` recognizes whole account-scoped 401/403 messages (for Google, only the whole Cloud Code Assist Terms of Service account-ban messages Google has returned, alone or with its fixed contact or appeal sentence, on a consistent 403 `PERMISSION_DENIED` envelope; project-scoped `CONSUMER_SUSPENDED` and service-enablement errors are excluded), serializes response-arrival decisions, resets on accepted HTTP responses, and excludes transient/quota/model/request failures. No provider body or account identifier enters its log. The opt-in counter defaults to two refusals; each process owns its counters. Durable pause diff --git a/tests/oauth/oauth-account-auto-pause.test.ts b/tests/oauth/oauth-account-auto-pause.test.ts index d81959f0b78..b846ca25bc0 100644 --- a/tests/oauth/oauth-account-auto-pause.test.ts +++ b/tests/oauth/oauth-account-auto-pause.test.ts @@ -248,20 +248,29 @@ test("Google structured account refusals classify without accepting model permis expect(classifyPermanentAccountRefusal(403, { error: { code: 403, status: "PERMISSION_DENIED", message: "This model is not available in this project" } })).toBeUndefined(); }); -// #7803: the Cloud Code Assist account-ban envelope reported in google-gemini/gemini-cli#19501. +// #7803: the Cloud Code Assist account-ban messages Google's backend returned in +// google-gemini/gemini-cli #19501 (with its line wrap), #20813/#27388 and #27199. const ccaTosDisabled = "This service has been disabled in this account for violation of Terms of Service."; +// Google's published feedback address, assembled so the privacy scan does not read it as personal data. +const ccaFeedbackAddress = ["gemini-code-assist-user-feedback", "google.com"].join("@"); +const ccaTosContact = `This service has been disabled in this account for violation of Terms of\n\t\tService. If you believe this is an error, contact ${ccaFeedbackAddress}.`; +const ccaTosAppeal = "This service has been disabled in this account for violation of Terms of Service. Please submit an appeal to continue using this product."; const googleEnvelope = (message: string, extra: Record = {}) => ({ error: { code: 403, status: "PERMISSION_DENIED", message, ...extra } }); test("the Cloud Code Assist Terms of Service account ban classifies as an account refusal", () => { - expect(classifyPermanentAccountRefusal(403, googleEnvelope(ccaTosDisabled))).toBe("account_permission_denied"); - expect(classifyPermanentAccountRefusal(403, googleEnvelope(` This service has been disabled in this account\n for violation of the Terms of Service `))) - .toBe("account_permission_denied"); + for (const message of [ccaTosDisabled, ccaTosContact, ccaTosAppeal, ` ${ccaTosAppeal.replace(/ /g, "\n ")} `]) { + expect(classifyPermanentAccountRefusal(403, googleEnvelope(message))).toBe("account_permission_denied"); + } }); test("project, model and partial Google refusals never classify as an account ban", () => { for (const message of [ `Request failed: ${ccaTosDisabled}`, + `Eligibility check failed: PERMISSION_DENIED (code 403): ${ccaTosAppeal}`, + `${ccaTosDisabled} If you believe this is an error, contact someone-else@example.test.`, + `${ccaTosAppeal} Model gemini-3-pro is not available.`, + `"${ccaTosDisabled}"`, "This service has been disabled in this account.", "This service has been disabled in this project for violation of Terms of Service.", "Permission denied: Consumer 'projects/123456' has been suspended.", @@ -287,7 +296,7 @@ test("an Antigravity Terms of Service ban pauses the sending account through nor const headers = { authorization: `Bearer ${provider.apiKey}` }; const observe = captureAccountAutoPauseAttempt("fixture", provider, { config }, new Headers(headers))!; const response = await fetchGoogleWithRetry("Antigravity", { url: "https://example.test", method: "POST", headers, body: "{}" }, { - executor: (async () => observe(Response.json(googleEnvelope(ccaTosDisabled), { status: 403 }))) as typeof fetch, + executor: (async () => observe(Response.json(googleEnvelope(ccaTosContact), { status: 403 }))) as typeof fetch, }); expect(response.status).toBe(403); expect(wasPermanentAccountRefusal(response)).toBe(true);