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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-01 14:55:35 -05:00 committed by GitHub
parent 141f202e40
commit 1ab159d3a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
67 changed files with 1738 additions and 1855 deletions

View File

@ -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);

View File

@ -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);
});

View File

@ -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 });
}
});

View File

@ -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<void> {
} 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<void> {
} 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: {

View File

@ -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<void> {
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);

View File

@ -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);
}

View File

@ -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

View File

@ -1435,6 +1435,7 @@ export type {
ToolConnectionTestToolAccess,
ToolConnectionAccessSummary,
ToolConnectionTestAgent,
ToolConnectionTestAgentAccessResponse,
ToolConnectionTestAgentsResponse,
ToolConnectionTestCallResult,
ToolConnectionTestCallStatus,

View File

@ -603,6 +603,7 @@ export type {
ToolConnectionTestToolAccess,
ToolConnectionAccessSummary,
ToolConnectionTestAgent,
ToolConnectionTestAgentAccessResponse,
ToolConnectionTestAgentsResponse,
ToolConnectionTestCallResult,
ToolConnectionTestCallStatus,

View File

@ -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;

View File

@ -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");
});

View File

@ -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 () => {

View File

@ -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",

View File

@ -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) {

View File

@ -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(

View File

@ -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 [];

View File

@ -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<string>();
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;

View File

@ -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) {

View File

@ -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<typeof signToolArguments>;
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)

View File

@ -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<string, string> = {
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,

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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 });
});
});

View File

@ -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 });
});
});

View File

@ -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);

View File

@ -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();

View File

@ -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}`);

View File

@ -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('<Route path="apps" element={<Browse />} />');
expect(appSource).toContain('<Route path="apps/browse" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/connections" element={<Connections />} />');
expect(appSource).toContain('<Route path="apps/connections" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />');
expect(appSource).toContain('<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />');
expect(appSource).not.toContain('import { Connections }');
expect(appSource).toContain('<Route path="apps/byo" element={<AppsConnect byoOnly />} />');
expect(appSource).toContain('path="apps/vercel-connect"');
expect(appSource).toContain('<AppsConnectEntryRoute credentialSource="vercel_connect" />');
@ -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";');
});
});

View File

@ -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() {
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
<Route path="tools" element={<LegacyToolsRedirect />} />
<Route path="tools/:tab" element={<LegacyToolsRedirect />} />
<Route element={<AppsExperimentalGate />}>
<Route path="apps" element={<Browse />} />
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
<Route path="apps/connections" element={<Connections />} />
<Route element={<AppsExperimentalGate />}>
<Route path="apps" element={<Browse />} />
<Route path="apps/browse" element={<Navigate to="/apps" replace />} />
<Route path="apps/connections" element={<Navigate to="/apps" replace />} />
<Route path="apps/byo" element={<AppsConnect byoOnly />} />
<Route
path="apps/vercel-connect"
@ -170,8 +169,8 @@ function boardRoutes() {
<Route path="apps/connect/:appKey" element={<Navigate to="/apps" replace />} />
<Route path="apps/connect/:appKey/:stage" element={<Navigate to="/apps" replace />} />
<Route path="apps/review" element={<AppsReview />} />
{/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */}
<Route path="apps/attention" element={<Navigate to="/apps/connections" replace />} />
{/* Connector health is inline on the Apps landing page; keep legacy links working. */}
<Route path="apps/attention" element={<Navigate to="/apps" replace />} />
<Route path="apps/gateways" element={<GatewaysList />} />
<Route path="apps/gateways/:gatewayId" element={<Navigate to="overview" replace />} />
<Route path="apps/gateways/:gatewayId/:tab" element={<GatewayDetail />} />
@ -180,6 +179,8 @@ function boardRoutes() {
<Route path="apps/advanced/profiles/new" element={<ProfileWizardRoute mode="new" />} />
<Route path="apps/advanced/profiles/:profileId/edit" element={<ProfileWizardRoute mode="edit" />} />
<Route path="apps/advanced/profiles/:profileId" element={<ProfileDetailRoute />} />
<Route path="apps/advanced/audit" element={<Navigate to="/apps" replace />} />
<Route path="apps/advanced/run-your-own" element={<Navigate to="/apps" replace />} />
<Route path="apps/advanced/:tab" element={<AdvancedToolsRoute />} />
<Route path="apps/app/:applicationId" element={<AppNotConnected />} />
<Route path="apps/app/:applicationId/:tab" element={<AppNotConnected />} />
@ -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}`;
}

View File

@ -47,6 +47,7 @@ import type {
ToolConnectionActivityResponse,
ToolConnectionLifecycleEventType,
ToolConnectionTestAgentsResponse,
ToolConnectionTestAgentAccessResponse,
ToolConnectionTestCallResult,
ToolConnectionTestCallStatus,
ToolActionRequest,
@ -427,6 +428,10 @@ export const toolsApi = {
api.get<ToolConnectionTestAgentsResponse>(
`/tool-connections/${connectionId}/test-agents`,
),
getTestAgentAccess: (connectionId: string, agentId: string) =>
api.get<ToolConnectionTestAgentAccessResponse>(
`/tool-connections/${connectionId}/test-agents/${agentId}/access`,
),
runTestCall: (
connectionId: string,
input: { agentId: string; toolName: string; parameters?: Record<string, unknown> },

View File

@ -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(<AppDetailSidebar kind="application" applicationId="app-1" />);
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(<AppDetailSidebar kind="application" applicationId="missing-app" />);
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);
});
});

View File

@ -92,14 +92,14 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
<aside className="flex h-full min-h-0 w-full flex-col border-r border-border bg-background">
<div className="flex shrink-0 flex-col gap-3 px-3 py-3">
<Link
to="/apps/connections"
to="/apps"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">All apps</span>
<span className="truncate">All connectors</span>
</Link>
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
<AppLogo

View File

@ -97,7 +97,7 @@ describe("AppsSidebar", () => {
vi.clearAllMocks();
});
it("renders Apps and Developer sections in one sidebar", async () => {
it("renders the consolidated connector and review doors without retired developer links", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@ -113,27 +113,22 @@ describe("AppsSidebar", () => {
await flushReact();
expect(container.textContent).toContain("Apps");
expect(container.textContent).toContain("Developer");
// The Developer boundary caption frames who the door is for (PAP-13241 §5).
expect(container.textContent).toContain("Advanced setup for developers");
expect(container.textContent).not.toContain("Developer");
expect(container.textContent).not.toContain("Advanced setup for developers");
expect(container.textContent).not.toContain("Most teams");
expect(container.textContent).not.toMatch(/you (?:won'?t|will not) need this/i);
// "Run your own" / "Paste a config" moved to the Connect-an-app page (PAP-10922);
// assert their absence at the item level below.
// Paste-config discovery now lives inside the custom connector row;
// assert both advanced setup items remain absent at the item level below.
// Consumer doors stay above the Developer boundary.
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps", label: "Browse", end: true }),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/connections", label: "Connections", end: true }),
expect.objectContaining({ to: "/apps", label: "Connectors", end: true }),
);
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/review", label: "Review" }),
);
const sidebarText = container.textContent ?? "";
expect(sidebarText.indexOf("Connections")).toBeGreaterThan(sidebarText.indexOf("Developer"));
// "Needs attention" is no longer a top-level door — it folds into Connections.
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ label: "Connections" }),
);
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ label: "Needs attention" }),
);
@ -154,8 +149,8 @@ describe("AppsSidebar", () => {
);
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Rules" }));
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Health" }));
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/audit", label: "Activity", end: true }),
expect(sidebarNavItemMock).not.toHaveBeenCalledWith(
expect.objectContaining({ to: "/apps/advanced/audit" }),
);
await act(async () => {

View File

@ -8,21 +8,13 @@ import { useReviewCount } from "@/pages/apps/useReviewCount";
import { SidebarNavItem } from "./SidebarNavItem";
/**
* Secondary sidebar for the prosumer Apps area (PAP-10856; three-door IA
* PAP-13254 / U3).
* Secondary sidebar for the Apps area.
*
* Back · APPS: Browse / Review (n)
* DEVELOPER: Connections / Activity
* Back · APPS: Connectors / Review (n)
*
* "Browse" is the store and "Review" holds decisions waiting on the user's
* OK. Connection management lives with the Developer tools.
* "Needs attention" is no longer a door: health/error triage folds into
* Connections as a status filter + banner, so approvals are never buried
* behind an error label. The Developer section was folded in from the retired
* ToolsSidebar (PAP-10915) so the whole Apps area shares one sidebar; a
* one-line caption frames who it's for (Finding A). "Run your own" and "Paste a
* config" moved out of the sidebar into rows on the Connect-an-app page
* (PAP-10922).
* The landing page combines connector discovery, account management, and
* connection health. Review keeps governed actions waiting on the user's OK.
* Advanced developer surfaces remain hidden unless one is explicitly enabled.
*/
export function AppsSidebar() {
const { selectedCompany } = useCompany();
@ -61,7 +53,7 @@ export function AppsSidebar() {
Apps
</div>
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/apps" label="Browse" icon={Store} end />
<SidebarNavItem to="/apps" label="Connectors" icon={Store} end />
<SidebarNavItem
to="/apps/review"
label="Review"
@ -71,24 +63,27 @@ export function AppsSidebar() {
badgeLabel="waiting for your OK"
/>
</div>
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Developer
</div>
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
Advanced setup for developers.
</p>
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/apps/connections" label="Connections" icon={AppWindow} end />
{developerTabs.map((tab) => (
<SidebarNavItem
key={tab.key}
to={advancedTabHref(tab.key)}
label={tab.label}
icon={tab.icon}
end
/>
))}
</div>
{developerTabs.length > 0 ? (
<>
<div className="px-3 pb-1 pt-4 text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
Developer
</div>
<p className="px-3 pb-1.5 text-(length:--text-micro) leading-snug text-muted-foreground/70">
Advanced setup for developers.
</p>
<div className="flex flex-col gap-0.5">
{developerTabs.map((tab) => (
<SidebarNavItem
key={tab.key}
to={advancedTabHref(tab.key)}
label={tab.label}
icon={tab.icon}
end
/>
))}
</div>
</>
) : null}
</nav>
</aside>
);

View File

@ -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) {
<p>
<span className="font-medium">{computedCount}</span> 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.
</p>
) : (
<p>
@ -164,12 +163,6 @@ export function EnforcementBanner(props: EnforcementBannerProps) {
</p>
)}
</div>
<Link
to="/apps/advanced/audit"
className="shrink-0 text-xs font-medium text-primary hover:underline"
>
View audit
</Link>
</div>
);
}

View File

@ -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 } },

View File

@ -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<React.ComponentProps<"button">, "title">) {
return (
<button
@ -46,10 +54,22 @@ export function RadioCard({
"disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
title={tooltip}
{...props}
>
<div className="flex w-full items-center justify-between gap-2">
<span className="text-sm font-medium">{title}</span>
<span className="flex min-w-0 items-center gap-2 text-sm font-medium">
{icon ? (
<span
data-slot="radio-card-icon"
className="shrink-0 text-muted-foreground"
aria-hidden="true"
>
{icon}
</span>
) : null}
<span>{title}</span>
</span>
{selected ? <Check className="h-4 w-4 shrink-0 text-primary" /> : null}
</div>
{description ? (
@ -111,6 +131,9 @@ export function RadioCardGroup({
selected={option.value === value}
title={option.title}
description={option.description}
icon={option.icon}
tooltip={option.tooltip}
aria-label={option.accessibleLabel}
disabled={disabled || option.disabled}
tabIndex={option.value === value ? 0 : -1}
onClick={() => onValueChange(option.value)}

View File

@ -8,17 +8,13 @@ import {
ChevronDown,
ChevronRight,
Copy,
ClipboardPaste,
Link2,
Loader2,
Lock,
Search,
ShieldCheck,
TerminalSquare,
UserRound,
UsersRound,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type {
Agent,
AppDefinition,
@ -53,7 +49,6 @@ import { ApiError } from "@/api/client";
import { toolsApi } from "@/api/tools";
import { agentsApi } from "@/api/agents";
import { appCopyFor, credentialFieldLabel } from "@/lib/app-gallery-copy";
import { advancedTabHref } from "@/pages/tools/tool-tabs";
import { AgentIcon } from "@/components/AgentIconPicker";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { InlineBanner } from "@/components/InlineBanner";
@ -583,7 +578,7 @@ export function ConnectionSetupFlow({
setCredentials({});
setConnectResult(null);
setStep("key");
navigate(withConnectionIntent("/apps/connect?byo=1&source=zapier", connectionIntentId));
navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId));
return;
}
if (credentialSource === "paperclip_vault" && canUseAutomaticOAuthFastPath(picked)) {
@ -619,31 +614,14 @@ export function ConnectionSetupFlow({
);
};
const openGallery = () => {
setEntry(null);
setGalleryName("");
setLinkUrl("");
setLinkName("");
setLinkNeedsKey(false);
setLinkKey("");
resetGenericAuthState();
setCredentials({});
setCuratedOAuthClientId("");
setCuratedOAuthClientSecret("");
setVercelConnector("");
setConnectionMethodKey("");
setConfigValues({});
setGoogleSheetsLinks("");
setGoogleSheetsError(null);
setConnectResult(null);
setInstallAgentIds(new Set(requestedAgentId ? [requestedAgentId] : []));
setInstallChoice(requestedAgentId ? "specific" : "all");
setGrantKind("organization");
const backToGallery = () => {
// Back is a wizard transition, so keep the selected app and entered draft
// intact. Picking another connector will replace that state explicitly.
setStep("gallery");
navigate(withConnectionIntent(
credentialSource === "vercel_connect"
? vercelConnectSourceHref()
: byoOnly ? "/apps/byo" : "/apps/connect?byo=1",
: byoOnly ? "/apps/byo" : "/apps",
connectionIntentId,
));
};
@ -1499,7 +1477,12 @@ export function ConnectionSetupFlow({
directOAuthRetryingRef.current = false;
}
}}
onCancel={onCancel ?? (() => navigate("/apps/browse"))}
onBack={() => {
setOAuthPhase("entry");
setOAuthError(null);
setAppStep("access");
}}
onCancel={onCancel ?? (() => navigate("/apps"))}
/>
);
}
@ -1529,11 +1512,12 @@ export function ConnectionSetupFlow({
setGenericOAuthPending(false);
setOAuthPhase("entry");
}}
onCancel={() => {
onBack={() => {
setGenericOAuthPending(false);
setOAuthPhase("entry");
setOAuthError(null);
}}
onCancel={onCancel ?? (() => navigate("/apps"))}
/>
);
}
@ -1643,8 +1627,6 @@ export function ConnectionSetupFlow({
setGrantKind(reconnectGrantKind ?? "organization");
setStep("key");
}}
onRunYourOwn={() => navigate(advancedTabHref("run-your-own"))}
onPasteConfig={() => navigate(advancedTabHref("paste-config"))}
/>
)}
@ -1684,16 +1666,9 @@ export function ConnectionSetupFlow({
setGoogleSheetsError(null);
}}
submitting={connectMutation.isPending}
// Back returns to Access, not to the gallery: the design requires the
// identity and agent selections to survive moving backward, and
// `openGallery` resets them.
onBack={() => {
if (resumeConnectionId || reconnectConnectionId) {
navigate("/apps");
return;
}
setAppStep("access");
}}
// Back returns to Access for new, resumed, and reconnected accounts.
// Cancel is the separate exit to the connector list.
onBack={() => setAppStep("access")}
onConnect={() => {
if (isGoogleSheetsRobotMethod(entry, connectionMethodKey)) {
const parsed = parseGoogleSheetIds(googleSheetsLinks);
@ -1771,7 +1746,7 @@ export function ConnectionSetupFlow({
matchedEntry={linkMatchedEntry}
onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined}
submitting={connectMutation.isPending || genericOAuthPending}
onBack={() => setStep("gallery")}
onBack={backToGallery}
onConnect={() => {
setLinkGuidance(null);
connectMutation.mutate(undefined);
@ -1784,15 +1759,13 @@ export function ConnectionSetupFlow({
link={linkUrl}
onLinkChange={setLinkUrl}
submitting={connectMutation.isPending}
onBack={() => navigate("/apps")}
onBack={backToGallery}
onConnect={() => connectMutation.mutate(undefined)}
/>
)}
{step === "access" && (
<AccessStep
appName={appName}
providerName={entry?.name ?? appName}
companyId={selectedCompanyId}
authKind={accessStepAuthKind}
grantKinds={fixedGrantKind ? [fixedGrantKind] : accessStepMethod?.grantKinds}
@ -1804,17 +1777,11 @@ export function ConnectionSetupFlow({
setInstallAgentIds={setInstallAgentIds}
lockedAgentId={requestedAgentId}
capabilities={galleryQuery.data?.capabilities}
guidance={accessStepMethod?.guidanceMd}
warnings={accessStepMethod?.warnings}
setupPrerequisite={entry?.setupPrerequisite}
docsUrl={accessStepMethod?.consoleLinks?.docs ?? entry?.docsUrl}
submitLabel={accessSubmitLabel}
identityLoading={Boolean(automaticOAuthEntry) && directOAuthLookupPending}
preserveAgentAccess={Boolean(automaticOAuthEntry && (resumableOAuthConnection || reconnectConnection))}
pending={connectMutation.isPending || oauthStartMutation.isPending}
// Leaving Access abandons the app choice entirely, so this resets the
// draft and returns to the gallery the operator came from.
onBack={() => (entry || linkUrl ? openGallery() : navigate("/apps"))}
onBack={backToGallery}
onContinue={() => {
if (directOAuthEntry) {
directOAuthAccessConfirmedRef.current = true;
@ -1843,7 +1810,7 @@ export function ConnectionSetupFlow({
installCount: installAgentIds.size,
enabledCount: Object.values(enabled).filter(Boolean).length,
})}
onDone={onCancel ?? (() => navigate("/apps/connections"))}
onDone={onCancel ?? (() => navigate("/apps"))}
/>
)}
</div>
@ -1916,6 +1883,7 @@ export function OAuthConnectStateScreen({
error,
authorizationHost,
onRetry,
onBack,
onCancel,
}: {
/** A curated app. Omit for a generic endpoint and pass `identity` instead. */
@ -1933,6 +1901,7 @@ export function OAuthConnectStateScreen({
*/
authorizationHost?: string | null;
onRetry: () => void;
onBack: () => void;
onCancel: () => void;
}) {
const serverName = entry?.name ?? identity?.name ?? "this server";
@ -2004,7 +1973,7 @@ export function OAuthConnectStateScreen({
{phase === "redirecting" ? `Opening ${serverName}` : "Preparing…"}
</Button>
)}
<Button type="button" variant="ghost" onClick={onCancel}>Back to apps</Button>
<Button type="button" variant="ghost" onClick={onBack}>Back</Button>
</div>
<p className="mt-5 flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="h-3.5 w-3.5" />
@ -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({
</div>
</div> : null}
{!vercelConnect ? <div className="border-t border-border pt-5">
<div className="text-sm font-semibold text-foreground">More ways to connect</div>
<p className="mt-1 text-xs text-muted-foreground">
For tools that arent in the gallery. Youll need details from the tools docs.
</p>
<div className="mt-3 flex flex-col gap-2">
<ConnectMethodRow
icon={TerminalSquare}
title="Run your own"
description="Register a command Paperclip runs in your workspace for a tool that isnt listed."
onClick={onRunYourOwn}
/>
<ConnectMethodRow
icon={ClipboardPaste}
title="Paste a config"
description="Already have a setup snippet from a README? Paste it and well connect it."
onClick={onPasteConfig}
/>
</div>
</div> : null}
</div>
);
}
function ConnectMethodRow({
icon: Icon,
title,
description,
onClick,
}: {
icon: LucideIcon;
title: string;
description: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"
>
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">{title}</div>
<div className="truncate text-xs text-muted-foreground">{description}</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
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 (
<div className="mx-auto max-w-2xl">
<div className="overflow-hidden rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-start gap-3 border-b border-border p-6">
<span className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ShieldCheck className="h-5 w-5" aria-hidden="true" />
</span>
<div>
<h2 className="text-xl font-bold tracking-tight">
{authKind === "oauth" ? "Choose access before sign-in" : "Choose access before adding credentials"}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{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."}
</p>
</div>
</div>
{guidance || warnings?.length || setupPrerequisite || docsUrl ? (
<div className="space-y-3 border-b border-border p-6">
{warnings?.map((warning) => (
<InlineBanner key={warning} tone="warning" compact>{warning}</InlineBanner>
))}
{guidance ? <p className="text-sm text-foreground">{guidance}</p> : null}
{setupPrerequisite ? (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<h3 className="text-sm font-semibold text-foreground">{setupPrerequisite.title}</h3>
<p className="mt-1 text-xs text-muted-foreground">{setupPrerequisite.description}</p>
{setupPrerequisite.steps?.length ? (
<ol
aria-label={`${setupPrerequisite.title} steps`}
className="mt-3 list-decimal space-y-1 pl-4 text-xs text-muted-foreground"
>
{setupPrerequisite.steps.map((step) => <li key={step}>{step}</li>)}
</ol>
) : null}
<a
href={setupPrerequisite.actionUrl}
target="_blank"
rel="noreferrer"
className="mt-3 inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
>
{setupPrerequisite.actionLabel}
<ArrowUpRight className="h-3 w-3" />
</a>
</div>
) : null}
{docsUrl ? (
<a
href={docsUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-semibold text-foreground underline underline-offset-2"
>
Review {providerName} setup requirements
<ArrowUpRight className="h-3 w-3" />
</a>
) : null}
</div>
) : null}
<div className="overflow-hidden rounded-xl border border-border">
<div className="divide-y divide-border">
<section className="p-6">
<div className="flex items-start gap-3">
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<UserRound className="h-4 w-4" aria-hidden="true" />
</span>
<div>
<h3 className="text-sm font-semibold text-foreground">Who is this credential for?</h3>
<p className="mt-1 text-xs text-muted-foreground">
Whose {appName} account should agents act as?
</p>
</div>
</div>
<h2 className="text-sm font-semibold text-foreground">Which humans can use this credential?</h2>
{identityLoading ? (
<div className="mt-4 grid gap-2 sm:grid-cols-2" aria-label="Loading connection identity">
<Skeleton className="h-20 w-full rounded-md" />
<Skeleton className="h-20 w-full rounded-md" />
</div>
) : needsIdentityChoice && allowedGrantKinds.length === 1 ? (
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
<div className="mt-4 flex items-center gap-3 rounded-md border border-border p-4">
{allowedGrantKinds[0] === "user" ? (
<UserRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<UserRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
) : (
<Building2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
<UsersRound className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
)}
<div>
<div className="text-sm font-medium text-foreground">
{allowedGrantKinds[0] === "user" ? "Just me." : "Everyone in the company"}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{allowedGrantKinds[0] === "user"
? `Agents use this ${appName} identity only when work runs for you.`
: `Eligible company members share this ${appName} identity.`}
{preserveAgentAccess ? " Reconnect keeps this identity type." : null}
</p>
<div className="text-sm font-medium text-foreground">
{allowedGrantKinds[0] === "user" ? "Just me" : "Any human in the company"}
</div>
</div>
) : needsIdentityChoice ? (
<RadioCardGroup
ariaLabel="Who is this credential for?"
ariaLabel="Which humans can use this credential?"
className="mt-4 sm:grid-cols-2"
value={grantKind}
onValueChange={(next) => setGrantKind(next as ConnectionGrantKind)}
@ -3627,12 +3454,12 @@ export function AccessStep({
{
value: "user",
title: "Just me",
description: "Agents use this identity only when work runs for you.",
icon: <UserRound className="h-4 w-4" aria-hidden="true" />,
},
{
value: "organization",
title: "Everyone in the company",
description: "Eligible company members use one shared identity.",
title: "Any human in the company",
icon: <UsersRound className="h-4 w-4" aria-hidden="true" />,
},
].filter((option) => allowedGrantKinds.includes(option.value as ConnectionGrantKind))}
/>
@ -3644,15 +3471,7 @@ export function AccessStep({
</section>
<section className="p-6">
<div className="flex items-start gap-3">
<span className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Bot className="h-4 w-4" aria-hidden="true" />
</span>
<div>
<h3 className="text-sm font-semibold text-foreground">Which agents can use this connection?</h3>
<p className="mt-1 text-xs text-muted-foreground">Choose where this connection will be available.</p>
</div>
</div>
<h2 className="text-sm font-semibold text-foreground">Which agents can use this connection?</h2>
{preserveAgentAccess ? (
<div className="mt-4 flex items-start gap-3 rounded-md border border-border bg-muted/40 p-4">
<UsersRound className="mt-0.5 h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
@ -3677,14 +3496,19 @@ export function AccessStep({
options={[
{
value: "specific",
title: "Agents I pick",
description: "Choose one or more agents you can edit.",
title: "Just agents I pick",
icon: <Bot className="h-4 w-4" aria-hidden="true" />,
},
{
value: "all",
title: "Any agent",
description: canSetCompanyInstall
? "Make this connection available to every agent."
icon: <BotGroupIcon />,
accessibleLabel: canSetCompanyInstall
? "Any agent"
: `Any agent. Unavailable: ${capabilities?.companyInstallReason ??
"Only someone who can configure this connection can choose this."}`,
tooltip: canSetCompanyInstall
? undefined
: capabilities?.companyInstallReason ??
"Only someone who can configure this connection can choose this.",
disabled: !canSetCompanyInstall,
@ -3728,6 +3552,15 @@ export function AccessStep({
);
}
function BotGroupIcon() {
return (
<span className="relative block h-4 w-5" aria-hidden="true">
<Bot className="absolute left-0 top-0 h-3.5 w-3.5" />
<Bot className="absolute bottom-0 right-0 h-3.5 w-3.5" />
</span>
);
}
/**
* Summary of what the Access step committed. Three lines, not badges: identity,
* reach, and the existing action summary each said once.

View File

@ -43,6 +43,11 @@ export const queryKeys = {
["tools", "connection", connectionId, "activity"] as const,
testAgents: (connectionId: string) =>
["tools", "connection", connectionId, "test-agents"] as const,
testAgentAccesses: () => ["tools", "test-agent-access"] as const,
testAgentAccessesForConnection: (connectionId: string) =>
["tools", "test-agent-access", connectionId] as const,
testAgentAccess: (connectionId: string, agentId: string) =>
["tools", "test-agent-access", connectionId, agentId] as const,
testCallStatus: (connectionId: string, actionRequestId: string) =>
[
"tools",

View File

@ -284,6 +284,7 @@ export function AgentToolsTab({ agent, companyId }: { agent: AgentDetailRecord;
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(companyId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.tools.effectiveProfilesForAgent(companyId, agent.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(variables.connection.id) }),
]);
},
onError: (_error, variables) => {

View File

@ -18,6 +18,8 @@ const listProfilesMock = vi.hoisted(() => vi.fn());
const listPoliciesMock = vi.hoisted(() => vi.fn());
const listConnectionActivityMock = vi.hoisted(() => vi.fn());
const listActionRequestsMock = vi.hoisted(() => vi.fn());
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const updateConnectionMock = vi.hoisted(() => vi.fn());
const finishAppMock = vi.hoisted(() => vi.fn());
const finalizeOAuthAccessMock = vi.hoisted(() => vi.fn());
@ -52,6 +54,9 @@ vi.mock("@/api/tools", () => ({
listConnectionActivityMock(connectionId, limit),
listActionRequests: (companyId: string, status: string) =>
listActionRequestsMock(companyId, status),
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) =>
getTestAgentAccessMock(connectionId, agentId),
updateConnection: (connectionId: string, input: unknown) =>
updateConnectionMock(connectionId, input),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
@ -368,6 +373,7 @@ describe("AppDetail", () => {
});
listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} });
listActionRequestsMock.mockResolvedValue({ actionRequests: [] });
listTestAgentsMock.mockResolvedValue({ agents: [] });
updateConnectionMock.mockResolvedValue(connection({ enabled: false }));
finishAppMock.mockResolvedValue({});
finalizeOAuthAccessMock.mockResolvedValue({});

View File

@ -299,6 +299,7 @@ export function AppDetail() {
}),
onMutate: () => setPending(true),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
@ -320,6 +321,7 @@ export function AppDetail() {
toolsApi.putConnectionInstalls(connectionId, installPayload(selectedCompanyId!, next)),
onSuccess: (snapshot) => {
queryClient.setQueryData(queryKeys.tools.connectionInstalls(connectionId), snapshot);
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(selectedCompanyId!) });
@ -480,7 +482,7 @@ export function AppDetail() {
body: `${appName} no longer has access and its credentials are deleted. Connecting it again needs a new sign-in or key.`,
tone: "success",
});
navigate("/apps/connections");
navigate("/apps");
},
onError: (error) =>
pushToast({
@ -516,6 +518,7 @@ export function AppDetail() {
const refreshTools = useMutation({
mutationFn: () => toolsApi.refreshCatalog(connectionId),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccessesForConnection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.catalog(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId!) });
@ -557,7 +560,7 @@ export function AppDetail() {
};
if (!connectionId || !activeTab) {
return <Navigate replace to={connectionId ? appTabHref(connectionId, "setup") : "/apps/connections"} />;
return <Navigate replace to={connectionId ? appTabHref(connectionId, "setup") : "/apps"} />;
}
if (!selectedCompanyId) {
@ -576,8 +579,8 @@ export function AppDetail() {
return (
<div className="max-w-3xl p-6">
<p className="text-sm text-muted-foreground">We couldn't find that app.</p>
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps/connections")}>
Back to apps
<Button className="mt-4" variant="outline" onClick={() => navigate("/apps")}>
Back to connectors
</Button>
</div>
);

View File

@ -132,7 +132,7 @@ export function AppNotConnected() {
body: `${appName} no longer shows in your apps. You can connect it again any time.`,
tone: "success",
});
navigate("/apps/connections");
navigate("/apps");
},
onError: (error) => {
pushToast({
@ -147,7 +147,7 @@ export function AppNotConnected() {
return <div className="p-6 text-sm text-muted-foreground">Select an organization to manage apps.</div>;
}
if (!applicationId || !activeTab) {
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "setup") : "/apps/connections"} replace />;
return <Navigate to={applicationId ? appApplicationTabHref(applicationId, "setup") : "/apps"} replace />;
}
if (applicationsQuery.isLoading || connectionsQuery.isLoading) {
return (
@ -161,7 +161,7 @@ export function AppNotConnected() {
return (
<div className="max-w-3xl space-y-3 p-6 text-sm text-muted-foreground">
<p>This app doesnt exist anymore.</p>
<Button variant="outline" size="sm" onClick={() => navigate("/apps/connections")}>Back to apps</Button>
<Button variant="outline" size="sm" onClick={() => navigate("/apps")}>Back to connectors</Button>
</div>
);
}

View File

@ -33,6 +33,7 @@ const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify"
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!;
const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!;
const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!;
vi.mock("@/api/tools", () => ({
toolsApi: {
@ -246,14 +247,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
return root;
}
it("shows only the paste-first connection choices on the BYO page", async () => {
it("shows only MCP URL setup on the BYO page", async () => {
await render(undefined, true);
const text = container.textContent ?? "";
expect(text).toContain("Connect your own MCP server");
expect(text).toContain("More ways to connect");
expect(text).toContain("Run your own");
expect(text).toContain("Paste a config");
expect(text).not.toContain("More ways to connect");
expect(text).not.toContain("Run your own");
expect(text).not.toContain("Paste a config");
expect(text).not.toContain("Search apps…");
expect(text).not.toContain("Pick the app you want your agents to use.");
expect(text).not.toContain("Zapier");
@ -293,24 +294,57 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).toContain("Access");
expect(container.textContent).toContain("Who is this credential for?");
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).not.toContain("Choose access before adding credentials");
expect(container.textContent).not.toContain("Set the identity and agent reach first");
expect(container.textContent).not.toContain("Whose GitHub account should agents act as?");
expect(container.textContent).not.toContain("Choose where this connection will be available.");
// Nothing about the credential itself is on screen yet.
expect(container.querySelector('input[type="password"]')).toBeNull();
const radios = Array.from(document.body.querySelectorAll('[role="radio"]'));
const justMe = radios.find((r) => r.textContent?.includes("Just me"));
const wholeOrg = radios.find((r) => r.textContent?.includes("Everyone in the company"));
const agentsIPick = radios.find((r) => r.textContent?.includes("Agents I pick"));
const wholeOrg = radios.find((r) => r.textContent?.includes("Any human in the company"));
const agentsIPick = radios.find((r) => r.textContent?.includes("Just agents I pick"));
const anyAgent = radios.find((r) => r.textContent?.includes("Any agent"));
expect(justMe).toBeTruthy();
expect(wholeOrg).toBeTruthy();
expect(justMe?.textContent).toBe("Just me");
expect(wholeOrg?.textContent).toBe("Any human in the company");
expect(agentsIPick?.textContent).toBe("Just agents I pick");
expect(anyAgent?.textContent).toBe("Any agent");
expect(justMe?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(wholeOrg?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(agentsIPick?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(1);
expect(anyAgent?.querySelectorAll('[data-slot="radio-card-icon"] svg')).toHaveLength(2);
// A flexible connection method defaults to the company identity...
expect(wholeOrg?.getAttribute("aria-checked")).toBe("true");
expect(justMe?.getAttribute("aria-checked")).toBe("false");
// ...and every agent is the product default for both access and install.
expect(agentsIPick?.getAttribute("aria-checked")).toBe("false");
expect(radios.find((r) => r.textContent?.includes("Any agent"))?.getAttribute("aria-checked"))
.toBe("true");
expect(anyAgent?.getAttribute("aria-checked")).toBe("true");
});
it("keeps provider guidance and card styling out of the shared access step", async () => {
mockParams.appKey = "pagerduty";
listGalleryMock.mockResolvedValue({ apps: [PAGERDUTY] });
await render();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).toContain("Which agents can use this connection?");
expect(container.textContent).not.toContain("A PagerDuty API token");
expect(container.textContent).not.toContain("Use a customer-created PagerDuty key");
expect(container.textContent).not.toContain("Review PagerDuty setup requirements");
const accessHeading = Array.from(container.querySelectorAll("h2")).find(
(heading) => heading.textContent === "Which humans can use this credential?",
);
const accessCard = accessHeading?.closest(".overflow-hidden");
expect(accessCard).toBeTruthy();
expect(accessCard?.classList.contains("bg-card")).toBe(false);
expect(accessCard?.classList.contains("shadow-sm")).toBe(false);
});
it("defaults to every agent and only blocks an empty explicit selection", async () => {
@ -321,7 +355,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => {
Array.from(document.body.querySelectorAll('[role="radio"]'))
.find((r) => r.textContent?.includes("Agents I pick"))
.find((r) => r.textContent?.includes("Just agents I pick"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -365,6 +399,17 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
.toBe("true");
});
it("uses Cancel to exit while the bottom Back button stays in the wizard", async () => {
mockParams.appKey = "github";
await render();
await act(async () => {
buttonByText("Cancel")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps");
});
/**
* Company-wide is a default, not a restriction. Flexible API-key and OAuth
* methods must still let the operator deliberately choose a personal identity.
@ -378,7 +423,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
return {
justMe: radios.find((r) => r.textContent?.includes("Just me")),
wholeOrg: radios.find((r) => r.textContent?.includes("Everyone in the company")),
wholeOrg: radios.find((r) => r.textContent?.includes("Any human in the company")),
};
};
@ -551,7 +596,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(document.body.textContent).toContain("Who is this credential for?");
expect(document.body.textContent).toContain("Which humans can use this credential?");
expect(document.body.textContent).toContain("Which agents can use this connection?");
expect(document.body.textContent).not.toContain("Pick the app you want your agents to use.");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
@ -564,25 +609,21 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(document.body.textContent).toContain("Who is this credential for?");
expect(document.body.textContent).toContain("Just me.");
expect(document.body.textContent).toContain("Which humans can use this credential?");
expect(document.body.textContent).toContain("Just me");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
});
it("defaults Google Drive setup to its write-capable connection method", async () => {
it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => {
mockParams.appKey = "google-drive";
listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] });
await render();
expect(container.textContent).toContain("Google Developer Preview access required");
expect(container.textContent).toContain("does not enable unrelated Paperclip customers");
expect(container.textContent).toContain("final project-registration email");
expect(
Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).find((link) =>
link.textContent?.includes("Apply or verify Developer Preview enrollment"),
)?.href,
).toBe("https://developers.google.com/workspace/preview");
expect(container.textContent).not.toContain("Google Developer Preview access required");
expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers");
expect(container.textContent).not.toContain("final project-registration email");
expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment");
await passAccessStep();
expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true");
@ -591,20 +632,19 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(container.textContent).toContain("Your OAuth app");
});
it("explains Shopify's public-storefront gate before collecting the store domain", async () => {
it("keeps Shopify prerequisite copy off access before collecting the store domain", async () => {
mockParams.appKey = "shopify";
listGalleryMock.mockResolvedValue({ apps: [SHOPIFY] });
await render();
expect(container.textContent).toContain("Launch the storefront before connecting");
expect(container.textContent).toContain("Storefront visibility to Public");
expect(container.textContent).toContain("private or password-protected storefront returns HTTP 401");
expect(
Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).find((link) =>
link.textContent?.includes("Open Shopify Admin"),
)?.href,
).toBe("https://admin.shopify.com/");
expect(container.textContent).not.toContain("Launch the storefront before connecting");
expect(container.textContent).not.toContain("Storefront visibility to Public");
expect(container.textContent).not.toContain("Open Shopify Admin");
await passAccessStep();
expect(container.textContent).not.toContain("Launch the storefront before connecting");
expect(container.textContent).not.toContain("Open Shopify Admin");
});
/**
@ -631,16 +671,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
).find((r) => r.textContent?.includes("Any agent"));
// Visible, with the reason, and not selectable.
// Visible and not selectable; the reason stays available without adding
// visual subtext to the compact option.
expect(anyAgent).toBeTruthy();
expect(anyAgent?.disabled).toBe(true);
expect(anyAgent?.textContent).toContain(
expect(anyAgent?.textContent).not.toContain(
"Your company policy limits this choice to connection managers.",
);
// "Agents I pick" is the live alternative, so the step is not a dead end.
expect(anyAgent?.getAttribute("title")).toBe(
"Your company policy limits this choice to connection managers.",
);
expect(anyAgent?.getAttribute("aria-label")).toContain(
"Your company policy limits this choice to connection managers.",
);
// "Just agents I pick" is the live alternative, so the step is not a dead end.
const pick = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('[role="radio"]'),
).find((r) => r.textContent?.includes("Agents I pick"));
).find((r) => r.textContent?.includes("Just agents I pick"));
expect(pick?.disabled).toBe(false);
// Continue refuses the forbidden choice even though it is the current one.
expect(buttonByText("Save and continue")?.disabled).toBe(true);
@ -652,7 +699,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
// A deep-linked app lands on Access first: identity and reach are chosen
// before the credential (PAP-17835).
expect(container.textContent).toContain("Who is this credential for?");
expect(container.textContent).toContain("Which humans can use this credential?");
await passAccessStep();
expect(container.textContent).toContain("Connect GitHub");
@ -665,7 +712,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
// Access comes first for a curated app; the method chooser shares a screen
// with the credential fields, so it sits behind it (PAP-17835).
expect(container.textContent).toContain("Who is this credential for?");
expect(container.textContent).toContain("Which humans can use this credential?");
await passAccessStep();
expect(container.textContent).toContain("How do you want to connect?");
@ -978,9 +1025,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(navigateTopLevelMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Choose access before sign-in");
expect(container.textContent).toContain("Which humans can use this credential?");
expect(container.textContent).not.toContain("Choose access before sign-in");
const identityRadios = Array.from(document.body.querySelectorAll('[role="radio"]'));
expect(identityRadios.find((radio) => radio.textContent?.includes("Everyone in the company"))?.getAttribute("aria-checked"))
expect(identityRadios.find((radio) => radio.textContent?.includes("Any human in the company"))?.getAttribute("aria-checked"))
.toBe("true");
await passAccessStep();
@ -1016,6 +1064,25 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).toHaveBeenCalledTimes(1);
});
it("backs from the sign-in checkpoint to Access without exiting the wizard", async () => {
mockSearch.value = "source=notion";
listGalleryMock.mockResolvedValueOnce({ apps: [NOTION] });
connectAppMock.mockReturnValueOnce(new Promise(() => {}));
await render();
await passAccessStep();
await submitCuratedOAuthSetup();
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Which humans can use this credential?");
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=notion&stage=access");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps");
});
it("resumes an existing Notion OAuth connection instead of creating another draft", async () => {
const interactionId = "11111111-1111-4111-8111-111111111111";
mockSearch.value = `source=notion&intent=${interactionId}`;
@ -1046,7 +1113,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await render();
expect(container.textContent).toContain("Reconnect keeps this identity type");
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
expect(container.textContent).toContain("Existing agent access stays the same");
expect(startOAuthMock).not.toHaveBeenCalled();
await passAccessStep();
await submitCuratedOAuthSetup();
@ -1191,7 +1259,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(container.textContent).toContain(
credentialPolicy === "per_user" ? "Just me." : "Everyone in the company",
credentialPolicy === "per_user" ? "Just me" : "Any human in the company",
);
expect(container.textContent).toContain("Existing agent access stays the same");
await passAccessStep();
@ -1244,7 +1312,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
await render();
expect(container.textContent).toContain("Everyone in the company");
expect(container.textContent).toContain("Any human in the company");
await passAccessStep();
await submitCuratedOAuthSetup();
@ -1450,7 +1518,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Reconnect keeps this identity type");
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
expect(container.textContent).toContain("Existing agent access stays the same");
await passAccessStep();
await submitCuratedOAuthSetup();
expect(startOAuthMock).toHaveBeenCalledWith("conn-refreshed", {
@ -1495,7 +1564,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
expect(connectAppMock).not.toHaveBeenCalled();
expect(startOAuthMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Reconnect keeps this identity type");
expect(container.textContent).not.toContain("Reconnect keeps this identity type");
expect(container.textContent).toContain("Existing agent access stays the same");
await passAccessStep();
await submitCuratedOAuthSetup();
expect(startOAuthMock).toHaveBeenCalledWith("conn-after-retry", {
@ -1628,7 +1698,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await render();
expect(connectAppMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Who is this credential for?");
expect(container.textContent).toContain("Which humans can use this credential?");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true });
});
@ -1741,7 +1811,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
});
it("keeps Zapier visible and finishes without a separate access or install step", async () => {
mockSearch.value = "byo=1&source=zapier";
mockSearch.value = "source=zapier";
listGalleryMock.mockResolvedValueOnce({
apps: [
{ ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } },
@ -1819,27 +1889,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
]);
});
// PAP-10922: "Run your own" / "Paste a config" moved from the sidebar to rows
// under "Connect with a link" on the gallery step.
it("offers 'Run your own' and 'Paste a config' rows that route into the Advanced door", async () => {
it("keeps alternate setup methods out of the connection wizard", async () => {
await render();
expect(container.textContent).toContain("More ways to connect");
const buttonContaining = (text: string) =>
Array.from(container.querySelectorAll("button")).find((b) =>
b.textContent?.includes(text),
);
await act(async () => {
buttonContaining("Run your own")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced");
await act(async () => {
buttonContaining("Paste a config")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/advanced/paste-config");
expect(container.textContent).not.toContain("More ways to connect");
expect(container.textContent).not.toContain("Run your own");
expect(container.textContent).not.toContain("Paste a config");
});
// PAP-11091: discoverability copy for remote MCP URLs — the link field
@ -1967,7 +2022,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
it("keeps the originating connection intent in wizard URLs", async () => {
const interactionId = "11111111-1111-4111-8111-111111111111";
mockSearch.value = `byo=1&intent=${interactionId}`;
mockSearch.value = `intent=${interactionId}`;
await render();
await act(async () => {
@ -1984,8 +2039,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
);
});
it("steps back from the key step to Access, and from Access to the BYO gallery", async () => {
mockSearch.value = "byo=1";
it("steps back from the key step to Access, and from Access to Connectors", async () => {
mockSearch.value = "";
mockParams.appKey = "github";
await render();
await passAccessStep();
@ -1997,13 +2052,15 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("Who is this credential for?");
expect(container.textContent).toContain("Which humans can use this credential?");
await act(async () => {
buttonByText("Back")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?byo=1");
expect(mockNavigate).toHaveBeenCalledWith("/apps");
expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect?byo=1");
});
it("leaving the default name connects with the app name", async () => {

View File

@ -10,6 +10,8 @@ const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
const archiveConnectionMock = vi.hoisted(() => vi.fn());
const pushToastMock = vi.hoisted(() => vi.fn());
const navigateMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
@ -17,6 +19,8 @@ vi.mock("@/api/tools", () => ({
listGallery: (companyId: string) => listGalleryMock(companyId),
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
archiveConnection: (connectionId: string, options?: { confirmComposioChildren?: boolean }) =>
archiveConnectionMock(connectionId, options),
},
}));
@ -44,6 +48,10 @@ vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("@/context/ToastContext", () => ({
useToast: () => ({ pushToast: pushToastMock }),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@ -56,7 +64,7 @@ async function act(callback: () => void | Promise<void>) {
}
async function flushReact() {
for (let i = 0; i < 5; i += 1) {
for (let index = 0; index < 5; index += 1) {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
@ -69,7 +77,7 @@ function galleryEntry(overrides: Record<string, unknown>) {
key: "github",
name: "GitHub",
logoUrl: "https://example.com/github.png",
tagline: "Let agents open PRs and issues.",
tagline: "Let agents open pull requests and issues.",
authKind: "oauth",
transportTemplate: {
transport: "mcp_remote",
@ -82,33 +90,45 @@ function galleryEntry(overrides: Record<string, unknown>) {
};
}
describe("Browse store door (PAP-13254 door 1)", () => {
function application(overrides: Record<string, unknown> = {}) {
return {
id: "app-notion",
name: "Notion",
description: "Read and update workspace content.",
status: "active",
applicationKey: "app-gallery:notion:one",
metadata: { sourceTemplateKey: "notion" },
...overrides,
};
}
function connection(overrides: Record<string, unknown> = {}) {
return {
id: "conn-notion",
applicationId: "app-notion",
name: "devinfoley@gmail.com",
status: "active",
enabled: true,
authKind: "oauth",
healthStatus: "ok",
healthMessage: null,
lastError: null,
createdByUserId: "user-1",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
...overrides,
};
}
describe("Connectors landing page", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
listGalleryMock.mockResolvedValue({
apps: [
galleryEntry({
key: "zapier",
name: "Zapier",
tagline: "Connect automations.",
}),
galleryEntry({
key: "jira",
name: "Jira",
tagline: "Track projects and issues.",
}),
galleryEntry({
key: "cloudflare",
name: "Cloudflare",
tagline: "Manage Cloudflare resources.",
}),
galleryEntry({
key: "notion",
name: "Notion",
tagline: "Read and update workspace content.",
}),
galleryEntry({ key: "notion", name: "Notion", tagline: "Read and update workspace content." }),
galleryEntry({ key: "jira", name: "Jira", tagline: "Track projects and issues." }),
galleryEntry({
key: "gmail",
name: "Gmail",
@ -118,16 +138,12 @@ describe("Browse store door (PAP-13254 door 1)", () => {
reason: "Gmail is not available on this Paperclip instance yet.",
},
}),
galleryEntry({
key: "acme",
name: "Acme CRM",
tagline: "Sync deals and contacts.",
}),
],
});
listApplicationsMock.mockResolvedValue({ applications: [] });
listConnectionsMock.mockResolvedValue({ connections: [] });
listUserDirectoryMock.mockResolvedValue({ users: [] });
archiveConnectionMock.mockResolvedValue(connection({ status: "archived" }));
container = document.createElement("div");
document.body.appendChild(container);
});
@ -135,6 +151,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
afterEach(() => {
act(() => root?.unmount());
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
@ -153,174 +170,66 @@ describe("Browse store door (PAP-13254 door 1)", () => {
await flushReact();
}
it("renders the store header, popular grid, gallery, and BYO card", async () => {
it("renders one connector list with the requested header and no gallery sections", async () => {
await renderBrowse();
const text = container.textContent ?? "";
expect(text).toContain("Browse");
expect(text).toContain("Choose an app or connect your own MCP server.");
expect(text).not.toContain("More integrations are coming soon.");
expect(text).not.toContain("Other integrations are previews.");
expect(text).toContain("Popular");
expect(text).toContain("All apps");
expect(text).toContain("Jira");
expect(text).toContain("Cloudflare");
expect(text).toContain("Acme CRM");
expect(container.querySelector("header")?.textContent).toBe("Connectors");
expect(
container.querySelector('header input[aria-label="Search connectors"]'),
).toBeTruthy();
expect(container.querySelector('[aria-label="Popular apps"]')).toBeNull();
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
expect(container.querySelector('[aria-label="All apps"]')).toBeNull();
expect(
Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="All apps"] > [data-app-slug]',
'[aria-label="Connector list"] > [data-app-slug]',
),
).map((tile) => tile.dataset.appSlug),
).toEqual(["acme", "cloudflare", "gmail", "jira", "notion", "zapier"]);
// Bring-your-own is a first-class row in the store.
expect(text).toContain("Connect your own tool");
expect(text).toContain("All discovered actions are enabled automatically.");
expect(text).not.toContain("review its actions before enabling it");
expect(text).not.toContain("Vercel Connect");
expect(text).not.toContain("Composio");
).map((row) => row.dataset.appSlug),
).toEqual(["gmail", "jira", "notion", "custom-mcp"]);
expect(container.querySelector('button[aria-label="Connect Jira"]')).toBeTruthy();
expect(
container.querySelector<HTMLButtonElement>('button[aria-label="Unavailable Gmail"]')?.disabled,
).toBe(true);
expect(container.textContent).toContain("Connect your own tool");
const customConnect = container.querySelector<HTMLButtonElement>(
'[data-app-slug="custom-mcp"] button',
);
await act(async () => {
customConnect?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).not.toHaveBeenCalled();
expect(container.textContent).toContain("Connect your own MCP server");
expect(container.textContent).toContain("Paste a config");
expect(container.textContent).not.toContain("Run your own");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Connect your own MCP server"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/byo");
await act(async () => {
Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Paste a config"))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/advanced/paste-config");
});
it("does not surface Vercel Connect even when the backend capability is enabled", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
galleryEntry({
key: "posthog",
name: "PostHog",
tagline: "Analyze product usage.",
}),
],
credentialSources: {
vercelConnect: {
available: true,
enabled: true,
authentication: "access_token",
manageUrl: "https://vercel.com/connect",
reason: null,
},
},
});
await renderBrowse();
expect(container.textContent).not.toContain("Vercel Connect");
expect(navigateMock).not.toHaveBeenCalledWith("/apps/vercel-connect");
});
it("routes every capability-backed app and explains instance-disabled apps", async () => {
await renderBrowse();
const zapierTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Connect for Zapier"]',
),
);
const jiraTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Connect for Jira"]',
),
);
const notionTiles = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Connect for Notion"]',
),
);
const gmailTile = container.querySelector<HTMLButtonElement>(
'button[aria-label="Unavailable for Gmail"]',
);
const tile = container.querySelector<HTMLButtonElement>(
'button[aria-label="Unavailable for Acme CRM"]',
);
const byoCard = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Connect your own tool"),
);
expect(zapierTiles).toHaveLength(2);
expect(zapierTiles.every((button) => !button.disabled)).toBe(true);
expect(notionTiles).toHaveLength(2);
expect(notionTiles.every((button) => !button.disabled)).toBe(true);
expect(jiraTiles).toHaveLength(2);
expect(jiraTiles.every((button) => !button.disabled)).toBe(true);
expect(tile?.disabled).toBe(true);
expect(gmailTile?.disabled).toBe(true);
expect(byoCard?.disabled).toBe(false);
expect(tile?.textContent).toContain("Unavailable");
expect(container.textContent).toContain(
"Gmail is not available on this Paperclip instance yet.",
);
expect(container.textContent).not.toContain("Coming soon");
expect(zapierTiles[0]?.textContent).toContain("Connect");
await act(async () => {
zapierTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=zapier");
await act(async () => {
notionTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=notion");
await act(async () => {
jiraTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?source=jira");
await act(async () => {
byoCard?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/connect?byo=1");
});
it("filters the gallery by the search query", async () => {
await renderBrowse();
const input = container.querySelector<HTMLInputElement>(
'input[type="search"]',
);
expect(input).toBeTruthy();
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, "cloudflare");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
const text = container.textContent ?? "";
expect(text).toContain("Results (1)");
expect(text).toContain("Cloudflare");
expect(text).not.toContain("Acme CRM");
// Popular grid is hidden while searching.
expect(text).not.toContain("Popular");
});
it("shows the connected owner, edits existing connections, and offers a deliberate second account", async () => {
listApplicationsMock.mockResolvedValue({
applications: [
{
id: "app-notion",
name: "Legacy integration",
status: "active",
applicationKey: "legacy:notion",
metadata: {},
},
],
});
it("sorts connected providers first and shows account, owner, status actions, and edit menus inline", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({
connections: [
{
id: "conn-one",
applicationId: "app-notion",
name: "Notion",
status: "active",
createdByUserId: "user-1",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
},
{ id: "conn-two", applicationId: "app-notion", status: "disabled" },
{ id: "conn-draft", applicationId: "app-notion", status: "draft" },
connection(),
connection({
id: "conn-expired",
name: "ops@example.com",
healthStatus: "error",
healthMessage: "The saved sign-in expired.",
}),
],
});
listUserDirectoryMock.mockResolvedValue({
@ -332,7 +241,7 @@ describe("Browse store door (PAP-13254 door 1)", () => {
id: "user-1",
name: "Dotta",
email: "dotta@example.com",
image: "https://example.com/dotta.png",
image: null,
},
},
],
@ -340,267 +249,190 @@ describe("Browse store door (PAP-13254 door 1)", () => {
await renderBrowse();
const editButtons = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Edit connections for Notion"]',
),
);
expect(editButtons).toHaveLength(3);
expect(
editButtons.every((button) =>
button.textContent?.includes("Edit connections"),
),
).toBe(true);
// Interrupted setup remains resumable but is not represented as a
// successful provider connection.
expect(container.textContent).toContain("2 connected");
expect(container.textContent).toContain("Dottas Notion");
expect(
container.querySelector('[title="Dotta"] [data-slot="avatar"]'),
).toBeTruthy();
const connectedAppTiles = Array.from(
const rows = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Connected apps"] > [data-app-slug]',
'[aria-label="Connector list"] > [data-app-slug]',
),
);
expect(connectedAppTiles).toHaveLength(1);
expect(connectedAppTiles[0]?.dataset.appSlug).toBe("notion");
const pageText = container.textContent ?? "";
expect(pageText.indexOf("Popular")).toBeLessThan(
pageText.indexOf("Connected"),
);
expect(pageText.indexOf("Connected")).toBeLessThan(
pageText.indexOf("All apps"),
);
const popularAppTiles = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Popular apps"] > [data-app-slug]',
),
);
const popularGrid = container.querySelector<HTMLElement>(
'[aria-label="Popular apps"]',
);
const connectedGrid = container.querySelector<HTMLElement>(
'[aria-label="Connected apps"]',
);
for (const grid of [popularGrid, connectedGrid]) {
expect(grid?.className).toContain("lg:grid-cols-4");
expect(grid?.className).toContain("xl:grid-cols-6");
}
for (const tile of popularAppTiles) {
expect(tile.className).toContain("min-w-0");
expect(
tile.querySelector('[data-slot="app-tile-status"]')?.className,
).toContain("min-h-4");
expect(
tile.querySelector('[data-slot="app-tile-primary-action"]')?.className,
).toContain("w-full");
expect(
tile.querySelector('[data-slot="app-tile-primary-action"] button')
?.className,
).toContain("max-w-full");
expect(
tile.querySelector('[data-slot="app-tile-secondary-action"]')
?.className,
).toContain("min-h-5");
}
const allAppTiles = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="All apps"] > [data-app-slug]',
),
);
const notionAllAppsTile = allAppTiles.find(
(tile) => tile.dataset.appSlug === "notion",
);
expect(notionAllAppsTile?.dataset.connected).toBe("true");
expect(notionAllAppsTile?.textContent).toContain("Dottas Notion");
expect(rows[0]?.dataset.appSlug).toBe("notion");
const notion = rows[0]!;
expect(notion.textContent).toContain("devinfoley@gmail.com");
expect(notion.textContent).toContain("ops@example.com");
expect(notion.textContent).toContain("Connected by");
expect(notion.textContent).toContain("Dotta");
expect(notion.textContent).toContain("The saved sign-in expired.");
expect(notion.querySelector('button[aria-label="Add account Notion"]')).toBeTruthy();
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-title"]')
?.className,
).toContain("break-words");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-title"]')
?.className,
).not.toContain("truncate");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-header-status"]')
?.className,
).toContain("min-h-5");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-connection-name"]')
?.className,
).toContain("break-words");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-connection-name"]')
?.className,
).not.toContain("truncate");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-details"]')
?.className,
).toContain("mt-auto");
expect(
notionAllAppsTile?.querySelector('[data-slot="app-tile-actions"]'),
notion.querySelector('button[aria-label="Manage devinfoley@gmail.com connection"]'),
).toBeTruthy();
expect(
notionAllAppsTile?.querySelector(
'button[aria-label="Add another Notion account"]',
),
).toBeTruthy();
expect(
notionAllAppsTile?.querySelector(
'button[aria-label="Edit connections for Notion"]',
),
notion.querySelector('button[aria-label="Manage ops@example.com connection"]'),
).toBeTruthy();
await act(async () => {
editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
notion
.querySelector<HTMLButtonElement>(
'button[aria-label="Open devinfoley@gmail.com connection settings"]',
)
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/app/app-notion/setup");
expect(navigateMock).toHaveBeenCalledWith("/apps/conn-notion/setup");
const addAnother = container.querySelector<HTMLButtonElement>(
'button[aria-label="Add another Notion account"]',
);
expect(addAnother?.textContent).toContain("Add new");
await act(async () => {
addAnother?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
notion
.querySelector<HTMLButtonElement>('button[aria-label="Add account Notion"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(
"/apps/connect?source=notion&applicationId=app-notion&name=Notion&new=1",
);
const reconnect = Array.from(notion.querySelectorAll("button")).find(
(button) => button.textContent === "Reconnect",
);
await act(async () => {
reconnect?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/apps/conn-expired/setup");
});
it("associates a legacy generic Zapier URL connection with the curated Zapier card", async () => {
listGalleryMock.mockResolvedValueOnce({
apps: [
galleryEntry({
key: "zapier",
name: "Zapier",
tagline: "Connect automations.",
urlPatterns: ["https://mcp.zapier.com/*"],
it("removes a connection from the overflow menu only after destructive confirmation", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({ connections: [connection()] });
await renderBrowse();
const menuTrigger = container.querySelector<HTMLButtonElement>(
'button[aria-label="Manage devinfoley@gmail.com connection"]',
);
expect(menuTrigger).toBeTruthy();
await act(async () => {
menuTrigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
});
await flushReact();
const removeItem = Array.from(document.body.querySelectorAll<HTMLElement>("[role=\"menuitem\"]"))
.find((item) => item.textContent?.trim() === "Remove connection");
expect(removeItem).toBeTruthy();
expect(removeItem?.getAttribute("data-variant")).toBe("destructive");
await act(async () => {
removeItem?.dispatchEvent(new Event("click", { bubbles: true }));
});
await flushReact();
expect(archiveConnectionMock).not.toHaveBeenCalled();
expect(document.body.textContent).toContain(
"Remove devinfoley@gmail.com connection?",
);
const confirmButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Remove connection",
);
expect(confirmButton).toBeTruthy();
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(archiveConnectionMock).toHaveBeenCalledWith("conn-notion", {
confirmComposioChildren: false,
});
expect(pushToastMock).toHaveBeenCalledWith(
expect.objectContaining({
title: "Connection removed",
tone: "success",
}),
);
});
it("keeps an interrupted account visible and resumes setup from its account row", async () => {
listApplicationsMock.mockResolvedValue({ applications: [application()] });
listConnectionsMock.mockResolvedValue({
connections: [
connection({
id: "conn-draft",
name: "Notion",
status: "draft",
healthStatus: "unchecked",
}),
],
});
listApplicationsMock.mockResolvedValueOnce({
applications: [
{
id: "app-zapier-link",
name: "Zapier for the company",
status: "active",
applicationKey: "app-gallery:link:legacy",
metadata: { source: "link" },
},
],
});
listConnectionsMock.mockResolvedValueOnce({
connections: [
{
id: "conn-zapier",
applicationId: "app-zapier-link",
name: "Zapier for the company",
status: "active",
config: { url: "https://mcp.zapier.com/api/v1/connect" },
transportConfig: { url: "https://mcp.zapier.com/api/v1/connect" },
},
],
});
await renderBrowse();
expect(
container.querySelector('button[aria-label="Connect for Zapier"]'),
).toBeNull();
expect(
container.querySelector(
'button[aria-label="Edit connection for Zapier"]',
),
).toBeTruthy();
expect(container.textContent).toContain("1 connected");
});
it("keeps an existing draft-only Notion connection resumable without calling it connected", async () => {
const applicationId = "057a2df6-175f-4dde-b246-743706444122";
const connectionId = "46dc23c1-ecfa-46f7-8e60-34a7cdbd661e";
listApplicationsMock.mockResolvedValue({
applications: [
{
id: applicationId,
name: "Notion",
status: "active",
applicationKey: "notion",
metadata: {},
},
],
});
listConnectionsMock.mockResolvedValue({
connections: [
{
id: connectionId,
applicationId,
name: "Notion",
status: "draft",
config: { sourceTemplateKey: "notion" },
transportConfig: {},
},
],
});
await renderBrowse();
const finishButtons = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'button[aria-label="Finish setup for Notion"]',
),
);
expect(finishButtons).toHaveLength(2);
expect(
container.querySelector('button[aria-label="Connect for Notion"]'),
).toBeNull();
expect(container.textContent).toContain("Setup incomplete");
expect(container.textContent).not.toContain("1 connected");
expect(container.querySelector('[aria-label="Connected apps"]')).toBeNull();
expect(
container.querySelector(
'button[aria-label="Add another Notion account"]',
),
).toBeNull();
const allAppTiles = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="All apps"] > [data-app-slug]',
),
const finish = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Finish setup",
);
const notionAllAppsTile = allAppTiles.find(
(tile) => tile.dataset.appSlug === "notion",
);
expect(notionAllAppsTile?.dataset.connected).toBe("false");
expect(notionAllAppsTile?.dataset.setupPending).toBe("true");
await act(async () => {
finishButtons[0]?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
finish?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith(
`/apps/connect?source=notion&resume=${connectionId}`,
"/apps/connect?source=notion&resume=conn-draft",
);
});
it("keeps the custom URL option available when gallery search has no matches", async () => {
it("filters the single list without restoring section chrome", async () => {
await renderBrowse();
const input = container.querySelector<HTMLInputElement>(
'input[type="search"]',
'input[aria-label="Search connectors"]',
);
const setter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
setter?.call(input, "missing app");
setter?.call(input, "jira");
input?.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
expect(container.textContent).toContain("No planned apps match");
expect(container.textContent).toContain("Connect your own tool");
const rows = Array.from(
container.querySelectorAll<HTMLElement>(
'[aria-label="Connector list"] > [data-app-slug]',
),
);
expect(rows.map((row) => row.dataset.appSlug)).toEqual(["jira"]);
expect(container.textContent).not.toContain("Popular");
expect(container.textContent).not.toContain("All apps");
});
it("shows existing accounts and an actionable warning when the gallery request fails", async () => {
listGalleryMock.mockRejectedValue(new Error("Gallery unavailable"));
listApplicationsMock.mockResolvedValue({
applications: [
application({
id: "custom-app",
name: "Internal search",
applicationKey: "custom:search",
metadata: { source: "link" },
}),
],
});
listConnectionsMock.mockResolvedValue({
connections: [
connection({
id: "custom-connection",
applicationId: "custom-app",
name: "search.internal.example",
config: {},
}),
],
});
await renderBrowse();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Couldnt load every connector",
);
expect(container.textContent).toContain("Internal search");
expect(container.textContent).toContain("search.internal.example");
expect(Array.from(container.querySelectorAll("button")).some(
(button) => button.textContent === "Try again",
)).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,6 @@ import {
type AppGalleryDisplayEntry,
} from "./app-definition-display";
import { useReviewCount } from "./useReviewCount";
import { AdvancedToolsLink } from "./store-cards";
import { connectionNameForCredentialPolicy, connectionTypeLabel } from "./connection-identity";
import {
ConnectionOwnerIdentity,
@ -531,11 +530,10 @@ export function Connections() {
</table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs text-muted-foreground">
Apps you connect become available to every agent unless you change Who can use it.
</p>
<AdvancedToolsLink />
</div>
</div>
)}

View File

@ -29,7 +29,17 @@ describe("app connect policy", () => {
expect(canEnterAppsConnect(new URLSearchParams("source=context7"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("source=zapier"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("source=unknown"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("byo=1&source=zapier"))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams("byo=1"))).toBe(false);
expect(canEnterAppsConnect(new URLSearchParams("byo=1&source=zapier"))).toBe(false);
});
it("keeps only exact custom MCP reconnects on the legacy BYO query contract", () => {
expect(canEnterAppsConnect(new URLSearchParams(
"byo=1&reconnect=connection-1&applicationId=application-1&link=https%3A%2F%2Fmcp.example.com",
))).toBe(true);
expect(canEnterAppsConnect(new URLSearchParams(
"byo=1&reconnect=connection-1&applicationId=application-1",
))).toBe(false);
});
it("admits retained hidden-provider reconnects without opening fresh setup", () => {

View File

@ -42,7 +42,15 @@ export function resolveAppsConnectRouteKey(input: {
}
export function canEnterAppsConnect(searchParams: URLSearchParams): boolean {
if (searchParams.get("byo") === "1") return true;
if (searchParams.get("byo") === "1") {
// The old BYO discovery page has moved to the Connectors list. Keep only
// exact custom-connection reconnects using this legacy query contract.
return Boolean(
searchParams.get("reconnect")?.trim()
&& searchParams.get("applicationId")?.trim()
&& searchParams.get("link")?.trim(),
);
}
const source = searchParams.get("source") ?? "";
const entry = getAppStoreDefinition(source);
// A retained connection may belong to a provider hidden from fresh catalog

View File

@ -9,6 +9,7 @@ import type { ToolCatalogEntry } from "@paperclipai/shared";
import { TestPanel, errorHints } from "./TestPanel";
const listTestAgentsMock = vi.hoisted(() => vi.fn());
const getTestAgentAccessMock = vi.hoisted(() => vi.fn());
const runTestCallMock = vi.hoisted(() => vi.fn());
const getTestCallStatusMock = vi.hoisted(() => vi.fn());
const declineActionRequestMock = vi.hoisted(() => vi.fn());
@ -16,6 +17,8 @@ const declineActionRequestMock = vi.hoisted(() => vi.fn());
vi.mock("@/api/tools", () => ({
toolsApi: {
listTestAgents: (connectionId: string) => listTestAgentsMock(connectionId),
getTestAgentAccess: (connectionId: string, agentId: string) =>
getTestAgentAccessMock(connectionId, agentId),
runTestCall: (connectionId: string, input: unknown) => runTestCallMock(connectionId, input),
getTestCallStatus: (connectionId: string, actionRequestId: string) =>
getTestCallStatusMock(connectionId, actionRequestId),
@ -177,12 +180,12 @@ const offEntry = catalogEntry({
let container: HTMLDivElement;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let root: any;
let client: QueryClient;
function renderPanel(
active: ToolCatalogEntry[] = [readEntry, writeAskEntry, offEntry],
quarantined: ToolCatalogEntry[] = [],
) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
root.render(
<QueryClientProvider client={client}>
<TestPanel connectionId="conn-1" appName="Google Sheets" active={active} quarantined={quarantined} />
@ -194,11 +197,14 @@ beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
listTestAgentsMock.mockReset();
getTestAgentAccessMock.mockReset();
runTestCallMock.mockReset();
getTestCallStatusMock.mockReset();
declineActionRequestMock.mockReset();
listTestAgentsMock.mockResolvedValue({ agents: [agent()] });
getTestAgentAccessMock.mockResolvedValue({ access: agent().effectiveAccess });
// Default ask-first polls report the request still waiting on approval.
getTestCallStatusMock.mockResolvedValue({
actionRequestId: "req-1",
@ -219,14 +225,30 @@ afterEach(() => {
});
describe("TestPanel", () => {
it("pairs the loading skeleton with explicit MCP wait copy and animation", async () => {
it("shows a lightweight agent-loading state before requesting permissions", async () => {
listTestAgentsMock.mockImplementation(() => new Promise(() => undefined));
await act(async () => renderPanel());
expect(container.textContent).toContain("Loading MCP actions, this may take a minute.");
expect(container.textContent).toContain("Loading agents…");
expect(container.querySelector(".animate-spin")).toBeTruthy();
expect(container.querySelectorAll('[data-slot="skeleton"]')).not.toHaveLength(0);
expect(getTestAgentAccessMock).not.toHaveBeenCalled();
});
it("loads permissions only for the selected agent and reuses the cached summary", async () => {
await act(async () => renderPanel());
await flushReact();
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
expect(getTestAgentAccessMock).toHaveBeenCalledWith("conn-1", "agent-claude");
await act(async () => root.unmount());
root = createRoot(container);
await act(async () => renderPanel());
await flushReact();
expect(getTestAgentAccessMock).toHaveBeenCalledTimes(1);
});
it("renders the test hierarchy and grouped actions with access badges", async () => {
@ -472,17 +494,13 @@ describe("TestPanel", () => {
});
it("shows the 'Last changed by' audit hint when the access summary carries one", async () => {
listTestAgentsMock.mockResolvedValue({
agents: [
agent({
effectiveAccess: {
...agent().effectiveAccess,
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
lastChangedByAgentId: "agent-admin",
lastChangedByName: "Dotta",
},
}),
],
getTestAgentAccessMock.mockResolvedValue({
access: {
...agent().effectiveAccess,
lastChangedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
lastChangedByAgentId: "agent-admin",
lastChangedByName: "Dotta",
},
});
await act(async () => renderPanel());
await flushReact();

View File

@ -14,6 +14,7 @@ import {
} from "lucide-react";
import type {
ToolCatalogEntry,
ToolConnectionAccessSummary,
ToolConnectionTestAgent,
ToolConnectionTestCallResult,
ToolConnectionTestCallStatus,
@ -70,6 +71,13 @@ function actionSubLine(entry: ToolCatalogEntry): string | null {
type DecisionMeta = { label: string; className: string };
type TestAgentWithAccess = ToolConnectionTestAgent & {
effectiveAccess: ToolConnectionAccessSummary;
};
const TEST_ACCESS_STALE_TIME_MS = 5 * 60_000;
const TEST_ACCESS_GC_TIME_MS = 30 * 60_000;
const DECISION_META: Record<ToolConnectionTestDecision, DecisionMeta> = {
allowed: {
label: "Allowed",
@ -116,10 +124,11 @@ export function TestPanel({
/** New, not-yet-reviewed actions — shown as Off so they're reachable to test. */
quarantined?: ToolCatalogEntry[];
}) {
const hasActions = active.length > 0 || quarantined.length > 0;
const testAgentsQuery = useQuery({
queryKey: queryKeys.tools.testAgents(connectionId),
queryFn: () => toolsApi.listTestAgents(connectionId),
enabled: !!connectionId,
enabled: !!connectionId && hasActions,
});
const agents = useMemo(
@ -129,17 +138,27 @@ export function TestPanel({
[testAgentsQuery.data],
);
const [agentId, setAgentId] = useState<string | null>(null);
const [requestedAgentId, setRequestedAgentId] = useState<string | null>(null);
// The API returns only agents this user may write to. Prefer the highest
// agent in that accessible slice of the org tree, regardless of whether a
// lower-ranked agent happens to have a broader app policy today.
useEffect(() => {
if (agentId && agents.some((a) => a.id === agentId)) return;
if (agents.length === 0) return;
setAgentId(agents[0].id);
}, [agents, agentId]);
const selectedAgent = agents.find((a) => a.id === agentId) ?? null;
const agentId = requestedAgentId && agents.some((agent) => agent.id === requestedAgentId)
? requestedAgentId
: agents[0]?.id ?? null;
const selectedAgentBase = agents.find((agent) => agent.id === agentId) ?? null;
const testAgentAccessQuery = useQuery({
queryKey: queryKeys.tools.testAgentAccess(connectionId, agentId ?? "__none__"),
queryFn: () => toolsApi.getTestAgentAccess(connectionId, agentId!),
enabled: !!connectionId && !!agentId && hasActions,
staleTime: TEST_ACCESS_STALE_TIME_MS,
gcTime: TEST_ACCESS_GC_TIME_MS,
refetchOnWindowFocus: false,
});
const selectedAgent = useMemo<TestAgentWithAccess | null>(() => (
selectedAgentBase && testAgentAccessQuery.data
? { ...selectedAgentBase, effectiveAccess: testAgentAccessQuery.data.access }
: null
), [selectedAgentBase, testAgentAccessQuery.data]);
// Per-action decision for the selected agent, keyed by both the upstream and
// gateway tool names so we can match whatever the catalog stores.
@ -182,12 +201,16 @@ export function TestPanel({
const visibleQuarantined = quarantinedActions.filter(matches);
const visibleCount = visibleRead.length + visibleWrite.length + visibleQuarantined.length;
if (!hasActions) {
return <EmptyState connectionId={connectionId} appName={appName} />;
}
if (testAgentsQuery.isLoading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading MCP actions, this may take a minute.
Loading agents
</div>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
@ -196,8 +219,13 @@ export function TestPanel({
);
}
if (active.length === 0 && quarantinedActions.length === 0) {
return <EmptyState connectionId={connectionId} appName={appName} />;
if (testAgentsQuery.isError) {
return (
<TestLoadError
message="We couldn't load the agents available for testing."
onRetry={() => { void testAgentsQuery.refetch(); }}
/>
);
}
if (agents.length === 0) {
@ -215,11 +243,34 @@ export function TestPanel({
);
}
if (testAgentAccessQuery.isError && !testAgentAccessQuery.data) {
return (
<TestLoadError
message={`We couldn't load ${selectedAgentBase?.name ?? "this agent"}'s permissions.`}
onRetry={() => { void testAgentAccessQuery.refetch(); }}
/>
);
}
if (testAgentAccessQuery.isLoading || !selectedAgent) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground" role="status">
<Loader2 className="h-4 w-4 animate-spin" />
Loading agent permissions
</div>
<Skeleton className="h-20 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
);
}
const sharedRowProps = {
connectionId,
appName,
allAgents: agents,
onSelectAgent: setAgentId,
onSelectAgent: setRequestedAgentId,
};
return (
@ -229,7 +280,7 @@ export function TestPanel({
appName={appName}
agents={agents}
selectedAgent={selectedAgent}
onSelect={setAgentId}
onSelect={setRequestedAgentId}
connectionId={connectionId}
/>
)}
@ -312,6 +363,17 @@ function EmptyState({ connectionId, appName }: { connectionId: string; appName:
);
}
function TestLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="py-8 text-center">
<p className="text-sm font-medium text-foreground">{message}</p>
<Button className="mt-3" size="sm" variant="outline" onClick={onRetry}>
Try again
</Button>
</div>
);
}
// ---------------------------------------------------------------------------
// Test-as header + agent picker
// ---------------------------------------------------------------------------
@ -325,7 +387,7 @@ function TestAsHeader({
}: {
appName: string;
agents: ToolConnectionTestAgent[];
selectedAgent: ToolConnectionTestAgent;
selectedAgent: TestAgentWithAccess;
onSelect: (agentId: string) => void;
connectionId: string;
}) {
@ -415,8 +477,7 @@ function AgentPicker({
<p className="px-3 py-4 text-center text-xs text-muted-foreground">No agents match.</p>
) : (
filtered.map((agent) => {
const summary = agent.effectiveAccess;
const noAccess = summary.allowedCount === 0 && summary.askFirstCount === 0;
const detail = agent.title?.trim() || agent.role;
return (
<button
key={agent.id}
@ -439,11 +500,7 @@ function AgentPicker({
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{agent.name}</span>
<span className="block truncate text-xs text-muted-foreground">
{noAccess
? "No access — not allowed for any action"
: `Allowed ${summary.allowedCount} · Ask first ${summary.askFirstCount} · Off ${summary.offCount}`}
</span>
<span className="block truncate text-xs text-muted-foreground">{detail}</span>
</span>
</button>
);
@ -516,7 +573,7 @@ function ActionGroup({
subheading?: string;
entries: ToolCatalogEntry[];
decisionFor: (entry: ToolCatalogEntry) => ToolConnectionTestDecision;
agent: ToolConnectionTestAgent;
agent: TestAgentWithAccess;
} & RowSharedProps) {
return (
<section>
@ -545,7 +602,7 @@ function ActionRow({
}: {
entry: ToolCatalogEntry;
decision: ToolConnectionTestDecision;
agent: ToolConnectionTestAgent;
agent: TestAgentWithAccess;
} & RowSharedProps) {
const [open, setOpen] = useState(() => Boolean(loadStoredAskFirstOutcome(shared.connectionId, entry, agent)));
const title = entry.title ?? entry.toolName;
@ -666,7 +723,7 @@ function ActionTester({
}: {
entry: ToolCatalogEntry;
decision: ToolConnectionTestDecision;
agent: ToolConnectionTestAgent;
agent: TestAgentWithAccess;
} & RowSharedProps) {
const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany();
@ -1286,7 +1343,6 @@ function AskFirstResult({
function OffExplanation({
entry,
connectionId,
appName,
agent,
allAgents,
onSelectAgent,
@ -1294,28 +1350,21 @@ function OffExplanation({
entry: ToolCatalogEntry;
connectionId: string;
appName: string;
agent: ToolConnectionTestAgent;
agent: TestAgentWithAccess;
allAgents: ToolConnectionTestAgent[];
onSelectAgent: (agentId: string) => void;
}) {
const title = entry.title ?? entry.toolName;
const permHref = `${appTabHref(connectionId, "permissions")}?focus=${encodeURIComponent(entry.id)}`;
// Decision for this action across every agent we can test as.
// Other agents are intentionally not summarized up front. Selecting one
// fetches and caches only that agent's access, keeping this screen fast even
// for large companies.
const others = allAgents.filter((a) => a.id !== agent.id);
const decisionOf = (a: ToolConnectionTestAgent): ToolConnectionTestDecision => {
const tool = a.effectiveAccess.tools.find(
(t) => t.toolName === entry.toolName || t.gatewayToolName === entry.toolName,
);
return tool?.decision ?? "off";
};
const allOff = allAgents.every((a) => decisionOf(a) === "off");
const whyBody = entry.status === "quarantined"
? "This action is new and hasn't been turned on yet."
: allOff
? "An admin set it to Off for all agents using this app."
: `${agent.name}'s access profile sets this action to Off.`;
: `${agent.name}'s access profile sets this action to Off.`;
// "Last changed by {Actor} · {relativeTime}" — only the access config carries
// this; a quarantined action has never been configured, so there's nothing to
@ -1326,9 +1375,6 @@ function OffExplanation({
? `Last changed${lastChangedByName ? ` by ${lastChangedByName}` : ""} · ${relTime(new Date(lastChangedAt))}`
: null;
const otherSettings = others.map((a) => ({ name: a.name, decision: decisionOf(a) }));
const tryAgents = others.filter((a) => decisionOf(a) !== "off");
return (
<div className="grid gap-3 md:grid-cols-(--gtc-62)">
<div className="space-y-3">
@ -1356,23 +1402,11 @@ function OffExplanation({
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Why this is off</p>
<p className="mt-1.5 text-xs text-muted-foreground">{whyBody}</p>
{auditHint && <p className="mt-1.5 text-(length:--text-micro) text-muted-foreground">{auditHint}</p>}
{otherSettings.length > 0 && (
<div className="mt-3">
<p className="text-(length:--text-micro) font-medium text-muted-foreground">Other agents using {appName}:</p>
<ul className="mt-1 space-y-0.5 text-(length:--text-micro) text-muted-foreground">
{otherSettings.map((s) => (
<li key={s.name}>
{s.name}: <span className="text-foreground">{DECISION_META[s.decision].label}</span>
</li>
))}
</ul>
</div>
)}
{tryAgents.length > 0 && (
{others.length > 0 && (
<div className="mt-3">
<p className="text-(length:--text-micro) font-medium text-muted-foreground">Try as a different agent:</p>
<div className="mt-1 flex flex-wrap gap-1.5">
{tryAgents.slice(0, 4).map((other) => (
{others.slice(0, 4).map((other) => (
<button
key={other.id}
type="button"

View File

@ -36,7 +36,15 @@ export function connectionDisplayNameForOwner(
applicationName: string,
owner: ConnectionOwnerProfile | null,
): string {
const connectionName = humanizeConnectionDisplayName(connection);
const rawName = connection.name.trim();
// Provider account identifiers are machine values, not prose. Preserve
// their casing and punctuation so an email address or tenant hostname stays
// recognizable in the inline account list.
const connectionName =
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawName) ||
/^(?:[a-z0-9-]+\.)+[a-z]{2,}$/i.test(rawName)
? rawName
: humanizeConnectionDisplayName(connection);
if (!owner) return connectionName;
if (connectionName.trim().toLocaleLowerCase() !== applicationName.trim().toLocaleLowerCase()) {
return connectionName;

View File

@ -1,54 +0,0 @@
import { ServerCog, Wrench } from "lucide-react";
import { Link } from "@/lib/router";
import { advancedTabHref } from "@/pages/tools/tool-tabs";
import { appSourceConnectHref } from "./app-connect-policy";
/** Popular gallery keys surfaced first in the Browse store (PAP-13254, door 1). */
export const POPULAR_KEYS = ["zapier", "notion", "posthog", "linear", "jira", "cloudflare"];
/** Deep-link into the Connect wizard's bring-your-own-tool URL flow. */
export const BYO_CONNECT_HREF = "/apps/connect?byo=1";
/** Zapier connects with the complete MCP URL issued by Zapier. */
export const ZAPIER_CONNECT_HREF = "/apps/connect?byo=1&source=zapier";
/** MCP-direct OAuth apps enter through the generic source deep link. */
export const NOTION_CONNECT_HREF = appSourceConnectHref("notion");
/**
* First-class "Connect your own tool" card (PAP-12371, Finding C; PAP-13254).
* Lives in Browse as a persistent row and launches the guided URL flow.
*/
export function ByoConnectCard({ onConnect }: { onConnect: () => void }) {
return (
<button
type="button"
onClick={onConnect}
className="flex w-full items-center gap-4 rounded-xl border border-dashed border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"
>
<span className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg border border-border bg-background">
<ServerCog className="h-5 w-5 text-muted-foreground" />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">Connect your own tool</div>
<div className="text-xs text-muted-foreground">
Paste the URL from a custom or self-hosted MCP server. All discovered actions are enabled automatically.
</div>
</div>
<span className="shrink-0 text-xs font-semibold text-primary">Connect </span>
</button>
);
}
/** Labeled door to the developer control-plane (PAP-12371, Finding A cross-link). */
export function AdvancedToolsLink() {
return (
<Link
to={advancedTabHref("run-your-own")}
className="inline-flex items-center gap-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
<Wrench className="h-3.5 w-3.5" />
Developer tools (advanced)
</Link>
);
}

View File

@ -408,7 +408,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
);
});
it("cancels to the original connection setup route without creating another draft", async () => {
it("backs up to the original connection setup route without creating another draft", async () => {
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
@ -417,7 +417,7 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
});
await flushReact();
await act(async () => {
buttonStartingWith("Back to apps")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
buttonStartingWith("Back")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps/conn-1/setup");
@ -425,6 +425,21 @@ describe("PasteConfigTab — activation handoff (PAP-11092)", () => {
expect(toolsApiMock.startOAuth).not.toHaveBeenCalled();
});
it("cancels the OAuth checkpoint to the apps page", async () => {
await pasteAndCheck(NOTION_PREVIEW, NOTION_CONFIG);
toolsApiMock.connectApp.mockResolvedValue(oauthConnectResult("javascript:alert(1)"));
await act(async () => {
buttonStartingWith("Check actions")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await act(async () => {
buttonStartingWith("Cancel")!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockNavigate).toHaveBeenCalledWith("/apps");
});
it("does not offer Continue for a stdio draft (draft-only, no link to hand off)", async () => {
await pasteAndCheck(
{

View File

@ -238,7 +238,8 @@ export function PasteConfigTab({ companyId }: { companyId: string }) {
setOAuthPhase("starting");
oauthStartMutation.mutate(connectResult.connectionId);
}}
onCancel={() => navigate(`/apps/${connectResult.connectionId}/setup`)}
onBack={() => navigate(`/apps/${connectResult.connectionId}/setup`)}
onCancel={() => navigate("/apps")}
/>
);
}

View File

@ -698,6 +698,7 @@ export function ProfilesTab({ companyId }: { companyId: string }) {
const invalidateProfiles = () => {
qc.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
qc.invalidateQueries({ queryKey: ["tools", companyId, "profiles", "effective"] });
qc.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
};
const resetProfileForm = () => {

View File

@ -1,290 +0,0 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Lock, Plus, ShieldCheck, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { queryKeys } from "@/lib/queryKeys";
import { toolsApi } from "@/api/tools";
import { useToast } from "@/context/ToastContext";
import { LoadingState, ErrorState, RelativeTime } from "./shared";
const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/i;
/** Slugify a display name into a `safeKeyPattern`-valid template id. */
function toTemplateId(name: string): string {
const slug = name
.trim()
.toLowerCase()
.replace(/[^a-z0-9._:-]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
return slug;
}
/** Split a typed command line into command + args on whitespace. */
function splitCommand(raw: string): { command: string; args: string[] } {
const parts = raw.trim().split(/\s+/).filter(Boolean);
return { command: parts[0] ?? "", args: parts.slice(1) };
}
type KeyRow = { id: number; value: string };
/**
* M8b "Run your own" tab on the Advanced door (PAP-10862, plan D8).
*
* Admin-only surface over P5a's command-template routes
* (`POST /companies/:id/tools/stdio-templates`). Registers a command that
* Paperclip will run in the company's isolated workspace and the keys it
* expects. One of the two M8 screens where "MCP" vocabulary is allowed.
*/
export function RunYourOwnTab({ companyId }: { companyId: string }) {
const qc = useQueryClient();
const { pushToast } = useToast();
const [name, setName] = useState("");
const [command, setCommand] = useState("");
const [keyRows, setKeyRows] = useState<KeyRow[]>([]);
const [nextRowId, setNextRowId] = useState(1);
const templates = useQuery({
queryKey: queryKeys.tools.stdioTemplates(companyId),
queryFn: () => toolsApi.listStdioTemplates(companyId),
});
const envKeys = useMemo(
() => keyRows.map((row) => row.value.trim()).filter(Boolean),
[keyRows],
);
const invalidKeys = envKeys.filter((key) => !ENV_KEY_RE.test(key));
const parsed = splitCommand(command);
const templateId = toTemplateId(name);
const canSubmit =
name.trim().length > 0 &&
parsed.command.length > 0 &&
templateId.length > 0 &&
invalidKeys.length === 0;
const createMutation = useMutation({
mutationFn: () =>
toolsApi.createStdioTemplate(companyId, {
templateId,
name: name.trim(),
command: parsed.command,
args: parsed.args,
envKeys,
}),
onSuccess: () => {
pushToast({ title: "Tool added", body: `"${name.trim()}" is ready to connect.`, tone: "success" });
setName("");
setCommand("");
setKeyRows([]);
qc.invalidateQueries({ queryKey: queryKeys.tools.stdioTemplates(companyId) });
},
});
const addKeyRow = () => {
setKeyRows((rows) => [...rows, { id: nextRowId, value: "" }]);
setNextRowId((id) => id + 1);
};
const adminTemplates = (templates.data?.templates ?? []).filter((t) => t.source === "admin");
return (
<div className="space-y-6">
<p className="max-w-2xl text-sm text-muted-foreground">
For a tool that runs from a command. Paperclip runs it in your organization's own isolated workspace.
Administrators only.
</p>
<div className="space-y-5 rounded-lg border border-border bg-card p-5">
<div className="space-y-1.5">
<Label htmlFor="ryo-name">Name</Label>
<Input
id="ryo-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="Acme tools"
maxLength={160}
/>
<p className="text-xs text-muted-foreground">What you'll call this tool in your apps list.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ryo-command">Command</Label>
<Input
id="ryo-command"
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder="npx -y @acme/mcp-tool"
spellCheck={false}
className="bg-slate-900 font-mono text-(length:--text-compact) text-slate-100 placeholder:text-slate-500 focus-visible:ring-slate-400"
/>
<p className="text-xs text-muted-foreground">The command that runs the tool. From the tool's README.</p>
</div>
<div className="space-y-2">
<div className="flex items-baseline gap-2">
<Label>Keys it needs</Label>
<span className="text-xs text-muted-foreground">· optional, depends on the tool</span>
</div>
{keyRows.length > 0 ? (
<div className="space-y-2">
{keyRows.map((row) => {
const value = row.value.trim();
const invalid = value.length > 0 && !ENV_KEY_RE.test(value);
return (
<div key={row.id} className="space-y-1">
<div className="flex items-center gap-2">
<Input
value={row.value}
onChange={(event) =>
setKeyRows((rows) =>
rows.map((r) => (r.id === row.id ? { ...r, value: event.target.value } : r)),
)
}
placeholder="API_KEY"
spellCheck={false}
className={`font-mono text-(length:--text-compact) ${invalid ? "border-destructive" : ""}`}
/>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Remove key"
onClick={() => setKeyRows((rows) => rows.filter((r) => r.id !== row.id))}
>
<X className="h-4 w-4" />
</Button>
</div>
{invalid ? (
<p className="text-xs text-destructive">
Use letters, numbers and underscores, starting with a letter or underscore (e.g. API_KEY).
</p>
) : null}
</div>
);
})}
</div>
) : null}
<Button type="button" variant="outline" size="sm" onClick={addKeyRow} className="gap-1.5">
<Plus className="h-3.5 w-3.5" />
Add a key
</Button>
</div>
<div className="flex items-start gap-2.5 rounded-md bg-muted/50 px-3 py-2.5">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<div className="text-xs">
<p className="font-medium text-foreground">
This runs in your company's own workspace, isolated from everything else.
</p>
<p className="mt-0.5 flex items-center gap-1 text-muted-foreground">
<Lock className="h-3 w-3" />
Only administrators see this option.
</p>
</div>
</div>
{createMutation.isError ? <ErrorState error={createMutation.error} /> : null}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => createMutation.mutate()} disabled={!canSubmit || createMutation.isPending}>
{createMutation.isPending ? "Adding…" : "Check & continue"}
</Button>
<span className="text-xs text-muted-foreground">
Paperclip will register the command and the keys it needs.
</span>
</div>
</div>
<div className="space-y-3">
<h3 className="text-sm font-semibold text-foreground">Your own tools</h3>
{templates.isLoading ? (
<LoadingState />
) : templates.isError ? (
<ErrorState error={templates.error} onRetry={() => templates.refetch()} />
) : adminTemplates.length === 0 ? (
<p className="text-sm text-muted-foreground">You haven't added any of your own tools yet.</p>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">Name</th>
<th className="px-4 py-2.5">Command</th>
<th className="px-4 py-2.5">Keys</th>
<th className="px-4 py-2.5">Added</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{adminTemplates.map((template) => (
<RunYourOwnRow key={template.templateId} companyId={companyId} template={template} />
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
function RunYourOwnRow({
companyId,
template,
}: {
companyId: string;
template: import("@/api/tools").StdioTemplateSummary;
}) {
const qc = useQueryClient();
const { pushToast } = useToast();
const disableMutation = useMutation({
mutationFn: () => toolsApi.disableStdioTemplate(companyId, template.templateId),
onSuccess: () => {
pushToast({ title: "Tool turned off", tone: "success" });
qc.invalidateQueries({ queryKey: queryKeys.tools.stdioTemplates(companyId) });
},
onError: (error) => {
pushToast({
title: "Couldn't turn it off",
body: error instanceof Error ? error.message : undefined,
tone: "error",
});
},
});
const disabled = template.status === "disabled";
const fullCommand = [template.command ?? "", ...(template.args ?? [])].join(" ").trim();
return (
<tr className="border-b border-border last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-foreground">{template.name}</div>
{disabled ? <Badge variant="outline">off</Badge> : null}
</td>
<td className="px-4 py-3">
<code className="font-mono text-(length:--text-micro) text-muted-foreground">{fullCommand || "—"}</code>
</td>
<td className="px-4 py-3 text-muted-foreground">
{template.envKeys.length > 0 ? template.envKeys.join(", ") : "none"}
</td>
<td className="px-4 py-3">
<RelativeTime value={template.createdAt} />
</td>
<td className="px-4 py-3 text-right">
{disabled ? null : (
<Button
variant="outline"
size="sm"
onClick={() => disableMutation.mutate()}
disabled={disableMutation.isPending}
>
Turn off
</Button>
)}
</td>
</tr>
);
}

View File

@ -29,18 +29,10 @@ vi.mock("./profiles/ProfilesIndex", () => ({
ProfilesIndex: () => <section>Tool profiles</section>,
}));
vi.mock("./AuditTab", () => ({
AuditTab: () => <section>Audit tab</section>,
}));
vi.mock("./PasteConfigTab", () => ({
PasteConfigTab: () => <section>Paste tab</section>,
}));
vi.mock("./RunYourOwnTab", () => ({
RunYourOwnTab: () => <section>Run your own tab</section>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
@ -82,19 +74,20 @@ describe("ToolsAccess", () => {
});
}
it.each(["applications", "connections", "overview", "examples"])(
"redirects retired %s tab links to All apps",
it.each(["applications", "connections", "overview", "examples", "audit"])(
"redirects retired %s tab links to Connectors",
async (tab) => {
mockParams.tab = tab;
await render();
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/connections", replace: true }));
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps", replace: true }));
},
);
it.each([
["runtime", "/apps/connections"],
["runtime", "/apps"],
["policies", "/apps/advanced/profiles"],
["run-your-own", "/apps"],
])("redirects the retired %s page to %s", async (tab, target) => {
mockParams.tab = tab;
await render();
@ -102,6 +95,14 @@ describe("ToolsAccess", () => {
expect(navigateMock).toHaveBeenCalledWith(expect.objectContaining({ to: target, replace: true }));
});
it("uses Paste a config as the only advanced setup page", async () => {
await render();
expect(container.textContent).toContain("Paste tab");
expect(container.textContent).not.toContain("Run your own");
expect(container.querySelector('a[href="/apps/advanced/paste-config"]')).toBeTruthy();
});
it("uses Profiles as the developer entry point without a second page shell", async () => {
await render();

View File

@ -5,10 +5,8 @@ import { cn } from "@/lib/utils";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { ProfilesIndex } from "./profiles/ProfilesIndex";
import { AuditTab } from "./AuditTab";
import { GatewaysTab } from "./GatewaysTab";
import { PasteConfigTab } from "./PasteConfigTab";
import { RunYourOwnTab } from "./RunYourOwnTab";
import { SmokeLabTab } from "./SmokeLabTab";
import {
ADVANCED_TABS,
@ -22,17 +20,13 @@ function renderTab(tab: ToolTabKey, companyId: string) {
switch (tab) {
case "profiles":
return <ProfilesIndex companyId={companyId} />;
case "audit":
return <AuditTab companyId={companyId} />;
case "gateways":
return <GatewaysTab companyId={companyId} />;
case "smoke-lab":
return <SmokeLabTab companyId={companyId} />;
case "paste-config":
return <PasteConfigTab companyId={companyId} />;
case "run-your-own":
default:
return <RunYourOwnTab companyId={companyId} />;
return <PasteConfigTab companyId={companyId} />;
}
}
@ -40,7 +34,7 @@ export function ToolsAccess() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const params = useParams<{ tab?: string }>();
const activeTab = (TOOL_TABS.find((t) => t.key === params.tab)?.key ?? "run-your-own") as ToolTabKey;
const activeTab = (TOOL_TABS.find((t) => t.key === params.tab)?.key ?? "paste-config") as ToolTabKey;
const advanced = isAdvancedSetupTab(activeTab);
const tabLabel = TOOL_TABS.find((t) => t.key === activeTab)?.label;
@ -51,7 +45,7 @@ export function ToolsAccess() {
...(advanced
? [{ label: "Advanced setup" }]
: [
{ label: "Advanced setup", href: advancedTabHref("run-your-own") },
{ label: "Advanced setup", href: advancedTabHref("paste-config") },
{ label: tabLabel ?? "Developer tools" },
]),
]);
@ -62,18 +56,23 @@ export function ToolsAccess() {
return <div className="p-6 text-sm text-muted-foreground">Select an organization to open advanced setup.</div>;
}
if (params.tab === "run-your-own") {
return <Navigate to="/apps" replace />;
}
// Retired developer tabs (PAP-10915/PAP-10928) — keep old links working.
if (
params.tab === "applications" ||
params.tab === "connections" ||
params.tab === "overview" ||
params.tab === "examples"
params.tab === "examples" ||
params.tab === "audit"
) {
return <Navigate to="/apps/connections" replace />;
return <Navigate to="/apps" replace />;
}
if (params.tab === "runtime") {
return <Navigate to="/apps/connections" replace />;
return <Navigate to="/apps" replace />;
}
if (params.tab === "policies") {
@ -82,7 +81,7 @@ export function ToolsAccess() {
if (advanced) {
// M8a/M8b chrome (PAP-10839 wires): Advanced badge, plain-words subtitle,
// and a two-tab switcher. The developer surface stays behind a quiet link.
// and a focused setup tab. The developer surface stays behind a quiet link.
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5 p-4 sm:p-6">
<header>

View File

@ -76,7 +76,10 @@ export function ProfileDetail({
enabled: pendingNewTools > 0,
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
};
const errorBody = (error: unknown) => String((error as Error)?.message ?? error);
const allowRows = useMemo(

View File

@ -120,6 +120,7 @@ export function ProfileWizard({
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
};
const live = useMemo(

View File

@ -72,8 +72,10 @@ export function ProfilesIndex({
[agents.data],
);
const invalidate = () =>
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.profiles(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.testAgentAccesses() });
};
const errorBody = (error: unknown) => String((error as Error)?.message ?? error);

View File

@ -3,8 +3,6 @@ import {
FlaskConical,
Layers,
Network,
ScrollText,
TerminalSquare,
} from "lucide-react";
/**
@ -14,15 +12,14 @@ import {
*/
export const ADVANCED_TOOLS_BASE = "/apps/advanced";
/** Build a tab href off the Advanced base. `run-your-own` is the bare base path (the door's default tab). */
/** Build a tab href off the Advanced base. */
export function advancedTabHref(tab: ToolTabKey): string {
return tab === "run-your-own" ? ADVANCED_TOOLS_BASE : `${ADVANCED_TOOLS_BASE}/${tab}`;
return `${ADVANCED_TOOLS_BASE}/${tab}`;
}
// M8a/M8b — the prosumer-facing Advanced setup tabs (PAP-10839 wires). The only
// screens where "MCP" vocabulary is permitted (PAP-10827).
export const ADVANCED_TABS = [
{ key: "run-your-own", label: "Run your own", icon: TerminalSquare },
{ key: "paste-config", label: "Paste a config", icon: ClipboardPaste },
] as const;
@ -33,7 +30,6 @@ export const ADVANCED_TABS = [
export const DEVELOPER_TABS = [
{ key: "gateways", label: "Gateways", icon: Network },
{ key: "profiles", label: "Profiles", icon: Layers },
{ key: "audit", label: "Activity", icon: ScrollText },
{ key: "smoke-lab", label: "Smoke Lab", icon: FlaskConical },
] as const;

View File

@ -113,6 +113,7 @@ function OAuthStateHost({
phase={phase}
error={error}
onRetry={() => undefined}
onBack={() => undefined}
onCancel={() => undefined}
/>
</div>

View File

@ -291,8 +291,6 @@ function SeededAccessStep({
<QueryClientProvider client={client}>
<div className="bg-background p-6">
<AccessStep
appName="Gmail"
providerName="Gmail"
companyId={COMPANY}
authKind={authKind}
grantKind={grantKind}
@ -312,7 +310,7 @@ function SeededAccessStep({
}
export const ConnectAccessJustMePickedAgents: Story = {
name: "3 · Connect Access — Just me + Agents I pick",
name: "3 · Connect Access — Just me + Just agents I pick",
render: () => (
<SeededAccessStep
authKind="oauth"

View File

@ -216,6 +216,7 @@ export const RadioCards: Story = {
value: "draft",
title: "Draft for review",
description: "The agent proposes; you approve before it ships.",
icon: <Users className="h-4 w-4" aria-hidden="true" />,
},
{
value: "auto",

View File

@ -3,6 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
ToolCatalogEntry,
ToolConnectionAccessSummary,
ToolConnectionTestAgent,
ToolConnectionTestCallResult,
ToolConnectionTestDecision,
@ -12,6 +13,10 @@ import { TestPanel } from "@/pages/apps/app-detail/TestPanel";
const CONNECTION = "conn-sheets";
type StoryAgent = ToolConnectionTestAgent & {
effectiveAccess: ToolConnectionAccessSummary;
};
// ---------------------------------------------------------------------------
// Catalog (12 actions: 7 read, 5 write) — mirrors the PAP-11348 wireframes.
// ---------------------------------------------------------------------------
@ -133,7 +138,7 @@ function buildAgent(
name: string,
decisions: Record<string, ToolConnectionTestDecision>,
orgDepth = 1,
): ToolConnectionTestAgent {
): StoryAgent {
const tools = CATALOG.map((entry) => decisionTool(entry, decisions[entry.toolName]));
return {
id,
@ -156,7 +161,7 @@ function buildAgent(
};
}
const AGENTS: ToolConnectionTestAgent[] = [
const AGENTS: StoryAgent[] = [
buildAgent("agent-claude", "ClaudeCoder", DECISIONS, 0),
buildAgent("agent-codex", "CodexCoder", {
...DECISIONS,
@ -221,11 +226,18 @@ function runScript(steps: Step[]) {
tick(0);
}
function seededClient(agents: ToolConnectionTestAgent[]): QueryClient {
function seededClient(agents: StoryAgent[]): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { staleTime: Infinity, gcTime: Infinity, retry: false, refetchOnMount: false } },
});
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), { agents });
client.setQueryData(queryKeys.tools.testAgents(CONNECTION), {
agents: agents.map(({ effectiveAccess: _effectiveAccess, ...agent }) => agent),
});
for (const agent of agents) {
client.setQueryData(queryKeys.tools.testAgentAccess(CONNECTION, agent.id), {
access: agent.effectiveAccess,
});
}
return client;
}
@ -239,7 +251,7 @@ function TestHost({
script?: Step[];
runResult?: ToolConnectionTestCallResult;
runDelayMs?: number;
agents?: ToolConnectionTestAgent[];
agents?: StoryAgent[];
quarantined?: ToolCatalogEntry[];
}) {
const client = useMemo(() => seededClient(agents), [agents]);
@ -368,7 +380,7 @@ export const OffAction: Story = {
// PAP-11404 — Off side panel polish: audit hint + quarantined variant.
const AGENTS_WITH_AUDIT: ToolConnectionTestAgent[] = AGENTS.map((agent, i) =>
const AGENTS_WITH_AUDIT: StoryAgent[] = AGENTS.map((agent, i) =>
i === 0
? {
...agent,