Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/misconfigured-health-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"executor": patch
---

**A disabled upstream API now reports `misconfigured` instead of `expired`**

A 403 caused by the provider disabling the API (Google SERVICE_DISABLED / accessNotConfigured shapes) is a configuration problem, not a credential problem — reconnecting cannot fix it. Health checks now classify it as a fifth status, `misconfigured`, shown with an amber badge and a link to the provider console instead of a Reconnect prompt. Ordinary 401/403 credential rejections still report `expired`.
364 changes: 364 additions & 0 deletions e2e/scenarios/google-disabled-api.test.ts

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/core/api/src/admin/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export const AdminUser = Schema.Struct({
* forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`.
*/
export const AdminConnectionHealth = Schema.Struct({
/** The stored verdict: healthy / expired / degraded / unknown. */
/** The stored verdict: healthy / expired / misconfigured / degraded / unknown. */
status: HealthStatus,
/** Epoch ms the check ran, so an operator can tell a fresh verdict from a
* stale one. */
Expand Down
72 changes: 72 additions & 0 deletions packages/core/sdk/src/health-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest";

import {
candidateIdentityTier,
classifyHttpStatus,
classifyProbeResponse,
rankResponseSample,
sortHealthCheckCandidatesByIdentity,
} from "./health-check";
Expand Down Expand Up @@ -66,3 +68,73 @@ describe("candidateIdentityTier", () => {
).toBe("user.getAuthUser");
});
});

// Probe classification: 401/403 mean "credential dead" EXCEPT the
// configuration 403 (Google accessNotConfigured / SERVICE_DISABLED), which
// must read misconfigured: the token authenticated, the API is disabled in
// the OAuth client's project, and reconnecting cannot fix it. This is the
// production case behind the "Expired when it is not" report (2026-07-10).
describe("classifyProbeResponse", () => {
const disabledMessage =
"Gmail API has not been used in project 000000000000 before or it is disabled. " +
"Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry.";

it("classifies Google's classic accessNotConfigured 403 as misconfigured", () => {
const body = {
error: {
code: 403,
message: disabledMessage,
errors: [
{ message: disabledMessage, domain: "usageLimits", reason: "accessNotConfigured" },
],
status: "PERMISSION_DENIED",
},
};
expect(classifyProbeResponse(403, body)).toBe("misconfigured");
});

it("classifies the newer SERVICE_DISABLED ErrorInfo detail as misconfigured", () => {
const body = {
error: {
code: 403,
message: disabledMessage,
status: "PERMISSION_DENIED",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "SERVICE_DISABLED",
domain: "googleapis.com",
},
],
},
};
expect(classifyProbeResponse(403, body)).toBe("misconfigured");
});

it("keeps a plain 403 (no recognized reason) as expired: false-expired is the safe default", () => {
expect(classifyProbeResponse(403, { error: { code: 403, message: "Forbidden" } })).toBe(
"expired",
);
expect(classifyProbeResponse(403, undefined)).toBe("expired");
expect(classifyProbeResponse(403, "Forbidden")).toBe("expired");
// A permission reason that is NOT a configuration marker stays expired.
expect(
classifyProbeResponse(403, {
error: { errors: [{ reason: "insufficientPermissions" }] },
}),
).toBe("expired");
});

it("never rewrites a 401: an authentication failure is a credential problem regardless of body", () => {
const body = { error: { errors: [{ reason: "accessNotConfigured" }] } };
expect(classifyProbeResponse(401, body)).toBe("expired");
});

it("matches the status-only classifier everywhere else", () => {
for (const status of [200, 204, 404, 429, 500, 503]) {
expect(classifyProbeResponse(status, { error: {} }), `status ${status}`).toBe(
classifyHttpStatus(status),
);
}
});
});
68 changes: 62 additions & 6 deletions packages/core/sdk/src/health-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,24 @@
import { Schema } from "effect";

// ---------------------------------------------------------------------------
// Status: the four states a connection can be in. `expired` is the one this
// Status: the five states a connection can be in. `expired` is the one this
// whole feature exists for (Google's 7-day dev-token revocation): the credential
// authenticated fine yesterday and now returns 401/403. `degraded` covers any
// other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is
// "never checked / no health check configured".
// authenticated fine yesterday and now returns 401/403. `misconfigured` is the
// 403 that is NOT a credential problem: the token authenticated, but the
// upstream rejects on configuration (a Google API not enabled in the OAuth
// client's GCP project): the fix is in the provider console, so telling the
// user to reconnect would mislead. `degraded` covers any other non-2xx
// (upstream 5xx, a 404 on a mis-picked operation); `unknown` is "never checked
// / no health check configured".
// ---------------------------------------------------------------------------

export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]);
export const HealthStatus = Schema.Literals([
"healthy",
"expired",
"misconfigured",
"degraded",
"unknown",
]);
export type HealthStatus = typeof HealthStatus.Type;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -139,13 +149,59 @@ export type HealthCheckCandidate = typeof HealthCheckCandidate.Type;
// ---------------------------------------------------------------------------

/** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth
* wall), everything else degraded. */
* wall), everything else degraded. Status-only fallback: when the response
* BODY is available, use `classifyProbeResponse` instead, which tells a
* credential 403 apart from a configuration 403. */
export const classifyHttpStatus = (status: number): HealthStatus => {
if (status >= 200 && status < 300) return "healthy";
if (status === 401 || status === 403) return "expired";
return "degraded";
};

// Error `reason` / `status` markers that make a 403 a CONFIGURATION rejection
// rather than a credential one. Deliberately narrow and provider-shaped:
// Google's error envelope carries `errors[].reason: "accessNotConfigured"`
// (message "<API> has not been used in project N before or it is disabled")
// and newer surfaces use `status: "PERMISSION_DENIED"` with
// `details[].reason: "SERVICE_DISABLED"`. Unrecognized 403s keep the expired
// classification: a false "expired" prompts a harmless reconnect, but a false
// "misconfigured" would hide a genuinely dead credential.
const CONFIGURATION_403_REASONS = new Set(["accessnotconfigured", "service_disabled"]);

/** Collect candidate `reason` markers from a Google-shaped error envelope:
* `error.errors[].reason` (classic) and `error.details[].reason` (newer
* google.rpc.ErrorInfo). Tolerant of partial shapes; returns []. */
const errorReasonMarkers = (body: unknown): string[] => {
if (body == null || typeof body !== "object") return [];
const error = (body as Record<string, unknown>)["error"];
if (error == null || typeof error !== "object") return [];
const markers: string[] = [];
for (const key of ["errors", "details"] as const) {
const entries = (error as Record<string, unknown>)[key];
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry == null || typeof entry !== "object") continue;
const reason = (entry as Record<string, unknown>)["reason"];
if (typeof reason === "string") markers.push(reason.toLowerCase());
}
}
return markers;
};

/** Classify a probe response from its status AND body. Everything is
* `classifyHttpStatus` except one carve-out: a 403 whose error body carries a
* known configuration reason (Google `accessNotConfigured` /
* `SERVICE_DISABLED`) is `misconfigured`, not `expired`: the credential
* authenticated; the upstream API is disabled in the OAuth client's project,
* and only enabling it there (not reconnecting) fixes it. */
export const classifyProbeResponse = (status: number, body: unknown): HealthStatus => {
const byStatus = classifyHttpStatus(status);
if (status !== 403 || byStatus !== "expired") return byStatus;
return errorReasonMarkers(body).some((reason) => CONFIGURATION_403_REASONS.has(reason))
? "misconfigured"
: "expired";
};

/** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a
* display string. Numeric segments index arrays. Returns undefined when the
* path is empty, missing, or lands on a non-scalar. */
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export {
HealthCheckCandidateParameter,
HealthCheckResponseField,
classifyHttpStatus,
classifyProbeResponse,
extractIdentity,
compareHealthCheckCandidates,
candidateIdentityTier,
Expand Down
1 change: 1 addition & 0 deletions packages/core/sdk/src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export {
HealthCheckCandidate,
HealthCheckCandidateParameter,
classifyHttpStatus,
classifyProbeResponse,
extractIdentity,
compareHealthCheckCandidates,
candidateIdentityTier,
Expand Down
6 changes: 4 additions & 2 deletions packages/plugins/openapi/src/sdk/backing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ToolName,
ToolResult,
authToolFailure,
classifyHttpStatus,
classifyProbeResponse,
detectInsufficientScope,
sortHealthCheckCandidatesByIdentity,
extractIdentity,
Expand Down Expand Up @@ -973,7 +973,9 @@ export const checkHealthOpenApi = (input: {
} satisfies HealthCheckResult;
}

const status = classifyHttpStatus(probe.result.status);
// Body-aware: a configuration 403 (Google accessNotConfigured /
// SERVICE_DISABLED) reads misconfigured, not expired.
const status = classifyProbeResponse(probe.result.status, probe.result.error);
const rawIdentity =
status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined;
// The identity is read straight off the raw body, so unlike the sample it
Expand Down
57 changes: 57 additions & 0 deletions packages/react/src/components/accounts-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,35 @@ import {

const OWNERS: readonly Owner[] = ["org", "user"];

/** Render a health-check detail with any bare https URL as a clickable link.
* Exists for the misconfigured verdict, whose detail is the provider's own
* remediation text (Google's includes the console URL that enables the
* disabled API); the rest of the string stays plain text. */
function DetailWithLinks(props: { readonly text: string }) {
const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g);
return (
<>
{parts.map((part, index) =>
part.startsWith("https://") ? (
<a
// Stable for a given detail string: parts are positional.
// oxlint-disable-next-line react/no-array-index-key
key={index}
href={part}
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-foreground"
>
{part}
</a>
) : (
part
),
)}
</>
);
}

// The confirm dialog names the connection the way the row does — stored
// identity label first, connection name otherwise. (No live probe identity
// here; that state lives inside the row.)
Expand Down Expand Up @@ -115,6 +144,10 @@ function AccountRow(props: {
const displayLabel = identity ?? String(connection.name);

const expired = status === "expired";
// `misconfigured` is deliberately NOT folded into `needsHealthAttention`: it
// gets its own amber "API disabled" badge and its own link-rendered detail
// below, because the remediation is a console visit, not a reconnect.
const misconfigured = status === "misconfigured";
const needsHealthAttention = status === "expired" || status === "degraded";
const healthDetail = needsHealthAttention ? probe?.detail : undefined;
const missingOAuthScopes = connection.missingOAuthScopes ?? [];
Expand All @@ -135,6 +168,13 @@ function AccountRow(props: {
);
} else if (exit.value.status === "expired") {
toast.error("Connection expired, reconnect to restore access");
} else if (exit.value.status === "misconfigured") {
// NOT a reconnect prompt: the credential is fine; the upstream API is
// disabled where the OAuth client lives. The detail carries the
// provider's own instruction (with a console link for Google).
toast.warning(
exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client",
);
} else if (exit.value.status === "degraded") {
toast.warning(exit.value.detail ?? "Connection check returned an error");
} else {
Expand All @@ -157,6 +197,14 @@ function AccountRow(props: {
{HEALTH_STATUS_LABEL[status]}
</Badge>
) : null}
{misconfigured ? (
<Badge
variant="outline"
className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500"
>
API disabled
</Badge>
) : null}
{needsReconsent ? (
<Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
Reconnect to grant access
Expand All @@ -168,6 +216,15 @@ function AccountRow(props: {
{connection.description}
</CardStackEntryDescription>
) : null}
{misconfigured && probe?.detail ? (
// Not CardStackEntryDescription: that truncates to one line, and this
// text IS the remediation (the enable-API console link must stay
// visible in full). Wrap instead; break anywhere so the long URL
// cannot overflow the row.
<p className="mt-1 whitespace-normal text-xs text-muted-foreground [overflow-wrap:anywhere]">
<DetailWithLinks text={probe.detail} />
</p>
) : null}
{healthDetail ? (
<CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground">
{healthDetail}
Expand Down
8 changes: 7 additions & 1 deletion packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1048,7 +1048,13 @@ function HealthStatusLine(props: {
}) {
const { status, identity, detail, httpStatus } = props.result;
const healthy = status === "healthy";
const tone = healthy ? "text-muted-foreground" : "text-destructive";
// Misconfigured is amber everywhere (see health-display.ts): the credential
// being validated is fine, so the verdict must not read as a key failure.
const tone = healthy
? "text-muted-foreground"
: status === "misconfigured"
? "text-amber-600 dark:text-amber-500"
: "text-destructive";
const font = props.variant === "response" ? "font-mono" : "";
return (
<div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}>
Expand Down
Loading
Loading