From 13bae6fa21375ea8301ee09fd9f73ab8b9ff5eda Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Sat, 12 Sep 2026 19:26:58 -0500 Subject: [PATCH] fix: reject unsupported REST tool connections without stdio validation (#13346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/connections/CONNECTOR-PLAYBOOK.md | 8 ++ packages/shared/src/app-definitions.test.ts | 7 ++ .../shared/src/app-definitions/anthropic.json | 28 ------- scripts/ingest-app-definitions.mjs | 4 +- .../connection-intents-service.test.ts | 34 +++++++-- .../src/__tests__/tool-access-service.test.ts | 74 +++++++++++++++++++ server/src/services/tool-access.ts | 18 ++++- ui/src/pages/apps/AppDetail.test.tsx | 25 +++++++ ui/src/pages/apps/AppsConnect.test.tsx | 44 ++++++++--- .../app-detail/AdvancedPanel.render.test.tsx | 49 ++++++++++++ .../pages/apps/app-detail/AdvancedPanel.tsx | 28 ++++++- 11 files changed, 269 insertions(+), 50 deletions(-) diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 5124927b72..ca77a2600b 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -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 diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index b387e5286a..37cbc57045 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -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", () => { diff --git a/packages/shared/src/app-definitions/anthropic.json b/packages/shared/src/app-definitions/anthropic.json index 65452bf305..bd16bb3444 100644 --- a/packages/shared/src/app-definitions/anthropic.json +++ b/packages/shared/src/app-definitions/anthropic.json @@ -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" - } } ] } diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index fb34cc8d6d..6abdf4af55 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -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 ( diff --git a/server/src/__tests__/connection-intents-service.test.ts b/server/src/__tests__/connection-intents-service.test.ts index d3cb60829a..8b1a5d14e5 100644 --- a/server/src/__tests__/connection-intents-service.test.ts +++ b/server/src/__tests__/connection-intents-service.test.ts @@ -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); }); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 2b961cb3d1..48b694029f 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -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); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index fe09182a12..0192c64e6b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -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, diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index c44df98cb9..8926bdff70 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -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({ diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index d096e55a26..2818f1d0df 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -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('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"); }); diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx index 1253193821..1db1cdd0d7 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.render.test.tsx @@ -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( + , + )); + 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(); diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index ba952948a0..57fecd7c32 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -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 (

- {oauth ? "Reconnect required" : "This app needs reconnecting"} + {methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"}

- {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.")}

@@ -218,6 +231,12 @@ export function ReconnectCard({

{reconnectUnavailableMessage ?? "You don't have permission to reconnect this identity."}

+ ) : methodUnavailable ? ( + ) : onReconnect ? ( ) : 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 ( ) : null} - {connection && connection.authKind !== "oauth" ? ( + {connection && !methodUnavailable && connection.authKind !== "oauth" ? (
) : null} - {connection?.authKind === "oauth" && (onReconnectIdentity || !canReplaceCredential) ? ( + {connection?.authKind === "oauth" && !methodUnavailable && (onReconnectIdentity || !canReplaceCredential) ? (

Reconnect