History

merge main

Rhys Sullivan committed 2 months ago

Commit 132b3ff2 parents aa4f2b2 ad702a933 files · +2812 −234Browse files

Unified · Split

33 files
  1. .changeset/mcp-stdio-edit-ui.md +26 −0
  2. .changeset/misconfigured-health-status.md +7 −0
  3. .changeset/oauth-refresh-cross-session.md +13 −0
  4. apps/cloud/src/engine/execution-stack.ts +3 −100
  5. apps/cloud/src/engine/first-party-oauth-clients.test.ts +99 −10
  6. apps/cloud/src/engine/first-party-oauth-clients.ts +336 −0
  7. apps/cloud/src/env-augment.d.ts +20 −0
  8. apps/local/vite.config.ts +35 −1
  9. e2e/local/stdio-mcp-edit-ui.test.ts +138 −0
  10. e2e/local/stdio-mcp.test.ts +42 −0
  11. e2e/scenarios/google-disabled-api.test.ts +364 −0
  12. e2e/selfhost/oauth-refresh-cross-session.test.ts +275 −0
  13. packages/core/api/src/admin/api.ts +1 −1
  14. packages/core/sdk/src/executor.ts +94 −23
  15. packages/core/sdk/src/health-check.test.ts +72 −0
  16. packages/core/sdk/src/health-check.ts +62 −6
  17. packages/core/sdk/src/index.ts +1 −0
  18. packages/core/sdk/src/oauth-client.ts +19 −0
  19. packages/core/sdk/src/oauth-first-party.test.ts +36 −0
  20. packages/core/sdk/src/oauth-flow.test.ts +423 −9
  21. packages/core/sdk/src/oauth-helpers.test.ts +183 −1
  22. packages/core/sdk/src/oauth-helpers.ts +169 −33
  23. packages/core/sdk/src/oauth-service.ts +23 −3
  24. packages/core/sdk/src/shared.ts +1 −0
  25. packages/plugins/mcp/src/react/AddMcpIntegration.tsx +1 −13
  26. packages/plugins/mcp/src/react/EditMcpIntegration.tsx +158 −22
  27. packages/plugins/mcp/src/react/stdio-fields.test.ts +70 −0
  28. packages/plugins/mcp/src/react/stdio-fields.ts +51 −0
  29. packages/plugins/openapi/src/sdk/backing.ts +4 −2
  30. packages/plugins/openapi/src/sdk/presets.ts +2 −2
  31. packages/react/src/components/accounts-section.tsx +57 −0
  32. packages/react/src/components/add-account-modal.tsx +7 −1
  33. packages/react/src/lib/health-display.ts +20 −7
.changeset/mcp-stdio-edit-ui.md+26−0
@@ -0,0 +1,26 @@
1+---
2+"executor": patch
3+---
4+
5+**Stdio MCP integrations can be edited from the UI**
6+
7+The integration Edit sheet showed stdio servers as read-only text and told you
8+to remove and recreate the integration to change its command. Fixing a typo in
9+an argument, moving a server to a new path, or adding a static environment
10+variable meant editing `executor.jsonc` by hand, or losing the integration's
11+connections and tool policies to a delete-and-re-add.
12+
13+The sheet now edits the command, its arguments, the working directory, and the
14+declared environment map, staged and applied by the sheet's own Save like the
15+remote editor beside it. Arguments use the same quote-aware parsing as the add
16+flow, so an argument containing spaces survives a round trip.
17+
18+The environment field edits the DECLARED static variables only. A stdio server
19+receives those plus a small fixed base set — it does not inherit executor's
20+environment — and secret values still belong to the connection, entered per
21+account against the server's declared `stdio_env` method.
22+
23+Saving revises the integration config, which is already enough to rebuild the
24+tool catalog: connections whose catalog predates the revision re-list on their
25+next read, so an edited command's tools are correct without an explicit
26+refresh.
.changeset/misconfigured-health-status.md+7−0
@@ -0,0 +1,7 @@
1+---
2+"executor": patch
3+---
4+
5+**A disabled upstream API now reports `misconfigured` instead of `expired`**
6+
7+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`.
.changeset/oauth-refresh-cross-session.md+13−0
@@ -0,0 +1,13 @@
1+---
2+"@executor-js/sdk": patch
3+---
4+
5+Share the OAuth refresh gate across execution stacks so a rotating refresh token is redeemed once.
6+
7+The in-flight refresh gate was built inside `createExecutor`, so it only covered one execution stack. A host builds a fresh stack per MCP session, and now per request, so two sessions resolving the same connection each read the same stored refresh token and each believed they were the refresh winner. Against a provider that rotates refresh tokens, the loser redeems a token the winner already spent, and a provider that detects reuse revokes the whole token family: the connection dies and the user has to reauthorize. The first refresh always succeeds, so the fault stayed invisible until a later expiry.
8+
9+The gate now hangs off the root database handle, which is the object hosts already share across sessions and requests, so every stack over one handle converges on one gate. Its key includes the tenant, because a gate that spans tenants would otherwise let two tenants collide on one entry.
10+
11+The grant also runs on its own detached fiber that callers await, rather than on whichever caller registered it. Sharing an entry across stacks would otherwise share the first caller's cancellation: a disconnected MCP client or an execution deadline would fail every peer waiting on that entry, and would abandon a refresh token the authorization server had already rotated, which is itself a dead connection. A cancelled peer now detaches without touching the grant, and a grant nobody is left waiting on still settles and still persists the rotated token.
12+
13+Deduplication covers one database handle in one process. A host that builds a fresh handle per request or per session, and any multi-instance or multi-replica deployment, is out of scope here and still needs database-backed coordination, such as a compare-and-swap on the stored refresh token.
apps/cloud/src/engine/execution-stack.ts+3−100
@@ -42,19 +42,14 @@ import { · 41 unmodified lines
4242 PluginsProvider,
4343 collectTables,
4444 } from "@executor-js/api/server";
45−import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
46−import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
4745 import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
48−import {
49− IntegrationSlug,
50− type AnyPlugin,
51− type FirstPartyOAuthClientConfig,
52−} from "@executor-js/sdk";
46+import { type AnyPlugin } from "@executor-js/sdk";
5347
5448 import executorConfig from "../../executor.config";
5549 import { cloudEnterpriseManagedRollout } from "../analytics/ema-rollout";
5650 import { DbService } from "../db/db";
5751 import { cloudDbProviderLayer } from "../db/fuma";
52+import { firstPartyOAuthClientsFor } from "./first-party-oauth-clients";
5853
5954 export { makeExecutionStack } from "@executor-js/api/server";
6055
@@ -95,98 +90,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 34 unmodified lines
9590 */
9691 export const CLOUD_MOUNT_PREFIX = "/api" as const;
9792
98−// Consumer Google launch boundary. Keep this list aligned with the scopes
99−// submitted for the Executor-owned production app: ordinary Workspace services
100−// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script,
101−// BigQuery, and Cloud Resource Manager have materially different audiences or
102−// provider requirements and remain BYO OAuth. The same scope source builds each
103−// catalog auth template, preventing picker/start drift.
104−const GOOGLE_FIRST_PARTY_PRESET_IDS = [
105− "google-calendar",
106− "google-meet",
107− "google-gmail",
108− "google-sheets",
109− "google-drive",
110− "google-docs",
111− "google-slides",
112− "google-forms",
113− "google-tasks",
114− "google-people",
115− "google-photos-library",
116− "google-photos-picker",
117− "google-search-console",
118−] as const;
119−
120−const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
121− ...new Set([
122− ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset),
123− // Connections created before the full-Gmail review retain this declared
124− // scope on reconnect. New Gmail presets request `mail.google.com`.
125− "https://www.googleapis.com/auth/gmail.modify",
126− ]),
127−];
128−
129−// Executor-owned provider apps, enabled per provider by setting BOTH env vars
130−// (id + secret). Each provider-side registration must list
131−// `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
132−// travels inside OAuth `state`, so the single static callback serves every org.
133−//
134−// The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` /
135−// `_TOKEN_URL` overrides exist so tests and dev instances can point the app at
136−// an emulated provider (`@executor-js/emulate`) and run the complete flow.
137−// Production leaves them unset.
138−export const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] => [
139− ...(env.FIRST_PARTY_GITHUB_CLIENT_ID && env.FIRST_PARTY_GITHUB_CLIENT_SECRET
140− ? [
141− {
142− name: "github",
143− authorizationUrl:
144− env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
145− tokenUrl:
146− env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
147− clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
148− clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
149− integrations: [IntegrationSlug.make("github_rest")],
150− // GitHub App user access tokens do not use classic OAuth scopes;
151− // their capabilities come from the app's registered permissions.
152− authorizationScopes: [],
153− },
154− ]
155− : []),
156− ...(env.FIRST_PARTY_GOOGLE_CLIENT_ID && env.FIRST_PARTY_GOOGLE_CLIENT_SECRET
157− ? [
158− {
159− name: "google",
160− authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
161− tokenUrl: "https://oauth2.googleapis.com/token",
162− clientId: env.FIRST_PARTY_GOOGLE_CLIENT_ID,
163− clientSecret: env.FIRST_PARTY_GOOGLE_CLIENT_SECRET,
164− allowedScopes: GOOGLE_FIRST_PARTY_ALLOWED_SCOPES,
165− // Withdrawn from the connect picker: no new connection is offered the
166− // Executor-owned Google app. The entry stays declared on purpose —
167− // every connection already minted against it keeps refreshing and
168− // reconnecting through it. Deleting this block, or unsetting the env
169− // vars, would strand those connections instead.
170− unlisted: true,
171− },
172− ]
173− : []),
174− ...(env.FIRST_PARTY_SLACK_CLIENT_ID && env.FIRST_PARTY_SLACK_CLIENT_SECRET
175− ? [
176− {
177− name: "slack",
178− authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
179− tokenUrl: "https://slack.com/api/oauth.v2.user.access",
180− resource: "https://mcp.slack.com",
181− clientId: env.FIRST_PARTY_SLACK_CLIENT_ID,
182− clientSecret: env.FIRST_PARTY_SLACK_CLIENT_SECRET,
183− integrations: [IntegrationSlug.make("slack")],
184− allowedScopes: slackMcpUserScopes,
185− },
186− ]
187− : []),
188−];
189−
19093 export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
19194 // SSRF / private-network egress guard. Config-driven, NOT a test flag:
19295 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
@@ -198,7 +101,7 @@ export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, ( · 5 unmodified lines
198101 // WorkOS Vault is cloud's credential storage implementation detail, not a
199102 // user-selectable provider surface.
200103 exposeCredentialProviders: false,
201− firstPartyOAuthClients: cloudFirstPartyOAuthClients(),
104+ firstPartyOAuthClients: firstPartyOAuthClientsFor(env),
202105 // Workers cancel request-scoped I/O once the response settles; the ambient
203106 // `waitUntil` binds to the in-flight invocation (HTTP request or DO call),
204107 // so stale tool-catalog rebuilds that outlive a read still converge.
apps/cloud/src/engine/first-party-oauth-clients.test.ts+99−10
@@ -1,7 +1,100 @@
1−import { env } from "cloudflare:workers";
2−import { beforeAll, describe, expect, it } from "@effect/vitest";
1+import { describe, expect, it } from "@effect/vitest";
32
4−import { cloudFirstPartyOAuthClients } from "./execution-stack";
3+import {
4+ firstPartyOAuthClientsFor,
5+ type FirstPartyOAuthClientEnv,
6+} from "./first-party-oauth-clients";
7+
8+const completeEnv: FirstPartyOAuthClientEnv = {
9+ FIRST_PARTY_AIRTABLE_CLIENT_ID: "airtable-id",
10+ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "airtable-secret",
11+ FIRST_PARTY_ATLASSIAN_CLIENT_ID: "atlassian-id",
12+ FIRST_PARTY_ATLASSIAN_CLIENT_SECRET: "atlassian-secret",
13+ FIRST_PARTY_BOX_CLIENT_ID: "box-id",
14+ FIRST_PARTY_BOX_CLIENT_SECRET: "box-secret",
15+ FIRST_PARTY_CLICKUP_CLIENT_ID: "clickup-id",
16+ FIRST_PARTY_CLICKUP_CLIENT_SECRET: "clickup-secret",
17+ FIRST_PARTY_FIGMA_CLIENT_ID: "figma-id",
18+ FIRST_PARTY_FIGMA_CLIENT_SECRET: "figma-secret",
19+ FIRST_PARTY_GITHUB_CLIENT_ID: "github-id",
20+ FIRST_PARTY_GITHUB_CLIENT_SECRET: "github-secret",
21+ FIRST_PARTY_GITLAB_CLIENT_ID: "gitlab-id",
22+ FIRST_PARTY_GITLAB_CLIENT_SECRET: "gitlab-secret",
23+ FIRST_PARTY_GOOGLE_CLIENT_ID: "google-id",
24+ FIRST_PARTY_GOOGLE_CLIENT_SECRET: "google-secret",
25+ FIRST_PARTY_HUBSPOT_CLIENT_ID: "hubspot-id",
26+ FIRST_PARTY_HUBSPOT_CLIENT_SECRET: "hubspot-secret",
27+ FIRST_PARTY_LINEAR_CLIENT_ID: "linear-id",
28+ FIRST_PARTY_LINEAR_CLIENT_SECRET: "linear-secret",
29+ FIRST_PARTY_MICROSOFT_CLIENT_ID: "microsoft-id",
30+ FIRST_PARTY_MICROSOFT_CLIENT_SECRET: "microsoft-secret",
31+ FIRST_PARTY_NOTION_CLIENT_ID: "notion-id",
32+ FIRST_PARTY_NOTION_CLIENT_SECRET: "notion-secret",
33+ FIRST_PARTY_SLACK_CLIENT_ID: "slack-id",
34+ FIRST_PARTY_SLACK_CLIENT_SECRET: "slack-secret",
35+};
36+
37+describe("cloud first-party OAuth clients", () => {
38+ it("enables every registered OAuth 2 provider from complete secret pairs", () => {
39+ const clients = firstPartyOAuthClientsFor(completeEnv);
40+
41+ expect(clients.map((client) => client.name)).toEqual([
42+ "airtable",
43+ "atlassian",
44+ "box",
45+ "clickup",
46+ "figma",
47+ "github",
48+ "gitlab",
49+ "google",
50+ "hubspot",
51+ "linear",
52+ "microsoft",
53+ "notion",
54+ "slack",
55+ ]);
56+ });
57+
58+ it("fails closed when either half of a provider secret pair is absent", () => {
59+ expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_ID: "id" })).toEqual([]);
60+ expect(firstPartyOAuthClientsFor({ FIRST_PARTY_AIRTABLE_CLIENT_SECRET: "secret" })).toEqual([]);
61+ });
62+
63+ it("carries provider-specific authorization and token contracts", () => {
64+ const byName = new Map(
65+ firstPartyOAuthClientsFor(completeEnv).map((client) => [client.name, client]),
66+ );
67+
68+ expect(byName.get("airtable")).toMatchObject({
69+ tokenEndpointAuthMethod: "basic",
70+ });
71+ expect(byName.get("atlassian")).toMatchObject({
72+ tokenRequestFormat: "json",
73+ authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" },
74+ });
75+ expect(byName.get("figma")).toMatchObject({
76+ tokenEndpointAuthMethod: "basic",
77+ allowedScopes: expect.arrayContaining(["folder_metadata:read", "folders:read"]),
78+ });
79+ expect(byName.get("hubspot")).toMatchObject({
80+ tokenUrl: "https://api.hubapi.com/oauth/v3/token",
81+ authorizationExtraParams: {
82+ optional_scope: "content crm.objects.custom.read crm.schemas.custom.read",
83+ },
84+ });
85+ expect(byName.get("linear")).toMatchObject({ authorizationScopeSeparator: "," });
86+ expect(byName.get("microsoft")).toMatchObject({
87+ additionalAuthorizationScopes: ["offline_access"],
88+ allowedScopes: expect.arrayContaining(["Mail.ReadWrite", "Files.ReadWrite.All"]),
89+ });
90+ expect(byName.get("notion")).toMatchObject({
91+ authorizationScopes: [],
92+ authorizationExtraParams: { owner: "user" },
93+ tokenEndpointAuthMethod: "basic",
94+ tokenRequestFormat: "json",
95+ });
96+ });
97+});
598
699 // The reviewed consumer scope boundary of the Executor-owned Google app.
7100 //
@@ -12,13 +105,9 @@ import { cloudFirstPartyOAuthClients } from "./execution-stack"; · 4 unmodified lines
12105 // scopes an `oauth.start` requests, and that admin scopes are refused).
13106 const GOOGLE_SCOPE = (suffix: string) => `https://www.googleapis.com/auth/${suffix}`;
14107
15−describe("cloud first-party oauth clients", () => {
16− beforeAll(() => {
17− env.FIRST_PARTY_GOOGLE_CLIENT_ID = "test-google-client";
18− env.FIRST_PARTY_GOOGLE_CLIENT_SECRET = "test-google-secret";
19− });
20−
21− const google = () => cloudFirstPartyOAuthClients().find((client) => client.name === "google");
108+describe("cloud first-party Google app", () => {
109+ const google = () =>
110+ firstPartyOAuthClientsFor(completeEnv).find((client) => client.name === "google");
22111
23112 it("declares the Google app but withholds it from every listing", () => {
24113 const client = google();
apps/cloud/src/engine/first-party-oauth-clients.ts+336−0
@@ -0,0 +1,336 @@
1+import { FIGMA_SUPPORTED_OAUTH_SCOPES } from "@executor-js/plugin-openapi/presets";
2+import { googleCatalogOAuthScopesForPreset } from "@executor-js/plugin-openapi/providers/google";
3+import {
4+ MICROSOFT_AUTHORIZATION_URL,
5+ MICROSOFT_TOKEN_URL,
6+} from "@executor-js/plugin-openapi/providers/microsoft";
7+import { slackMcpUserScopes } from "@executor-js/react/lib/slack-mcp-oauth";
8+import { IntegrationSlug, type FirstPartyOAuthClientConfig } from "@executor-js/sdk";
9+
10+/** Cloud secret bindings that enable host-operated OAuth clients. A provider
11+ * is absent unless both values in its pair are present. */
12+export interface FirstPartyOAuthClientEnv {
13+ readonly FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
14+ readonly FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
15+ readonly FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
16+ readonly FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string;
17+ readonly FIRST_PARTY_BOX_CLIENT_ID?: string;
18+ readonly FIRST_PARTY_BOX_CLIENT_SECRET?: string;
19+ readonly FIRST_PARTY_CLICKUP_CLIENT_ID?: string;
20+ readonly FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string;
21+ readonly FIRST_PARTY_FIGMA_CLIENT_ID?: string;
22+ readonly FIRST_PARTY_FIGMA_CLIENT_SECRET?: string;
23+ readonly FIRST_PARTY_GITHUB_CLIENT_ID?: string;
24+ readonly FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
25+ readonly FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;
26+ readonly FIRST_PARTY_GITHUB_TOKEN_URL?: string;
27+ readonly FIRST_PARTY_GITLAB_CLIENT_ID?: string;
28+ readonly FIRST_PARTY_GITLAB_CLIENT_SECRET?: string;
29+ readonly FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
30+ readonly FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
31+ readonly FIRST_PARTY_HUBSPOT_CLIENT_ID?: string;
32+ readonly FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string;
33+ readonly FIRST_PARTY_LINEAR_CLIENT_ID?: string;
34+ readonly FIRST_PARTY_LINEAR_CLIENT_SECRET?: string;
35+ readonly FIRST_PARTY_MICROSOFT_CLIENT_ID?: string;
36+ readonly FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string;
37+ readonly FIRST_PARTY_NOTION_CLIENT_ID?: string;
38+ readonly FIRST_PARTY_NOTION_CLIENT_SECRET?: string;
39+ readonly FIRST_PARTY_SLACK_CLIENT_ID?: string;
40+ readonly FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
41+}
42+
43+const AIRTABLE_SCOPES = [
44+ "data.recordComments:read",
45+ "data.recordComments:write",
46+ "data.records:read",
47+ "data.records:write",
48+ "data.records:manage",
49+ "schema.bases:read",
50+ "schema.bases:write",
51+ "user.email:read",
52+ "workspacesAndBases:read",
53+ "workspacesAndBases:write",
54+ "workspacesAndBases.shares:manage",
55+ "webhook:manage",
56+] as const;
57+
58+const ATLASSIAN_SCOPES = [
59+ "read:me",
60+ "read:account",
61+ "read:jira-work",
62+ "manage:jira-project",
63+ "manage:jira-configuration",
64+ "read:jira-user",
65+ "write:jira-work",
66+ "manage:jira-webhook",
67+ "read:servicedesk-request",
68+ "manage:servicedesk-customer",
69+ "write:servicedesk-request",
70+ "write:confluence-content",
71+ "read:confluence-space.summary",
72+ "write:confluence-space",
73+ "write:confluence-file",
74+ "read:confluence-props",
75+ "write:confluence-props",
76+ "manage:confluence-configuration",
77+ "read:confluence-content.all",
78+ "read:confluence-content.summary",
79+ "search:confluence",
80+ "read:confluence-content.permission",
81+ "read:confluence-user",
82+ "read:confluence-groups",
83+ "write:confluence-groups",
84+ "offline_access",
85+] as const;
86+
87+const BOX_SCOPES = [
88+ "root_readonly",
89+ "root_readwrite",
90+ "sign_requests.readwrite",
91+ "ai.readwrite",
92+ "manage_webhook",
93+ "manage_triggers",
94+] as const;
95+
96+const GITLAB_SCOPES = [
97+ "api",
98+ "read_api",
99+ "read_user",
100+ "create_runner",
101+ "manage_runner",
102+ "k8s_proxy",
103+ "mcp",
104+ "mcp_orbit",
105+ "read_repository",
106+ "write_repository",
107+ "read_registry",
108+ "write_registry",
109+ "read_virtual_registry",
110+ "write_virtual_registry",
111+ "read_observability",
112+ "write_observability",
113+ "ai_features",
114+ "openid",
115+ "profile",
116+ "email",
117+] as const;
118+
119+const HUBSPOT_REQUIRED_SCOPES = [
120+ "oauth",
121+ "account-info.security.read",
122+ "cms.domains.read",
123+ "cms.domains.write",
124+ "crm.export",
125+ "crm.import",
126+ "crm.lists.read",
127+ "crm.lists.write",
128+ "crm.objects.companies.read",
129+ "crm.objects.companies.write",
130+ "crm.objects.contacts.read",
131+ "crm.objects.contacts.write",
132+ "crm.objects.deals.read",
133+ "crm.objects.deals.write",
134+ "crm.objects.marketing_events.read",
135+ "crm.objects.marketing_events.write",
136+ "crm.objects.owners.read",
137+ "crm.objects.quotes.read",
138+ "crm.objects.quotes.write",
139+ "crm.schemas.companies.read",
140+ "crm.schemas.companies.write",
141+ "crm.schemas.contacts.read",
142+ "crm.schemas.contacts.write",
143+ "sales-email-read",
144+ "settings.users.read",
145+ "settings.users.write",
146+ "tickets",
147+ "timeline",
148+] as const;
149+
150+const HUBSPOT_OPTIONAL_SCOPES = [
151+ "content",
152+ "crm.objects.custom.read",
153+ "crm.schemas.custom.read",
154+] as const;
155+
156+const MICROSOFT_SCOPES = [
157+ "User.Read",
158+ "Calendars.ReadWrite",
159+ "Channel.ReadBasic.All",
160+ "ChannelMessage.Read.All",
161+ "ChannelMessage.Send",
162+ "Chat.ReadWrite",
163+ "Files.ReadWrite.All",
164+ "Mail.ReadWrite",
165+ "Mail.Send",
166+ "MailboxSettings.ReadWrite",
167+ "OnlineMeetings.ReadWrite",
168+ "Sites.ReadWrite.All",
169+ "Team.ReadBasic.All",
170+ "offline_access",
171+] as const;
172+
173+// Consumer Google launch boundary. Keep this list aligned with the scopes
174+// submitted for the Executor-owned production app: ordinary Workspace services
175+// plus Photos, Meet, and Search Console. Admin, Classroom, YouTube, Apps Script,
176+// BigQuery, and Cloud Resource Manager have materially different audiences or
177+// provider requirements and remain BYO OAuth. The same scope source builds each
178+// catalog auth template, preventing picker/start drift.
179+const GOOGLE_FIRST_PARTY_PRESET_IDS = [
180+ "google-calendar",
181+ "google-meet",
182+ "google-gmail",
183+ "google-sheets",
184+ "google-drive",
185+ "google-docs",
186+ "google-slides",
187+ "google-forms",
188+ "google-tasks",
189+ "google-people",
190+ "google-photos-library",
191+ "google-photos-picker",
192+ "google-search-console",
193+] as const;
194+
195+const GOOGLE_ALLOWED_SCOPES: readonly string[] = [
196+ ...new Set([
197+ ...GOOGLE_FIRST_PARTY_PRESET_IDS.flatMap(googleCatalogOAuthScopesForPreset),
198+ // Connections created before the full-Gmail review retain this declared
199+ // scope on reconnect. New Gmail presets request `mail.google.com`.
200+ "https://www.googleapis.com/auth/gmail.modify",
201+ ]),
202+];
203+
204+const client = (
205+ clientId: string | undefined,
206+ clientSecret: string | undefined,
207+ config: Omit<FirstPartyOAuthClientConfig, "clientId" | "clientSecret">,
208+): readonly FirstPartyOAuthClientConfig[] =>
209+ clientId && clientSecret ? [{ ...config, clientId, clientSecret }] : [];
210+
211+/** Build the enabled first-party registry from secret bindings. Provider
212+ * protocol details and scope ceilings live here so the cloud composition root
213+ * cannot drift from the registered production clients.
214+ *
215+ * Each provider-side registration must list
216+ * `${VITE_PUBLIC_SITE_URL}/api/oauth/callback` as its callback; the org slug
217+ * travels inside OAuth `state`, so the single static callback serves every
218+ * org.
219+ *
220+ * The endpoint URLs default to the real provider; the `_AUTHORIZE_URL` /
221+ * `_TOKEN_URL` overrides exist so tests and dev instances can point the app at
222+ * an emulated provider (`@executor-js/emulate`) and run the complete flow.
223+ * Production leaves them unset. */
224+export const firstPartyOAuthClientsFor = (
225+ env: FirstPartyOAuthClientEnv,
226+): readonly FirstPartyOAuthClientConfig[] => [
227+ ...client(env.FIRST_PARTY_AIRTABLE_CLIENT_ID, env.FIRST_PARTY_AIRTABLE_CLIENT_SECRET, {
228+ name: "airtable",
229+ authorizationUrl: "https://airtable.com/oauth2/v1/authorize",
230+ tokenUrl: "https://airtable.com/oauth2/v1/token",
231+ tokenEndpointAuthMethod: "basic",
232+ authorizationScopes: AIRTABLE_SCOPES,
233+ allowedScopes: AIRTABLE_SCOPES,
234+ }),
235+ ...client(env.FIRST_PARTY_ATLASSIAN_CLIENT_ID, env.FIRST_PARTY_ATLASSIAN_CLIENT_SECRET, {
236+ name: "atlassian",
237+ authorizationUrl: "https://auth.atlassian.com/authorize",
238+ tokenUrl: "https://auth.atlassian.com/oauth/token",
239+ tokenRequestFormat: "json",
240+ authorizationScopes: ATLASSIAN_SCOPES,
241+ allowedScopes: ATLASSIAN_SCOPES,
242+ authorizationExtraParams: { audience: "api.atlassian.com", prompt: "consent" },
243+ }),
244+ ...client(env.FIRST_PARTY_BOX_CLIENT_ID, env.FIRST_PARTY_BOX_CLIENT_SECRET, {
245+ name: "box",
246+ authorizationUrl: "https://account.box.com/api/oauth2/authorize",
247+ tokenUrl: "https://api.box.com/oauth2/token",
248+ authorizationScopes: BOX_SCOPES,
249+ allowedScopes: BOX_SCOPES,
250+ }),
251+ ...client(env.FIRST_PARTY_CLICKUP_CLIENT_ID, env.FIRST_PARTY_CLICKUP_CLIENT_SECRET, {
252+ name: "clickup",
253+ authorizationUrl: "https://app.clickup.com/api",
254+ tokenUrl: "https://api.clickup.com/api/v2/oauth/token",
255+ tokenRequestFormat: "json",
256+ authorizationScopes: [],
257+ }),
258+ ...client(env.FIRST_PARTY_FIGMA_CLIENT_ID, env.FIRST_PARTY_FIGMA_CLIENT_SECRET, {
259+ name: "figma",
260+ authorizationUrl: "https://www.figma.com/oauth",
261+ tokenUrl: "https://api.figma.com/v1/oauth/token",
262+ tokenEndpointAuthMethod: "basic",
263+ integrations: [IntegrationSlug.make("figma_api")],
264+ authorizationScopes: FIGMA_SUPPORTED_OAUTH_SCOPES,
265+ allowedScopes: FIGMA_SUPPORTED_OAUTH_SCOPES,
266+ }),
267+ ...client(env.FIRST_PARTY_GITHUB_CLIENT_ID, env.FIRST_PARTY_GITHUB_CLIENT_SECRET, {
268+ name: "github",
269+ authorizationUrl:
270+ env.FIRST_PARTY_GITHUB_AUTHORIZE_URL ?? "https://github.com/login/oauth/authorize",
271+ tokenUrl: env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
272+ integrations: [IntegrationSlug.make("github_rest")],
273+ // GitHub App user access tokens do not use classic OAuth scopes; their
274+ // capabilities come from the app's registered permissions.
275+ authorizationScopes: [],
276+ }),
277+ ...client(env.FIRST_PARTY_GITLAB_CLIENT_ID, env.FIRST_PARTY_GITLAB_CLIENT_SECRET, {
278+ name: "gitlab",
279+ authorizationUrl: "https://gitlab.com/oauth/authorize",
280+ tokenUrl: "https://gitlab.com/oauth/token",
281+ authorizationScopes: GITLAB_SCOPES,
282+ allowedScopes: GITLAB_SCOPES,
283+ }),
284+ ...client(env.FIRST_PARTY_GOOGLE_CLIENT_ID, env.FIRST_PARTY_GOOGLE_CLIENT_SECRET, {
285+ name: "google",
286+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
287+ tokenUrl: "https://oauth2.googleapis.com/token",
288+ allowedScopes: GOOGLE_ALLOWED_SCOPES,
289+ // Withdrawn from the connect picker: no new connection is offered the
290+ // Executor-owned Google app. The entry stays declared on purpose — every
291+ // connection already minted against it keeps refreshing and reconnecting
292+ // through it. Deleting this block, or unsetting the env vars, would strand
293+ // those connections instead.
294+ unlisted: true,
295+ }),
296+ ...client(env.FIRST_PARTY_HUBSPOT_CLIENT_ID, env.FIRST_PARTY_HUBSPOT_CLIENT_SECRET, {
297+ name: "hubspot",
298+ authorizationUrl: "https://app.hubspot.com/oauth/authorize",
299+ tokenUrl: "https://api.hubapi.com/oauth/v3/token",
300+ authorizationScopes: HUBSPOT_REQUIRED_SCOPES,
301+ allowedScopes: [...HUBSPOT_REQUIRED_SCOPES, ...HUBSPOT_OPTIONAL_SCOPES],
302+ authorizationExtraParams: { optional_scope: HUBSPOT_OPTIONAL_SCOPES.join(" ") },
303+ }),
304+ ...client(env.FIRST_PARTY_LINEAR_CLIENT_ID, env.FIRST_PARTY_LINEAR_CLIENT_SECRET, {
305+ name: "linear",
306+ authorizationUrl: "https://linear.app/oauth/authorize",
307+ tokenUrl: "https://api.linear.app/oauth/token",
308+ authorizationScopes: ["read", "write"],
309+ authorizationScopeSeparator: ",",
310+ allowedScopes: ["read", "write"],
311+ }),
312+ ...client(env.FIRST_PARTY_MICROSOFT_CLIENT_ID, env.FIRST_PARTY_MICROSOFT_CLIENT_SECRET, {
313+ name: "microsoft",
314+ authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
315+ tokenUrl: MICROSOFT_TOKEN_URL,
316+ allowedScopes: MICROSOFT_SCOPES,
317+ additionalAuthorizationScopes: ["offline_access"],
318+ }),
319+ ...client(env.FIRST_PARTY_NOTION_CLIENT_ID, env.FIRST_PARTY_NOTION_CLIENT_SECRET, {
320+ name: "notion",
321+ authorizationUrl: "https://api.notion.com/v1/oauth/authorize",
322+ tokenUrl: "https://api.notion.com/v1/oauth/token",
323+ authorizationExtraParams: { owner: "user" },
324+ tokenEndpointAuthMethod: "basic",
325+ tokenRequestFormat: "json",
326+ authorizationScopes: [],
327+ }),
328+ ...client(env.FIRST_PARTY_SLACK_CLIENT_ID, env.FIRST_PARTY_SLACK_CLIENT_SECRET, {
329+ name: "slack",
330+ authorizationUrl: "https://slack.com/oauth/v2_user/authorize",
331+ tokenUrl: "https://slack.com/api/oauth.v2.user.access",
332+ resource: "https://mcp.slack.com",
333+ integrations: [IntegrationSlug.make("slack")],
334+ allowedScopes: slackMcpUserScopes,
335+ }),
336+];
apps/cloud/src/env-augment.d.ts+20−0
@@ -72,6 +72,16 @@ declare global { · 71 unmodified lines
7272 // unset pair simply ships no first-party app for that provider. The
7373 // registered callback on the provider side must be
7474 // `${VITE_PUBLIC_SITE_URL}/api/oauth/callback`.
75+ FIRST_PARTY_AIRTABLE_CLIENT_ID?: string;
76+ FIRST_PARTY_AIRTABLE_CLIENT_SECRET?: string;
77+ FIRST_PARTY_ATLASSIAN_CLIENT_ID?: string;
78+ FIRST_PARTY_ATLASSIAN_CLIENT_SECRET?: string;
79+ FIRST_PARTY_BOX_CLIENT_ID?: string;
80+ FIRST_PARTY_BOX_CLIENT_SECRET?: string;
81+ FIRST_PARTY_CLICKUP_CLIENT_ID?: string;
82+ FIRST_PARTY_CLICKUP_CLIENT_SECRET?: string;
83+ FIRST_PARTY_FIGMA_CLIENT_ID?: string;
84+ FIRST_PARTY_FIGMA_CLIENT_SECRET?: string;
7585 FIRST_PARTY_GITHUB_CLIENT_ID?: string;
7686 FIRST_PARTY_GITHUB_CLIENT_SECRET?: string;
7787 // Endpoint overrides for the GitHub first-party app, so tests/dev can
@@ -79,8 +89,18 @@ declare global { · 1 unmodified line
7989 // production (the real github.com endpoints are the defaults).
8090 FIRST_PARTY_GITHUB_AUTHORIZE_URL?: string;
8191 FIRST_PARTY_GITHUB_TOKEN_URL?: string;
92+ FIRST_PARTY_GITLAB_CLIENT_ID?: string;
93+ FIRST_PARTY_GITLAB_CLIENT_SECRET?: string;
8294 FIRST_PARTY_GOOGLE_CLIENT_ID?: string;
8395 FIRST_PARTY_GOOGLE_CLIENT_SECRET?: string;
96+ FIRST_PARTY_HUBSPOT_CLIENT_ID?: string;
97+ FIRST_PARTY_HUBSPOT_CLIENT_SECRET?: string;
98+ FIRST_PARTY_LINEAR_CLIENT_ID?: string;
99+ FIRST_PARTY_LINEAR_CLIENT_SECRET?: string;
100+ FIRST_PARTY_MICROSOFT_CLIENT_ID?: string;
101+ FIRST_PARTY_MICROSOFT_CLIENT_SECRET?: string;
102+ FIRST_PARTY_NOTION_CLIENT_ID?: string;
103+ FIRST_PARTY_NOTION_CLIENT_SECRET?: string;
84104 FIRST_PARTY_SLACK_CLIENT_ID?: string;
85105 FIRST_PARTY_SLACK_CLIENT_SECRET?: string;
86106
apps/local/vite.config.ts+35−1
@@ -1,5 +1,6 @@
11 import { readFileSync } from "node:fs";
2−import { resolve } from "node:path";
2+import { createRequire } from "node:module";
3+import { dirname, resolve } from "node:path";
34 import { Readable } from "node:stream";
45 import { fileURLToPath } from "node:url";
56 import { defineConfig, type Plugin } from "vite";
@@ -42,6 +43,19 @@ const EXECUTOR_GITHUB_URL = ( · 36 unmodified lines
4243 const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
4344 const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
4445
46+// js-yaml reaches the browser through @executor-js/plugin-openapi's UI, so it
47+// belongs in the pre-bundle below. Vite resolves `optimizeDeps.include` from
48+// `root`, which here is packages/app, and bun's isolated install keeps js-yaml
49+// in the openapi plugin's own node_modules — out of reach of both a plain
50+// "js-yaml" specifier and the "<pkg> > <dep>" form from that directory. Point
51+// the bare specifier at the package the plugin already resolves, so `root` can
52+// name it. It is the same copy either way, so nothing about resolution changes.
53+const JS_YAML_DIR = dirname(
54+ createRequire(fileURLToPath(new URL("../../packages/plugins/openapi/", import.meta.url))).resolve(
55+ "js-yaml",
56+ ),
57+);
58+
4559 const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response =>
4660 new Response(
4761 JSON.stringify(
@@ -215,6 +229,25 @@ export default defineConfig({ · 167 unmodified lines
215229 outDir: resolve(import.meta.dirname, "dist"),
216230 emptyOutDir: true,
217231 },
232+ // Deps vite only discovers once a lazy-loaded React chunk actually renders
233+ // (e.g. opening the integration Edit sheet, or the MCP/OpenAPI add-source
234+ // flow). Discovering one mid-run forces a re-optimize and a full page
235+ // reload, which throws away whatever the person was doing — an open sheet
236+ // and its form state included. Pre-bundle them at boot so vite never
237+ // discovers them mid-run. Keep in sync with apps/host-selfhost and
238+ // apps/cloud.
239+ optimizeDeps: {
240+ include: [
241+ "effect/Match",
242+ "effect/Predicate",
243+ "effect/Exit",
244+ "effect/Option",
245+ "effect/Cause",
246+ "effect/Data",
247+ "effect/Schema",
248+ "js-yaml",
249+ ],
250+ },
218251 define: {
219252 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
220253 // The local app IS the npm-installed CLI, so its update card shows the npm
@@ -229,6 +262,7 @@ export default defineConfig({ · 8 unmodified lines
229262 },
230263 resolve: {
231264 tsconfigPaths: true,
265+ alias: { "js-yaml": JS_YAML_DIR },
232266 },
233267 server: {
234268 port: parseInt(process.env.PORT ?? "5173", 10),
e2e/local/stdio-mcp-edit-ui.test.ts+138−0
@@ -0,0 +1,138 @@
1+// Local-only — the integration Edit sheet for a STDIO MCP server, driven in a
2+// real browser. `local` is the only surface that enables stdio MCP
3+// (`dangerouslyAllowStdioMCP: true`), so it is the only place this sheet can be
4+// exercised end to end.
5+//
6+// The sheet used to say "Stdio MCP integrations cannot be edited. Remove and
7+// recreate the integration with the updated command." (#812) — changing a
8+// command meant editing `executor.jsonc` by hand, or losing the integration's
9+// connections and policies to a delete-and-re-add.
10+//
11+// The assertion is deliberately not "the field accepted my text". The scenario
12+// edits the DECLARED env map through the form and then reads the tool catalog:
13+// the fixture advertises `saw_declared_env` only when that variable is present
14+// in the spawned child's environment, so the tool appearing proves the edited
15+// config travelled the whole way — form, config write, respawn, rediscovery.
16+import { fileURLToPath } from "node:url";
17+
18+import { expect } from "@effect/vitest";
19+import { Effect } from "effect";
20+import { HttpApiClient } from "effect/unstable/httpapi";
21+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
22+import { composePluginApi } from "@executor-js/api/server";
23+import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
24+
25+import { scenario } from "../src/scenario";
26+import { Browser, Cli, RunDir, Target } from "../src/services";
27+import { withLocalServer } from "./local-server";
28+
29+const api = composePluginApi([mcpHttpPlugin()] as const);
30+
31+const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
32+
33+const SECRET = "s3cr3t-typed-into-the-edit-sheet";
34+
35+scenario(
36+ "Local · a stdio MCP server's command and environment are editable from the integration Edit sheet",
37+ { timeout: 300_000 },
38+ Effect.gen(function* () {
39+ const cli = yield* Cli;
40+ const browser = yield* Browser;
41+ const target = yield* Target;
42+ const runDir = yield* RunDir;
43+ const identity = yield* target.newIdentity();
44+
45+ yield* withLocalServer(cli, runDir, (server) =>
46+ Effect.gen(function* () {
47+ const client = yield* HttpApiClient.make(api, {
48+ baseUrl: new URL("/api", server.origin).toString(),
49+ transformClient: HttpClient.mapRequest((request) =>
50+ HttpClientRequest.setHeader(request, "authorization", `Bearer ${server.token}`),
51+ ),
52+ }).pipe(Effect.provide(FetchHttpClient.layer));
53+
54+ const slug = "e2e-stdio-editable";
55+
56+ // A plain stdio server: no declared env, so the env-gated tool is absent
57+ // until the edit adds the variable.
58+ yield* client.mcp.addServer({
59+ payload: {
60+ transport: "stdio",
61+ name: "E2E Stdio Editable",
62+ command: "node",
63+ args: [FIXTURE],
64+ slug,
65+ },
66+ });
67+
68+ const before = yield* client.tools.list({ query: { integration: slug } });
69+ expect(
70+ before.map((t) => t.name),
71+ "the server starts with its base tool and no declared env",
72+ ).toContain("echo_tool");
73+ expect(
74+ before.map((t) => t.name),
75+ "the env-gated tool is absent before the edit",
76+ ).not.toContain("saw_declared_env");
77+
78+ yield* browser.session(identity, async ({ page, step }) => {
79+ await step("Open the stdio integration from the console", async () => {
80+ await page.goto(server.url, { waitUntil: "domcontentloaded" });
81+ await page.getByTestId(`integration-entry-${slug}`).first().click();
82+ // Wait for the detail page's own data, not just its shell: clicking
83+ // Edit while the accounts panel is still a skeleton races the layout
84+ // shift that lands when it resolves.
85+ await page.getByText("default").first().waitFor({ timeout: 30_000 });
86+ await page.getByRole("button", { name: "Edit" }).waitFor({ timeout: 30_000 });
87+ });
88+
89+ await step("Open the Edit sheet — the stdio command is editable", async () => {
90+ await page.getByRole("button", { name: "Edit" }).click();
91+ await page.getByText("Edit integration").waitFor({ timeout: 30_000 });
92+ await page.getByText("Server command").waitFor({ timeout: 30_000 });
93+ // The read-only dead end this replaces.
94+ expect(
95+ await page.getByText("Stdio MCP integrations cannot be edited").count(),
96+ "the read-only message is gone",
97+ ).toBe(0);
98+ expect(
99+ await page.getByRole("textbox", { name: "Command" }).inputValue(),
100+ "the stored command is loaded into the form",
101+ ).toBe("node");
102+ });
103+
104+ await step("Declare an environment variable and save", async () => {
105+ await page
106+ .getByRole("textbox", { name: "Environment variables" })
107+ .fill(`EXECUTOR_E2E_SECRET=${SECRET}`);
108+ await page.getByRole("button", { name: "Save" }).click();
109+ await page.getByText("Server command").waitFor({ state: "hidden", timeout: 30_000 });
110+ });
111+ });
112+
113+ // The edit persisted as the DECLARED static env map on the config.
114+ const stored = yield* client.mcp.getServer({ params: { slug } });
115+ expect(
116+ stored?.config.transport === "stdio" ? stored.config.env : undefined,
117+ "the sheet wrote the declared env map",
118+ ).toEqual({ EXECUTOR_E2E_SECRET: SECRET });
119+ expect(
120+ stored?.config.transport === "stdio" ? stored.config.command : undefined,
121+ "the command it did not touch is unchanged",
122+ ).toBe("node");
123+
124+ // And it reached the spawned server: the fixture gates this tool's very
125+ // existence on that variable being in its own environment.
126+ const after = yield* client.tools.list({ query: { integration: slug } });
127+ expect(
128+ after.map((t) => t.name),
129+ "the edited environment reached the respawned server and the catalog was rebuilt",
130+ ).toContain("saw_declared_env");
131+ expect(
132+ after.map((t) => t.name),
133+ "the rebuild kept the tools that still exist",
134+ ).toContain("echo_tool");
135+ }),
136+ );
137+ }),
138+);
e2e/local/stdio-mcp.test.ts+42−0
@@ -222,6 +222,48 @@ scenario( · 221 unmodified lines
222222 autoTools.map((t) => t.name),
223223 "auto negotiation falls back to legacy and still discovers tools",
224224 ).toContain("echo_tool");
225+ expect(
226+ autoTools.map((t) => t.name),
227+ "this server declared no env, so the env-gated tool is absent to begin with",
228+ ).not.toContain("saw_declared_env");
229+
230+ // --- Editing a stdio server's config (what the integration Edit sheet
231+ // now does instead of telling you to remove and recreate). The tool
232+ // catalog is persisted per connection, so a plain config replace is
233+ // only enough because core stamps `config_revised_at` on a config
234+ // write and every connection whose catalog predates that stamp is
235+ // rebuilt on the next read. The edit path therefore needs NO explicit
236+ // refresh of its own — this asserts that, so nobody adds one back.
237+ //
238+ // Adding a declared env var is the lever: the fixture advertises
239+ // `saw_declared_env` only when that variable reached the child, so the
240+ // tool appearing with no further action proves both halves — the new
241+ // config reached the spawn, and the catalog was rebuilt from it. ---
242+ const editedConfig = {
243+ ...autoStored!.config,
244+ env: { EXECUTOR_E2E_SECRET: SECRET },
245+ } as typeof autoStored.config;
246+
247+ yield* client.mcp.configureServer({
248+ params: { slug: autoSlug },
249+ payload: { config: editedConfig },
250+ });
251+
252+ const editedStored = yield* client.mcp.getServer({ params: { slug: autoSlug } });
253+ expect(
254+ JSON.stringify(editedStored?.config ?? {}),
255+ "the edit persisted, and left the untouched negotiation mode alone",
256+ ).toContain('"versionNegotiation":"auto"');
257+
258+ const editedTools = yield* client.tools.list({ query: { integration: autoSlug } });
259+ expect(
260+ editedTools.map((t) => t.name),
261+ "the edited config reached the spawn and the catalog was rediscovered",
262+ ).toContain("saw_declared_env");
263+ expect(
264+ editedTools.map((t) => t.name),
265+ "rediscovery replaced the catalog rather than dropping what still exists",
266+ ).toContain("echo_tool");
225267 }),
226268 { env: DAEMON_ENV },
227269 );
e2e/scenarios/google-disabled-api.test.ts+364−0
@@ -0,0 +1,364 @@
1+// A Google 403 for a DISABLED API must NOT read as an expired credential.
2+//
3+// Production case (2026-07-10): a user's "Personal Google" connection showed
4+// the red Expired badge although its OAuth token was alive and refreshing.
5+// The org's Google OAuth client lives in a GCP project without the Gmail /
6+// Drive / Calendar APIs enabled, so the health probe gets Google's
7+// SERVICE_DISABLED 403 ("Gmail API has not been used in project ... or it is
8+// disabled"). The old status-only classifier mapped every 401/403 to
9+// "expired", so a healthy credential rendered as Expired and the UI told the
10+// user to reconnect - advice that cannot fix a disabled upstream API.
11+//
12+// The scenario replays that exact journey against the Google emulator: a real
13+// OAuth grant probes healthy; then the upstream starts answering the probe
14+// operation with Google's real SERVICE_DISABLED body (fault injection - the
15+// token stays valid, exactly like prod). The fixed classifier reads the error
16+// body and reports "misconfigured": on screen that is the amber "API
17+// disabled" badge with Google's own remediation text (including the console
18+// link that enables the API), and no reconnect prompt.
19+import { randomBytes } from "node:crypto";
20+
21+import { expect } from "@effect/vitest";
22+import { Effect } from "effect";
23+import type { HttpApiClient } from "effect/unstable/httpapi";
24+import { composePluginApi } from "@executor-js/api/server";
25+import { connectEmulator, type EmulatorClient } from "@executor-js/emulate";
26+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
27+import {
28+ AuthTemplateSlug,
29+ ConnectionName,
30+ IntegrationSlug,
31+ OAuthClientSlug,
32+} from "@executor-js/sdk/shared";
33+
34+import { createEmulatorInstance } from "../src/emulator-instance";
35+import { scenario } from "../src/scenario";
36+import { Api, Browser, Target } from "../src/services";
37+import type { Identity, Target as TargetShape } from "../src/target";
38+import type { BrowserSurface } from "../src/surfaces/browser";
39+
40+const api = composePluginApi([openApiHttpPlugin()] as const);
41+type Client = HttpApiClient.ForApi<typeof api>;
42+const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
43+const CONNECTION = ConnectionName.make("main");
44+
45+// The Gmail preset's declared health check, and the probe the fault hijacks.
46+const GMAIL_HEALTH_OPERATION = "gmail.users.labels.list";
47+
48+// Google's real error body for an API that is not enabled in the OAuth
49+// client's GCP project. Verbatim shape from production: HTTP 403 with reason
50+// `accessNotConfigured` - an authorization-configuration error, NOT an
51+// expired/revoked credential.
52+const DISABLED_API_MESSAGE =
53+ "Gmail API has not been used in project 000000000000 before or it is disabled. " +
54+ "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry.";
55+const disabledApiBody = {
56+ error: {
57+ code: 403,
58+ message: DISABLED_API_MESSAGE,
59+ errors: [
60+ { message: DISABLED_API_MESSAGE, domain: "usageLimits", reason: "accessNotConfigured" },
61+ ],
62+ status: "PERMISSION_DENIED",
63+ },
64+};
65+
66+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
67+
68+const decodeHtml = (value: string): string =>
69+ value
70+ .replaceAll("&amp;", "&")
71+ .replaceAll("&quot;", '"')
72+ .replaceAll("&#x27;", "'")
73+ .replaceAll("&lt;", "<")
74+ .replaceAll("&gt;", ">");
75+
76+const inputFields = (html: string): Record<string, string> => {
77+ const fields: Record<string, string> = {};
78+ for (const input of html.matchAll(/<input\b[^>]*>/gi)) {
79+ const tag = input[0];
80+ const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1];
81+ const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? "";
82+ if (name) fields[decodeHtml(name)] = decodeHtml(value);
83+ }
84+ return fields;
85+};
86+
87+const formAction = (html: string, fallback: string): string => {
88+ const action = html.match(/<form\b[^>]*\baction=["']([^"']+)["']/i)?.[1];
89+ return action ? decodeHtml(action) : fallback;
90+};
91+
92+const completeGoogleConsent = (authorizationUrl: string) =>
93+ Effect.promise(async () => {
94+ const page = await fetch(authorizationUrl);
95+ if (!page.ok) throw new Error(`Google emulator authorize failed: ${page.status}`);
96+ const html = await page.text();
97+ const callback = await fetch(formAction(html, authorizationUrl), {
98+ method: "POST",
99+ body: new URLSearchParams(inputFields(html)),
100+ redirect: "manual",
101+ });
102+ const location = callback.headers.get("location");
103+ if (callback.status !== 302 || !location) {
104+ throw new Error(`Google emulator consent did not redirect: ${callback.status}`);
105+ }
106+ const code = new URL(location).searchParams.get("code");
107+ if (!code) throw new Error("Google emulator callback did not include a code");
108+ return code;
109+ });
110+
111+const createGoogleEmulator = Effect.gen(function* () {
112+ const baseUrl = yield* createEmulatorInstance("google", "disabled-api");
113+ const client = yield* Effect.promise(() => connectEmulator({ baseUrl }));
114+ return { client, baseUrl };
115+});
116+
117+const addGooglePresetFromCatalog = (
118+ browser: BrowserSurface,
119+ identity: Identity,
120+ presetName: string,
121+ slug: string,
122+) =>
123+ browser.session(identity, async ({ page, step }) => {
124+ await step(`Open ${presetName} from the connect catalog`, async () => {
125+ await page.goto("/integrations", { waitUntil: "networkidle" });
126+ await page
127+ .getByRole("button", { name: /Connect/ })
128+ .first()
129+ .click();
130+ const dialog = page.getByRole("dialog", { name: "Connect an integration" });
131+ await dialog.waitFor();
132+ await dialog.getByPlaceholder(/Search or paste a URL/).fill(presetName);
133+ await dialog.getByRole("link", { name: new RegExp(`^${presetName}\\b`) }).click();
134+ });
135+
136+ await step(`Add the ${presetName} integration`, async () => {
137+ await page.waitForURL(/\/integrations\/add\/openapi/);
138+ await page.getByRole("heading", { name: "Add OpenAPI integration" }).waitFor();
139+ const button = page.getByRole("button", { name: "Add integration" });
140+ await button.waitFor({ timeout: 120_000 });
141+ await button.click({ timeout: 120_000 });
142+ await page.waitForURL(new RegExp(`/integrations/${slug}\\b`), { timeout: 120_000 });
143+ });
144+ });
145+
146+const connectGoogleAccount = (input: {
147+ readonly client: Client;
148+ readonly emulator: EmulatorClient;
149+ readonly emulatorBaseUrl: string;
150+ readonly target: TargetShape;
151+ readonly integration: IntegrationSlug;
152+ readonly oauthClient: OAuthClientSlug;
153+}) =>
154+ Effect.gen(function* () {
155+ const redirectUri = new URL("/api/oauth/callback", input.target.baseUrl).toString();
156+ const credential = yield* Effect.promise(() =>
157+ input.emulator.credentials.mint({
158+ type: "oauth-authorization-code",
159+ redirect_uris: [redirectUri],
160+ }),
161+ );
162+ if (!credential.client_id || !credential.client_secret) {
163+ return yield* Effect.die(`Google emulator did not mint an OAuth client`);
164+ }
165+
166+ yield* input.client.openapi.configure({
167+ params: { slug: input.integration },
168+ payload: { baseUrl: input.emulatorBaseUrl },
169+ });
170+ yield* input.client.oauth.createClient({
171+ payload: {
172+ owner: "org",
173+ slug: input.oauthClient,
174+ grant: "authorization_code",
175+ authorizationUrl: `${input.emulatorBaseUrl}/o/oauth2/v2/auth`,
176+ tokenUrl: `${input.emulatorBaseUrl}/oauth2/token`,
177+ clientId: credential.client_id,
178+ clientSecret: credential.client_secret,
179+ originIntegration: input.integration,
180+ },
181+ });
182+
183+ const started = yield* input.client.oauth.start({
184+ payload: {
185+ client: input.oauthClient,
186+ clientOwner: "org",
187+ owner: "org",
188+ name: CONNECTION,
189+ integration: input.integration,
190+ template: GOOGLE_AUTH_TEMPLATE,
191+ redirectUri,
192+ },
193+ });
194+ expect(started.status, "OAuth starts with an emulator redirect").toBe("redirect");
195+ if (started.status !== "redirect") return yield* Effect.die("OAuth unexpectedly connected");
196+
197+ const code = yield* completeGoogleConsent(started.authorizationUrl);
198+ const completed = yield* input.client.oauth.complete({
199+ payload: { state: started.state, code },
200+ });
201+ expect(completed.integration, "OAuth completion creates the connection").toBe(
202+ input.integration,
203+ );
204+ });
205+
206+scenario(
207+ "Google · a disabled upstream API reads as API disabled with the enable link, not Expired",
208+ { timeout: 300_000 },
209+ Effect.gen(function* () {
210+ const target = yield* Target;
211+ const browser = yield* Browser;
212+ const { client: makeClient } = yield* Api;
213+ const identity = yield* target.newIdentity();
214+ const client = yield* makeClient(api, identity);
215+ const emulator = yield* createGoogleEmulator;
216+ const slug = IntegrationSlug.make("google_gmail");
217+ const oauthClient = OAuthClientSlug.make(unique("google_gmail_oauth"));
218+
219+ yield* Effect.ensuring(
220+ Effect.gen(function* () {
221+ yield* addGooglePresetFromCatalog(browser, identity, "Gmail", String(slug));
222+
223+ const stored = yield* client.integrations.healthCheckGet({ params: { slug } });
224+ expect(stored?.operation, "the Gmail preset declares its labels probe").toBe(
225+ GMAIL_HEALTH_OPERATION,
226+ );
227+
228+ yield* connectGoogleAccount({
229+ client,
230+ emulator: emulator.client,
231+ emulatorBaseUrl: emulator.baseUrl,
232+ target,
233+ integration: slug,
234+ oauthClient,
235+ });
236+
237+ // Baseline: the freshly granted token probes healthy against the
238+ // emulator - same as prod the day the account was connected.
239+ const healthy = yield* client.connections.checkHealth({
240+ params: { owner: "org", integration: slug, name: CONNECTION },
241+ query: { ifStaleMs: 0 },
242+ });
243+ expect(healthy.status, `the fresh grant probes healthy: ${JSON.stringify(healthy)}`).toBe(
244+ "healthy",
245+ );
246+
247+ // The upstream flips: the probe operation now answers with Google's
248+ // real SERVICE_DISABLED 403. ONLY that route faults - the OAuth token
249+ // endpoints stay live, so the credential itself remains valid and
250+ // refreshable, exactly the production condition. Generous `times`
251+ // covers the UI's automatic revalidations on top of explicit checks.
252+ yield* Effect.promise(() =>
253+ emulator.client.faults.arm({
254+ match: { operationId: GMAIL_HEALTH_OPERATION },
255+ response: { status: 403, body: disabledApiBody },
256+ times: 100,
257+ }),
258+ );
259+ yield* Effect.promise(() => emulator.client.ledger.clear());
260+
261+ // The fix under test: a 403 that means "enable this API in your GCP
262+ // project" classifies as misconfigured, not as a dead credential.
263+ const verdict = yield* client.connections.checkHealth({
264+ params: { owner: "org", integration: slug, name: CONNECTION },
265+ query: { ifStaleMs: 0 },
266+ });
267+ expect(
268+ verdict.status,
269+ `the SERVICE_DISABLED 403 classifies as misconfigured: ${JSON.stringify(verdict)}`,
270+ ).toBe("misconfigured");
271+ expect(verdict.httpStatus, "the probe observed the 403").toBe(403);
272+ expect(
273+ verdict.detail,
274+ "the stored detail names the real cause (a disabled API, not an expired token)",
275+ ).toContain("has not been used in project");
276+
277+ // The 403 came from the armed fault on the probe route - the token
278+ // was never rejected by the emulator's auth layer.
279+ const ledger = yield* Effect.promise(() => emulator.client.ledger.list(50));
280+ const probeEntry = ledger.find((entry) => entry.operationId === GMAIL_HEALTH_OPERATION);
281+ expect(probeEntry?.faulted, "the probe was answered by the armed fault").toBe(true);
282+
283+ yield* browser.session(identity, async ({ page, step }) => {
284+ const connections = page.locator("section").filter({
285+ has: page.getByRole("heading", { level: 3, name: "Connections" }),
286+ });
287+
288+ await step(
289+ "The connection shows the amber API disabled badge - not Expired - with no clicks",
290+ async () => {
291+ await page.goto(`/integrations/${slug}`, { waitUntil: "networkidle" });
292+ await connections
293+ .getByText("API disabled", { exact: true })
294+ .waitFor({ timeout: 30_000 });
295+ await connections.getByLabel("Status: API disabled").waitFor();
296+ // The dead-credential story must be gone entirely.
297+ expect(
298+ await connections.getByText("Expired", { exact: true }).count(),
299+ "no Expired badge on a misconfigured connection",
300+ ).toBe(0);
301+ },
302+ );
303+
304+ await step(
305+ "The row carries Google's own instruction, console link included",
306+ async () => {
307+ const detail = connections.getByText(/has not been used in project/);
308+ await detail.waitFor();
309+ const consoleLink = connections.getByRole("link", {
310+ name: /console\.developers\.google\.com/,
311+ });
312+ await consoleLink.waitFor();
313+ expect(
314+ await consoleLink.getAttribute("href"),
315+ "the enable-API console link is clickable",
316+ ).toContain("console.developers.google.com");
317+ // Visible in FULL, not just present in the DOM: a one-line
318+ // truncate keeps the link in the tree while clipping it from
319+ // view, which is exactly the regression this guards against.
320+ // A clipped element overflows horizontally; a wrapped one
321+ // grows taller instead.
322+ const clipped = await detail.evaluate(
323+ (el) =>
324+ el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
325+ );
326+ expect(clipped, "the remediation text wraps instead of clipping").toBe(false);
327+ const linkVisible = await consoleLink.evaluate((el) => {
328+ const rect = el.getBoundingClientRect();
329+ const detailRect = el.closest("p")?.getBoundingClientRect();
330+ return detailRect ? rect.right <= detailRect.right + 1 : false;
331+ });
332+ expect(linkVisible, "the console link sits inside the visible detail box").toBe(true);
333+ },
334+ );
335+
336+ await step(
337+ "Check now explains the disabled API instead of prescribing reconnect",
338+ async () => {
339+ await connections.locator('button[aria-haspopup="menu"]').click();
340+ await page.getByRole("menuitem", { name: "Check now" }).click();
341+ await page
342+ .getByText(/has not been used in project/)
343+ .first()
344+ .waitFor({ timeout: 30_000 });
345+ expect(
346+ await page.getByText("Connection expired, reconnect to restore access").count(),
347+ "the reconnect toast never fires for a configuration 403",
348+ ).toBe(0);
349+ },
350+ );
351+ });
352+ }),
353+ Effect.gen(function* () {
354+ yield* client.connections
355+ .remove({ params: { owner: "org", integration: slug, name: CONNECTION } })
356+ .pipe(Effect.ignore);
357+ yield* client.oauth
358+ .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } })
359+ .pipe(Effect.ignore);
360+ yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
361+ }),
362+ );
363+ }),
364+);
e2e/selfhost/oauth-refresh-cross-session.test.ts+275−0
@@ -0,0 +1,275 @@
1+// Selfhost-only: two MCP sessions that hit an expired token at the same moment
2+// must share ONE refresh-token grant, never race two.
3+//
4+// Issue #1520: the in-flight refresh gate lived inside a single execution
5+// stack, but the self-host builds a fresh stack per MCP session, so each
6+// session believed it was the refresh winner and redeemed the same stored
7+// refresh token. Providers that rotate refresh tokens answer the second
8+// redemption with `invalid_grant: refresh token reuse detected` and may revoke
9+// the whole token family — the connection dies and the user must reauthorize.
10+// The first refresh cycle succeeds, so the bug stays invisible until a later
11+// expiry.
12+//
13+// The journey: an OpenAPI integration completes a real authorization-code flow
14+// against a live test authorization server; the upstream then rejects both
15+// sessions' first call with a 401 at the same instant (it holds both requests
16+// until both have arrived, so the contention is forced rather than left to the
17+// scheduler); and the authorization server's own request ledger proves exactly
18+// one refresh grant was issued and both retries carried the same new bearer.
19+import { randomBytes } from "node:crypto";
20+import { createServer, type ServerResponse } from "node:http";
21+
22+import { expect } from "@effect/vitest";
23+import { Effect } from "effect";
24+import { composePluginApi } from "@executor-js/api/server";
25+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
26+import {
27+ AuthTemplateSlug,
28+ ConnectionName,
29+ IntegrationSlug,
30+ OAuthClientSlug,
31+} from "@executor-js/sdk/shared";
32+import { serveOAuthTestServer } from "@executor-js/sdk/testing";
33+
34+import { scenario } from "../src/scenario";
35+import { Api, Mcp, Target } from "../src/services";
36+
37+const api = composePluginApi([openApiHttpPlugin()] as const);
38+
39+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
40+
41+/** Both sessions call once, are rejected together, then retry once. */
42+const SESSIONS = 2;
43+
44+type UpstreamHandle = {
45+ readonly url: string;
46+ readonly bearers: () => readonly string[];
47+ readonly close: () => void;
48+};
49+
50+/**
51+ * Upstream that rejects the whole first wave at once.
52+ *
53+ * The barrier is the point: it holds every session's first call until all have
54+ * arrived, then 401s them together, which forces both sessions into a genuinely
55+ * simultaneous refresh instead of hoping the scheduler interleaves them.
56+ */
57+const serveUpstream = () =>
58+ Effect.acquireRelease(
59+ Effect.callback<UpstreamHandle>((resume) => {
60+ const bearers: string[] = [];
61+ const held: ServerResponse[] = [];
62+ const server = createServer((request, response) => {
63+ if (request.method === "GET" && (request.url ?? "").startsWith("/issues")) {
64+ bearers.push((request.headers.authorization ?? "").replace(/^Bearer\s+/i, ""));
65+ if (held.length < SESSIONS) {
66+ held.push(response);
67+ if (held.length === SESSIONS) {
68+ for (const rejected of held) {
69+ rejected.writeHead(401, { "content-type": "application/json" });
70+ rejected.end(JSON.stringify({ error: "invalid_token" }));
71+ }
72+ }
73+ return;
74+ }
75+ response.writeHead(200, { "content-type": "application/json" });
76+ response.end(JSON.stringify({ issues: [] }));
77+ return;
78+ }
79+ response.writeHead(404, { "content-type": "application/json" });
80+ response.end(JSON.stringify({ error: "not_found" }));
81+ });
82+ server.listen(0, "127.0.0.1", () => {
83+ const address = server.address();
84+ const port = typeof address === "object" && address ? address.port : 0;
85+ resume(
86+ Effect.succeed({
87+ url: `http://127.0.0.1:${port}`,
88+ bearers: () => [...bearers],
89+ close: () => {
90+ server.close();
91+ server.closeAllConnections();
92+ },
93+ }),
94+ );
95+ });
96+ }),
97+ (server) => Effect.sync(server.close),
98+ );
99+
100+const spec = (
101+ baseUrl: string,
102+ oauth: { readonly authorizationEndpoint: string; readonly tokenEndpoint: string },
103+): string =>
104+ JSON.stringify({
105+ openapi: "3.0.3",
106+ info: { title: "Issues API", version: "1.0.0" },
107+ servers: [{ url: baseUrl }],
108+ paths: {
109+ "/issues": {
110+ get: {
111+ operationId: "listIssues",
112+ security: [{ oauth: ["issues.read"] }],
113+ responses: { "200": { description: "issues" } },
114+ },
115+ },
116+ },
117+ components: {
118+ securitySchemes: {
119+ oauth: {
120+ type: "oauth2",
121+ flows: {
122+ authorizationCode: {
123+ authorizationUrl: oauth.authorizationEndpoint,
124+ tokenUrl: oauth.tokenEndpoint,
125+ scopes: { "issues.read": "Read issues" },
126+ },
127+ },
128+ },
129+ },
130+ },
131+ });
132+
133+const invokeByAddressCode = (address: string) => `
134+const segments = ${JSON.stringify(address)}.split(".").slice(1);
135+let node = tools;
136+for (const segment of segments) node = node[segment];
137+const result = await node({});
138+return JSON.stringify(result);
139+`;
140+
141+const completeAuthorization = (authorizationUrl: string) =>
142+ Effect.promise(async () => {
143+ const authorize = await fetch(authorizationUrl, { redirect: "manual" });
144+ const loginUrl = authorize.headers.get("location");
145+ if (!loginUrl) return null;
146+ const login = await fetch(loginUrl, {
147+ method: "POST",
148+ headers: { authorization: `Basic ${Buffer.from("alice:password").toString("base64")}` },
149+ redirect: "manual",
150+ });
151+ const callbackUrl = login.headers.get("location");
152+ if (!callbackUrl) return null;
153+ return new URL(callbackUrl).searchParams.get("code");
154+ });
155+
156+scenario(
157+ "OAuth refresh · separate MCP sessions share one rotating-token refresh grant",
158+ { timeout: 180_000 },
159+ Effect.scoped(
160+ Effect.gen(function* () {
161+ const target = yield* Target;
162+ const { client: makeClient } = yield* Api;
163+ const mcp = yield* Mcp;
164+ const identity = yield* target.newIdentity();
165+ const client = yield* makeClient(api, identity);
166+ const upstream = yield* serveUpstream();
167+ const oauth = yield* serveOAuthTestServer({ scopes: ["issues.read"] });
168+ const slug = unique("refreshcrosssession");
169+ const clientSlug = OAuthClientSlug.make(unique("refreshcrosssessionc"));
170+
171+ yield* Effect.ensuring(
172+ Effect.gen(function* () {
173+ yield* client.openapi.addSpec({
174+ payload: {
175+ spec: { kind: "blob", value: spec(upstream.url, oauth) },
176+ slug,
177+ baseUrl: upstream.url,
178+ authenticationTemplate: [
179+ {
180+ slug: "oauth",
181+ kind: "oauth2",
182+ authorizationUrl: oauth.authorizationEndpoint,
183+ tokenUrl: oauth.tokenEndpoint,
184+ scopes: ["issues.read"],
185+ },
186+ ],
187+ },
188+ });
189+ yield* client.oauth.createClient({
190+ payload: {
191+ owner: "org",
192+ slug: clientSlug,
193+ grant: "authorization_code",
194+ authorizationUrl: oauth.authorizationEndpoint,
195+ tokenUrl: oauth.tokenEndpoint,
196+ clientId: "test-client",
197+ clientSecret: "test-secret",
198+ originIntegration: IntegrationSlug.make(slug),
199+ },
200+ });
201+ const started = yield* client.oauth.start({
202+ payload: {
203+ client: clientSlug,
204+ clientOwner: "org",
205+ owner: "org",
206+ name: ConnectionName.make("main"),
207+ integration: IntegrationSlug.make(slug),
208+ template: AuthTemplateSlug.make("oauth"),
209+ },
210+ });
211+ expect(started.status, "oauth.start redirects to the authorization server").toBe(
212+ "redirect",
213+ );
214+ if (started.status !== "redirect") return yield* Effect.die("no redirect");
215+ const code = yield* completeAuthorization(started.authorizationUrl);
216+ expect(code, "the authorization server issued a callback code").toBeDefined();
217+ if (code == null) return yield* Effect.die("no authorization code");
218+ yield* client.oauth.complete({ payload: { state: started.state, code } });
219+
220+ const address = (yield* client.tools.list({ query: {} }))
221+ .filter((tool) => String(tool.integration) === slug)
222+ .map((tool) => String(tool.address))
223+ .find((tool) => tool.endsWith("listIssues"));
224+ expect(address, "the OAuth-protected tool is in the catalog").toBeDefined();
225+ if (!address) return yield* Effect.die("no listIssues tool");
226+ yield* oauth.clearRequests;
227+
228+ const sessions = Array.from({ length: SESSIONS }, () => mcp.session(identity));
229+ const call = (session: (typeof sessions)[number]) =>
230+ Effect.gen(function* () {
231+ let result = yield* session.call("execute", { code: invokeByAddressCode(address) });
232+ let approvals = 0;
233+ while (result.text.includes("executionId:") && approvals < 10) {
234+ result = yield* session.approvePaused(result.text);
235+ approvals += 1;
236+ }
237+ // Without a shared gate the loser redeems a retired refresh token
238+ // and the authorization server answers invalid_grant, so this is
239+ // the assertion that carries the user-visible failure.
240+ expect(result.ok, `MCP execute completed: ${result.text.slice(0, 400)}`).toBe(true);
241+ });
242+
243+ yield* Effect.all(sessions.map(call), { concurrency: "unbounded" });
244+
245+ const refreshGrants = (yield* oauth.requests).filter(
246+ (request) =>
247+ request.path === "/token" && request.body.includes("grant_type=refresh_token"),
248+ );
249+ expect(refreshGrants, "both sessions joined one refresh grant").toHaveLength(1);
250+ const bearers = upstream.bearers();
251+ expect(bearers, "both rejected calls retried after the refresh").toHaveLength(
252+ SESSIONS * 2,
253+ );
254+ expect(bearers[2], "the first retry used a new bearer").not.toBe(bearers[0]);
255+ expect(bearers[3], "both retries used the refreshed bearer").toBe(bearers[2]);
256+ }),
257+ Effect.gen(function* () {
258+ yield* client.connections
259+ .remove({
260+ params: {
261+ owner: "org",
262+ integration: IntegrationSlug.make(slug),
263+ name: ConnectionName.make("main"),
264+ },
265+ })
266+ .pipe(Effect.ignore);
267+ yield* client.oauth
268+ .removeClient({ params: { slug: clientSlug }, payload: { owner: "org" } })
269+ .pipe(Effect.ignore);
270+ yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
271+ }),
272+ );
273+ }),
274+ ),
275+);
packages/core/api/src/admin/api.ts+1−1
@@ -145,7 +145,7 @@ export const AdminUser = Schema.Struct({ · 144 unmodified lines
145145 * forbidden-field lists in `platform-view.test.ts` and `admin-users.test.ts`.
146146 */
147147 export const AdminConnectionHealth = Schema.Struct({
148− /** The stored verdict: healthy / expired / degraded / unknown. */
148+ /** The stored verdict: healthy / expired / misconfigured / degraded / unknown. */
149149 status: HealthStatus,
150150 /** Epoch ms the check ran, so an operator can tell a fresh verdict from a
151151 * stale one. */
packages/core/sdk/src/executor.ts+94−23
@@ -199,6 +199,47 @@ const PLUGIN_STORAGE_DELETE_KEY_BATCH_SIZE = 90; · 198 unmodified lines
199199 const PLUGIN_STORAGE_CREATE_ROW_BATCH_SIZE = 90;
200200 const MAX_APPROVAL_ARGUMENT_PREVIEW_CHARS = 4_000;
201201
202+// ---------------------------------------------------------------------------
203+// In-flight OAuth refresh gate — a CROSS-STACK resource.
204+//
205+// Concurrent resolves of one connection must share a single refresh-token
206+// grant: the authorization server rotates the refresh token, so a second
207+// grant redeems a token the first already consumed, and a provider that
208+// detects reuse revokes the whole token family. The first refresh cycle still
209+// succeeds, so the fault hides until a later expiry.
210+//
211+// The gate therefore cannot live on a single execution stack. A host builds a
212+// fresh scoped executor per MCP session (and, since request-scoped stack
213+// builds, per request), so a per-`createExecutor` map put every session in its
214+// own gate and deduplicated nothing. Hanging it off the root DB handle instead
215+// converges every stack over one handle on one map — the same object hosts
216+// already treat as their shared, process-lived resource.
217+//
218+// SCOPE OF THE GUARANTEE: dedup reaches exactly as far as one root DB handle
219+// in one process. A host that hands every scoped executor a FRESH handle keys
220+// a different map each time and gets no dedup — silently, because an unshared
221+// gate still behaves correctly for the one caller holding it. Multi-instance
222+// deployments are outside it for the same reason: a process-local map cannot
223+// see a peer isolate or replica. Both need database-backed coordination
224+// (compare-and-swap on the stored refresh token) rather than a wider map.
225+//
226+// Weakly keyed so the map dies with the handle and a host that opens and drops
227+// handles does not leak one gate per handle.
228+type RefreshGate = Map<
229+ string,
230+ Deferred.Deferred<string | null, StorageFailure | CredentialResolutionError>
231+>;
232+
233+const refreshGateByRootDb = new WeakMap<object, RefreshGate>();
234+
235+const refreshGateFor = (rootDb: object): RefreshGate => {
236+ const existing = refreshGateByRootDb.get(rootDb);
237+ if (existing) return existing;
238+ const created: RefreshGate = new Map();
239+ refreshGateByRootDb.set(rootDb, created);
240+ return created;
241+};
242+
202243 // ---------------------------------------------------------------------------
203244 // Elicitation handler — resolved once at `createExecutor({ onElicitation })`
204245 // and overridable per `execute`. A tool that requests user input mid-execution
@@ -1697,6 +1738,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1492 unmodified lines
16971738 });
16981739 const rootDbUntyped = "db" in dbInput ? dbInput.db : dbInput;
16991740 const closeDb = "db" in dbInput ? dbInput.close : undefined;
1741+ // Shared with every other execution stack built over this same handle —
1742+ // see the gate's definition for what that does and does not cover.
1743+ const refreshInFlight = refreshGateFor(rootDbUntyped);
17001744 yield* Effect.try({
17011745 try: () => {
17021746 validateExecutorDbTables(tables, rootDbUntyped.internal.tables);
@@ -1920,17 +1964,18 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 217 unmodified lines
19201964 ),
19211965 });
19221966
1923− // In-flight refresh gate — concurrent resolves of the same connection share
1924− // one refresh (mirrors v1's refresh deferred-map) so we never fire two
1925− // refresh-token grants for the same connection in parallel (the AS rotates
1926− // the refresh token; the second request would race on a consumed token).
1927− const refreshInFlight = new Map<
1928− string,
1929− Effect.Effect<string | null, StorageFailure | CredentialResolutionError>
1930− >();
1931−
1967+ // Key for the shared in-flight refresh gate (`refreshInFlight`, bound at
1968+ // the top of `createExecutor`). The tenant leads because the gate spans
1969+ // every execution stack over one DB handle, so it spans tenants too:
1970+ // without it, two tenants whose rows agree on owner/subject/integration/
1971+ // name would collide on one entry and one tenant's caller could be handed
1972+ // the other's access token. JSON.stringify keeps the components
1973+ // unambiguous: tenant, subject, integration, and name are opaque strings
1974+ // that may contain any delimiter, so a delimiter-joined key would let
1975+ // tenant "a" + subject "user:b" collide with tenant "a:user" + subject
1976+ // "b" — the same cross-tenant bleed by another route.
19321977 const connectionKey = (row: ConnectionRow): string =>
1933− `${row.owner}:${row.subject}:${row.integration}:${row.name}`;
1978+ JSON.stringify([tenant, row.owner, row.subject, row.integration, row.name]);
19341979
19351980 const loadOAuthClientRow = (
19361981 owner: Owner,
@@ -1955,6 +2000,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 18 unmodified lines
19552000 readonly tokenUrl: string;
19562001 readonly grant: string;
19572002 readonly resource: string | null;
2003+ readonly tokenEndpointAuthMethod?: "body" | "basic";
2004+ readonly tokenRequestFormat?: "form" | "json";
19582005 }
19592006
19602007 /** What drove a refresh: the pre-call expiry check (`proactive`), or an
@@ -2267,6 +2314,12 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 306 unmodified lines
22672314 // audience the original grant was bound to. Dropping it here
22682315 // made refresh asymmetric with authorize for first-party apps.
22692316 resource: firstParty.resource ?? null,
2317+ ...(firstParty.tokenEndpointAuthMethod === undefined
2318+ ? {}
2319+ : { tokenEndpointAuthMethod: firstParty.tokenEndpointAuthMethod }),
2320+ ...(firstParty.tokenRequestFormat === undefined
2321+ ? {}
2322+ : { tokenRequestFormat: firstParty.tokenRequestFormat }),
22702323 } satisfies RefreshClient;
22712324 }
22722325 const clientOwner = (row.oauth_client_owner ?? row.owner) as Owner;
@@ -2398,6 +2451,8 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 125 unmodified lines
23982451 // RFC 8707: keep the re-minted token bound to the same resource
23992452 // (MCP servers require this on refresh).
24002453 resource: clientRow.resource ?? undefined,
2454+ clientAuth: clientRow.tokenEndpointAuthMethod,
2455+ requestFormat: clientRow.tokenRequestFormat,
24012456 endpointUrlPolicy: config.oauthEndpointUrlPolicy,
24022457 fetch: config.fetch,
24032458 }).pipe(
@@ -2530,26 +2585,42 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 126 unmodified lines
25302585 // token; parallel grants would race on a consumed token — v1's refresh
25312586 // deferred-map). The gate is cleared once the refresh settles so a later
25322587 // expiry can refresh again.
2533− Effect.gen(function* () {
2588+ Effect.suspend(() => {
25342589 const key = connectionKey(row);
25352590 // Joining an in-flight grant is correct for BOTH triggers: whatever
25362591 // that peer mints is newer than the token this fiber just saw rejected,
25372592 // which is exactly what a reactive retry wants. The gate is cleared on
25382593 // settle, so a 401 arriving after a refresh completed starts a fresh
2539− // grant rather than replaying the stale memoized one.
2594+ // grant rather than replaying a stale result.
25402595 const existing = refreshInFlight.get(key);
2541− if (existing) return yield* existing;
2542− // `Effect.cached` memoizes the grant onto a deferred: it runs once and
2543− // replays to every awaiter sharing this entry.
2544− const memoized = yield* Effect.cached(performTokenRefresh(row, provider, trigger));
2545− const gated = memoized.pipe(
2546− Effect.ensuring(Effect.sync(() => refreshInFlight.delete(key))),
2596+ if (existing) return Deferred.await(existing);
2597+
2598+ // The grant runs on a DETACHED fiber and every caller — including this
2599+ // one — only awaits its deferred. The entry is shared across execution
2600+ // stacks, so the fiber that registers it is merely the first arrival,
2601+ // not an owner. Running the grant ON that fiber would hand it that
2602+ // caller's interruption: a disconnected MCP client, an execution
2603+ // deadline or a cancelled tool call would fail every peer awaiting the
2604+ // same entry with an interrupt none of them caused and none can act on.
2605+ // Awaiting is per-caller, so a cancelled peer detaches without touching
2606+ // the grant or its siblings, and a grant nobody is left waiting on
2607+ // still settles and still persists the rotated token — which is what
2608+ // keeps the next caller off a consumed one. Token requests are bounded
2609+ // by `AbortSignal.timeout`, so the detached fiber cannot outlive its
2610+ // request.
2611+ const deferred = Deferred.makeUnsafe<
2612+ string | null,
2613+ StorageFailure | CredentialResolutionError
2614+ >();
2615+ // Nothing suspends between the lookup above and this registration, so
2616+ // check-and-set is atomic against peer fibers and cannot double-fire.
2617+ refreshInFlight.set(key, deferred);
2618+ const run = performTokenRefresh(row, provider, trigger).pipe(
2619+ Effect.exit,
2620+ Effect.flatMap((exit) => Deferred.done(deferred, exit)),
2621+ Effect.ensuring(Effect.sync(() => void refreshInFlight.delete(key))),
25472622 );
2548− // Re-check after building (a peer fiber may have registered first while
2549− // we built ours) so everyone converges on the same shared grant.
2550− const winner = refreshInFlight.get(key) ?? gated;
2551− if (winner === gated) refreshInFlight.set(key, gated);
2552− return yield* winner;
2623+ return Effect.forkDetach(run).pipe(Effect.andThen(Deferred.await(deferred)));
25532624 });
25542625
25552626 // Resolve every named input of a connection (`variable → value`). A
packages/core/sdk/src/health-check.test.ts+72−0
@@ -5,6 +5,8 @@ import { describe, expect, it } from "@effect/vitest"; · 4 unmodified lines
55
66 import {
77 candidateIdentityTier,
8+ classifyHttpStatus,
9+ classifyProbeResponse,
810 rankResponseSample,
911 sortHealthCheckCandidatesByIdentity,
1012 } from "./health-check";
@@ -66,3 +68,73 @@ describe("candidateIdentityTier", () => { · 55 unmodified lines
6668 ).toBe("user.getAuthUser");
6769 });
6870 });
71+
72+// Probe classification: 401/403 mean "credential dead" EXCEPT the
73+// configuration 403 (Google accessNotConfigured / SERVICE_DISABLED), which
74+// must read misconfigured: the token authenticated, the API is disabled in
75+// the OAuth client's project, and reconnecting cannot fix it. This is the
76+// production case behind the "Expired when it is not" report (2026-07-10).
77+describe("classifyProbeResponse", () => {
78+ const disabledMessage =
79+ "Gmail API has not been used in project 000000000000 before or it is disabled. " +
80+ "Enable it by visiting https://console.developers.google.com/apis/api/gmail.googleapis.com/overview?project=000000000000 then retry.";
81+
82+ it("classifies Google's classic accessNotConfigured 403 as misconfigured", () => {
83+ const body = {
84+ error: {
85+ code: 403,
86+ message: disabledMessage,
87+ errors: [
88+ { message: disabledMessage, domain: "usageLimits", reason: "accessNotConfigured" },
89+ ],
90+ status: "PERMISSION_DENIED",
91+ },
92+ };
93+ expect(classifyProbeResponse(403, body)).toBe("misconfigured");
94+ });
95+
96+ it("classifies the newer SERVICE_DISABLED ErrorInfo detail as misconfigured", () => {
97+ const body = {
98+ error: {
99+ code: 403,
100+ message: disabledMessage,
101+ status: "PERMISSION_DENIED",
102+ details: [
103+ {
104+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
105+ reason: "SERVICE_DISABLED",
106+ domain: "googleapis.com",
107+ },
108+ ],
109+ },
110+ };
111+ expect(classifyProbeResponse(403, body)).toBe("misconfigured");
112+ });
113+
114+ it("keeps a plain 403 (no recognized reason) as expired: false-expired is the safe default", () => {
115+ expect(classifyProbeResponse(403, { error: { code: 403, message: "Forbidden" } })).toBe(
116+ "expired",
117+ );
118+ expect(classifyProbeResponse(403, undefined)).toBe("expired");
119+ expect(classifyProbeResponse(403, "Forbidden")).toBe("expired");
120+ // A permission reason that is NOT a configuration marker stays expired.
121+ expect(
122+ classifyProbeResponse(403, {
123+ error: { errors: [{ reason: "insufficientPermissions" }] },
124+ }),
125+ ).toBe("expired");
126+ });
127+
128+ it("never rewrites a 401: an authentication failure is a credential problem regardless of body", () => {
129+ const body = { error: { errors: [{ reason: "accessNotConfigured" }] } };
130+ expect(classifyProbeResponse(401, body)).toBe("expired");
131+ });
132+
133+ it("matches the status-only classifier everywhere else", () => {
134+ for (const status of [200, 204, 404, 429, 500, 503]) {
135+ expect(classifyProbeResponse(status, { error: {} }), `status ${status}`).toBe(
136+ classifyHttpStatus(status),
137+ );
138+ }
139+ });
140+});
packages/core/sdk/src/health-check.ts+62−6
@@ -18,14 +18,24 @@ · 17 unmodified lines
1818 import { Schema } from "effect";
1919
2020 // ---------------------------------------------------------------------------
21−// Status: the four states a connection can be in. `expired` is the one this
21+// Status: the five states a connection can be in. `expired` is the one this
2222 // whole feature exists for (Google's 7-day dev-token revocation): the credential
23−// authenticated fine yesterday and now returns 401/403. `degraded` covers any
24−// other non-2xx (upstream 5xx, a 404 on a mis-picked operation); `unknown` is
25−// "never checked / no health check configured".
23+// authenticated fine yesterday and now returns 401/403. `misconfigured` is the
24+// 403 that is NOT a credential problem: the token authenticated, but the
25+// upstream rejects on configuration (a Google API not enabled in the OAuth
26+// client's GCP project): the fix is in the provider console, so telling the
27+// user to reconnect would mislead. `degraded` covers any other non-2xx
28+// (upstream 5xx, a 404 on a mis-picked operation); `unknown` is "never checked
29+// / no health check configured".
2630 // ---------------------------------------------------------------------------
2731
28−export const HealthStatus = Schema.Literals(["healthy", "expired", "degraded", "unknown"]);
32+export const HealthStatus = Schema.Literals([
33+ "healthy",
34+ "expired",
35+ "misconfigured",
36+ "degraded",
37+ "unknown",
38+]);
2939 export type HealthStatus = typeof HealthStatus.Type;
3040
3141 // ---------------------------------------------------------------------------
@@ -139,13 +149,59 @@ export type HealthCheckCandidate = typeof HealthCheckCandidate.Type; · 107 unmodified lines
139149 // ---------------------------------------------------------------------------
140150
141151 /** Map an HTTP status to a health state: 2xx healthy, 401/403 expired (the auth
142− * wall), everything else degraded. */
152+ * wall), everything else degraded. Status-only fallback: when the response
153+ * BODY is available, use `classifyProbeResponse` instead, which tells a
154+ * credential 403 apart from a configuration 403. */
143155 export const classifyHttpStatus = (status: number): HealthStatus => {
144156 if (status >= 200 && status < 300) return "healthy";
145157 if (status === 401 || status === 403) return "expired";
146158 return "degraded";
147159 };
148160
161+// Error `reason` / `status` markers that make a 403 a CONFIGURATION rejection
162+// rather than a credential one. Deliberately narrow and provider-shaped:
163+// Google's error envelope carries `errors[].reason: "accessNotConfigured"`
164+// (message "<API> has not been used in project N before or it is disabled")
165+// and newer surfaces use `status: "PERMISSION_DENIED"` with
166+// `details[].reason: "SERVICE_DISABLED"`. Unrecognized 403s keep the expired
167+// classification: a false "expired" prompts a harmless reconnect, but a false
168+// "misconfigured" would hide a genuinely dead credential.
169+const CONFIGURATION_403_REASONS = new Set(["accessnotconfigured", "service_disabled"]);
170+
171+/** Collect candidate `reason` markers from a Google-shaped error envelope:
172+ * `error.errors[].reason` (classic) and `error.details[].reason` (newer
173+ * google.rpc.ErrorInfo). Tolerant of partial shapes; returns []. */
174+const errorReasonMarkers = (body: unknown): string[] => {
175+ if (body == null || typeof body !== "object") return [];
176+ const error = (body as Record<string, unknown>)["error"];
177+ if (error == null || typeof error !== "object") return [];
178+ const markers: string[] = [];
179+ for (const key of ["errors", "details"] as const) {
180+ const entries = (error as Record<string, unknown>)[key];
181+ if (!Array.isArray(entries)) continue;
182+ for (const entry of entries) {
183+ if (entry == null || typeof entry !== "object") continue;
184+ const reason = (entry as Record<string, unknown>)["reason"];
185+ if (typeof reason === "string") markers.push(reason.toLowerCase());
186+ }
187+ }
188+ return markers;
189+};
190+
191+/** Classify a probe response from its status AND body. Everything is
192+ * `classifyHttpStatus` except one carve-out: a 403 whose error body carries a
193+ * known configuration reason (Google `accessNotConfigured` /
194+ * `SERVICE_DISABLED`) is `misconfigured`, not `expired`: the credential
195+ * authenticated; the upstream API is disabled in the OAuth client's project,
196+ * and only enabling it there (not reconnecting) fixes it. */
197+export const classifyProbeResponse = (status: number, body: unknown): HealthStatus => {
198+ const byStatus = classifyHttpStatus(status);
199+ if (status !== 403 || byStatus !== "expired") return byStatus;
200+ return errorReasonMarkers(body).some((reason) => CONFIGURATION_403_REASONS.has(reason))
201+ ? "misconfigured"
202+ : "expired";
203+};
204+
149205 /** Resolve a dot-path (`a.b.0.c`) against a JSON value and coerce the leaf to a
150206 * display string. Numeric segments index arrays. Returns undefined when the
151207 * path is empty, missing, or lands on a non-scalar. */
packages/core/sdk/src/index.ts+1−0
@@ -122,6 +122,7 @@ export { · 121 unmodified lines
122122 HealthCheckCandidateParameter,
123123 HealthCheckResponseField,
124124 classifyHttpStatus,
125+ classifyProbeResponse,
125126 extractIdentity,
126127 compareHealthCheckCandidates,
127128 candidateIdentityTier,
packages/core/sdk/src/oauth-client.ts+19−0
@@ -187,6 +187,25 @@ export interface FirstPartyOAuthClientConfig { · 186 unmodified lines
187187 * GitHub Apps, whose capabilities are configured on the app and whose OAuth
188188 * user-token flow does not use scopes. Omit for normal OAuth clients. */
189189 readonly authorizationScopes?: readonly string[];
190+ /** Scopes the host always adds to the integration-declared set. Use this for
191+ * provider lifecycle scopes such as Atlassian and Microsoft
192+ * `offline_access`; unlike `authorizationScopes`, this preserves the
193+ * integration's least-privilege scopes. */
194+ readonly additionalAuthorizationScopes?: readonly string[];
195+ /** Separator used in the authorize URL's `scope` parameter. OAuth defaults
196+ * to a space; legacy providers such as Linear require a comma. */
197+ readonly authorizationScopeSeparator?: string;
198+ /** Provider-specific, non-secret authorize parameters fixed by the host
199+ * registration contract (for example Atlassian's `audience`). These are
200+ * merged after generic provider defaults and may intentionally override
201+ * them. */
202+ readonly authorizationExtraParams?: Readonly<Record<string, string>>;
203+ /** Token endpoint client-auth transport. Omitted means
204+ * `client_secret_post`; `basic` sends the secret only in HTTP Basic auth. */
205+ readonly tokenEndpointAuthMethod?: "body" | "basic";
206+ /** Token endpoint request encoding. OAuth defaults to URL-encoded form;
207+ * providers such as Atlassian, ClickUp, and Notion require JSON. */
208+ readonly tokenRequestFormat?: "form" | "json";
190209 /** Withdraw the app from every listing surface without retiring it. It stops
191210 * appearing in `listClients` — so connect pickers and the agent-facing
192211 * client list never offer it — while remaining fully resolvable by slug.
packages/core/sdk/src/oauth-first-party.test.ts+36−0
@@ -199,6 +199,42 @@ describe("first-party oauth clients", () => { · 198 unmodified lines
199199 ),
200200 );
201201
202+ it.effect("applies first-party lifecycle scopes, separators, and authorize parameters", () =>
203+ Effect.scoped(
204+ Effect.gen(function* () {
205+ const server = yield* serveOAuthTestServer({ scopes: ["read", "offline_access"] });
206+ const { executor } = yield* makeTestWorkspaceHarness({
207+ plugins,
208+ firstPartyOAuthClients: [
209+ {
210+ ...firstPartyClientFor(server),
211+ allowedScopes: ["read", "offline_access"],
212+ additionalAuthorizationScopes: ["offline_access"],
213+ authorizationScopeSeparator: ",",
214+ authorizationExtraParams: { audience: "api.example.com", prompt: "consent" },
215+ },
216+ ],
217+ });
218+ yield* executor.acme.seed(["read"]);
219+
220+ const started = yield* executor.oauth.start({
221+ owner: "org",
222+ client: FIRST_PARTY,
223+ clientOwner: "org",
224+ name: ConnectionName.make("lifecycle"),
225+ integration: INTEG,
226+ template: TEMPLATE,
227+ });
228+ expect(started.status).toBe("redirect");
229+ if (started.status !== "redirect") return;
230+ const authorizationUrl = new URL(started.authorizationUrl);
231+ expect(authorizationUrl.searchParams.get("scope")).toBe("read,offline_access");
232+ expect(authorizationUrl.searchParams.get("audience")).toBe("api.example.com");
233+ expect(authorizationUrl.searchParams.get("prompt")).toBe("consent");
234+ }),
235+ ),
236+ );
237+
202238 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
203239 Effect.scoped(
204240 Effect.gen(function* () {
packages/core/sdk/src/oauth-flow.test.ts+423−9
@@ -1,5 +1,6 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Deferred, Effect, Fiber, Predicate } from "effect";
3+import { withQueryContext } from "@executor-js/fumadb/query";
34
45 import {
56 AuthTemplateSlug,
@@ -8,6 +9,8 @@ import { · 2 unmodified lines
89 OAuthClientSlug,
910 OAuthState,
1011 ProviderKey,
12+ Subject,
13+ Tenant,
1114 ToolAddress,
1215 ToolName,
1316 } from "./ids";
@@ -68,6 +71,69 @@ const oauthPlugin = definePlugin(() => ({ · 54 unmodified lines
6871
6972 const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const;
7073
74+// Stated explicitly where a test builds a SECOND root database handle by hand:
75+// both handles must carry the same owner-policy context to address one
76+// connection, so the values cannot be left to `makeTestConfig`'s defaults.
77+const SHARED_STORE_TENANT = "test-tenant";
78+const SHARED_STORE_SUBJECT = "test-subject";
79+
80+/** The URL a `fetch` double was handed, however the caller spelled it. */
81+const fetchTarget = (input: Parameters<typeof globalThis.fetch>[0]): string =>
82+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
83+
84+/**
85+ * A `fetch` that holds token-endpoint requests open AFTER the authorization
86+ * server has answered them.
87+ *
88+ * Parking after the response is the point. The refresh token has been rotated
89+ * upstream by then, and the in-flight gate entry is still registered, so a peer
90+ * arriving during the park has to resolve against an OPEN grant rather than a
91+ * settled one. Parking before the response would prove nothing: the grant would
92+ * never reach the server, and a peer that went on to run its own grant would
93+ * find the stored token still live and succeed.
94+ *
95+ * Idle until `arm()`, so connection setup (the authorization-code exchange)
96+ * runs through untouched.
97+ */
98+const makeTokenRequestPark = () => {
99+ let armed = false;
100+ let onSeen: (() => void) | null = null;
101+ const seen = new Promise<void>((resolve) => {
102+ onSeen = resolve;
103+ });
104+ let onRelease: (() => void) | null = null;
105+ const parked = new Promise<void>((resolve) => {
106+ onRelease = resolve;
107+ });
108+ // oxlint-disable-next-line executor/no-raw-fetch -- test boundary: the park wraps the platform fetch and must delegate back to it, which is the only seam that can hold a token request open mid-grant.
109+ const platformFetch: typeof globalThis.fetch = globalThis.fetch;
110+ const fetch: typeof globalThis.fetch = async (input, init) => {
111+ const response = await platformFetch(input, init);
112+ if (armed && new URL(fetchTarget(input)).pathname === "/token") {
113+ onSeen?.();
114+ await parked;
115+ }
116+ return response;
117+ };
118+ return {
119+ fetch,
120+ arm: () => {
121+ armed = true;
122+ },
123+ /** Resolves once a token request has been answered and is being held. */
124+ seen,
125+ release: () => onRelease?.(),
126+ };
127+};
128+
129+/** Every refresh-token grant the authorization server was asked for. */
130+const refreshGrantsIn = (
131+ requests: ReadonlyArray<{ readonly path: string; readonly body: string }>,
132+) =>
133+ requests.filter(
134+ (request) => request.path === "/token" && request.body.includes("grant_type=refresh_token"),
135+ );
136+
71137 interface TokenEndpointCall {
72138 readonly host: string;
73139 readonly grantType: string | null;
@@ -887,14 +953,344 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 813 unmodified lines
887953 ),
888954 );
889955
956+ // Issue #1520, in one process. A self-host builds a FRESH execution stack per
957+ // MCP session over ONE database handle, so two sessions resolving the same
958+ // connection each read the same stored refresh token and each believe they
959+ // are the refresh winner. The authorization server rotates that token, so the
960+ // loser redeems one the winner already spent, and a server that detects reuse
961+ // revokes the whole family: the connection dies and the user must
962+ // reauthorize. The first refresh always succeeds, which is why the fault
963+ // stays invisible until a later expiry.
964+ it.effect("two execution stacks over one host database share a single refresh grant", () =>
965+ Effect.scoped(
966+ Effect.gen(function* () {
967+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
968+ const park = makeTokenRequestPark();
969+
970+ // One database handle and one credential store under two execution
971+ // stacks — what a self-host holds while two MCP sessions are open.
972+ const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };
973+ const sessionA = yield* createExecutor(config);
974+ const sessionB = yield* createExecutor(config);
975+ yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
976+ yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore));
977+ yield* Effect.addFinalizer(() =>
978+ Effect.promise(() => config.testDb.close()).pipe(Effect.ignore),
979+ );
980+
981+ yield* sessionA.acme.seed();
982+ yield* sessionA.oauth.createClient({
983+ owner: "org",
984+ slug: CLIENT,
985+ authorizationUrl: server.authorizationEndpoint,
986+ tokenUrl: server.tokenEndpoint,
987+ grant: "authorization_code",
988+ clientId: "test-client",
989+ clientSecret: "test-secret",
990+ });
991+ const started = yield* sessionA.oauth.start({
992+ owner: "org",
993+ client: CLIENT,
994+ clientOwner: "org",
995+ name: ConnectionName.make("main"),
996+ integration: INTEG,
997+ template: TEMPLATE,
998+ });
999+ expect(started.status).toBe("redirect");
1000+ if (started.status !== "redirect") return;
1001+ const callback = yield* server.completeAuthorizationCodeFlow({
1002+ authorizationUrl: started.authorizationUrl,
1003+ });
1004+ yield* sessionA.oauth.complete({ state: started.state, code: callback.code });
1005+
1006+ const address = ToolAddress.make("tools.acme.org.main.whoami");
1007+ const original = (yield* sessionA.execute(address, {})) as { token: string };
1008+
1009+ // Expire the access token so BOTH stacks must refresh.
1010+ yield* Effect.promise(() =>
1011+ config.db.updateMany("connection", {
1012+ where: (b) => b("name", "=", "main"),
1013+ set: { expires_at: Date.now() - 60_000 },
1014+ }),
1015+ );
1016+ yield* server.clearRequests;
1017+ park.arm();
1018+
1019+ const first = yield* Effect.forkChild(sessionA.execute(address, {}));
1020+ const second = yield* Effect.forkChild(sessionB.execute(address, {}));
1021+ // Release only once a grant has been answered and is being held open,
1022+ // so the peer resolves against a grant that is still in flight. Without
1023+ // the park the peer could arrive after the winner had already settled,
1024+ // find a fresh token, refresh nothing, and pass this test for the wrong
1025+ // reason.
1026+ yield* Effect.promise(() => park.seen);
1027+ park.release();
1028+
1029+ const firstToken = (yield* Fiber.join(first)) as { token: string };
1030+ const secondToken = (yield* Fiber.join(second)) as { token: string };
1031+
1032+ expect(firstToken.token, "the refresh minted a new access token").not.toBe(original.token);
1033+ expect(secondToken.token, "both stacks resolved the SAME refreshed token").toBe(
1034+ firstToken.token,
1035+ );
1036+ expect(
1037+ refreshGrantsIn(yield* server.requests),
1038+ "one refresh grant for the connection, not one per execution stack",
1039+ ).toHaveLength(1);
1040+ }),
1041+ ),
1042+ );
1043+
1044+ // The gate spans tenants (one map per root DB handle), so its key must keep
1045+ // tenant and subject unambiguous. Both are opaque strings that may contain
1046+ // any delimiter: under a colon-joined key, tenant "a" + subject "user:b" and
1047+ // tenant "a:user" + subject "b" both flatten to "a:user:user:b:…", so two
1048+ // DIFFERENT tenants' refreshes would share one gate entry and one tenant's
1049+ // caller would be handed the other tenant's access token.
1050+ it.effect(
1051+ "colliding tenant/subject pairs never share a refresh gate entry",
1052+ () =>
1053+ Effect.scoped(
1054+ Effect.gen(function* () {
1055+ const serverA = yield* serveOAuthTestServer({ scopes: ["read"] });
1056+ const serverB = yield* serveOAuthTestServer({ scopes: ["read"] });
1057+ const parkA = makeTokenRequestPark();
1058+ const parkB = makeTokenRequestPark();
1059+
1060+ // ONE root DB handle under TWO tenants — the shape a multi-tenant host
1061+ // holds — so both executors share one refresh gate. `shared.db` stays
1062+ // bound to tenant A's owner-policy context; tenant B's row edits below
1063+ // build their own scoped handle by hand. Each tenant gets its OWN
1064+ // credential store instance: the memory store keys items without a
1065+ // tenant, so sharing one across tenants would cross their tokens at
1066+ // the store layer and mask the gate-key collision this test is about.
1067+ const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const;
1068+ const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const;
1069+ const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" });
1070+ const configA = { ...shared, fetch: parkA.fetch };
1071+ const configB = {
1072+ ...shared,
1073+ plugins: pluginsB,
1074+ tenant: Tenant.make("a:user"),
1075+ subject: Subject.make("b"),
1076+ fetch: parkB.fetch,
1077+ };
1078+ const sessionA = yield* createExecutor(configA);
1079+ const sessionB = yield* createExecutor(configB);
1080+ yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
1081+ yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore));
1082+ yield* Effect.addFinalizer(() =>
1083+ Effect.promise(() => shared.testDb.close()).pipe(Effect.ignore),
1084+ );
1085+
1086+ // Each tenant mints its own USER-owned connection (user rows carry the
1087+ // session subject, which is what the colliding pair needs) against its
1088+ // own authorization server, so token provenance is observable.
1089+ const connect = (session: typeof sessionA, server: typeof serverA) =>
1090+ Effect.gen(function* () {
1091+ yield* session.acme.seed();
1092+ yield* session.oauth.createClient({
1093+ owner: "org",
1094+ slug: CLIENT,
1095+ authorizationUrl: server.authorizationEndpoint,
1096+ tokenUrl: server.tokenEndpoint,
1097+ grant: "authorization_code",
1098+ clientId: "test-client",
1099+ clientSecret: "test-secret",
1100+ });
1101+ const started = yield* session.oauth.start({
1102+ owner: "user",
1103+ client: CLIENT,
1104+ clientOwner: "org",
1105+ name: ConnectionName.make("mine"),
1106+ integration: INTEG,
1107+ template: TEMPLATE,
1108+ });
1109+ expect(started.status).toBe("redirect");
1110+ if (started.status !== "redirect") return;
1111+ const callback = yield* server.completeAuthorizationCodeFlow({
1112+ authorizationUrl: started.authorizationUrl,
1113+ });
1114+ yield* session.oauth.complete({ state: started.state, code: callback.code });
1115+ });
1116+ yield* connect(sessionA, serverA);
1117+ yield* connect(sessionB, serverB);
1118+
1119+ const address = ToolAddress.make("tools.acme.user.mine.whoami");
1120+ const originalA = (yield* sessionA.execute(address, {})) as { token: string };
1121+ const originalB = (yield* sessionB.execute(address, {})) as { token: string };
1122+ expect(originalB.token).not.toBe(originalA.token);
1123+
1124+ // Expire BOTH rows so both tenants must refresh. `shared.db` is bound
1125+ // to tenant A; tenant B's partition needs its own scoped handle.
1126+ const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" });
1127+ yield* Effect.promise(() =>
1128+ shared.db.updateMany("connection", {
1129+ where: (b) => b("name", "=", "mine"),
1130+ set: { expires_at: Date.now() - 60_000 },
1131+ }),
1132+ );
1133+ yield* Effect.promise(() =>
1134+ dbB.updateMany("connection", {
1135+ where: (b) => b("name", "=", "mine"),
1136+ set: { expires_at: Date.now() - 60_000 },
1137+ }),
1138+ );
1139+
1140+ parkA.arm();
1141+ parkB.arm();
1142+ const first = yield* Effect.forkChild(sessionA.execute(address, {}));
1143+ // Hold tenant A's grant open so its gate entry is still registered
1144+ // when tenant B performs its lookup of the would-be colliding key.
1145+ yield* Effect.promise(() => parkA.seen);
1146+ const second = yield* Effect.forkChild(sessionB.execute(address, {}));
1147+ // With a collision-free key, tenant B misses the gate and sends its
1148+ // OWN grant. Under a colliding key it would await tenant A's deferred
1149+ // and never reach its server, so cap the wait with a real timer (the
1150+ // test clock is virtual, so Effect.sleep would never fire) instead of
1151+ // hanging the suite; the assertions below then report the bleed.
1152+ yield* Effect.promise(() =>
1153+ Promise.race([parkB.seen, new Promise((resolve) => setTimeout(resolve, 2_000))]),
1154+ );
1155+ parkA.release();
1156+ parkB.release();
1157+
1158+ const tokenA = (yield* Fiber.join(first)) as { token: string };
1159+ const tokenB = (yield* Fiber.join(second)) as { token: string };
1160+
1161+ expect(tokenA.token, "tenant A refreshed to a new token").not.toBe(originalA.token);
1162+ expect(tokenB.token, "tenant B refreshed to a new token").not.toBe(originalB.token);
1163+ expect(tokenB.token, "no cross-tenant token bleed").not.toBe(tokenA.token);
1164+ expect(yield* serverA.acceptsAccessToken(tokenA.token)).toBe(true);
1165+ expect(
1166+ yield* serverB.acceptsAccessToken(tokenB.token),
1167+ "tenant B's token was minted by tenant B's own authorization server",
1168+ ).toBe(true);
1169+ expect(
1170+ refreshGrantsIn(yield* serverA.requests),
1171+ "tenant A ran its own refresh grant",
1172+ ).toHaveLength(1);
1173+ expect(
1174+ refreshGrantsIn(yield* serverB.requests),
1175+ "tenant B ran its own refresh grant — two distinct refresh executions",
1176+ ).toHaveLength(1);
1177+ }),
1178+ ),
1179+ // Two authorization servers, two full mint flows and two refreshes — about
1180+ // twice the cost of the single-tenant gate tests, which sits on the 5s
1181+ // default when the test runs cold.
1182+ 20_000,
1183+ );
1184+
1185+ // The gate entry is shared, so the stack that REGISTERS a grant is only the
1186+ // first arrival, not its owner. Running the grant on that caller's fiber
1187+ // would hand it that caller's interruption — a disconnected MCP client, an
1188+ // execution deadline, a cancelled tool call — and abandon a refresh token the
1189+ // authorization server has ALREADY rotated. What the store still holds is
1190+ // then dead, and the next grant is answered invalid_grant: the interruption
1191+ // would have killed the connection. So the grant runs detached, and callers
1192+ // only await it.
1193+ it.effect("an interrupted first arrival still settles the grant and persists its token", () =>
1194+ Effect.scoped(
1195+ Effect.gen(function* () {
1196+ const server = yield* serveOAuthTestServer({ scopes: ["read"] });
1197+ const park = makeTokenRequestPark();
1198+
1199+ const config = { ...makeTestConfig({ plugins }), fetch: park.fetch };
1200+ const sessionA = yield* createExecutor(config);
1201+ const sessionB = yield* createExecutor(config);
1202+ yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
1203+ yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore));
1204+ yield* Effect.addFinalizer(() =>
1205+ Effect.promise(() => config.testDb.close()).pipe(Effect.ignore),
1206+ );
1207+
1208+ yield* sessionA.acme.seed();
1209+ yield* sessionA.oauth.createClient({
1210+ owner: "org",
1211+ slug: CLIENT,
1212+ authorizationUrl: server.authorizationEndpoint,
1213+ tokenUrl: server.tokenEndpoint,
1214+ grant: "authorization_code",
1215+ clientId: "test-client",
1216+ clientSecret: "test-secret",
1217+ });
1218+ const started = yield* sessionA.oauth.start({
1219+ owner: "org",
1220+ client: CLIENT,
1221+ clientOwner: "org",
1222+ name: ConnectionName.make("main"),
1223+ integration: INTEG,
1224+ template: TEMPLATE,
1225+ });
1226+ expect(started.status).toBe("redirect");
1227+ if (started.status !== "redirect") return;
1228+ const callback = yield* server.completeAuthorizationCodeFlow({
1229+ authorizationUrl: started.authorizationUrl,
1230+ });
1231+ yield* sessionA.oauth.complete({ state: started.state, code: callback.code });
1232+
1233+ const address = ToolAddress.make("tools.acme.org.main.whoami");
1234+ const original = (yield* sessionA.execute(address, {})) as { token: string };
1235+ yield* Effect.promise(() =>
1236+ config.db.updateMany("connection", {
1237+ where: (b) => b("name", "=", "main"),
1238+ set: { expires_at: Date.now() - 60_000 },
1239+ }),
1240+ );
1241+ yield* server.clearRequests;
1242+ park.arm();
1243+
1244+ // The first arrival registers the grant, and its session drops the
1245+ // instant the authorization server has rotated the token — the worst
1246+ // possible moment, and the one a disconnecting MCP client picks.
1247+ const arrival = yield* Effect.forkChild(Effect.exit(sessionA.execute(address, {})));
1248+ yield* Effect.promise(() => park.seen);
1249+ yield* Fiber.interrupt(arrival);
1250+ park.release();
1251+
1252+ // That the grant finishes at all, with nobody left waiting on it, is
1253+ // the property under test: a rotated token that is never persisted is a
1254+ // dead connection.
1255+ const persisted = yield* Effect.promise(async () => {
1256+ for (let attempt = 0; attempt < 500; attempt += 1) {
1257+ const row = await config.db.findFirst("connection", {
1258+ where: (b) => b("name", "=", "main"),
1259+ });
1260+ const expiresAt = row?.expires_at;
1261+ if (expiresAt != null && Number(expiresAt) > Date.now()) return true;
1262+ await new Promise((resolve) => setTimeout(resolve, 10));
1263+ }
1264+ return false;
1265+ });
1266+ expect(persisted, "the detached grant settled and persisted its rotated token").toBe(true);
1267+
1268+ const recovered = (yield* sessionB.execute(address, {})) as { token: string };
1269+ expect(recovered.token, "the peer stack resolved the rotated token").not.toBe(
1270+ original.token,
1271+ );
1272+ expect(
1273+ yield* server.acceptsAccessToken(recovered.token),
1274+ "and the authorization server still honours it",
1275+ ).toBe(true);
1276+ expect(
1277+ refreshGrantsIn(yield* server.requests),
1278+ "the interrupted arrival's grant settled, so no second grant was needed",
1279+ ).toHaveLength(1);
1280+ }),
1281+ ),
1282+ );
1283+
8901284 // Two product instances, one connection, one credential store. The in-flight
891− // refresh gate serialises refreshes WITHIN an instance and cannot see across
892− // them, so nothing but the store itself stands between two refreshers and
893− // the same rotated token. The provider below opens a seam exactly where the
894− // danger is — between the read of the stored refresh token and whatever the
895− // reader writes next — because a probe that "tests" the store by rewriting
896− // the value it just read would put the spent token back over the peer's
897− // rotated one, and kill the connection it was added to protect.
1285+ // refresh gate serialises refreshes across every execution stack over ONE
1286+ // root database handle and cannot see past it, so between two INSTANCES —
1287+ // two replicas, two isolates — nothing but the store itself stands between
1288+ // two refreshers and the same rotated token. The provider below opens a seam
1289+ // exactly where the danger is — between the read of the stored refresh token
1290+ // and whatever the reader writes next — because a probe that "tests" the
1291+ // store by rewriting the value it just read would put the spent token back
1292+ // over the peer's rotated one, and kill the connection it was added to
1293+ // protect.
8981294 it.effect(
8991295 "a refresher paused after reading the stored token never writes it back over a peer's rotated one",
9001296 () =>
@@ -936,12 +1332,30 @@ describe("oauth token refresh in resolveConnectionValue", () => { · 35 unmodified lines
9361332 // One database and one credential store, two executors over them —
9371333 // the deployment this race needs and the one a single harness
9381334 // cannot express.
1335+ //
1336+ // Each executor gets its OWN root database handle onto that one
1337+ // database, because that handle is what identifies an instance: the
1338+ // in-flight refresh gate is shared per handle, so two executors over
1339+ // the SAME handle are two execution stacks in one instance and the
1340+ // second would simply join the first's grant — closing the very
1341+ // window this test exists to open. A second replica holds a second
1342+ // handle, which is what the extra `withQueryContext` wrapper is.
9391343 const config = {
940− ...makeTestConfig({ plugins: [oauthPlugin] as const }),
1344+ ...makeTestConfig({
1345+ plugins: [oauthPlugin] as const,
1346+ tenant: SHARED_STORE_TENANT,
1347+ subject: SHARED_STORE_SUBJECT,
1348+ }),
9411349 providers: [sharedStore],
9421350 };
9431351 const instanceA = yield* createExecutor(config);
944− const instanceB = yield* createExecutor(config);
1352+ const instanceB = yield* createExecutor({
1353+ ...config,
1354+ db: withQueryContext(config.testDb.db, {
1355+ tenant: SHARED_STORE_TENANT,
1356+ subject: SHARED_STORE_SUBJECT,
1357+ }),
1358+ });
9451359 yield* Effect.addFinalizer(() =>
9461360 Effect.promise(() => config.testDb.close()).pipe(Effect.ignore),
9471361 );
packages/core/sdk/src/oauth-helpers.test.ts+183−1
@@ -6,7 +6,8 @@ · 5 unmodified lines
66 // ---------------------------------------------------------------------------
77
88 import { describe, expect, it } from "@effect/vitest";
9−import { Cause, Effect, Exit, Ref } from "effect";
9+import { Cause, Effect, Exit, Ref, Schema } from "effect";
10+import type * as Tracer from "effect/Tracer";
1011 import { HttpServerResponse } from "effect/unstable/http";
1112
1213 import {
@@ -34,8 +35,11 @@ interface TokenCall { · 21 unmodified lines
3435 readonly url: string;
3536 readonly headers: Readonly<Record<string, string>>;
3637 readonly body: URLSearchParams;
38+ readonly jsonBody: unknown;
3739 }
3840
41+const decodeJsonBody = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
42+
3943 type TokenHandler = (call: TokenCall) => HttpServerResponse.HttpServerResponse;
4044
4145 const json = (status: number, body: unknown): HttpServerResponse.HttpServerResponse =>
@@ -52,6 +56,9 @@ const serveTokenEndpoint = (handler: TokenHandler) => · 10 unmodified lines
5256 url: request.url ?? "/",
5357 headers: request.headers,
5458 body: new URLSearchParams(bodyText),
59+ jsonBody: request.headers["content-type"]?.startsWith("application/json")
60+ ? decodeJsonBody(bodyText)
61+ : null,
5562 };
5663 yield* Ref.update(calls, (all) => [...all, call]);
5764 return handler(call);
@@ -103,6 +110,45 @@ const tokenResponse = · 45 unmodified lines
103110 () =>
104111 json(200, body);
105112
113+/** Records each span's attributes and its ending exit, so a test can assert on
114+ * exactly what telemetry would export for the token-request span. */
115+interface RecordedSpan {
116+ readonly attributes: Map<string, unknown>;
117+ endExit?: unknown;
118+}
119+
120+const makeRecordingTracer = (spans: Map<string, RecordedSpan>): Tracer.Tracer => ({
121+ span: (options) => {
122+ const record: RecordedSpan = { attributes: new Map() };
123+ spans.set(options.name, record);
124+ let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };
125+ return {
126+ _tag: "Span",
127+ name: options.name,
128+ spanId: "0000000000000001",
129+ traceId: "00000000000000000000000000000001",
130+ parent: options.parent,
131+ annotations: options.annotations,
132+ get status() {
133+ return status;
134+ },
135+ attributes: record.attributes,
136+ links: options.links,
137+ sampled: options.sampled,
138+ kind: options.kind,
139+ end: (endTime, exit) => {
140+ record.endExit = exit;
141+ status = { _tag: "Ended", startTime: options.startTime, endTime, exit };
142+ },
143+ attribute: (key, value) => {
144+ record.attributes.set(key, value);
145+ },
146+ event: () => undefined,
147+ addLinks: () => undefined,
148+ };
149+ },
150+});
151+
106152 const tokenResponseFetch =
107153 (body: unknown): typeof globalThis.fetch =>
108154 async () =>
@@ -260,6 +306,56 @@ describe("buildAuthorizationUrl", () => { · 151 unmodified lines
260306 });
261307
262308 describe("exchangeAuthorizationCode", () => {
309+ it.effect("supports JSON token exchange with HTTP Basic client authentication", () =>
310+ withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
311+ Effect.gen(function* () {
312+ yield* exchangeAuthorizationCode({
313+ tokenUrl,
314+ clientId: "cid",
315+ clientSecret: "csecret",
316+ redirectUrl: "https://app.example.com/cb",
317+ codeVerifier: "verifier",
318+ code: "abc",
319+ clientAuth: "basic",
320+ requestFormat: "json",
321+ });
322+ const call = (yield* calls)[0]!;
323+ expect(call.headers["content-type"]).toBe("application/json");
324+ expect(call.headers["authorization"]).toBe("Basic Y2lkOmNzZWNyZXQ=");
325+ expect(call.jsonBody).toEqual({
326+ grant_type: "authorization_code",
327+ code: "abc",
328+ redirect_uri: "https://app.example.com/cb",
329+ code_verifier: "verifier",
330+ });
331+ }),
332+ ),
333+ );
334+
335+ it.effect("supports JSON token exchange with client credentials in the body", () =>
336+ withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
337+ Effect.gen(function* () {
338+ yield* exchangeAuthorizationCode({
339+ tokenUrl,
340+ clientId: "cid",
341+ clientSecret: "csecret",
342+ redirectUrl: "https://app.example.com/cb",
343+ codeVerifier: "verifier",
344+ code: "abc",
345+ requestFormat: "json",
346+ });
347+ expect((yield* calls)[0]!.jsonBody).toEqual({
348+ grant_type: "authorization_code",
349+ code: "abc",
350+ redirect_uri: "https://app.example.com/cb",
351+ code_verifier: "verifier",
352+ client_id: "cid",
353+ client_secret: "csecret",
354+ });
355+ }),
356+ ),
357+ );
358+
263359 it.effect("posts form-urlencoded body with grant_type=authorization_code and PKCE verifier", () =>
264360 withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) =>
265361 Effect.gen(function* () {
@@ -765,6 +861,9 @@ describe("exchangeAuthorizationCode", () => { · 499 unmodified lines
765861 }),
766862 );
767863
864+ // Non-secret description text must keep propagating even from a confidential
865+ // client: the scrub below removes ONLY the exact submitted secret, never the
866+ // AS's verdict prose around it.
768867 it.effect("propagates RFC 6749 error_description text in the OAuth2Error", () =>
769868 withTokenEndpoint(
770869 () =>
@@ -778,6 +877,7 @@ describe("exchangeAuthorizationCode", () => { · 7 unmodified lines
778877 exchangeAuthorizationCode({
779878 tokenUrl,
780879 clientId: "cid",
880+ clientSecret: "csecret",
781881 redirectUrl: "https://cb",
782882 codeVerifier: "v",
783883 code: "c",
@@ -786,10 +886,67 @@ describe("exchangeAuthorizationCode", () => { · 2 unmodified lines
786886 expect(Exit.isFailure(exit)).toBe(true);
787887 if (!Exit.isFailure(exit)) return;
788888 expect(JSON.stringify(exit.cause)).toContain("Code expired");
889+ const failure = Cause.squash(exit.cause) as OAuth2Error;
890+ expect(failure.error).toBe("invalid_grant");
789891 }),
790892 ),
791893 );
792894
895+ // The review's canary for the first-party secret leak. A provider that
896+ // echoes the submitted client_secret inside error_description would carry a
897+ // DEPLOYMENT-WIDE credential into the OAuth2Error message — and from there
898+ // into OAuthCompleteError, the popup's browser-visible errorDetails, and the
899+ // token-request span. Assert on the WHOLE rendered failure, not just the
900+ // message: what a log line or a `JSON.stringify` prints includes the retained
901+ // rejection, so an echo surviving anywhere in the cause is still a leak.
902+ it.effect("scrubs a client secret echoed in error_description from the whole failure", () => {
903+ const spans = new Map<string, RecordedSpan>();
904+ return withTokenEndpoint(
905+ () =>
906+ json(400, {
907+ error: "invalid_client",
908+ error_description:
909+ "authentication failed for secret SECRET-CANARY-must-not-escape, check your credentials",
910+ }),
911+ ({ tokenUrl }) =>
912+ Effect.gen(function* () {
913+ const exit = yield* Effect.exit(
914+ exchangeAuthorizationCode({
915+ tokenUrl,
916+ clientId: "cid",
917+ clientSecret: "SECRET-CANARY-must-not-escape",
918+ redirectUrl: "https://cb",
919+ codeVerifier: "v",
920+ code: "c",
921+ }),
922+ );
923+ expect(Exit.isFailure(exit)).toBe(true);
924+ if (!Exit.isFailure(exit)) return;
925+ for (const rendered of [Cause.pretty(exit.cause), JSON.stringify(exit.cause)]) {
926+ expect(rendered).not.toContain("SECRET-CANARY-must-not-escape");
927+ }
928+ const failure = Cause.squash(exit.cause) as OAuth2Error;
929+ expect(failure).toBeInstanceOf(OAuth2Error);
930+ // Masked, not dropped: the verdict prose around the secret survives,
931+ // and so does everything classification and the span read.
932+ expect(failure.message).toContain("[redacted]");
933+ expect(failure.message).toContain("check your credentials");
934+ expect(failure.message).not.toContain("SECRET-CANARY-must-not-escape");
935+ expect(failure.error).toBe("invalid_client");
936+ expect(failure.status).toBe(400);
937+ // The token-request span — attributes AND ending exit — is what the
938+ // exporter sees; the echo must not survive there either.
939+ const tokenSpan = spans.get("executor.oauth.token_request");
940+ expect(tokenSpan?.attributes.get("executor.oauth.error_code")).toBe("invalid_client");
941+ const spanRendered = JSON.stringify({
942+ attributes: [...(tokenSpan?.attributes ?? [])],
943+ exit: tokenSpan?.endExit,
944+ });
945+ expect(spanRendered).not.toContain("SECRET-CANARY-must-not-escape");
946+ }),
947+ ).pipe(Effect.withTracer(makeRecordingTracer(spans)));
948+ });
949+
793950 it.effect("includes HTTP status and body preview for non-OAuth token endpoint errors", () =>
794951 withTokenEndpoint(
795952 () => HttpServerResponse.text("route not found", { status: 404 }),
@@ -1192,6 +1349,31 @@ describe("exchangeClientCredentials", () => { · 396 unmodified lines
11921349 });
11931350
11941351 describe("refreshAccessToken", () => {
1352+ it.effect("persists provider-compatible JSON refresh rotation requests", () =>
1353+ withTokenEndpoint(
1354+ tokenResponse({ ...validRefreshBody, refresh_token: "rotated" }),
1355+ ({ tokenUrl, calls }) =>
1356+ Effect.gen(function* () {
1357+ const result = yield* refreshAccessToken({
1358+ tokenUrl,
1359+ clientId: "cid",
1360+ clientSecret: "csecret",
1361+ refreshToken: "old",
1362+ scopes: ["read", "offline_access"],
1363+ requestFormat: "json",
1364+ });
1365+ expect(result.refresh_token).toBe("rotated");
1366+ expect((yield* calls)[0]!.jsonBody).toEqual({
1367+ grant_type: "refresh_token",
1368+ refresh_token: "old",
1369+ scope: "read offline_access",
1370+ client_id: "cid",
1371+ client_secret: "csecret",
1372+ });
1373+ }),
1374+ ),
1375+ );
1376+
11951377 it.effect("normalizes Slack's comma-delimited scopes on refresh", () =>
11961378 Effect.gen(function* () {
11971379 const result = yield* refreshAccessToken({
packages/core/sdk/src/oauth-helpers.ts+169−33
@@ -641,8 +641,23 @@ const oauthErrorFromResponseBody = ( · 640 unmodified lines
641641 });
642642 };
643643
644−const toOAuth2Error = (cause: unknown): OAuth2Error => {
644+/** Exact-string scrub of the submitted client secret from text bound for an
645+ * `OAuth2Error`. A misbehaving authorization server can echo the credential it
646+ * was just sent back inside `error_description` — a field that is deliberately
647+ * previewable everywhere else in this module because it is the AS's
648+ * human-readable verdict. The message it lands in reaches the OAuth popup's
649+ * browser-visible error details, persisted connection health, and telemetry,
650+ * and for a first-party client the secret is deployment-wide. The helper knows
651+ * exactly which string it submitted, so this is a targeted replacement of that
652+ * one value, not a heuristic. */
653+const redactSubmittedClientSecret = (
654+ text: string,
655+ clientSecret: string | null | undefined,
656+): string => (clientSecret ? text.replaceAll(clientSecret, "[redacted]") : text);
657+
658+const toOAuth2Error = (cause: unknown, clientSecret?: string | null): OAuth2Error => {
645659 if (isOAuth2Error(cause)) return cause;
660+ const scrub = (text: string): string => redactSubmittedClientSecret(text, clientSecret);
646661 if (typeof cause === "object" && cause !== null) {
647662 const c = cause as {
648663 error?: unknown;
@@ -657,8 +672,10 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 8 unmodified lines
657672 ? c.message
658673 : undefined;
659674 return new OAuth2Error({
660− message: `OAuth token exchange failed: ${description ?? code ?? "unknown error"}`,
661− error: code,
675+ message: scrub(`OAuth token exchange failed: ${description ?? code ?? "unknown error"}`),
676+ // The code is scrubbed too: a conform envelope's `error` is an arbitrary
677+ // string the AS chose, and it flows into the token-request span attribute.
678+ error: code === undefined ? undefined : scrub(code),
662679 cause,
663680 });
664681 }
@@ -674,13 +691,18 @@ const toOAuth2Error = (cause: unknown): OAuth2Error => { · 9 unmodified lines
674691 *
675692 * `fallbackMessage` is for the paths that hold the error Response directly
676693 * rather than catching a thrown oauth4webapi error: there is no library
677− * message to build on, so the caller names the step instead. */
694+ * message to build on, so the caller names the step instead.
695+ *
696+ * `clientSecret` is the secret the failed request submitted, when the client
697+ * is confidential. Every constructed message is scrubbed of it, because the
698+ * body it is built from is the AS's and may echo the credential back. */
678699 const toOAuth2ErrorWithHttpSummary = (
679700 cause: unknown,
680− options?: { readonly fallbackMessage?: string },
701+ options?: { readonly fallbackMessage?: string; readonly clientSecret?: string | null },
681702 ): Effect.Effect<OAuth2Error> => {
682703 if (isOAuth2Error(cause)) return Effect.succeed(cause);
683− const base = toOAuth2Error(cause);
704+ const scrub = (text: string): string => redactSubmittedClientSecret(text, options?.clientSecret);
705+ const base = toOAuth2Error(cause, options?.clientSecret);
684706 const response = responseFromOAuthErrorCause(cause);
685707 if (!response) {
686708 // No Response, but possibly a body the library already parsed off one it
@@ -704,8 +726,8 @@ const toOAuth2ErrorWithHttpSummary = ( · 17 unmodified lines
704726 // exactly the leak the message allowlist exists to prevent. The other
705727 // branches keep their cause because theirs is diagnostic, not a body.
706728 new OAuth2Error({
707− message: `${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`,
708− error: base.error ?? envelope?.error,
729+ message: scrub(`${options?.fallbackMessage ?? base.message} (${summary.join("; ")})`),
730+ error: base.error ?? (envelope?.error === undefined ? undefined : scrub(envelope.error)),
709731 status: PARSED_BODY_CAUSE_STATUS,
710732 }),
711733 );
@@ -727,20 +749,37 @@ const toOAuth2ErrorWithHttpSummary = ( · 15 unmodified lines
727749 : `${headline}: ${recovered.code}${
728750 recovered.description === undefined ? "" : ` — ${recovered.description}`
729751 }`;
752+ const code = base.error ?? recovered?.code;
753+ // The diagnostic rejection is dropped when the submitted secret is visible
754+ // in its serialised form — oauth4webapi's ResponseBodyError carries the
755+ // AS's `error_description` as an own enumerable field, so anything that
756+ // renders the WHOLE failure (`Cause.pretty`, `JSON.stringify`) would
757+ // replay the echo straight past the message scrub above. Everything
758+ // classification reads — status, code, HTTP summary — is already lifted
759+ // out, mirroring the malformed-200 branch's treatment of token-bearing
760+ // rejections; the ordinary no-echo failure keeps its cause untouched.
761+ const causeEchoesSecret =
762+ Boolean(options?.clientSecret) && safeStringify(cause).includes(options?.clientSecret ?? "");
730763 return new OAuth2Error({
731− message: `${described} (${summary})`,
732− error: base.error ?? recovered?.code,
764+ message: scrub(`${described} (${summary})`),
765+ error: code === undefined ? undefined : scrub(code),
733766 // Carried even when no code was recovered: the status is what tells a
734767 // caller whether the AS refused (4xx — permanent, stop) or stumbled (5xx
735768 // — retry). Most real refusals arrive with no code at all.
736769 status: response.status,
737− cause,
770+ ...(causeEchoesSecret ? {} : { cause }),
738771 });
739772 });
740773 };
741774
742−const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2Error> =>
743− toOAuth2ErrorWithHttpSummary(cause).pipe(Effect.flatMap((error) => Effect.fail(error)));
775+/** Curried on the submitted client secret so every `Effect.catch` site names
776+ * the credential its request carried — the scrub cannot work without it. */
777+const failOAuth2WithHttpSummary =
778+ (clientSecret: string | null | undefined) =>
779+ (cause: unknown): Effect.Effect<never, OAuth2Error> =>
780+ toOAuth2ErrorWithHttpSummary(cause, { clientSecret }).pipe(
781+ Effect.flatMap((error) => Effect.fail(error)),
782+ );
744783
745784 /** Fail from a token-endpoint error Response the caller holds directly — the
746785 * `genericTokenEndpointRequest` paths, where oauth4webapi hands back the raw
@@ -751,8 +790,9 @@ const failOAuth2WithHttpSummary = (cause: unknown): Effect.Effect<never, OAuth2E · 4 unmodified lines
751790 const failOAuth2FromErrorResponse = (
752791 response: Response,
753792 fallbackMessage: string,
793+ clientSecret: string | null | undefined,
754794 ): Effect.Effect<never, OAuth2Error> =>
755− toOAuth2ErrorWithHttpSummary(response, { fallbackMessage }).pipe(
795+ toOAuth2ErrorWithHttpSummary(response, { fallbackMessage, clientSecret }).pipe(
756796 Effect.flatMap((error) => Effect.fail(error)),
757797 );
758798
@@ -1069,6 +1109,9 @@ export type ExchangeAuthorizationCodeInput = { · 310 unmodified lines
10691109 readonly codeVerifier: string;
10701110 readonly code: string;
10711111 readonly clientAuth?: ClientAuthMethod;
1112+ /** Encoding required by the provider's token endpoint. OAuth defaults to
1113+ * URL-encoded form; a small set of providers require a JSON object. */
1114+ readonly requestFormat?: "form" | "json";
10721115 readonly idTokenSigningAlgValuesSupported?: readonly string[];
10731116 /** RFC 8707 Resource Indicator. MCP Auth spec MUST-requires this on
10741117 * the token request when the client knows the resource it intends
@@ -1079,6 +1122,59 @@ export type ExchangeAuthorizationCodeInput = { · 4 unmodified lines
10791122 readonly fetch?: typeof globalThis.fetch;
10801123 };
10811124
1125+const base64BasicCredentials = (clientId: string, clientSecret: string): string => {
1126+ const bytes = new TextEncoder().encode(`${clientId}:${clientSecret}`);
1127+ let binary = "";
1128+ for (const byte of bytes) binary += String.fromCharCode(byte);
1129+ return globalThis.btoa(binary);
1130+};
1131+
1132+const jsonTokenEndpointRequest = async (input: {
1133+ readonly tokenUrl: string;
1134+ readonly clientId: string;
1135+ readonly clientSecret?: string | null;
1136+ readonly clientAuth: ClientAuthMethod;
1137+ readonly grantType: "authorization_code" | "refresh_token";
1138+ readonly parameters: Readonly<Record<string, string>>;
1139+ readonly timeoutMs?: number;
1140+ readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy;
1141+ readonly fetch?: typeof globalThis.fetch;
1142+}): Promise<Response> => {
1143+ const tokenUrl = assertSupportedOAuthEndpointUrl(
1144+ input.tokenUrl,
1145+ "Token URL",
1146+ input.endpointUrlPolicy,
1147+ );
1148+ const headers = new Headers({
1149+ accept: "application/json",
1150+ "content-type": "application/json",
1151+ });
1152+ const confidential = Boolean(input.clientSecret);
1153+ if (confidential && input.clientAuth === "basic") {
1154+ headers.set(
1155+ "authorization",
1156+ `Basic ${base64BasicCredentials(input.clientId, input.clientSecret ?? "")}`,
1157+ );
1158+ }
1159+ const body = {
1160+ grant_type: input.grantType,
1161+ ...input.parameters,
1162+ ...(confidential && input.clientAuth === "basic"
1163+ ? {}
1164+ : {
1165+ client_id: input.clientId,
1166+ ...(confidential ? { client_secret: input.clientSecret ?? "" } : {}),
1167+ }),
1168+ };
1169+ // oxlint-disable-next-line executor/no-raw-fetch -- boundary: provider token exchange is the SDK's HTTP boundary and preserves its injected fetch seam
1170+ return await (input.fetch ?? globalThis.fetch)(tokenUrl, {
1171+ method: "POST",
1172+ headers,
1173+ body: JSON.stringify(body),
1174+ signal: AbortSignal.timeout(input.timeoutMs ?? OAUTH2_DEFAULT_TIMEOUT_MS),
1175+ });
1176+};
1177+
10821178 export const exchangeAuthorizationCode = (
10831179 input: ExchangeAuthorizationCodeInput,
10841180 ): Effect.Effect<OAuth2TokenResponse, OAuth2Error> =>
@@ -1106,24 +1202,37 @@ export const exchangeAuthorizationCode = ( · 21 unmodified lines
11061202 if (input.resource) {
11071203 params.set("resource", input.resource);
11081204 }
1109− const response = await oauth.genericTokenEndpointRequest(
1110− as,
1111− client,
1112− clientAuth,
1113− "authorization_code",
1114− params,
1115− oauth4webapiRequestOptions(
1116− input.tokenUrl,
1117− input.timeoutMs,
1118− input.endpointUrlPolicy,
1119− input.fetch,
1120− ),
1121− );
1205+ const response =
1206+ input.requestFormat === "json"
1207+ ? await jsonTokenEndpointRequest({
1208+ tokenUrl: input.tokenUrl,
1209+ clientId: input.clientId,
1210+ clientSecret: input.clientSecret,
1211+ clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1212+ grantType: "authorization_code",
1213+ parameters: Object.fromEntries(params),
1214+ timeoutMs: input.timeoutMs,
1215+ endpointUrlPolicy: input.endpointUrlPolicy,
1216+ fetch: input.fetch,
1217+ })
1218+ : await oauth.genericTokenEndpointRequest(
1219+ as,
1220+ client,
1221+ clientAuth,
1222+ "authorization_code",
1223+ params,
1224+ oauth4webapiRequestOptions(
1225+ input.tokenUrl,
1226+ input.timeoutMs,
1227+ input.endpointUrlPolicy,
1228+ input.fetch,
1229+ ),
1230+ );
11221231 return await processTokenEndpointResponse(as, client, response);
11231232 },
11241233 catch: (cause) => cause,
11251234 }).pipe(
1126− Effect.catch(failOAuth2WithHttpSummary),
1235+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
11271236 withTokenRequestSpan({
11281237 grantType: "authorization_code",
11291238 tokenUrl: input.tokenUrl,
@@ -1186,7 +1295,7 @@ export const exchangeClientCredentials = ( · 56 unmodified lines
11861295 },
11871296 catch: (cause) => cause,
11881297 }).pipe(
1189− Effect.catch(failOAuth2WithHttpSummary),
1298+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
11901299 withTokenRequestSpan({
11911300 grantType: "client_credentials",
11921301 tokenUrl: input.tokenUrl,
@@ -1208,6 +1317,9 @@ export type RefreshAccessTokenInput = { · 15 unmodified lines
12081317 readonly scopes?: readonly string[];
12091318 readonly scopeSeparator?: string;
12101319 readonly clientAuth?: ClientAuthMethod;
1320+ /** Encoding required by the provider's token endpoint. OAuth defaults to
1321+ * URL-encoded form; a small set of providers require a JSON object. */
1322+ readonly requestFormat?: "form" | "json";
12111323 readonly idTokenSigningAlgValuesSupported?: readonly string[];
12121324 /** RFC 8707 Resource Indicator — MCP spec MUST-requires this on
12131325 * refresh requests so the new access token's audience is bound to
@@ -1241,6 +1353,26 @@ export const refreshAccessToken = ( · 27 unmodified lines
12411353 }
12421354 const additionalParameters =
12431355 Array.from(extraParams.keys()).length > 0 ? extraParams : undefined;
1356+ if (input.requestFormat === "json") {
1357+ const response = await jsonTokenEndpointRequest({
1358+ tokenUrl: input.tokenUrl,
1359+ clientId: input.clientId,
1360+ clientSecret: input.clientSecret,
1361+ clientAuth: input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD,
1362+ grantType: "refresh_token",
1363+ parameters: {
1364+ refresh_token: input.refreshToken,
1365+ ...(input.scopes && input.scopes.length > 0
1366+ ? { scope: input.scopes.join(input.scopeSeparator ?? " ") }
1367+ : {}),
1368+ ...(input.resource ? { resource: input.resource } : {}),
1369+ },
1370+ timeoutMs: input.timeoutMs,
1371+ endpointUrlPolicy: input.endpointUrlPolicy,
1372+ fetch: input.fetch,
1373+ });
1374+ return await processTokenEndpointResponse(as, client, response);
1375+ }
12441376 const response = await oauth.refreshTokenGrantRequest(
12451377 as,
12461378 client,
@@ -1265,7 +1397,7 @@ export const refreshAccessToken = ( · 18 unmodified lines
12651397 },
12661398 catch: (cause) => cause,
12671399 }).pipe(
1268− Effect.catch(failOAuth2WithHttpSummary),
1400+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
12691401 withTokenRequestSpan({
12701402 grantType: "refresh_token",
12711403 tokenUrl: input.tokenUrl,
@@ -1375,10 +1507,14 @@ export const exchangeSubjectTokenForIdJag = ( · 103 unmodified lines
13751507 );
13761508 },
13771509 catch: (cause) => cause,
1378− }).pipe(Effect.catch(failOAuth2WithHttpSummary));
1510+ }).pipe(Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)));
13791511
13801512 if (!response.ok) {
1381− return yield* failOAuth2FromErrorResponse(response, "ID-JAG token exchange was rejected");
1513+ return yield* failOAuth2FromErrorResponse(
1514+ response,
1515+ "ID-JAG token exchange was rejected",
1516+ input.clientSecret,
1517+ );
13821518 }
13831519
13841520 // Nothing else reads this body, so it is consumed directly. A read failure
@@ -1485,7 +1621,7 @@ export const redeemIdJagAssertion = ( · 100 unmodified lines
14851621 },
14861622 catch: (cause) => cause,
14871623 }).pipe(
1488− Effect.catch(failOAuth2WithHttpSummary),
1624+ Effect.catch(failOAuth2WithHttpSummary(input.clientSecret)),
14891625 withTokenRequestSpan({
14901626 grantType: JWT_BEARER_GRANT_TYPE,
14911627 tokenUrl: input.tokenUrl,
packages/core/sdk/src/oauth-service.ts+23−3
@@ -516,6 +516,8 @@ interface LoadedOAuthClient { · 515 unmodified lines
516516 /** Resolved literal secret (read from the provider via the stored item id). */
517517 readonly clientSecret: string;
518518 readonly resource: string | null;
519+ readonly tokenEndpointAuthMethod?: "body" | "basic";
520+ readonly tokenRequestFormat?: "form" | "json";
519521 }
520522
521523 /** Where an OAuth app's client secret is stored in the default writable
@@ -618,6 +620,8 @@ export const loadedFirstPartyClient = ( · 96 unmodified lines
618620 readonly clientId: string;
619621 readonly clientSecret: string;
620622 readonly resource: string | null;
623+ readonly tokenEndpointAuthMethod?: "body" | "basic";
624+ readonly tokenRequestFormat?: "form" | "json";
621625 } => ({
622626 slug: String(firstPartyOAuthClientSlug(config.name)),
623627 authorizationUrl: config.authorizationUrl,
@@ -626,6 +630,12 @@ export const loadedFirstPartyClient = ( · 2 unmodified lines
626630 clientId: config.clientId,
627631 clientSecret: config.clientSecret,
628632 resource: config.resource ?? null,
633+ ...(config.tokenEndpointAuthMethod === undefined
634+ ? {}
635+ : { tokenEndpointAuthMethod: config.tokenEndpointAuthMethod }),
636+ ...(config.tokenRequestFormat === undefined
637+ ? {}
638+ : { tokenRequestFormat: config.tokenRequestFormat }),
629639 });
630640
631641 export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
@@ -1697,6 +1707,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1065 unmodified lines
16971707 : scopePolicy.kind === "discover"
16981708 ? requestedScopes
16991709 : yield* filterAuthorizationCodeScopes(client, requestedScopes);
1710+ const completeAuthorizationScopes = dedupeScopes([
1711+ ...authorizationRequestedScopes,
1712+ ...(firstParty?.additionalAuthorizationScopes ?? []),
1713+ ]);
17001714
17011715 // authorization_code: persist a session + build the authorize URL.
17021716 const verifier = createPkceCodeVerifier();
@@ -1758,7 +1772,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 55 unmodified lines
17581772 payload: {
17591773 owner: input.owner,
17601774 clientOwner: input.clientOwner,
1761− requestedScopes: authorizationRequestedScopes,
1775+ requestedScopes: completeAuthorizationScopes,
17621776 },
17631777 expires_at: expiresAt,
17641778 created_at: now,
@@ -1771,14 +1785,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 6 unmodified lines
17711785 authorizationUrl: client.authorizationUrl,
17721786 clientId: client.clientId,
17731787 redirectUrl: flowRedirectUri,
1774− scopes: authorizationRequestedScopes,
1788+ scopes: completeAuthorizationScopes,
17751789 state: providerState,
17761790 codeChallenge: challenge,
1791+ scopeSeparator: firstParty?.authorizationScopeSeparator,
17771792 resource: client.resource ?? undefined,
17781793 // Provider quirks (Google: access_type=offline + prompt=consent) —
17791794 // without these Google returns no refresh token and won't re-consent
17801795 // to widen scopes on reconnect.
1781− extraParams: providerAuthorizeExtras(client.authorizationUrl),
1796+ extraParams: {
1797+ ...providerAuthorizeExtras(client.authorizationUrl),
1798+ ...(firstParty?.authorizationExtraParams ?? {}),
1799+ },
17821800 endpointUrlPolicy: deps.endpointUrlPolicy,
17831801 }),
17841802 catch: (cause) =>
@@ -1897,6 +1915,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 112 unmodified lines
18971915 redirectUrl: session.redirectUrl,
18981916 codeVerifier: session.pkceVerifier,
18991917 code: input.code,
1918+ clientAuth: client.tokenEndpointAuthMethod,
1919+ requestFormat: client.tokenRequestFormat,
19001920 resource: client.resource ?? undefined,
19011921 endpointUrlPolicy: deps.endpointUrlPolicy,
19021922 fetch,
packages/core/sdk/src/shared.ts+1−0
@@ -134,6 +134,7 @@ export { · 133 unmodified lines
134134 HealthCheckCandidate,
135135 HealthCheckCandidateParameter,
136136 classifyHttpStatus,
137+ classifyProbeResponse,
137138 extractIdentity,
138139 compareHealthCheckCandidates,
139140 candidateIdentityTier,
packages/plugins/mcp/src/react/AddMcpIntegration.tsx+1−13
@@ -41,6 +41,7 @@ import { McpRemoteIntegrationFields } from "./McpRemoteIntegrationFields"; · 40 unmodified lines
4141 import { McpRequestHeadersEditor } from "./McpRequestHeadersEditor";
4242 import { mcpHeadersFromRows, type McpHeaderRow } from "./request-headers";
4343 import { mcpAuthMethodInputFromEditorValue, mcpWireAuthInput } from "./auth-method-config";
44+import { parseStdioArgs } from "./stdio-fields";
4445 import { isProbableMcpEndpoint } from "./probe-url";
4546 import { cloudflareNeedsCodemodeOptOut } from "../sdk/cloudflare-codemode";
4647 import { mcpPresets, type McpPreset } from "../sdk/presets";
@@ -62,19 +63,6 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 15 unmodified lines
6263 return mcpPresets.find((p) => p.id === id);
6364 }
6465
65−// Splits the raw args field into tokens, honoring double-quoted groups so an
66−// argument with spaces stays intact.
67−function parseStdioArgs(raw: string): string[] {
68− if (!raw.trim()) return [];
69− const args: string[] = [];
70− const regex = /[^\s"]+|"([^"]*)"/g;
71− let match;
72− while ((match = regex.exec(raw)) !== null) {
73− args.push(match[1] ?? match[0]);
74− }
75− return args;
76−}
77−
7866 // ---------------------------------------------------------------------------
7967 // State machine (remote flow)
8068 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/EditMcpIntegration.tsx+158−22
@@ -13,10 +13,16 @@ import { · 12 unmodified lines
1313 type AuthMethodRow,
1414 type AuthMethodSeed,
1515 } from "@executor-js/react/components/auth-method-list-editor";
16−import { Badge } from "@executor-js/react/components/badge";
17−import { FormErrorAlert } from "@executor-js/react/lib/integration-add";
16+import {
17+ CardStack,
18+ CardStackContent,
19+ CardStackEntryField,
20+} from "@executor-js/react/components/card-stack";
21+import { Input } from "@executor-js/react/components/input";
22+import { Textarea } from "@executor-js/react/components/textarea";
23+import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add";
1824
19−import { configureMcpAuth, mcpServerAtom } from "./atoms";
25+import { configureMcpAuth, configureMcpServer, mcpServerAtom } from "./atoms";
2026 import type {
2127 McpAuthMethod,
2228 McpCanonicalAuthMethodInput,
@@ -27,6 +33,7 @@ import { · 4 unmodified lines
2733 mcpAuthMethodInputFromEditorValue,
2834 mcpWireAuthInput,
2935 } from "./auth-method-config";
36+import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
3037
3138 type McpServer = {
3239 readonly slug: IntegrationSlug;
@@ -174,30 +181,160 @@ function RemoteEdit(props: { · 141 unmodified lines
174181 }
175182
176183 // ---------------------------------------------------------------------------
177−// Stdio read-only view
184+// Stdio edit — the command, its arguments, the working directory, and the
185+// DECLARED static environment. Secret env vars are not edited here: a stdio
186+// server declares them as a `stdio_env` auth method and their values live on
187+// the connection, managed from the integration page's accounts hub. Changes
188+// are staged and applied by the sheet's Save, like the remote editor above.
178189 // ---------------------------------------------------------------------------
179190
180−function StdioReadOnly(props: {
181− server: McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> };
191+type McpStdioConfig = Extract<McpIntegrationConfig, { transport: "stdio" }>;
192+
193+/** Order-independent identity for a declared env map, so re-ordering the lines
194+ * in the field is not reported as a change. */
195+const envIdentity = (env: Readonly<Record<string, string>> | undefined): string =>
196+ Object.entries(env ?? {})
197+ .sort(([a], [b]) => a.localeCompare(b))
198+ .map(([key, value]) => `${key}=${value}`)
199+ .join("\n");
200+
201+function StdioEdit(props: {
202+ server: McpServer & { config: McpStdioConfig };
203+ onPendingChange?: EditSheetSectionProps["onPendingChange"];
182204 }) {
183− const { command, args } = props.server.config;
205+ const { server } = props;
206+ const doConfigure = useAtomSet(configureMcpServer, { mode: "promiseExit" });
207+
208+ const [command, setCommand] = useState(server.config.command);
209+ const [args, setArgs] = useState(() => formatStdioArgs(server.config.args));
210+ const [cwd, setCwd] = useState(server.config.cwd ?? "");
211+ const [env, setEnv] = useState(() => formatStdioEnv(server.config.env));
212+ const [error, setError] = useState<string | null>(null);
213+
214+ // The edited config. `configureServer` replaces the whole blob, so anything
215+ // this form does not surface (`versionNegotiation`, `authenticationTemplate`)
216+ // is carried through untouched. Empty optional fields are omitted rather than
217+ // written as `undefined`.
218+ const edited = useMemo<McpStdioConfig>(() => {
219+ const { args: _args, cwd: _cwd, env: _env, ...rest } = server.config;
220+ const nextArgs = parseStdioArgs(args);
221+ const nextEnv = parseStdioEnv(env);
222+ const nextCwd = cwd.trim();
223+ return {
224+ ...rest,
225+ command: command.trim(),
226+ ...(nextArgs.length > 0 ? { args: nextArgs } : {}),
227+ ...(Object.keys(nextEnv).length > 0 ? { env: nextEnv } : {}),
228+ ...(nextCwd !== "" ? { cwd: nextCwd } : {}),
229+ };
230+ }, [args, command, cwd, env, server.config]);
231+
232+ const changed =
233+ edited.command !== server.config.command ||
234+ formatStdioArgs(edited.args) !== formatStdioArgs(server.config.args) ||
235+ (edited.cwd ?? "") !== (server.config.cwd ?? "") ||
236+ envIdentity(edited.env) !== envIdentity(server.config.env);
237+
238+ // Staged apply, run by the sheet's Save. Persisting the config re-runs
239+ // discovery on every connection, so the tool catalog matches the new command.
240+ const applyStaged = useCallback(async (): Promise<EditSheetApplyResult> => {
241+ setError(null);
242+ if (edited.command === "") {
243+ setError("A command is required.");
244+ return { ok: false };
245+ }
246+ const exit = await doConfigure({
247+ params: { slug: server.slug },
248+ payload: { config: edited },
249+ reactivityKeys: integrationWriteKeys,
250+ });
251+ if (Exit.isFailure(exit)) {
252+ setError(errorMessageFromExit(exit, "Failed to update the server command"));
253+ return { ok: false };
254+ }
255+ return { ok: true, summary: "Server command updated." };
256+ }, [doConfigure, edited, server.slug]);
257+
258+ const onPendingChangeRef = useRef(props.onPendingChange);
259+ onPendingChangeRef.current = props.onPendingChange;
260+ useEffect(() => {
261+ onPendingChangeRef.current?.(changed ? applyStaged : null);
262+ return () => onPendingChangeRef.current?.(null);
263+ }, [changed, applyStaged]);
264+
184265 return (
185− <div className="space-y-3 border-t border-border/60 pt-5">
266+ <div className="space-y-4 border-t border-border/60 pt-5">
186267 <div className="space-y-1">
187268 <p className="text-sm font-medium text-foreground">Server command</p>
188269 <p className="text-xs text-muted-foreground">
189− Stdio MCP integrations cannot be edited. Remove and recreate the integration with the
190− updated command.
270+ Changes apply when you save. The server's tools are then rediscovered with the new
271+ command.
191272 </p>
192273 </div>
193− <div className="flex items-center gap-3 rounded-md border border-border/60 bg-muted/40 px-3 py-2">
194− <p className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
195− {command} {(args ?? []).join(" ")}
196− </p>
197− <Badge variant="secondary" className="text-xs">
198− stdio
199− </Badge>
200− </div>
274+
275+ <CardStack>
276+ <CardStackContent className="border-t-0">
277+ <CardStackEntryField
278+ label="Command"
279+ description="- The executable to run (e.g. npx, uvx, node)."
280+ >
281+ <Input
282+ value={command}
283+ aria-label="Command"
284+ onChange={(e) => setCommand((e.target as HTMLInputElement).value)}
285+ placeholder="npx"
286+ className="font-mono text-sm"
287+ />
288+ </CardStackEntryField>
289+
290+ <CardStackEntryField
291+ label="Arguments"
292+ description="- Space-separated arguments passed to the command."
293+ >
294+ <Input
295+ value={args}
296+ aria-label="Arguments"
297+ onChange={(e) => setArgs((e.target as HTMLInputElement).value)}
298+ placeholder="-y chrome-devtools-mcp@latest"
299+ className="font-mono text-sm"
300+ />
301+ </CardStackEntryField>
302+
303+ <CardStackEntryField
304+ label="Working directory"
305+ description="- Optional. Where the command runs."
306+ >
307+ <Input
308+ value={cwd}
309+ aria-label="Working directory"
310+ onChange={(e) => setCwd((e.target as HTMLInputElement).value)}
311+ placeholder="/path/to/server"
312+ className="font-mono text-sm"
313+ />
314+ </CardStackEntryField>
315+
316+ <CardStackEntryField
317+ label="Environment variables"
318+ description="- One KEY=value per line. The server receives these plus a small base set; it does not inherit executor's environment."
319+ >
320+ <Textarea
321+ value={env}
322+ aria-label="Environment variables"
323+ onChange={(e) => setEnv((e.target as HTMLTextAreaElement).value)}
324+ placeholder={"LOG_LEVEL=debug\nREGION=eu-west-1"}
325+ className="font-mono text-sm"
326+ />
327+ </CardStackEntryField>
328+ </CardStackContent>
329+ </CardStack>
330+
331+ <p className="text-xs text-muted-foreground">
332+ Secret values do not belong here. A server that needs an API key declares its variable name
333+ as an authentication method, and the value is entered per connection from the accounts
334+ panel.
335+ </p>
336+
337+ {error && <FormErrorAlert message={error} />}
201338 </div>
202339 );
203340 }
@@ -219,10 +356,9 @@ export default function EditMcpIntegration({ · 15 unmodified lines
219356
220357 if (server.config.transport === "stdio") {
221358 return (
222− <StdioReadOnly
223− server={
224− server as McpServer & { config: Extract<McpIntegrationConfig, { transport: "stdio" }> }
225− }
359+ <StdioEdit
360+ server={server as McpServer & { config: McpStdioConfig }}
361+ {...(onPendingChange ? { onPendingChange } : {})}
226362 />
227363 );
228364 }
packages/plugins/mcp/src/react/stdio-fields.test.ts+70−0
@@ -0,0 +1,70 @@
1+import { describe, expect, it } from "@effect/vitest";
2+
3+import { formatStdioArgs, formatStdioEnv, parseStdioArgs, parseStdioEnv } from "./stdio-fields";
4+
5+describe("parseStdioArgs", () => {
6+ it("splits on whitespace", () => {
7+ expect(parseStdioArgs("-y chrome-devtools-mcp@latest")).toEqual([
8+ "-y",
9+ "chrome-devtools-mcp@latest",
10+ ]);
11+ });
12+
13+ it("keeps a double-quoted group intact", () => {
14+ expect(parseStdioArgs('--dir "/Users/a b/mcp" --port 3000')).toEqual([
15+ "--dir",
16+ "/Users/a b/mcp",
17+ "--port",
18+ "3000",
19+ ]);
20+ });
21+
22+ it("returns nothing for a blank field", () => {
23+ expect(parseStdioArgs(" ")).toEqual([]);
24+ });
25+});
26+
27+describe("formatStdioArgs", () => {
28+ it("re-quotes tokens containing whitespace so the round trip is lossless", () => {
29+ const args = ["--dir", "/Users/a b/mcp", "--port", "3000"];
30+ expect(parseStdioArgs(formatStdioArgs(args))).toEqual(args);
31+ });
32+
33+ it("renders an absent list as an empty field", () => {
34+ expect(formatStdioArgs(undefined)).toBe("");
35+ });
36+});
37+
38+describe("parseStdioEnv", () => {
39+ it("reads one KEY=value per line", () => {
40+ expect(parseStdioEnv("LOG_LEVEL=debug\nREGION=eu-west-1")).toEqual({
41+ LOG_LEVEL: "debug",
42+ REGION: "eu-west-1",
43+ });
44+ });
45+
46+ it("splits on the first = only, so a value may contain =", () => {
47+ expect(parseStdioEnv("DSN=postgres://u:p@host/db?a=b")).toEqual({
48+ DSN: "postgres://u:p@host/db?a=b",
49+ });
50+ });
51+
52+ it("keeps a declared-but-empty value", () => {
53+ expect(parseStdioEnv("NO_COLOR=")).toEqual({ NO_COLOR: "" });
54+ });
55+
56+ it("drops blank lines, comments, and lines with no key", () => {
57+ expect(parseStdioEnv("\n# a comment\n=orphan\nA=1\n")).toEqual({ A: "1" });
58+ });
59+});
60+
61+describe("formatStdioEnv", () => {
62+ it("round-trips a declared map", () => {
63+ const env = { LOG_LEVEL: "debug", DSN: "postgres://u:p@host/db?a=b" };
64+ expect(parseStdioEnv(formatStdioEnv(env))).toEqual(env);
65+ });
66+
67+ it("renders an absent map as an empty field", () => {
68+ expect(formatStdioEnv(undefined)).toBe("");
69+ });
70+});
packages/plugins/mcp/src/react/stdio-fields.ts+51−0
@@ -0,0 +1,51 @@
1+// ---------------------------------------------------------------------------
2+// Text ↔ config codecs for the stdio fields, shared by the add flow
3+// (`AddMcpIntegration`) and the edit sheet (`EditMcpIntegration`). Both render
4+// the same single-line arguments field and the same KEY=value environment
5+// block, so the parsing lives in one place and round-trips: a config that is
6+// formatted and re-parsed is unchanged.
7+// ---------------------------------------------------------------------------
8+
9+/** Split the raw arguments field into tokens, honoring double-quoted groups so
10+ * an argument with spaces stays intact. */
11+export function parseStdioArgs(raw: string): string[] {
12+ if (!raw.trim()) return [];
13+ const args: string[] = [];
14+ const regex = /[^\s"]+|"([^"]*)"/g;
15+ let match;
16+ while ((match = regex.exec(raw)) !== null) {
17+ args.push(match[1] ?? match[0]);
18+ }
19+ return args;
20+}
21+
22+/** Render stored arguments back into the single-line field, re-quoting any
23+ * token that contains whitespace so `parseStdioArgs` recovers it. */
24+export function formatStdioArgs(args: readonly string[] | undefined): string {
25+ return (args ?? []).map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)).join(" ");
26+}
27+
28+/** Parse the KEY=value block into the declared static env map. Splits on the
29+ * FIRST `=` only, so a value may itself contain `=`. Blank lines, `#`
30+ * comments, and lines without a key are dropped; an empty value is kept
31+ * (declaring a variable as empty is meaningful to a subprocess). */
32+export function parseStdioEnv(raw: string): Record<string, string> {
33+ const env: Record<string, string> = {};
34+ for (const line of raw.split("\n")) {
35+ const trimmed = line.trim();
36+ if (trimmed === "" || trimmed.startsWith("#")) continue;
37+ const separator = trimmed.indexOf("=");
38+ if (separator <= 0) continue;
39+ const key = trimmed.slice(0, separator).trim();
40+ if (key === "") continue;
41+ env[key] = trimmed.slice(separator + 1).trim();
42+ }
43+ return env;
44+}
45+
46+/** Render the stored env map back into the KEY=value block. */
47+export function formatStdioEnv(env: Readonly<Record<string, string>> | undefined): string {
48+ return Object.entries(env ?? {})
49+ .map(([key, value]) => `${key}=${value}`)
50+ .join("\n");
51+}
packages/plugins/openapi/src/sdk/backing.ts+4−2
@@ -7,7 +7,7 @@ import { · 6 unmodified lines
77 ToolName,
88 ToolResult,
99 authToolFailure,
10− classifyHttpStatus,
10+ classifyProbeResponse,
1111 detectInsufficientScope,
1212 sortHealthCheckCandidatesByIdentity,
1313 extractIdentity,
@@ -973,7 +973,9 @@ export const checkHealthOpenApi = (input: { · 959 unmodified lines
973973 } satisfies HealthCheckResult;
974974 }
975975
976− const status = classifyHttpStatus(probe.result.status);
976+ // Body-aware: a configuration 403 (Google accessNotConfigured /
977+ // SERVICE_DISABLED) reads misconfigured, not expired.
978+ const status = classifyProbeResponse(probe.result.status, probe.result.error);
977979 const rawIdentity =
978980 status === "healthy" ? extractIdentity(probe.result.data, spec.identityField) : undefined;
979981 // The identity is read straight off the raw body, so unlike the sample it
packages/plugins/openapi/src/sdk/presets.ts+2−2
@@ -40,10 +40,10 @@ export const FIGMA_SUPPORTED_OAUTH_SCOPES = [ · 39 unmodified lines
4040 "file_dev_resources:write",
4141 "file_metadata:read",
4242 "file_versions:read",
43+ "folder_metadata:read",
44+ "folders:read",
4345 "library_assets:read",
4446 "library_content:read",
45− "project_metadata:read",
46− "projects:read",
4747 "team_library_content:read",
4848 "webhooks:read",
4949 "webhooks:write",
packages/react/src/components/accounts-section.tsx+57−0
@@ -70,6 +70,35 @@ import { · 69 unmodified lines
7070
7171 const OWNERS: readonly Owner[] = ["org", "user"];
7272
73+/** Render a health-check detail with any bare https URL as a clickable link.
74+ * Exists for the misconfigured verdict, whose detail is the provider's own
75+ * remediation text (Google's includes the console URL that enables the
76+ * disabled API); the rest of the string stays plain text. */
77+function DetailWithLinks(props: { readonly text: string }) {
78+ const parts = props.text.split(/(https:\/\/[^\s,;)]+)/g);
79+ return (
80+ <>
81+ {parts.map((part, index) =>
82+ part.startsWith("https://") ? (
83+ <a
84+ // Stable for a given detail string: parts are positional.
85+ // oxlint-disable-next-line react/no-array-index-key
86+ key={index}
87+ href={part}
88+ target="_blank"
89+ rel="noreferrer"
90+ className="underline underline-offset-2 hover:text-foreground"
91+ >
92+ {part}
93+ </a>
94+ ) : (
95+ part
96+ ),
97+ )}
98+ </>
99+ );
100+}
101+
73102 // The confirm dialog names the connection the way the row does — stored
74103 // identity label first, connection name otherwise. (No live probe identity
75104 // here; that state lives inside the row.)
@@ -115,6 +144,10 @@ function AccountRow(props: { · 39 unmodified lines
115144 const displayLabel = identity ?? String(connection.name);
116145
117146 const expired = status === "expired";
147+ // `misconfigured` is deliberately NOT folded into `needsHealthAttention`: it
148+ // gets its own amber "API disabled" badge and its own link-rendered detail
149+ // below, because the remediation is a console visit, not a reconnect.
150+ const misconfigured = status === "misconfigured";
118151 const needsHealthAttention = status === "expired" || status === "degraded";
119152 const healthDetail = needsHealthAttention ? probe?.detail : undefined;
120153 const missingOAuthScopes = connection.missingOAuthScopes ?? [];
@@ -135,6 +168,13 @@ function AccountRow(props: { · 14 unmodified lines
135168 );
136169 } else if (exit.value.status === "expired") {
137170 toast.error("Connection expired, reconnect to restore access");
171+ } else if (exit.value.status === "misconfigured") {
172+ // NOT a reconnect prompt: the credential is fine; the upstream API is
173+ // disabled where the OAuth client lives. The detail carries the
174+ // provider's own instruction (with a console link for Google).
175+ toast.warning(
176+ exit.value.detail ?? "An upstream API is disabled for this connection's OAuth client",
177+ );
138178 } else if (exit.value.status === "degraded") {
139179 toast.warning(exit.value.detail ?? "Connection check returned an error");
140180 } else {
@@ -157,6 +197,14 @@ function AccountRow(props: { · 16 unmodified lines
157197 {HEALTH_STATUS_LABEL[status]}
158198 </Badge>
159199 ) : null}
200+ {misconfigured ? (
201+ <Badge
202+ variant="outline"
203+ className="shrink-0 border-amber-600/40 text-amber-600 dark:text-amber-500"
204+ >
205+ API disabled
206+ </Badge>
207+ ) : null}
160208 {needsReconsent ? (
161209 <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
162210 Reconnect to grant access
@@ -168,6 +216,15 @@ function AccountRow(props: { · 5 unmodified lines
168216 {connection.description}
169217 </CardStackEntryDescription>
170218 ) : null}
219+ {misconfigured && probe?.detail ? (
220+ // Not CardStackEntryDescription: that truncates to one line, and this
221+ // text IS the remediation (the enable-API console link must stay
222+ // visible in full). Wrap instead; break anywhere so the long URL
223+ // cannot overflow the row.
224+ <p className="mt-1 whitespace-normal text-xs text-muted-foreground [overflow-wrap:anywhere]">
225+ <DetailWithLinks text={probe.detail} />
226+ </p>
227+ ) : null}
171228 {healthDetail ? (
172229 <CardStackEntryDescription className="mt-1 overflow-visible whitespace-normal text-clip text-xs text-muted-foreground">
173230 {healthDetail}
packages/react/src/components/add-account-modal.tsx+7−1
@@ -1048,7 +1048,13 @@ function HealthStatusLine(props: { · 1047 unmodified lines
10481048 }) {
10491049 const { status, identity, detail, httpStatus } = props.result;
10501050 const healthy = status === "healthy";
1051− const tone = healthy ? "text-muted-foreground" : "text-destructive";
1051+ // Misconfigured is amber everywhere (see health-display.ts): the credential
1052+ // being validated is fine, so the verdict must not read as a key failure.
1053+ const tone = healthy
1054+ ? "text-muted-foreground"
1055+ : status === "misconfigured"
1056+ ? "text-amber-600 dark:text-amber-500"
1057+ : "text-destructive";
10521058 const font = props.variant === "response" ? "font-mono" : "";
10531059 return (
10541060 <div className={`flex min-w-0 items-center gap-2 text-xs ${tone} ${font}`}>
packages/react/src/lib/health-display.ts+20−7
@@ -6,19 +6,26 @@ · 5 unmodified lines
66
77 import type { HealthStatus } from "@executor-js/sdk/shared";
88
9−/** State form: badge text, indicator tooltips, "what is the current state". */
9+/** State form: badge text, indicator tooltips, "what is the current state".
10+ * `misconfigured` reads "API disabled": the one concrete cause it detects
11+ * today (an upstream API not enabled in the OAuth client's project), named
12+ * plainly so the user looks at the provider console, not the credential. */
1013 export const HEALTH_STATUS_LABEL: Record<HealthStatus, string> = {
1114 healthy: "Healthy",
1215 expired: "Expired",
16+ misconfigured: "API disabled",
1317 degraded: "Degraded",
1418 unknown: "Unchecked",
1519 };
1620
1721 /** Text tone per status, for verdict lines and previews. Same per-status color
18− * decision as the indicator dots below; change them together. */
22+ * decision as the indicator dots below; change them together. `misconfigured`
23+ * shares degraded's amber: it needs attention, but the credential is fine, so
24+ * it must not carry expired's destructive red. */
1925 export const HEALTH_TEXT_CLASS: Record<HealthStatus, string> = {
2026 healthy: "text-emerald-600 dark:text-emerald-400",
2127 expired: "text-destructive",
28+ misconfigured: "text-amber-600 dark:text-amber-500",
2229 degraded: "text-amber-600 dark:text-amber-500",
2330 unknown: "text-muted-foreground",
2431 };
@@ -32,22 +39,27 @@ export const HEALTH_INDICATOR_COLOR: Record< · 7 unmodified lines
3239 > = {
3340 healthy: { dot: "bg-emerald-500", ring: "ring-emerald-500/70" },
3441 expired: { dot: "bg-destructive", ring: "ring-destructive/70" },
42+ misconfigured: { dot: "bg-amber-500", ring: "ring-amber-500/70" },
3543 degraded: { dot: "bg-amber-500", ring: "ring-amber-500/70" },
3644 unknown: { dot: "bg-muted-foreground/40", ring: "ring-muted-foreground/40" },
3745 };
3846
3947 /** Severity for worst-of aggregation. `unknown` is excluded on purpose: a
4048 * never-probed connection carries no signal, so it must not drag a group
41− * verdict in either direction. */
49+ * verdict in either direction. `misconfigured` outranks `degraded` (a named
50+ * configuration break beats a generic error) but not `expired` (a dead
51+ * credential blocks everything). */
4252 const HEALTH_SEVERITY: Record<Exclude<HealthStatus, "unknown">, number> = {
4353 healthy: 1,
4454 degraded: 2,
45− expired: 3,
55+ misconfigured: 3,
56+ expired: 4,
4657 };
4758
48−/** Collapse many connection statuses to the group's worst: expired > degraded
49− * > healthy. `unknown` entries are ignored; when nothing else remains (empty
50− * input or all unknown) there is no verdict and the caller renders nothing. */
59+/** Collapse many connection statuses to the group's worst: expired >
60+ * misconfigured > degraded > healthy. `unknown` entries are ignored; when
61+ * nothing else remains (empty input or all unknown) there is no verdict and
62+ * the caller renders nothing. */
5163 export const worstHealthStatus = (statuses: readonly HealthStatus[]): HealthStatus | null => {
5264 let worst: Exclude<HealthStatus, "unknown"> | null = null;
5365 for (const status of statuses) {
@@ -64,6 +76,7 @@ export const HEALTH_BADGE_VARIANT: Record< · 10 unmodified lines
6476 > = {
6577 healthy: "secondary",
6678 expired: "destructive",
79+ misconfigured: "outline",
6780 degraded: "outline",
6881 unknown: "outline",
6982 };