From 1ab159d3a751a1b6c16212467b586e165d65709e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:55:35 -0500 Subject: [PATCH] feat(apps): consolidate connector management (#12684) Completes the post-managed-OAuth connector lifecycle, Paperclip Cloud provisioning defaults, governed test flows, and consolidated Apps UI.\n\nCo-Authored-By: Paperclip --- cli/src/__tests__/agent-jwt-env.test.ts | 13 + cli/src/__tests__/onboard.test.ts | 3 + cli/src/__tests__/worktree.test.ts | 8 + cli/src/commands/onboard.ts | 10 +- cli/src/commands/worktree.ts | 7 +- cli/src/config/env.ts | 20 + doc/MCP-RUNTIME-OPERATIONS.md | 2 +- packages/shared/src/index.ts | 1 + packages/shared/src/types/index.ts | 1 + packages/shared/src/types/tool-access.ts | 6 +- .../src/__tests__/tool-access-service.test.ts | 94 +- server/src/__tests__/worktree-config.test.ts | 2 + server/src/routes/openapi.ts | 8 + server/src/routes/tool-access.ts | 31 +- .../paperclip-cloud-connector.test.ts | 8 + .../src/services/paperclip-cloud-connector.ts | 6 +- server/src/services/tool-access.ts | 19 +- server/src/services/tool-content-guards.ts | 5 + server/src/services/tool-gateway.ts | 45 +- server/src/worktree-config.ts | 16 +- tests/e2e/app-not-connected.spec.ts | 32 +- tests/e2e/applications-crud.spec.ts | 52 +- tests/e2e/apps-dark-mode-shots.spec.ts | 24 +- tests/e2e/apps-prosumer-mcp-flow.spec.ts | 35 +- tests/e2e/connection-intents.spec.ts | 9 +- tests/e2e/mcp-user-stories.spec.ts | 2 +- tests/e2e/smoke-lab.shared.ts | 2 +- ui/src/App.test.tsx | 9 +- ui/src/App.tsx | 19 +- ui/src/api/tools.ts | 5 + .../components/AppConnectionSidebar.test.tsx | 8 +- ui/src/components/AppConnectionSidebar.tsx | 4 +- ui/src/components/AppsSidebar.test.tsx | 27 +- ui/src/components/AppsSidebar.tsx | 59 +- ui/src/components/EnforcementBanner.tsx | 9 +- ui/src/components/Layout.test.tsx | 2 +- ui/src/components/ui/radio-card.tsx | 27 +- .../connections/ConnectionSetupFlow.tsx | 277 +--- ui/src/lib/queryKeys.ts | 5 + ui/src/pages/AgentToolsTab.tsx | 1 + ui/src/pages/apps/AppDetail.test.tsx | 6 + ui/src/pages/apps/AppDetail.tsx | 11 +- ui/src/pages/apps/AppNotConnected.tsx | 6 +- ui/src/pages/apps/AppsConnect.test.tsx | 199 +-- ui/src/pages/apps/Browse.test.tsx | 640 ++++------ ui/src/pages/apps/Browse.tsx | 1127 +++++++++-------- ui/src/pages/apps/Connections.tsx | 4 +- ui/src/pages/apps/app-connect-policy.test.ts | 12 +- ui/src/pages/apps/app-connect-policy.ts | 10 +- .../pages/apps/app-detail/TestPanel.test.tsx | 46 +- ui/src/pages/apps/app-detail/TestPanel.tsx | 144 ++- ui/src/pages/apps/connection-owner.tsx | 10 +- ui/src/pages/apps/store-cards.tsx | 54 - ui/src/pages/tools/PasteConfigTab.test.tsx | 19 +- ui/src/pages/tools/PasteConfigTab.tsx | 3 +- ui/src/pages/tools/ProfilesTab.tsx | 1 + ui/src/pages/tools/RunYourOwnTab.tsx | 290 ----- ui/src/pages/tools/ToolsAccess.test.tsx | 25 +- ui/src/pages/tools/ToolsAccess.tsx | 25 +- ui/src/pages/tools/profiles/ProfileDetail.tsx | 5 +- ui/src/pages/tools/profiles/ProfileWizard.tsx | 1 + ui/src/pages/tools/profiles/ProfilesIndex.tsx | 4 +- ui/src/pages/tools/tool-tabs.ts | 8 +- .../stories/notion-connect-flow.stories.tsx | 1 + .../permitted-vs-installed.stories.tsx | 4 +- .../stories/primitives-coverage.stories.tsx | 1 + ui/storybook/stories/test-tab.stories.tsx | 24 +- 67 files changed, 1738 insertions(+), 1855 deletions(-) delete mode 100644 ui/src/pages/apps/store-cards.tsx delete mode 100644 ui/src/pages/tools/RunYourOwnTab.tsx diff --git a/cli/src/__tests__/agent-jwt-env.test.ts b/cli/src/__tests__/agent-jwt-env.test.ts index b26ae018df..fd1b47a760 100644 --- a/cli/src/__tests__/agent-jwt-env.test.ts +++ b/cli/src/__tests__/agent-jwt-env.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { ensureAgentJwtSecret, + ensureToolActionSigningSecret, mergePaperclipEnvEntries, readAgentJwtSecretFromEnv, readPaperclipEnvEntries, @@ -24,6 +25,7 @@ describe("agent jwt env helpers", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; }); afterEach(() => { @@ -42,6 +44,17 @@ describe("agent jwt env helpers", () => { expect(contents).toContain("PAPERCLIP_AGENT_JWT_SECRET="); }); + it("creates an independent tool-action signing secret next to the config", () => { + const configPath = tempConfigPath(); + const result = ensureToolActionSigningSecret(configPath); + + expect(result.created).toBe(true); + expect(result.secret).toHaveLength(64); + const entries = readPaperclipEnvEntries(resolveAgentJwtEnvFile(configPath)); + expect(entries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET).toBe(result.secret); + expect(entries.PAPERCLIP_AGENT_JWT_SECRET).toBeUndefined(); + }); + it("loads secret from .env next to explicit config path", () => { const configPath = tempConfigPath(); const envPath = resolveAgentJwtEnvFile(configPath); diff --git a/cli/src/__tests__/onboard.test.ts b/cli/src/__tests__/onboard.test.ts index 6a77d75d4a..83d9a6b092 100644 --- a/cli/src/__tests__/onboard.test.ts +++ b/cli/src/__tests__/onboard.test.ts @@ -92,6 +92,7 @@ describe("onboard", () => { beforeEach(() => { process.env = { ...ORIGINAL_ENV }; delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; delete process.env.PAPERCLIP_DB_BACKUP_DIR; @@ -248,6 +249,8 @@ describe("onboard", () => { expect(raw.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage")); expect(raw.secrets.localEncrypted.keyFilePath).toBe(path.join(instanceRoot, "secrets", "master.key")); expect(fs.existsSync(path.join(instanceRoot, ".env"))).toBe(true); + expect(fs.readFileSync(path.join(instanceRoot, ".env"), "utf8")) + .toContain("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET="); expect(fs.existsSync(path.join(instanceRoot, "secrets", "master.key"))).toBe(true); }); diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 1e5a9ee949..461ae23936 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -1471,10 +1471,12 @@ describe("worktree helpers", () => { const repoRoot = path.join(tempRoot, "repo"); const originalCwd = process.cwd(); const originalJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; + const originalToolActionSigningSecret = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; try { fs.mkdirSync(repoRoot, { recursive: true }); process.env.PAPERCLIP_AGENT_JWT_SECRET = "worktree-shared-secret"; + process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = "worktree-tool-action-secret"; process.chdir(repoRoot); await worktreeInitCommand({ @@ -1486,6 +1488,7 @@ describe("worktree helpers", () => { const envPath = path.join(repoRoot, ".paperclip", ".env"); const envContents = fs.readFileSync(envPath, "utf8"); expect(envContents).toContain("PAPERCLIP_AGENT_JWT_SECRET=worktree-shared-secret"); + expect(envContents).toContain("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET=worktree-tool-action-secret"); expect(envContents).toContain("PAPERCLIP_WORKTREE_NAME=repo"); expect(envContents).toMatch(/PAPERCLIP_WORKTREE_COLOR=\"#[0-9a-f]{6}\"/); } finally { @@ -1495,6 +1498,11 @@ describe("worktree helpers", () => { } else { process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwtSecret; } + if (originalToolActionSigningSecret === undefined) { + delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; + } else { + process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = originalToolActionSigningSecret; + } fs.rmSync(tempRoot, { recursive: true, force: true }); } }); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index a950d48b4d..61948dac52 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -28,7 +28,7 @@ import { findPaperclipConfigKeyWarnings, type PaperclipConfig, } from "../config/schema.js"; -import { ensureAgentJwtSecret, resolveAgentJwtEnvFile } from "../config/env.js"; +import { ensureAgentJwtSecret, ensureToolActionSigningSecret, resolveAgentJwtEnvFile } from "../config/env.js"; import { ensureLocalSecretsKeyFile } from "../config/secrets-key.js"; import { promptDatabase } from "../prompts/database.js"; import { promptLlm } from "../prompts/llm.js"; @@ -453,6 +453,10 @@ export async function onboard(opts: OnboardOptions): Promise { } else { p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); } + const toolActionSigningSecret = ensureToolActionSigningSecret(configPath); + if (toolActionSigningSecret.created) { + p.log.success(`Created ${pc.cyan("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET")} in ${pc.dim(envFilePath)}`); + } const keyResult = ensureLocalSecretsKeyFile(existingConfig, configPath); if (keyResult.status === "created") { @@ -689,6 +693,10 @@ export async function onboard(opts: OnboardOptions): Promise { } else { p.log.info(`Using existing ${pc.cyan("PAPERCLIP_AGENT_JWT_SECRET")} in ${pc.dim(envFilePath)}`); } + const toolActionSigningSecret = ensureToolActionSigningSecret(configPath); + if (toolActionSigningSecret.created) { + p.log.success(`Created ${pc.cyan("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET")} in ${pc.dim(envFilePath)}`); + } const config: PaperclipConfig = { $meta: { diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 6b7c76ed70..e4e87cc14d 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -67,7 +67,7 @@ import { prepareEmbeddedPostgresNativeRuntime, } from "@paperclipai/db"; import type { Command } from "commander"; -import { ensureAgentJwtSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; +import { ensureAgentJwtSecret, ensureToolActionSigningSecret, loadPaperclipEnvFile, mergePaperclipEnvEntries, readPaperclipEnvEntries, resolvePaperclipEnvFile } from "../config/env.js"; import { expandHomePrefix } from "../config/home.js"; import type { PaperclipConfig } from "../config/schema.js"; import { readConfig, resolveConfigPath, writeConfig } from "../config/store.js"; @@ -2530,14 +2530,19 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise { const existingAgentJwtSecret = nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ?? nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET); + const existingToolActionSigningSecret = + nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ?? + nonEmpty(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET); mergePaperclipEnvEntries( { ...buildWorktreeEnvEntries(paths, branding), ...(existingAgentJwtSecret ? { PAPERCLIP_AGENT_JWT_SECRET: existingAgentJwtSecret } : {}), + ...(existingToolActionSigningSecret ? { PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: existingToolActionSigningSecret } : {}), }, paths.envPath, ); ensureAgentJwtSecret(paths.configPath); + ensureToolActionSigningSecret(paths.configPath); loadPaperclipEnvFile(paths.configPath); const copiedGitHooks = copyGitHooksToWorktreeGitDir(cwd); diff --git a/cli/src/config/env.ts b/cli/src/config/env.ts index 6787ee190a..dd47364a02 100644 --- a/cli/src/config/env.ts +++ b/cli/src/config/env.ts @@ -6,6 +6,7 @@ import { updateEnvFileContents, writeEnvFileAtomicallyIfChanged } from "@papercl import { resolveConfigPath } from "./store.js"; const JWT_SECRET_ENV_KEY = "PAPERCLIP_AGENT_JWT_SECRET"; +const TOOL_ACTION_SIGNING_SECRET_ENV_KEY = "PAPERCLIP_TOOL_ACTION_SIGNING_SECRET"; const PAPERCLIP_OWNED_ENV_KEY_PATTERN = /^PAPERCLIP_[A-Z0-9_]+$/; function resolveEnvFilePath(configPath?: string) { return path.resolve(path.dirname(resolveConfigPath(configPath)), ".env"); @@ -93,6 +94,25 @@ export function ensureAgentJwtSecret(configPath?: string): { secret: string; cre return { secret, created }; } +export function ensureToolActionSigningSecret(configPath?: string): { secret: string; created: boolean } { + loadAgentJwtEnvFile(resolveEnvFilePath(configPath)); + const existingEnv = process.env[TOOL_ACTION_SIGNING_SECRET_ENV_KEY]; + if (isNonEmpty(existingEnv)) { + return { secret: existingEnv.trim(), created: false }; + } + + const envFilePath = resolveEnvFilePath(configPath); + const existingFile = readPaperclipEnvEntries(envFilePath)[TOOL_ACTION_SIGNING_SECRET_ENV_KEY]; + const secret = isNonEmpty(existingFile) ? existingFile.trim() : randomBytes(32).toString("hex"); + const created = !isNonEmpty(existingFile); + + if (created) { + mergePaperclipEnvEntries({ [TOOL_ACTION_SIGNING_SECRET_ENV_KEY]: secret }, envFilePath); + } + + return { secret, created }; +} + export function writeAgentJwtEnv(secret: string, filePath = resolveEnvFilePath()): void { mergePaperclipEnvEntries({ [JWT_SECRET_ENV_KEY]: secret }, filePath); } diff --git a/doc/MCP-RUNTIME-OPERATIONS.md b/doc/MCP-RUNTIME-OPERATIONS.md index d3a9f32235..cdeb247487 100644 --- a/doc/MCP-RUNTIME-OPERATIONS.md +++ b/doc/MCP-RUNTIME-OPERATIONS.md @@ -4,7 +4,7 @@ This runbook covers Paperclip Tools & Access runtime slots for MCP connections. Do not print raw bearer tokens, gateway session tokens, credential headers, environment variables, or secret values while following this runbook. The APIs below return redacted state and audit metadata; keep shell tracing disabled when exporting credentials. -Tool action approvals require `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to be set independently from auth/JWT secrets. Rotate it deliberately: changing it invalidates outstanding signed tool-action approvals, so drain or reject pending approvals before rotation. +Tool action approvals require `PAPERCLIP_TOOL_ACTION_SIGNING_SECRET` to be set independently from auth/JWT secrets. `paperclipai onboard` generates it for local instances, and worktree setup propagates or generates an independent value in the worktree `.env`; operator-managed deployments must set it explicitly. Rotate it deliberately: changing it invalidates outstanding signed tool-action approvals, so drain or reject pending approvals before rotation. ## Support Matrix diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1223ef0fe5..0234d42ac9 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1435,6 +1435,7 @@ export type { ToolConnectionTestToolAccess, ToolConnectionAccessSummary, ToolConnectionTestAgent, + ToolConnectionTestAgentAccessResponse, ToolConnectionTestAgentsResponse, ToolConnectionTestCallResult, ToolConnectionTestCallStatus, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 3456eaba3e..36b9fcd9e0 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -603,6 +603,7 @@ export type { ToolConnectionTestToolAccess, ToolConnectionAccessSummary, ToolConnectionTestAgent, + ToolConnectionTestAgentAccessResponse, ToolConnectionTestAgentsResponse, ToolConnectionTestCallResult, ToolConnectionTestCallStatus, diff --git a/packages/shared/src/types/tool-access.ts b/packages/shared/src/types/tool-access.ts index 44dc9f6ee3..bca22a7248 100644 --- a/packages/shared/src/types/tool-access.ts +++ b/packages/shared/src/types/tool-access.ts @@ -1573,13 +1573,17 @@ export interface ToolConnectionTestAgent { status: string; /** Zero-based depth in the company reporting tree; roots are highest-ranked. */ orgDepth: number; - effectiveAccess: ToolConnectionAccessSummary; } export interface ToolConnectionTestAgentsResponse { agents: ToolConnectionTestAgent[]; } +/** Display summary for one selected Test-tab agent. */ +export interface ToolConnectionTestAgentAccessResponse { + access: ToolConnectionAccessSummary; +} + /** Result of `POST /tool-connections/:id/test-calls`. */ export interface ToolConnectionTestCallResult { decision: ToolConnectionTestDecision; diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 2c9050a219..809cdfb652 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2310,7 +2310,7 @@ describeEmbeddedPostgres("tool access service", () => { }); }); - it("lists testable agents with per-connection effective access summaries", async () => { + it("lists testable agents without calculating every agent's access summary", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; await grantBoardUser(db, company.id, userId, ["tools:use"]); @@ -2335,13 +2335,18 @@ describeEmbeddedPostgres("tool access service", () => { expect(res.body.agents[0]).toMatchObject({ id: agent.id, orgDepth: 0, - effectiveAccess: { - connectionId: connection.id, - toolCount: 1, - allowedCount: 1, - askFirstCount: 0, - offCount: 0, - }, + }); + expect(res.body.agents[0]).not.toHaveProperty("effectiveAccess"); + + const accessRes = await request(app) + .get(`/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`) + .expect(200); + expect(accessRes.body.access).toMatchObject({ + connectionId: connection.id, + toolCount: 1, + allowedCount: 1, + askFirstCount: 0, + offCount: 0, }); }); @@ -2412,10 +2417,10 @@ describeEmbeddedPostgres("tool access service", () => { const app = createRouteApp(db, actor, createToolGatewayService(db, { toolActionSigningSecret: "test-secret" })); const res = await request(app) - .get(`/api/tool-connections/${connection.id}/test-agents`) + .get(`/api/tool-connections/${connection.id}/test-agents/${agent.id}/access`) .expect(200); - const summary = res.body.agents[0].effectiveAccess; + const summary = res.body.access; expect(typeof summary.lastChangedAt).toBe("string"); expect(summary.lastChangedByAgentId).toBe(agent.id); expect(summary.lastChangedByName).toBe(agent.name); @@ -2523,6 +2528,44 @@ describeEmbeddedPostgres("tool access service", () => { ])); }); + it("cancels an ask-first test request when approval signing is unavailable", async () => { + vi.stubEnv("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET", ""); + const company = await createCompany(db); + const userId = `tool-tester-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, ["tools:use"]); + const agent = await createAgent(db, company.id); + const { connection } = await createRemoteToolFixture(db, company.id); + await db.insert(toolPolicies).values({ + companyId: company.id, + name: `Ask first without signing ${randomUUID()}`, + policyType: "require_approval", + priority: 100, + selectors: { connectionId: connection.id }, + }); + const app = createRouteApp( + db, + boardSessionActor(company.id, "operator", userId), + createToolGatewayService(db, { toolActionSigningSecret: " " }), + ); + + const res = await request(app) + .post(`/api/tool-connections/${connection.id}/test-calls`) + .send({ agentId: agent.id, toolName: "send_email", parameters: { to: "a@example.com" } }) + .expect(500); + + expect(res.body).toMatchObject({ + reasonCode: "signing_secret_unconfigured", + error: expect.stringContaining("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET"), + }); + const [actionRequest] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, company.id)); + expect(actionRequest).toMatchObject({ status: "cancelled", signedArguments: null }); + const [invocation] = await db.select().from(toolInvocations).where(eq(toolInvocations.companyId, company.id)); + expect(invocation).toMatchObject({ + status: "failed", + errorCode: "signing_secret_unconfigured", + }); + }); + it("audits ask-first test calls with the real board actor and selected agent", async () => { const company = await createCompany(db); const userId = `tool-tester-${randomUUID()}`; @@ -3418,6 +3461,7 @@ describeEmbeddedPostgres("tool access service", () => { .put(`/api/tool-connections/${connection.id}/installs`) .send({ installs: [] }), await request(app).get(`/api/tool-connections/${connection.id}/test-agents`), + await request(app).get(`/api/tool-connections/${connection.id}/test-agents/${randomUUID()}/access`), await request(app) .post(`/api/tool-connections/${connection.id}/test-calls`) .send({ agentId: randomUUID(), toolName: "read_notes", parameters: {} }), @@ -3620,7 +3664,7 @@ describeEmbeddedPostgres("tool access service", () => { it("serves the app gallery manifest through the board route", async () => { const company = await createCompany(db); - const app = createRouteApp(db); + const app = createRouteApp(db, undefined, undefined, { paperclipCloudConnector: null }); const res = await request(app).get(`/api/companies/${company.id}/tools/gallery`); @@ -8097,6 +8141,12 @@ describeEmbeddedPostgres("tool access service", () => { status: "pending", canonicalArgumentsHash: "args-hash", canonicalArgumentsSummary: { summary: "redacted", redactedFields: [] }, + signedArguments: signToolArguments({ + invocationId: invocation.id, + toolName: invocation.toolName, + canonicalArguments: canonicalToolArguments({ redacted: true }), + signingSecret: "attention-test-secret", + }), }); const res = await request(app).get(`/api/companies/${company.id}/tools/apps/attention`); @@ -8152,7 +8202,7 @@ describeEmbeddedPostgres("tool access service", () => { schemaHash: "s1", }).returning(); const canonicalArguments = canonicalToolArguments({ key: "alpha", value: "one" }); - const invocationValues = [1, 2, 3].map(() => ({ + const invocationValues = [1, 2, 3, 4].map(() => ({ companyId: company.id, applicationId: application.id, connectionId: connection.id, @@ -8164,7 +8214,7 @@ describeEmbeddedPostgres("tool access service", () => { approvalState: "pending" as const, status: "awaiting_approval" as const, })); - const [validInvocation, missingSignatureInvocation, oldSecretInvocation] = + const [validInvocation, missingSignatureInvocation, staleMissingSignatureInvocation, oldSecretInvocation] = await db.insert(toolInvocations).values(invocationValues).returning(); const validSignedArguments = signToolArguments({ invocationId: validInvocation.id, @@ -8178,7 +8228,7 @@ describeEmbeddedPostgres("tool access service", () => { canonicalArguments, signingSecret: "old-secret", }); - const [validRequest, missingSignatureRequest, oldSecretRequest] = await db.insert(toolActionRequests).values([ + const [validRequest, missingSignatureRequest, staleMissingSignatureRequest, oldSecretRequest] = await db.insert(toolActionRequests).values([ { companyId: company.id, invocationId: validInvocation.id, @@ -8195,6 +8245,19 @@ describeEmbeddedPostgres("tool access service", () => { canonicalArgumentsSummary: { summary: canonicalArguments, sha256: "args-hash", sizeBytes: canonicalArguments.length }, signedArguments: null, }, + { + companyId: company.id, + invocationId: staleMissingSignatureInvocation.id, + status: "pending", + canonicalArgumentsHash: "args-hash", + canonicalArgumentsSummary: { + summary: canonicalArguments, + sha256: "args-hash", + sizeBytes: canonicalArguments.length, + }, + signedArguments: null, + createdAt: new Date(Date.now() - 3 * 60 * 1000), + }, { companyId: company.id, invocationId: oldSecretInvocation.id, @@ -8214,6 +8277,9 @@ describeEmbeddedPostgres("tool access service", () => { // An unsigned request is still being created; the read hides it but keeps it // pending, so the creator can finish signing and the later approve succeeds. expect(statusById.get(missingSignatureRequest.id)).toBe("pending"); + // If the creator never finishes signing, Review retires the stale orphan + // instead of leaving a permanent badge for a request no human can approve. + expect(statusById.get(staleMissingSignatureRequest.id)).toBe("cancelled"); // A request signed with a rotated/old secret is unverifiable and is cancelled. expect(statusById.get(oldSecretRequest.id)).toBe("cancelled"); }); diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index 56e3063af3..39ac47576d 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -171,10 +171,12 @@ describe("worktree config repair", () => { expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`); expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"'); expect(repairedEnv).toContain("PAPERCLIP_AGENT_JWT_SECRET=shared-secret"); + expect(repairedEnv).toContain("PAPERCLIP_TOOL_ACTION_SIGNING_SECRET="); expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); expect(process.env.PORT).toBe("3101"); expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component"); expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false"); + expect(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET).toHaveLength(64); }); it("disables backups in an otherwise isolated existing worktree config", async () => { diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 7f7b1ff036..547f0d225d 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -950,6 +950,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "GET /api/tool-connections/{connectionId}/catalog", "GET /api/tool-connections/{connectionId}/activity", "GET /api/tool-connections/{connectionId}/test-agents", + "GET /api/tool-connections/{connectionId}/test-agents/{agentId}/access", "POST /api/tool-connections/{connectionId}/test-calls", "GET /api/tool-connections/{connectionId}/test-calls/{actionRequestId}", "POST /api/agents/me/connections/{connectionId}/start-authorization", @@ -7681,6 +7682,13 @@ registerCurrentRoute({ summary: "List agents available for tool connection test calls", }); +registerCurrentRoute({ + method: "get", + path: "/api/tool-connections/{connectionId}/test-agents/{agentId}/access", + tags: ["tool-access"], + summary: "Summarize one agent's effective access to a tool connection", +}); + registerCurrentRoute({ method: "post", path: "/api/tool-connections/{connectionId}/test-calls", diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 6f20e9a07f..22dc5d0a5f 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1485,7 +1485,12 @@ function connectorEnrollmentPrincipal(req: Request): string { router.get("/tool-connections/:connectionId", async (req, res) => { assertBoard(req); - const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + const connection = await getAccessibleResource( + req, + res, + svc.getConnection(req.params.connectionId as string), + "Tool connection not found", + ); if (!connection) return; res.json(connection); }); @@ -1831,17 +1836,31 @@ function connectorEnrollmentPrincipal(req: Request): string { title: agent.title, status: agent.status, orgDepth: orgDepthByAgentId.get(agent.id) ?? 0, - effectiveAccess: await options.toolGateway.summarizeConnectionAccessForAgent({ - companyId: connection.companyId, - connectionId: connection.id, - agentId: agent.id, - }), }); } candidates.sort((a, b) => a.orgDepth - b.orgDepth || a.name.localeCompare(b.name)); res.json({ agents: candidates }); }); + router.get("/tool-connections/:connectionId/test-agents/:agentId/access", async (req, res) => { + assertBoard(req); + if (!options.toolGateway) { + res.status(501).json({ error: "Tool gateway service is not configured" }); + return; + } + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; + await assertBoardAnyToolPermission(req, connection.companyId, ["tools:use", "tools:manage_connections"]); + const agentId = req.params.agentId as string; + await assertCanTestAsAgent(req, connection.companyId, agentId); + const accessSummary = await options.toolGateway.summarizeConnectionAccessForAgent({ + companyId: connection.companyId, + connectionId: connection.id, + agentId, + }); + res.json({ access: accessSummary }); + }); + router.post("/tool-connections/:connectionId/test-calls", validate(toolConnectionTestCallSchema), async (req, res) => { assertBoard(req); if (!options.toolGateway) { diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index d75be2d577..6fc923e5f0 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -12,6 +12,7 @@ import { createPaperclipCloudConnector, GMAIL_CONNECTOR_SCOPES, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, + paperclipCloudConnectorCapabilitiesFromEnv, paperclipCloudConnectorConfigFromEnv, PaperclipCloudConnectorError, type PaperclipCloudConnectorConfig, @@ -271,6 +272,13 @@ describe("Paperclip Cloud connector", () => { expect(legacyError).toMatchObject({ code: "CONNECTOR_MIGRATION_REQUIRED" }); expect(String(legacyError)).toContain("incompatible legacy protocol"); }); + + it("keeps gallery capability discovery available during incomplete enrollment", async () => { + await expect(paperclipCloudConnectorCapabilitiesFromEnv({ + PAPERCLIP_CLOUD_CONNECTOR_BASE_URL: "https://my-staging.paperclip.app", + PAPERCLIP_CLOUD_CONNECTOR_ENVIRONMENT: "staging", + })).resolves.toEqual([]); + }); }); function seal( diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index b2df0185ad..f25f6f6751 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -357,7 +357,11 @@ export async function paperclipCloudConnectorCapabilitiesFromEnv( try { config = paperclipCloudConnectorConfigFromEnv(env); } catch (error) { - if (error instanceof PaperclipCloudConnectorError && error.code === "CONNECTOR_MIGRATION_REQUIRED") return []; + // Gallery discovery is useful even while connector enrollment is pending or + // local connector settings are incomplete. Treat every connector-config + // error as "no managed profiles" here; enrollment/status surfaces still + // report the actionable configuration problem. + if (error instanceof PaperclipCloudConnectorError) return []; throw error; } if (!config) return []; diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index e94facb478..a21eee694a 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -1,6 +1,6 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; -import { and, asc, desc, eq, gte, inArray, isNull, lt, max, ne, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, max, ne, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -157,7 +157,7 @@ import { } from "./remote-url-credentials.js"; import { secretService } from "./secrets.js"; import { toolAccessPolicyService } from "./tool-access-policy.js"; -import { readSignedToolArgumentsPayload } from "./tool-content-guards.js"; +import { readSignedToolArgumentsPayload, TOOL_ACTION_REQUEST_SIGNING_GRACE_MS } from "./tool-content-guards.js"; import { effectiveToolProfileBindings, narrowestScopeBindings, @@ -5575,7 +5575,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} db .select() .from(toolActionRequests) - .where(and(eq(toolActionRequests.companyId, companyId), eq(toolActionRequests.status, "pending"))), + .where(and( + eq(toolActionRequests.companyId, companyId), + eq(toolActionRequests.status, "pending"), + isNotNull(toolActionRequests.signedArguments), + )), db .select() .from(toolInvocations) @@ -12434,7 +12438,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} // that window. Hide such a request from the queue, but do not cancel it — // cancelling here races the two-step create and makes the later approve // fail with action_not_pending. Only cancel a request that carries a - // signature we cannot verify (secret rotation or tampering). + // signature we cannot verify (secret rotation or tampering), or an + // unsigned row whose creator has exceeded the signing grace period. const unsignedRequestIds = new Set(); const invalidRequestIds: string[] = []; for (const request of requests) { @@ -12444,7 +12449,11 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} continue; } if (request.signedArguments === null) { - unsignedRequestIds.add(request.id); + if (Date.now() - request.createdAt.getTime() >= TOOL_ACTION_REQUEST_SIGNING_GRACE_MS) { + invalidRequestIds.push(request.id); + } else { + unsignedRequestIds.add(request.id); + } continue; } let readable = false; diff --git a/server/src/services/tool-content-guards.ts b/server/src/services/tool-content-guards.ts index 317ee4d9d1..73ca2899f6 100644 --- a/server/src/services/tool-content-guards.ts +++ b/server/src/services/tool-content-guards.ts @@ -54,6 +54,11 @@ export class ToolActionSigningSecretMissingError extends Error { } } +// Creating an approval is a two-step insert/sign operation. Readers must allow +// a short window for the creator to attach the signature before treating a null +// signature as an abandoned, unapprovable request. +export const TOOL_ACTION_REQUEST_SIGNING_GRACE_MS = 2 * 60 * 1000; + export function resolveToolActionSigningSecret(env: ToolActionSigningSecretEnv = process.env as ToolActionSigningSecretEnv) { const secret = env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET?.trim(); if (!secret) { diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 2a68992819..92ce8e3465 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -109,6 +109,7 @@ import { readSignedToolArgumentsPayload, signToolArguments, summarizeToolValue, + TOOL_ACTION_REQUEST_SIGNING_GRACE_MS, ToolActionSigningSecretMissingError, ToolContentValidationError, validateToolContent, @@ -162,7 +163,6 @@ const ACTION_REQUEST_EXECUTION_WAIT_MS = APPROVED_EXECUTION_TIMEOUT_MS + 5_000; // treat an unsigned row as abandoned after this grace time from createdAt. This // grace must exceed the normal sign path (approval-snapshot fetch + interaction // create) so a live create keeps its own row. -const UNSIGNED_ACTION_REQUEST_ABANDON_MS = 2 * 60 * 1000; const MAX_REMOTE_MCP_RESPONSE_BYTES = 1_000_000; const ACTIVE_GATEWAY_RUN_STATUSES = new Set(["running"]); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -5692,7 +5692,7 @@ export function createToolGatewayService( const pendingUnsigned = pendingRequest.status === "pending" && pendingRequest.signedArguments === null - && Date.now() - pendingRequest.createdAt.getTime() >= UNSIGNED_ACTION_REQUEST_ABANDON_MS; + && Date.now() - pendingRequest.createdAt.getTime() >= TOOL_ACTION_REQUEST_SIGNING_GRACE_MS; const pendingExpired = pendingRequest.status === "pending" && pendingRequest.expiresAt !== null @@ -6365,14 +6365,39 @@ export function createToolGatewayService( const approvalSnapshot = await connectedRemoteApprovalSnapshot(session, tool, { requireResolvedCredentials: true, }); - const signedArguments = signToolArguments({ - invocationId, - toolName: tool.name, - canonicalArguments, - approvalSnapshot: approvalSnapshot ?? undefined, - executionOnApprove: true, - signingSecret: options.toolActionSigningSecret, - }); + let signedArguments: ReturnType; + try { + signedArguments = signToolArguments({ + invocationId, + toolName: tool.name, + canonicalArguments, + approvalSnapshot: approvalSnapshot ?? undefined, + executionOnApprove: true, + signingSecret: options.toolActionSigningSecret, + }); + } catch (error) { + await db + .update(toolActionRequests) + .set({ status: "cancelled", resolvedAt: new Date(), updatedAt: new Date() }) + .where(and(eq(toolActionRequests.id, recorded.actionRequest.id), eq(toolActionRequests.status, "pending"))); + if (error instanceof ToolActionSigningSecretMissingError) { + await db + .update(toolInvocations) + .set({ + status: "failed", + errorCode: "signing_secret_unconfigured", + errorMessage: error.message, + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(toolInvocations.id, invocationId)); + throw new ToolGatewayHttpError(500, error.message, "signing_secret_unconfigured", { + invocationId, + tool: tool.name, + }); + } + throw error; + } const previewMarkdown = buildHumanizedActionPreview({ tool, argumentsSummary: argumentValidation.summary }); await db .update(toolActionRequests) diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index be97f403c2..bd8d5d129d 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { randomBytes } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { mergePaperclipConfig, @@ -530,6 +531,15 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { } } + const existingContents = fs.existsSync(context.envPath) + ? fs.readFileSync(context.envPath, "utf8") + : null; + const existingEnvEntries = parseEnvFile(existingContents ?? ""); + const toolActionSigningSecret = + nonEmpty(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ?? + nonEmpty(existingEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ?? + randomBytes(32).toString("hex"); + const managedEnvEntries: Record = { PAPERCLIP_HOME: context.homeDir, PAPERCLIP_INSTANCE_ID: context.instanceId, @@ -538,13 +548,11 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { PAPERCLIP_IN_WORKTREE: "true", PAPERCLIP_DB_BACKUP_ENABLED: "false", PAPERCLIP_WORKTREE_NAME: context.worktreeName, + PAPERCLIP_TOOL_ACTION_SIGNING_SECRET: toolActionSigningSecret, }; process.env.PAPERCLIP_DB_BACKUP_ENABLED = "false"; - - const existingContents = fs.existsSync(context.envPath) - ? fs.readFileSync(context.envPath, "utf8") - : null; + process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = toolActionSigningSecret; const repairedContents = updateEnvFileContents( existingContents ?? emptyWorktreeEnvFileContents(), managedEnvEntries, diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts index 60328294c5..4b448eab64 100644 --- a/tests/e2e/app-not-connected.spec.ts +++ b/tests/e2e/app-not-connected.spec.ts @@ -86,7 +86,7 @@ test.describe.serial("not-connected app page", () => { applicationId = body.application.id as string; // Archive the connection (Remove app), then resurrect the application so - // it shows on /apps/connections as "Not connected" — the state in Dotta's screenshot. + // its connector card offers a fresh Connect action. const archive = await request.delete(`/api/tool-connections/${connectionId}`); expect(archive.ok(), `archive failed ${archive.status()}: ${await archive.text()}`).toBe(true); const revive = await request.patch(`/api/tool-applications/${applicationId}`, { @@ -101,12 +101,16 @@ test.describe.serial("not-connected app page", () => { test("not-connected row opens the app page, not the generic wizard", async ({ page }) => { await page.goto(`/${seed.prefix}/apps/connections`); - const row = page.locator("tbody tr", { hasText: "Bla" }); + const row = page + .getByRole("list", { name: "Connector list" }) + .getByRole("listitem") + .filter({ has: page.getByRole("heading", { name: "Bla", exact: true }) }); await expect(row).toBeVisible({ timeout: 30_000 }); - await expect(row.getByText("Not connected")).toBeVisible(); - await expect(row.getByRole("button", { name: "Connect" })).toBeVisible(); + await expect(row).toHaveAttribute("data-connected", "false"); + const connectButton = row.getByRole("button", { name: "Connect Bla" }); + await expect(connectButton).toBeVisible(); - await row.click(); + await connectButton.click(); await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/app/${applicationId}/setup$`), { timeout: 20_000 }); await expect(page.getByRole("heading", { name: "Bla" })).toBeVisible({ timeout: 20_000 }); await expect(page.getByRole("heading", { name: "Previous setup" })).toBeVisible(); @@ -160,9 +164,12 @@ test.describe.serial("not-connected app page", () => { await expect(page.getByRole("heading", { name: "Connect this app" })).toBeVisible(); await page.goto(`/${seed.prefix}/apps/connections`); - const row = page.locator("tbody tr", { hasText: "Bla" }); + const row = page + .getByRole("list", { name: "Connector list" }) + .getByRole("listitem") + .filter({ has: page.getByRole("heading", { name: "Bla", exact: true }) }); await expect(row).toBeVisible({ timeout: 30_000 }); - await expect(row.getByRole("button", { name: "Connect" })).toBeVisible(); + await expect(row.getByRole("button", { name: "Connect Bla" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-03-reconnected-row.png`, fullPage: true }); }); @@ -186,9 +193,14 @@ test.describe.serial("not-connected app page", () => { await page.getByRole("button", { name: "Remove app", exact: true }).click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-04-app-page-danger.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); - await expect(page.locator("tbody tr", { hasText: "Doomed app" })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await expect( + page + .getByRole("list", { name: "Connector list" }) + .getByRole("listitem") + .filter({ has: page.getByRole("heading", { name: "Doomed app", exact: true }) }), + ).toHaveCount(0); }); }); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index 75a729a757..c08fdad975 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -58,7 +58,7 @@ async function createConnection( async function gotoApps(page: Page, prefix: string) { await page.goto(`/${prefix}/apps/connections`); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 }); } test.describe.serial("applications lifecycle", () => { @@ -84,36 +84,42 @@ test.describe.serial("applications lifecycle", () => { await gotoApps(page, seed.prefix); - // The connected app starts with a "Healthy" pill and an "Edit" action. A + // The connected app starts with a "Connected" status. A // background health sweep then probes the connection endpoint. The test // endpoint is an unreachable loopback URL, so the probe fails and the pill - // becomes "Needs attention" and the action becomes "Reconnect". Both are + // becomes "Needs attention" and adds a "Reconnect" action. Both are // connected states that navigate to the same provider setup page. This test // proves the connected-vs-not-connected split, not the transient health // label, so accept either connected state instead of the racy exact label. // The pill is derived from two react-query fetches (applications + // connections), so keep the same generous window the rest of this spec uses. - const connectedRow = page.locator("tbody tr", { hasText: connectedName }); + const connectorList = page.getByRole("list", { name: "Connector list" }); + const connectedRow = connectorList + .getByRole("listitem") + .filter({ has: page.getByRole("heading", { name: connectedName, exact: true }) }); await expect(connectedRow).toBeVisible(); - await expect(connectedRow.getByText(/^(Healthy|Needs attention)$/)).toBeVisible({ timeout: 30_000 }); - await expect(connectedRow.getByRole("button", { name: /^(Edit|Reconnect)$/ })).toBeVisible(); + await expect(connectedRow.getByText(/^(Connected|Needs attention)$/)).toBeVisible({ timeout: 30_000 }); + const openConnection = connectedRow.getByRole("button", { name: /^Open .* connection settings$/ }); + await expect(openConnection).toBeVisible(); // The not-connected app has no connection, so the health sweep never touches - // it and its "Not connected" pill and "Connect" action stay deterministic. - const notConnectedRow = page.locator("tbody tr", { hasText: notConnectedName }); + // it and its Connect action stay deterministic. + const notConnectedRow = connectorList + .getByRole("listitem") + .filter({ has: page.getByRole("heading", { name: notConnectedName, exact: true }) }); await expect(notConnectedRow).toBeVisible(); - await expect(notConnectedRow.getByText("Not connected")).toBeVisible({ timeout: 30_000 }); - await expect(notConnectedRow.getByRole("button", { name: "Connect" })).toBeVisible(); + await expect(notConnectedRow).toHaveAttribute("data-connected", "false"); + await expect(notConnectedRow.getByRole("button", { name: `Connect ${notConnectedName}` })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-list.png`, fullPage: true }); - await connectedRow.getByRole("button", { name: /^(Edit|Reconnect)$/ }).click(); + await openConnection.click(); await expect(page).toHaveURL( new RegExp(`/${seed.prefix}/apps/${connected.id}/setup$`), { timeout: 20_000 }, ); await gotoApps(page, seed.prefix); - await notConnectedRow.getByRole("button", { name: "Connect" }).click(); + await notConnectedRow.getByRole("button", { name: `Connect ${notConnectedName}` }).click(); await expect(page).toHaveURL( new RegExp(`/${seed.prefix}/apps/app/${notConnected.id}/setup$`), { timeout: 20_000 }, @@ -152,10 +158,15 @@ test.describe.serial("applications lifecycle", () => { await expect(page.getByRole("button", { name: "Yes, remove it" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-connected.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); - await expect(page.locator("tbody tr", { hasText: renamed })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await expect( + page + .getByRole("list", { name: "Connector list" }) + .getByRole("listitem") + .filter({ has: page.getByRole("heading", { name: renamed, exact: true }) }), + ).toHaveCount(0); }); test("not-connected app advanced page removes the application", async ({ page, request }) => { @@ -168,9 +179,14 @@ test.describe.serial("applications lifecycle", () => { await page.getByRole("button", { name: "Remove app", exact: true }).click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/applications-crud-current-remove-not-connected.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); - await expect(page.locator("tbody tr", { hasText: cleanAppName })).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await expect( + page + .getByRole("list", { name: "Connector list" }) + .getByRole("listitem") + .filter({ has: page.getByRole("heading", { name: cleanAppName, exact: true }) }), + ).toHaveCount(0); }); }); diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index c7afab7f1f..c98d055592 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -128,26 +128,25 @@ test.describe.serial("dark-mode Apps surfaces", () => { test("apps list dark mode with attention banner", async ({ page }) => { await forceDark(page); await page.goto(`/${seed.prefix}/apps/connections`); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText(/needs attention/i).first()).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-01-apps-dark.png`, fullPage: true }); }); - test("attention banner dark mode", async ({ page }) => { + test("attention details dark mode", async ({ page }) => { await forceDark(page); await page.goto(`/${seed.prefix}/apps/connections`); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText(/connection needs attention/i).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/connect ECONNREFUSED/i).first()).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-02-attention-dark.png`, fullPage: true }); }); - test("advanced door defaults to Run your own with the merged Apps sidebar", async ({ page }) => { + test("advanced door shows paste config with the merged Apps sidebar", async ({ page }) => { await forceDark(page); await page.goto(`/${seed.prefix}/apps/advanced`); await expect(page.getByRole("heading", { name: "Advanced setup" })).toBeVisible({ timeout: 30_000 }); - // Run your own is now the default tab (Apps navigation); merged sidebar shows Apps items too. - await expect(page.getByText(/isolated workspace/i).first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("link", { name: "Connections" })).toBeVisible(); + await expect(page.getByText(/Paste the MCP config snippet/i).first()).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("link", { name: "Connectors" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-03-advanced-run-dark.png`, fullPage: true }); // Sidebar and tab switcher both link Paste a config — either lands on /paste-config. @@ -163,10 +162,11 @@ test.describe.serial("dark-mode Apps surfaces", () => { await expect(page.getByRole("heading", { name: "Access profiles" })).toBeVisible({ timeout: 30_000 }); await expect(page.locator('a[href$="/apps/advanced/gateways"]', { hasText: "Gateways" })).toHaveCount(0); await expect(page.locator('a[href$="/apps/advanced/profiles"]', { hasText: "Profiles" })).toHaveCount(0); - await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toBeVisible(); + await expect(page.locator('a[href$="/apps/advanced/audit"]', { hasText: "Activity" })).toHaveCount(0); + await expect(page.locator('a[href$="/activity"]', { hasText: "Activity" })).toBeVisible(); await expect(page.getByRole("link", { name: "Applications", exact: true })).toHaveCount(0); // Apps section lives in the same sidebar now. - await expect(page.locator('a[href$="/apps/connections"]', { hasText: "Connections" })).toBeVisible(); + await expect(page.locator('a[href$="/apps"]', { hasText: "Connectors" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-05-developer-overview-dark.png`, fullPage: true }); }); @@ -184,9 +184,9 @@ test.describe.serial("dark-mode Apps surfaces", () => { await page.getByRole("button", { name: "Remove app", exact: true }).click(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-06-danger-zone-dark.png`, fullPage: true }); await page.getByRole("button", { name: "Yes, remove it" }).click(); - await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps/connections$`), { timeout: 20_000 }); + await expect(page).toHaveURL(new RegExp(`/${seed.prefix}/apps$`), { timeout: 20_000 }); await expect(page.getByText("App removed").first()).toBeVisible({ timeout: 20_000 }); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-07-after-remove-dark.png`, fullPage: true }); }); }); diff --git a/tests/e2e/apps-prosumer-mcp-flow.spec.ts b/tests/e2e/apps-prosumer-mcp-flow.spec.ts index ea8335da80..f8a6a9c7b0 100644 --- a/tests/e2e/apps-prosumer-mcp-flow.spec.ts +++ b/tests/e2e/apps-prosumer-mcp-flow.spec.ts @@ -117,8 +117,13 @@ async function gotoApps(page: Page, prefix: string) { async function gotoConnect(page: Page, prefix: string) { await page.goto(`/${prefix}/apps`); - await expect(page.getByRole("heading", { name: "Browse" })).toBeVisible({ timeout: 30_000 }); - await page.getByRole("button", { name: /Connect your own tool/i }).click(); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 }); + const customConnector = page + .getByRole("list", { name: "Connector list" }) + .getByRole("listitem") + .filter({ hasText: "Connect your own tool" }); + await customConnector.getByRole("button", { name: "Connect", exact: true }).click(); + await customConnector.getByRole("button", { name: "Connect your own MCP server" }).click(); } async function gotoAdvanced(page: Page, prefix: string) { @@ -177,7 +182,7 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { // The new connection should show up on /apps/connections. await gotoApps(page, seed.prefix); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 15_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-apps-list.png`, fullPage: true }); }); @@ -215,8 +220,8 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { // Needs-attention page should surface this connection. await gotoNeedsAttention(page, seed.prefix); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); - await expect(page.getByText(/connection needs attention/i).first()).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText("Needs attention", { exact: true }).first()).toBeVisible({ timeout: 30_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-07-needs-attention.png`, fullPage: true }); // App detail should expose the reconnect call-to-action. @@ -251,27 +256,15 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => { } }); - test("/apps/advanced still mounts both tabs", async ({ page, request }) => { + test("/apps/advanced mounts the paste-config path", async ({ page, request }) => { const seed = await newCompany(request, "advanced"); await gotoAdvanced(page, seed.prefix); await expect(page.getByRole("heading", { name: "Advanced setup" })).toBeVisible({ timeout: 20_000 }); await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-09-advanced-default.png`, fullPage: true }); - // M8a paste tab. - const pasteTab = page.getByRole("tab", { name: /Paste/i }).first(); - if (await pasteTab.isVisible().catch(() => false)) { - await pasteTab.click(); - await page.waitForTimeout(250); - await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-10-advanced-paste-tab.png`, fullPage: true }); - } - - // M8b run-your-own tab. - const ownTab = page.getByRole("tab", { name: /Run your own|Self host|Stdio|Local/i }).first(); - if (await ownTab.isVisible().catch(() => false)) { - await ownTab.click(); - await page.waitForTimeout(250); - await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-11-advanced-own-tab.png`, fullPage: true }); - } + await expect(page.getByRole("link", { name: "Paste a config" })).toBeVisible(); + await expect(page.getByRole("link", { name: /Run your own|Self host|Stdio|Local/i })).toHaveCount(0); + await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-10-advanced-paste-tab.png`, fullPage: true }); }); }); diff --git a/tests/e2e/connection-intents.spec.ts b/tests/e2e/connection-intents.spec.ts index 65d12deec0..b45e23a515 100644 --- a/tests/e2e/connection-intents.spec.ts +++ b/tests/e2e/connection-intents.spec.ts @@ -208,10 +208,15 @@ test("store setup and task connection intent share one fake provider through con // Entry point one: connect and test the provider through the Connections store. await page.goto(`/${seed.prefix}/apps`); - await expect(page.getByRole("heading", { name: "Browse" })).toBeVisible({ + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000, }); - await page.getByRole("button", { name: /Connect your own tool/i }).click(); + const customConnector = page + .getByRole("list", { name: "Connector list" }) + .getByRole("listitem") + .filter({ hasText: "Connect your own tool" }); + await customConnector.getByRole("button", { name: "Connect", exact: true }).click(); + await customConnector.getByRole("button", { name: "Connect your own MCP server" }).click(); await page .getByPlaceholder("https://example.com/actions") .fill(provider.url); diff --git a/tests/e2e/mcp-user-stories.spec.ts b/tests/e2e/mcp-user-stories.spec.ts index 6314a32d90..2a42dffd8c 100644 --- a/tests/e2e/mcp-user-stories.spec.ts +++ b/tests/e2e/mcp-user-stories.spec.ts @@ -410,7 +410,7 @@ test.describe.serial("MCP prod Phase 5a user-story harness", () => { const health = await request.post(`/api/tool-connections/${connectionId}/health-check`); expect(health.status()).toBe(502); await page.goto(`/${seed.prefix}/apps/connections`); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 30_000 }); await screenshot(page, "US-8", "01-needs-attention"); const recovered = await startMockMcp(); diff --git a/tests/e2e/smoke-lab.shared.ts b/tests/e2e/smoke-lab.shared.ts index d69568727f..1cb8eaf75b 100644 --- a/tests/e2e/smoke-lab.shared.ts +++ b/tests/e2e/smoke-lab.shared.ts @@ -165,7 +165,7 @@ async function navigateForEvidence(page: Page, seed: Seed, connectionId: string, } if (scenario.uiEntryPath === "attention") { await page.goto(`/${seed.prefix}/apps/connections`); - await expect(page.getByRole("heading", { name: "Connections" })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 20_000 }); return; } await page.goto(`/${seed.prefix}/apps/${connectionId}`); diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 73b591f416..bfe777b0c2 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -245,10 +245,13 @@ describe("Skill Studio routes", () => { }); describe("Apps routes", () => { - it("uses browse as the Apps landing page and gives connections a canonical URL", () => { + it("uses one connector landing page and redirects retired browse, connections, and audit URLs", () => { expect(appSource).toContain('} />'); expect(appSource).toContain('} />'); - expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + expect(appSource).toContain('} />'); + expect(appSource).not.toContain('import { Connections }'); expect(appSource).toContain('} />'); expect(appSource).toContain('path="apps/vercel-connect"'); expect(appSource).toContain(''); @@ -259,7 +262,7 @@ describe("Apps routes", () => { }); it("redirects legacy Rules and Health links to the remaining developer surfaces", () => { - expect(appSource).toContain('if (tab === "runtime") return "/apps/connections";'); + expect(appSource).toContain('if (tab === "runtime" || tab === "audit") return "/apps";'); expect(appSource).toContain('if (tab === "policies") return "/apps/advanced/profiles";'); }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5f94bba5e8..23b9bd9b2d 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -65,7 +65,6 @@ import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { AdvancedToolsRoute } from "./pages/tools/AdvancedToolsRoute"; import { ProfileWizardRoute } from "./pages/tools/profiles/ProfileWizardRoute"; import { ProfileDetailRoute } from "./pages/tools/profiles/ProfileDetailRoute"; -import { Connections } from "./pages/apps/Connections"; import { Browse } from "./pages/apps/Browse"; import { AppsConnect } from "./pages/apps/AppsConnect"; import { canEnterAppsConnect } from "./pages/apps/app-connect-policy"; @@ -157,10 +156,10 @@ function boardRoutes() { } /> } /> } /> - }> - } /> - } /> - } /> + }> + } /> + } /> + } /> } /> } /> } /> } /> - {/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */} - } /> + {/* Connector health is inline on the Apps landing page; keep legacy links working. */} + } /> } /> } /> } /> @@ -180,6 +179,8 @@ function boardRoutes() { } /> } /> } /> + } /> + } /> } /> } /> } /> @@ -464,8 +465,8 @@ function LegacyToolsRedirect() { function legacyToolsRedirectTarget(tab?: string) { if (!tab) return "/apps/advanced/profiles"; - if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections"; - if (tab === "runtime") return "/apps/connections"; + if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps"; + if (tab === "runtime" || tab === "audit") return "/apps"; if (tab === "policies") return "/apps/advanced/profiles"; return `/apps/advanced/${tab}`; } diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 01011afd2e..78a252c377 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -47,6 +47,7 @@ import type { ToolConnectionActivityResponse, ToolConnectionLifecycleEventType, ToolConnectionTestAgentsResponse, + ToolConnectionTestAgentAccessResponse, ToolConnectionTestCallResult, ToolConnectionTestCallStatus, ToolActionRequest, @@ -427,6 +428,10 @@ export const toolsApi = { api.get( `/tool-connections/${connectionId}/test-agents`, ), + getTestAgentAccess: (connectionId: string, agentId: string) => + api.get( + `/tool-connections/${connectionId}/test-agents/${agentId}/access`, + ), runTestCall: ( connectionId: string, input: { agentId: string; toolName: string; parameters?: Record }, diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx index e243a70dde..12bac9e761 100644 --- a/ui/src/components/AppConnectionSidebar.test.tsx +++ b/ui/src/components/AppConnectionSidebar.test.tsx @@ -189,7 +189,7 @@ describe("AppConnectionSidebar", () => { it("renders a back link and the connected app tabs with Test after Setup", async () => { await renderSidebar(); - expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); expect(container.textContent).toContain("GitHub"); expect(container.querySelectorAll("[data-to]").length).toBe(5); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true })); @@ -269,7 +269,7 @@ describe("AppConnectionSidebar", () => { await renderSidebar(); - expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); expect(container.textContent).toContain("GitHub"); expect(mockToolsApi.getConnection).not.toHaveBeenCalled(); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/setup", label: "Setup", end: true })); @@ -291,7 +291,7 @@ describe("AppConnectionSidebar", () => { await renderSidebar(); expect(container.textContent).toContain("App"); - expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); expect(container.querySelectorAll("[data-to]").length).toBe(5); }); @@ -304,7 +304,7 @@ describe("AppConnectionSidebar", () => { await renderSidebar(); expect(container.textContent).toContain("App"); - expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); + expect(container.querySelector('a[href="/apps"]')?.textContent).toContain("All connectors"); expect(container.querySelectorAll("[data-to]").length).toBe(4); }); }); diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx index 762d532c5e..83ba453044 100644 --- a/ui/src/components/AppConnectionSidebar.tsx +++ b/ui/src/components/AppConnectionSidebar.tsx @@ -92,14 +92,14 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) { ); diff --git a/ui/src/components/EnforcementBanner.tsx b/ui/src/components/EnforcementBanner.tsx index 9743231353..71e7489a5a 100644 --- a/ui/src/components/EnforcementBanner.tsx +++ b/ui/src/components/EnforcementBanner.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { cva, type VariantProps } from "class-variance-authority"; import { ShieldAlert, ShieldCheck, type LucideIcon } from "lucide-react"; -import { Link } from "@/lib/router"; import { cn } from "@/lib/utils"; import { queryKeys } from "@/lib/queryKeys"; import { toolsApi } from "@/api/tools"; @@ -154,7 +153,7 @@ export function EnforcementBanner(props: EnforcementBannerProps) {

{computedCount} governed tool call {computedCount === 1 ? " was" : "s were"} denied or failed in the last hour. Access is enforced - server-side by the tool gateway — review what was blocked and why in the audit log. + server-side by the tool gateway — open the affected connector to review what was blocked and why.

) : (

@@ -164,12 +163,6 @@ export function EnforcementBanner(props: EnforcementBannerProps) {

)} - - View audit → - ); } diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index b39ce59406..6dbda8fe0c 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -593,7 +593,7 @@ describe("Layout", () => { }); it("keeps the Apps sidebar on the M8 advanced-setup tabs", async () => { - currentPathname = "/PAP/apps/advanced/run-your-own"; + currentPathname = "/PAP/apps/advanced/paste-config"; const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, diff --git a/ui/src/components/ui/radio-card.tsx b/ui/src/components/ui/radio-card.tsx index 07ea6c2208..f8515bc601 100644 --- a/ui/src/components/ui/radio-card.tsx +++ b/ui/src/components/ui/radio-card.tsx @@ -6,10 +6,14 @@ export type RadioCardOption = { value: string; title: string; description?: string; + icon?: React.ReactNode; + accessibleLabel?: string; + tooltip?: string; /** * Disable this one option while its siblings stay live. For a choice the * viewer's capabilities forbid: the option stays legible, with its reason in - * `description`, instead of vanishing and making the scope unexplained. + * `description` or `tooltip`, instead of vanishing and making the scope + * unexplained. */ disabled?: boolean; }; @@ -24,12 +28,16 @@ export function RadioCard({ selected, title, description, + icon, + tooltip, className, ...props }: { selected: boolean; title: string; description?: string; + icon?: React.ReactNode; + tooltip?: string; } & Omit, "title">) { return ( )} - +

@@ -2092,14 +2061,12 @@ function GalleryStep({ source = null, onPick, onUseLink, - onRunYourOwn, - onPasteConfig, }: { loading: boolean; apps: AppDefinition[]; /** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */ byo?: boolean; - /** Canonical BYO page: keep the URL path and alternate methods, without the app gallery. */ + /** Canonical BYO page: keep the focused URL setup without the app gallery. */ byoOnly?: boolean; /** Isolated Vercel catalog: no native-provider or bring-your-own setup paths. */ vercelConnect?: boolean; @@ -2111,8 +2078,6 @@ function GalleryStep({ source?: string | null; onPick: (entry: AppDefinition) => void; onUseLink: (link: string) => void; - onRunYourOwn: () => void; - onPasteConfig: () => void; }) { const [search, setSearch] = useState(""); const [linkInput, setLinkInput] = useState(""); @@ -2332,59 +2297,10 @@ function GalleryStep({ : null} - {!vercelConnect ?

-
More ways to connect
-

- For tools that aren’t in the gallery. You’ll need details from the tool’s docs. -

-
- - -
-
: null} ); } -function ConnectMethodRow({ - icon: Icon, - title, - description, - onClick, -}: { - icon: LucideIcon; - title: string; - description: string; - onClick: () => void; -}) { - return ( - - ); -} - function normalizeAppLink(value: string): string | null { try { const parsed = new URL(value.trim()); @@ -3435,8 +3351,6 @@ function MethodConfigField({ * get. Hick's Law: two choices, not a matrix. Both use full-row radio targets. */ export function AccessStep({ - appName, - providerName, companyId, authKind, grantKinds, @@ -3448,10 +3362,6 @@ export function AccessStep({ setInstallAgentIds, lockedAgentId, capabilities, - guidance, - warnings, - setupPrerequisite, - docsUrl, submitLabel, identityLoading = false, preserveAgentAccess = false, @@ -3459,8 +3369,6 @@ export function AccessStep({ onBack, onContinue, }: { - appName: string; - providerName: string; companyId: string; authKind: ToolConnectionAuthKind; grantKinds?: ConnectionGrantKind[]; @@ -3476,10 +3384,6 @@ export function AccessStep({ companyInstallReason?: string | null; editableAgentIds?: string[]; } | null; - guidance?: string; - warnings?: string[]; - setupPrerequisite?: AppDefinition["setupPrerequisite"]; - docsUrl?: string; submitLabel: string; /** Wait for a durable OAuth connection before showing a reconnect identity. */ identityLoading?: boolean; @@ -3494,7 +3398,7 @@ export function AccessStep({ queryFn: () => agentsApi.list(companyId), }); const allAgents: Agent[] = (agentsQuery.data ?? []).filter((a) => a.status !== "terminated"); - // "Agents I pick" means agents this person may actually edit. When the server + // "Just agents I pick" means agents this person may actually edit. When the server // has not told us, fall back to every live agent rather than an empty list — // an empty picker would read as "you have no agents". const editableAgentIds = capabilities?.editableAgentIds; @@ -3520,106 +3424,29 @@ export function AccessStep({ return (
-
-
- - -
-

- {authKind === "oauth" ? "Choose access before sign-in" : "Choose access before adding credentials"} -

-

- {authKind === "oauth" - ? `Set the identity and agent reach first. Then ${providerName} will ask you to authorize that exact connection.` - : "Set the identity and agent reach first. Then add the credential for that exact connection."} -

-
-
- - {guidance || warnings?.length || setupPrerequisite || docsUrl ? ( -
- {warnings?.map((warning) => ( - {warning} - ))} - {guidance ?

{guidance}

: null} - {setupPrerequisite ? ( -
-

{setupPrerequisite.title}

-

{setupPrerequisite.description}

- {setupPrerequisite.steps?.length ? ( -
    - {setupPrerequisite.steps.map((step) =>
  1. {step}
  2. )} -
- ) : null} - - {setupPrerequisite.actionLabel} - - -
- ) : null} - {docsUrl ? ( - - Review {providerName} setup requirements - - - ) : null} -
- ) : null} - +
-
- - -
-

Who is this credential for?

-

- Whose {appName} account should agents act as? -

-
-
+

Which humans can use this credential?

{identityLoading ? (
) : needsIdentityChoice && allowedGrantKinds.length === 1 ? ( -
+
{allowedGrantKinds[0] === "user" ? ( -
-
- - -
-

Which agents can use this connection?

-

Choose where this connection will be available.

-
-
+

Which agents can use this connection?

{preserveAgentAccess ? (