fix: reject unsupported REST tool connections without stdio validation (#13346)

## Thinking Path

> - Paperclip manages AI agents and their connections.
> - Connection checks must use the configured transport.
> - The tool service treated every remaining transport as local stdio.
> - Anthropic's old REST method therefore failed with a templateId
error. A REST connection with a valid stdio template could incorrectly
pass.
> - Anthropic now has a supported AI-account flow. This pull request
removes its obsolete REST setup option and limits stdio checks to stdio
connections.
> - Users can connect an AI account, and existing unsupported
connections receive an accurate error.

## Linked Issues or Issue Description

Related: #13248 added the supported AI-account flow. Searches for
related REST health and templateId bugs found no duplicate fix.

**What happened?**

The Anthropic REST API-key connection showed `Local stdio MCP
connections must use an approved templateId`. Health checks and catalog
discovery both fell through to the local stdio path. A REST connection
with an approved template could report success and expose the template's
catalog without a REST integration.

**Expected behavior**

Only local stdio connections use command templates. Unsupported
transports return an accurate HTTP 422 error. New Anthropic accounts use
the supported runtime authentication flow.

**Steps to reproduce**

1. Check out the test-only commit `924e6e85a` in a separate worktree and
install dependencies.
2. Run `pnpm exec vitest run packages/shared/src/app-definitions.test.ts
server/src/__tests__/tool-access-service.test.ts -t 'unsupported
REST|obsolete Anthropic'`.
3. The tests exercise saved Anthropic REST configuration and an
unsupported REST connection containing an approved stdio template. They
cover health checks and catalog discovery separately.
4. Run the same tests on the fix commit. They pass. The full affected
files also pass.

**Paperclip version or commit**

Reproduced against master `6cef9743c`.

**Deployment mode**

Server transport handling. Reproduced with an isolated embedded
PostgreSQL test database. No provider account or live credentials are
required.

## What Changed

- Restrict stdio health checks and tool discovery to `local_stdio`.
- Return and audit `tool_connection_transport_unsupported` with HTTP 422
for unsupported tool transports.
- Remove Anthropic's obsolete REST method from the generated catalog and
its durable ingestion source. Keep its subscription and API-key AI
methods.
- Cover the reported error, false-success case, rejected obsolete setup,
connection removal, and the UI's AI-account submission path.
- Replace impossible reconnect forms for removed methods with supported
setup, while preserving connection removal.
- Preserve AI-versus-tool intent isolation for legacy requests and
reject new unsupported Anthropic tool requests.
- Document recovery for existing unsupported connections.

## Verification

- Clean-worktree red/green: the same command failed all six regression
cases at `924e6e85a` and passed all six at `4d3de9de0`. The failing run
includes the reported templateId error.
- Green: all 555 tests across the six affected test files passed.
- Recovery UI red/green: three added cases failed before the recovery
fix and passed afterward; all 200 tests across setup, detail, and
advanced controls passed.
- After the recovery UI update, UI typecheck/build and token gates
passed again.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm check:token-gates` — passed.
- Catalog regeneration — passed with the documented
`PAPERCLIP_CONTENT_TEMPLATES` override for the local capture corpus.
- Full CI on `69fb31fd4` — passed all general and serialized test
shards, browser shards, typecheck, build, runner verification, and
canary dry run:
https://github.com/paperclipai/paperclip/actions/runs/34726975425.
- The local serial `pnpm test:run` was stopped after the fixture
correction superseded that run; full-suite verification above comes from
CI. All 555 affected tests passed locally, including all 17
connection-intent tests after the correction.
- Greptile — 5/5, successful check on final commit `69fb31fd4`, no
unresolved findings.
- No live Anthropic validation was performed. The UI regression uses a
fake key and a mocked AI-account response.

## Risks

Existing obsolete REST connections remain in needs-attention state.
Users must add an account through the supported flow and remove the old
connection. Credentials and grants are not transferred automatically.
Removal remains covered. The specialized AgentMail and Composio paths
keep their existing behavior. There are no schema or permission changes.

## Model Used

OpenAI GPT-6 through Codex. The exact serving model ID and
context-window capacity are not exposed in this session. Used reasoning,
code editing, shell tools, and test execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-12 19:26:58 -05:00 committed by GitHub
parent e704e1c9ae
commit 13bae6fa21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 269 additions and 50 deletions

View File

@ -146,6 +146,14 @@ These axes produce combinations such as:
transport is a REST API. Most current API-key catalog entries authenticate a
remote MCP server.
Anthropic accounts use the `runtime_auth` AI connection methods. Its obsolete
`api-key` REST tool method is no longer offered. Existing unsupported REST tool
connections fail health and catalog checks with HTTP 422 and
`tool_connection_transport_unsupported`; they never use local stdio templates
or report a successful MCP probe. Add the provider through its supported account
flow, then remove the obsolete connection. This does not transfer credentials
or grants automatically.
For `mcp_remote`, header credentials and secret-bearing generated URLs have the
complete generic runtime path. The schema also names `query`, `body_json`, and
`env` key placements for specialized transports, but accepting a value in the

View File

@ -238,6 +238,13 @@ const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [
writeTools: readonly string[];
}>;
describe("AppDefinition catalog", () => {
it("offers Anthropic runtime authentication without the unsupported REST tool method", () => {
const anthropic = APP_DEFINITIONS.find((app) => app.slug === "anthropic")!;
expect(anthropic.methods.map((method) => method.key)).toEqual(["ai-subscription", "ai-api_key"]);
expect(anthropic.methods.every((method) => method.purpose === "ai" && method.transport === "runtime_auth")).toBe(true);
expect(getAvailableConnectionMethod(anthropic, "api-key")).toBeNull();
});
it("validates all Wave 1 definitions", () =>
expect(() => appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow());
it("contains every established provider plus the reviewed self-serve catalog", () => {

View File

@ -70,34 +70,6 @@
"location": "env",
"name": "ANTHROPIC_API_KEY"
}
},
{
"key": "api-key",
"transport": "rest_api",
"auth": "api_key",
"ownershipModes": [
"customer"
],
"whenToUse": "Use credentials from your provider account.",
"defaults": {
"serviceHost": "api.anthropic.com"
},
"guidanceMd": "Create a key in the Anthropic Console and rotate it if it has been exposed.",
"riskTier": "S3",
"credentialFields": [
{
"key": "apiKey",
"label": "API key",
"type": "password",
"required": true,
"placeholder": "sk-ant-api03-...",
"secret": true
}
],
"keyPlacement": {
"location": "header",
"name": "x-api-key"
}
}
]
}

View File

@ -1490,7 +1490,9 @@ for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true,
let app=apps.find(a=>a.slug===slug);
if(!app){app={schemaVersion:1,slug,name,description:`Connect ${name} accounts for your agents.`,categories:["ai"],branding:brandingFor(slug),urlPatterns:[{"openai":"https://api.openai.com/*","openrouter":"https://openrouter.ai/api/*","xai":"https://api.x.ai/*"}[slug]],methods:[]};apps.push(app);}
const methods=(subscription?["subscription","api_key"]:["api_key"]).map(authMethod=>({key:`ai-${authMethod}`,label:authMethod==="subscription"?`${name} subscription`:`${name} API key`,purpose:"ai",transport:"runtime_auth",auth:authMethod==="subscription"?"oauth":"api_key",ai:{provider:slug,method:authMethod},grantKinds:["user","organization"],ownershipModes:["customer"],whenToUse:"Authenticate an agent with this account.",guidanceMd:"Use your personal account or an explicitly shared company account.",riskTier:"S3",...(authMethod==="api_key"?{credentialFields:[field("apiKey","API key","Enter API key")],keyPlacement:{location:"env",name:envKey}}:{})}));
app.methods.unshift(...methods);
// Legacy REST entries have no tool execution adapter. Only offer the supported
// AI account flow; saved REST connections remain removable through Connections.
app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
}
const validateApp = (app) => {
if (

View File

@ -793,7 +793,7 @@ describeEmbeddedPostgres("connectionIntentService", () => {
await expect(service.search(claims, "notion"))
.rejects.toThrow("no longer active");
});
it("keeps runtime authentication requests distinct from the same provider's tool requests", async () => {
it("keeps runtime authentication separate from obsolete Anthropic tool requests", async () => {
const companyId = claims.company_id;
const agentId = randomUUID();
const issueId = randomUUID();
@ -808,16 +808,38 @@ describeEmbeddedPostgres("connectionIntentService", () => {
await db.insert(aiConnectionDefaults).values({ companyId, userId: claims.responsible_user_id!, provider: "anthropic", method: "api_key", grantId: grant!.id });
const aiClaims = { ...claims, sub: agentId, run_id: aiRunId };
const service = connectionIntentService(db);
const toolRequest = await service.request(aiClaims, "anthropic");
await expect(service.request(aiClaims, "anthropic")).rejects.toMatchObject({
status: 422,
message: "Connection service anthropic is not available",
});
// Preserve an intent created before the obsolete REST method was removed.
// It must neither alias the AI request nor accept an AI account as tools.
const toolRequest = await issueThreadInteractionService(db).createConnectionIntent(
{ id: issueId, companyId },
{
payload: {
version: 1,
serviceSlug: "anthropic",
serviceName: "Anthropic",
serviceLogoUrl: null,
requestingAgentId: agentId,
requestingAgentName: "AI Agent",
phase: "requested",
},
sourceRunId: aiRunId,
addresseeUserId: claims.responsible_user_id!,
idempotencyKey: `connection-intent:${aiRunId}:${claims.responsible_user_id}:anthropic`,
},
);
const aiRequest = await service.request(aiClaims, "anthropic", { purpose: "ai" });
expect(aiRequest.state).toBe("needs_user_action");
expect(aiRequest.interactionId).not.toBe(toolRequest.interactionId);
expect(aiRequest.interactionId).not.toBe(toolRequest.id);
expect((await service.setupOptions(aiRequest.interactionId!)).aiConnection).toEqual(binding);
expect((await service.setupOptions(toolRequest.interactionId!)).existingConnections).toEqual([]);
await expect(service.complete(toolRequest.interactionId!, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy");
expect((await service.setupOptions(toolRequest.id)).existingConnections).toEqual([]);
await expect(service.complete(toolRequest.id, connection!.id, claims.responsible_user_id!)).rejects.toThrow("cannot satisfy");
await expect(service.complete(aiRequest.interactionId!, connection!.id, claims.responsible_user_id!)).resolves.toMatchObject({ status: "accepted" });
expect((await service.request(aiClaims, "anthropic", { purpose: "ai" })).state).toBe("ready");
expect((await service.request(aiClaims, "anthropic")).state).toBe("needs_user_action");
await expect(service.request(aiClaims, "anthropic")).rejects.toMatchObject({ status: 422 });
expect((await service.search(aiClaims, "openrouter")).results.some(result => result.service === "openrouter")).toBe(false);
});

View File

@ -2614,6 +2614,80 @@ describeEmbeddedPostgres("tool access service", () => {
expect(health.connection.healthStatus).toBe("ok");
});
it.each(
[
{ sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
{
sourceTemplateKey: "unsupported-rest-fixture",
templateId: "paperclip.echo-calculator-time",
},
].flatMap((config) =>
(["checkHealth", "refreshCatalog"] as const).map((operation) => ({ config, operation })),
),
)("rejects unsupported REST tool connections without stdio validation: %j", async ({ config, operation }) => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
const application = await service.createApplication(company.id, {
name: "REST regression fixture",
type: "rest_api",
});
const connection = await service.createConnection(company.id, {
applicationId: application.id,
name: "REST regression fixture",
transport: "rest_api",
config,
enabled: true,
status: "active",
});
const fetchMock = vi.spyOn(globalThis, "fetch");
const message = "This connection has no supported tool integration. Add a supported account or MCP connection from Connectors.";
await expect(service[operation](connection.id)).rejects.toMatchObject({
status: 422,
message,
details: { code: "tool_connection_transport_unsupported" },
});
const [saved] = await db.select().from(toolConnections)
.where(eq(toolConnections.id, connection.id));
expect(saved).toMatchObject({ healthStatus: "error", healthMessage: message });
expect(fetchMock).not.toHaveBeenCalled();
expect(await service.listRuntimeSlots(company.id)).toEqual([]);
expect(await db.select().from(toolCatalogEntries)
.where(eq(toolCatalogEntries.connectionId, connection.id))).toEqual([]);
const audit = await db.select().from(toolAccessAuditEvents)
.where(eq(toolAccessAuditEvents.connectionId, connection.id));
expect(audit).toEqual(expect.arrayContaining([
expect.objectContaining({
action: operation === "checkHealth" ? "tool_connection.health_check" : "tool_connection.catalog_refresh",
outcome: "failure",
reasonCode: "tool_connection_transport_unsupported",
}),
]));
// Removing a method from the catalog must not strand its saved connections.
expect(await service.archiveConnection(connection.id)).toMatchObject({
connection: { status: "archived" },
});
});
it("rejects the obsolete Anthropic REST setup before storing credentials", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);
await expect(service.connectGalleryApp(company.id, {
galleryKey: "anthropic",
connectionMethodKey: "api-key",
credentialValues: { "credentials.apiKey": "rest-regression-secret" },
}, { actorType: "user", actorId: "board" })).rejects.toMatchObject({
status: 422,
message: "This app does not have an available connection method",
});
expect(await db.select().from(toolConnections)
.where(eq(toolConnections.companyId, company.id))).toEqual([]);
expect(await db.select().from(companySecrets)
.where(eq(companySecrets.companyId, company.id))).toEqual([]);
});
it("registers an approved local stdio template and exposes its runtime slot", async () => {
const company = await createCompany(db);
const service = createTestToolAccessService(db);

View File

@ -2776,10 +2776,18 @@ function healthFailureHttpStatus(failure: {
}): number {
if (failure.status === "missing_secret") return 422;
if (failure.code === "composio_api_key_rejected") return 422;
if (failure.code === "tool_connection_transport_unsupported") return 422;
if (failure.code.endsWith("_endpoint_rejected")) return 422;
return 502;
}
function unsupportedToolConnectionTransport() {
return unprocessable(
"This connection has no supported tool integration. Add a supported account or MCP connection from Connectors.",
{ code: "tool_connection_transport_unsupported" },
);
}
function sanitizeHttpFailure(error: unknown): {
status: ToolConnectionHealthStatus;
message: string;
@ -2797,6 +2805,9 @@ function sanitizeHttpFailure(error: unknown): {
}
if (error instanceof HttpError) {
const code = asRecord(error.details).code;
if (code === "tool_connection_transport_unsupported") {
return { status: "error", message: error.message, code };
}
if (code === "composio_connected_account_inactive") {
return { status: "degraded", message: error.message, code };
}
@ -7428,6 +7439,9 @@ export function toolAccessService(
await validateComposioConnection(connection);
return [];
}
if (connection.transport !== "local_stdio") {
throw unsupportedToolConnectionTransport();
}
await resolveCredentialHeaders(connection);
return localTools(connection);
}
@ -7579,9 +7593,11 @@ export function toolAccessService(
await remoteTools(connection, credentialHeaders, actor);
} else if (isComposioConnection(connection)) {
await validateComposioConnection(connection);
} else {
} else if (connection.transport === "local_stdio") {
await resolveCredentialHeaders(connection);
await stdioTemplateId(connection.companyId, connection.config);
} else {
throw unsupportedToolConnectionTransport();
}
const updated = await updateConnectionHealth(
connection,

View File

@ -5,6 +5,7 @@ import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getAppStoreDefinition } from "@paperclipai/shared";
import { AppDetail } from "./AppDetail";
import { APP_TABS } from "./app-tabs";
@ -1172,6 +1173,30 @@ describe("AppDetail", () => {
expect(container.textContent).toContain("Which agents can use this connection?");
});
it.each(["permissions", "review"])("offers a supported replacement for an obsolete Anthropic connection on %s", async (tab) => {
mockParams.tab = tab;
listApplicationsMock.mockResolvedValue({ applications: [] });
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")!] });
getConnectionMock.mockResolvedValue(connection({
name: "Anthropic",
transport: "rest_api",
authKind: "api_key",
config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
healthStatus: "error",
healthMessage: "This connection has no supported tool integration.",
}));
await renderAppDetail();
expect(container.querySelector('input[type="password"]')).toBeNull();
expect(findButton("Check & reconnect")).toBeUndefined();
expect(findButton("Reconnect")).toBeUndefined();
expect(container.textContent).toContain("Connection no longer supported");
expect(container.textContent).toContain("then remove this connection");
expect(container.querySelector('a[href="/apps/connect?source=anthropic"]')?.textContent)
.toBe("Add supported connection");
});
it("offers retry for a transient GitHub error without asking for another login", async () => {
mockParams.tab = "permissions";
getConnectionMock.mockResolvedValue(connection({

View File

@ -6,13 +6,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CONNECTABLE_APP_DEFINITIONS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, getAppStoreDefinition } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import { aiConnectionsApi } from "@/api/ai-connections";
import { queryKeys } from "@/lib/queryKeys";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import { AppsConnect } from "./AppsConnect";
const listGalleryMock = vi.hoisted(() => vi.fn());
const experimentalMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: experimentalMock } }));
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: {
getExperimental: experimentalMock,
get: async () => ({ defaultEnvironmentId: "local-env" }),
getGeneral: async () => ({}),
} }));
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const getConnectionMock = vi.hoisted(() => vi.fn());
@ -438,24 +443,43 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// credential is entered.
// -------------------------------------------------------------------------
it("keeps the existing Anthropic tool method reachable alongside AI authentication", async () => {
it("offers supported Anthropic AI authentication without the obsolete REST tool method", async () => {
const createAiAccount = vi.spyOn(aiConnectionsApi, "create").mockResolvedValue({
connectionId: "anthropic-ai-account", grantId: "anthropic-ai-grant",
});
mockParams.appKey = "anthropic";
listGalleryMock.mockResolvedValue({ apps: [getAppStoreDefinition("anthropic")] });
await render();
const client = new QueryClient({ defaultOptions: { queries: {
retry: false,
staleTime: Infinity,
} } });
client.setQueryData(queryKeys.environments.list("company-1"), [
{ id: "local-env", name: "Local", driver: "local", status: "active", config: {} },
]);
client.setQueryData(queryKeys.environments.capabilities("company-1"), {});
client.setQueryData(queryKeys.instance.settings, { defaultEnvironmentId: "local-env" });
client.setQueryData(queryKeys.instance.generalSettings, {});
client.setQueryData(queryKeys.health, { deploymentMode: "authenticated", localAiLoginSupported: false });
await render(client);
await passAccessStep();
expect(container.textContent).toContain("How do you want to connect?");
expect(radioContaining("Claude subscription")).toBeTruthy();
expect(radioContaining("Claude API key")).toBeTruthy();
await act(async () => radioContaining("Use an API key")!.click());
expect(container.textContent).toContain("Connect account");
expect(container.textContent).toContain("Connection name");
expect(container.textContent).not.toContain("How do you want to connect?");
expect(radioContaining("Use an API key")).toBeUndefined();
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => buttonContaining("Use API key instead")!.click());
await act(async () => buttonContaining("Claude")!.click());
await flushReact();
const key = container.querySelector<HTMLInputElement>('input[type="password"]');
expect(key).toBeTruthy();
await act(async () => setInputValue(key!, "fixture-anthropic-tool-key"));
await act(async () => setInputValue(key!, "fixture-anthropic-ai-key"));
await act(async () => buttonByText("Connect")!.click());
await flushReact();
expect(connectAppMock).toHaveBeenCalledWith("company-1", expect.objectContaining({
galleryKey: "anthropic", connectionMethodKey: "api-key",
expect(createAiAccount).toHaveBeenCalledWith("company-1", expect.objectContaining({
provider: "anthropic", method: "api_key", apiKey: "fixture-anthropic-ai-key",
}));
expect(mockNavigate).toHaveBeenCalledWith("/apps/anthropic-ai-account/permissions");
expect(connectAppMock).not.toHaveBeenCalled();
expect(container.textContent).not.toContain("Connect for tool access instead");
});

View File

@ -3,6 +3,7 @@
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAppStoreDefinition } from "@paperclipai/shared";
import { DangerZone } from "./AdvancedPanel";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -50,6 +51,54 @@ function expandDangerZone(node: HTMLDivElement) {
* operator commits.
*/
describe("DangerZone", () => {
it("keeps removal available without reconnecting an obsolete Anthropic method", () => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const onRemove = vi.fn();
act(() => root.render(
<DangerZone
appName="Anthropic"
connection={{
id: "obsolete-anthropic",
uid: "obsolete-anthropic",
companyId: "company-1",
applicationId: "app-1",
name: "Anthropic",
connectionKind: "managed",
connectionPurpose: "tool",
ownership: "customer",
transport: "rest_api",
authKind: "api_key",
credentialSource: "paperclip_vault",
credentialPolicy: "shared",
transportConfig: {},
config: { sourceTemplateKey: "anthropic", connectionMethodKey: "api-key" },
credentialSecretRefs: [],
healthStatus: "error",
healthCheckedAt: null,
lastError: "This connection has no supported tool integration.",
enabled: true,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-09-12T00:00:00Z"),
updatedAt: new Date("2026-09-12T00:00:00Z"),
}}
galleryEntry={getAppStoreDefinition("anthropic")!}
removing={false}
onRemove={onRemove}
/>,
));
expandDangerZone(container);
const button = (label: string) => Array.from(container!.querySelectorAll("button"))
.find((candidate) => candidate.textContent?.trim() === label);
expect(button("Reconnect")).toBeUndefined();
act(() => button("Remove app")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
act(() => button("Yes, remove it")!.dispatchEvent(new MouseEvent("click", { bubbles: true })));
expect(onRemove).toHaveBeenCalledOnce();
act(() => root.unmount());
});
it("keeps dangerous actions folded by default", () => {
const node = renderDangerZone();

View File

@ -22,6 +22,7 @@ import { redactUrlSecrets } from "@/lib/redact-url-secrets";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff";
import { cn } from "@/lib/utils";
import { Link } from "@/lib/router";
import type { AppDetailSectionProps } from "./types";
import { RevokeGrantDialog } from "./IdentitiesSection";
@ -88,6 +89,15 @@ export function AdvancedPanel({
);
}
function connectionMethodUnavailable(connection: ToolConnection, galleryEntry: AppDefinition | null): boolean {
const methodKey = connection.config?.connectionMethodKey;
return typeof methodKey === "string"
&& methodKey.length > 0
&& !!galleryEntry
&& Array.isArray(galleryEntry.methods)
&& !getAvailableConnectionMethod(galleryEntry, methodKey);
}
function KeySection({
connection,
galleryEntry,
@ -200,15 +210,18 @@ export function ReconnectCard({
});
const oauth = connection.authKind === "oauth";
const managedByVercel = connection.credentialSource === "vercel_connect";
const methodUnavailable = connectionMethodUnavailable(connection, galleryEntry);
return (
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{oauth ? "Reconnect required" : "This app needs reconnecting"}
{methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"}
</h2>
<p className="mt-0.5 text-sm text-amber-800 dark:text-amber-200">
{connection.healthMessage?.trim() || (oauth
{methodUnavailable
? "Add a supported connection from Connectors, then remove this connection."
: connection.healthMessage?.trim() || (oauth
? "Authorization expired or was revoked. Sign in again to restore access."
: "The key stopped working. Paste a new one to get it back online.")}
</p>
@ -218,6 +231,12 @@ export function ReconnectCard({
<p className="text-sm text-amber-800 dark:text-amber-200">
{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}
</p>
) : methodUnavailable ? (
<Button size="sm" variant="outline" asChild>
<Link to={`/apps/connect?source=${encodeURIComponent(galleryEntry!.slug)}`}>
Add supported connection
</Link>
</Button>
) : onReconnect ? (
<Button size="sm" variant="outline" onClick={onReconnect}>Reconnect</Button>
) : managedByVercel && !oauth ? (
@ -449,6 +468,7 @@ export function DangerZone({
const paused = connection
? connection.enabled === false || connection.status === "disabled"
: false;
const methodUnavailable = connection ? connectionMethodUnavailable(connection, galleryEntry) : false;
return (
<Collapsible
@ -487,7 +507,7 @@ export function DangerZone({
</div>
) : null}
{connection && connection.authKind !== "oauth" ? (
{connection && !methodUnavailable && connection.authKind !== "oauth" ? (
<div className="py-4">
<KeySection
connection={connection}
@ -499,7 +519,7 @@ export function DangerZone({
</div>
) : null}
{connection?.authKind === "oauth" && (onReconnectIdentity || !canReplaceCredential) ? (
{connection?.authKind === "oauth" && !methodUnavailable && (onReconnectIdentity || !canReplaceCredential) ? (
<div className="flex flex-wrap items-center justify-between gap-3 py-4">
<div>
<p className="text-sm font-medium text-foreground">Reconnect</p>