mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the control plane for companies that use AI agents. > - Apps give humans and agents controlled access to external services. > - The existing app detail flow split permissions, tests, setup, and activity across separate pages. > - The split made access rules harder to understand and made reconnect work hard to find. > - New write actions also defaulted to Ask first, which did not match the intended connection policy. > - This pull request combines permission control and action testing, removes the setup page, and moves connection activity into Audit. > - The benefit is one clear place to configure, test, reconnect, and review each app. ## Linked Issues or Issue Description **What existing behavior does this improve?** The installed app Permissions, Test, Setup, and Activity views. **Subsystem affected** Cross-cutting. This change updates the React UI, shared app defaults, server permission behavior, tests, smoke scripts, and connection documentation. **Current behavior** App access and action testing use separate pages. The app detail view also links to a setup page after installation. Connection activity uses a separate tab. New write actions default to Ask first. **Proposed behavior** Permissions uses the connection access language from the initial flow. It includes searchable Read and Write sections, a three-state permission control, and a Test dialog for each action. Reconnect appears below a Needs attention header on Permissions and Review. Old Setup and Test links redirect to Permissions. Old Activity links redirect to the filtered company Audit feed. New write actions default to Allowed. **Reason and benefit** A person can understand and test app access without moving between several pages. Reconnect work stays visible where the person reviews the connection. Audit events use one consistent feed and filter model. New connections have the intended default policy. **Breaking changes** The Setup, Test, and app Activity tabs are removed. Existing deep links redirect to their replacement pages. Existing saved action permissions do not change. Only defaults for new write actions change. **Additional context** This builds on the managed app connection work in #12728. A search found no duplicate open pull request or issue. ## What Changed - Combined action testing with Permissions. - Added searchable Read and Write action groups. - Added Off, Ask first, and Allowed controls with tooltips. - Added an action Test dialog with agent selection, arguments, and formatted results. - Removed the installed-app Setup and Activity tabs. - Added reconnect guidance to Permissions and Review when a connection needs attention. - Routed connection activity into the company Audit feed and preserved the Apps & tools filter in streamlined Audit. - Moved connection removal to the Connectors-page management menu. - Made new write actions default to Allowed across connection creation paths. - Updated regression tests, browser suites, smoke scripts, and connection documentation. ## Verification - `pnpm check:token-gates` - `pnpm exec vitest run packages/shared/src/app-definitions.test.ts server/src/__tests__/generic-mcp-connection.test.ts server/src/__tests__/tool-access-service.test.ts ui/src/components/AppConnectionSidebar.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/AppNotConnected.test.tsx ui/src/pages/apps/AppsConnect.test.tsx ui/src/pages/apps/Browse.test.tsx ui/src/pages/apps/Connections.test.tsx ui/src/pages/apps/composio-services.test.ts ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/tools/PasteConfigTab.test.tsx` (517 tests passed) - `pnpm exec vitest run ui/src/pages/apps/app-detail/TestPanel.test.tsx ui/src/pages/audit/AuditHub.test.tsx ui/src/pages/audit/AuditFeed.test.tsx ui/src/pages/apps/AppDetail.test.tsx ui/src/pages/apps/Browse.test.tsx` (96 tests passed) - Targeted Playwright verification for connection removal, rename on Permissions, inline action testing, and Smoke Lab Audit evidence (5 flows passed) - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,755 passing tests and 20 unrelated macOS harness failures. The failures use `/tmp` versus `/private/tmp`, invalid ports above 65535, and workspace fixtures outside this change. ## Risks - Low migration risk. This change has no database migration. - Old app-detail URLs depend on redirect compatibility. - New connections grant write actions by default. Finalization remains configure-authorized and audited, Ask first and Off remain available per action, and existing connections keep their saved policy. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected - check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, exact model ID `gpt-5`. The client does not expose the context-window size. The model used reasoning, repository tools, code execution, and browser verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
376 lines
13 KiB
TypeScript
376 lines
13 KiB
TypeScript
import { expect, test, type APIRequestContext } from "@playwright/test";
|
|
import { createServer, type Server } from "node:http";
|
|
import { listenOnFetchAllowedPort } from "./fetch-allowed-port";
|
|
|
|
type Json = Record<string, unknown>;
|
|
type Seed = { companyId: string; prefix: string };
|
|
type Agent = { id: string; name: string };
|
|
|
|
async function json<T = Json>(
|
|
response: Awaited<ReturnType<APIRequestContext["get"]>>,
|
|
): Promise<T> {
|
|
expect(
|
|
response.ok(),
|
|
`${response.url()} failed ${response.status()}: ${await response.text()}`,
|
|
).toBe(true);
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function newCompany(request: APIRequestContext): Promise<Seed> {
|
|
const company = await json<{ id: string; issuePrefix: string }>(
|
|
await request.post("/api/companies", {
|
|
data: { name: `Connection intent E2E ${Date.now()}` },
|
|
}),
|
|
);
|
|
return { companyId: company.id, prefix: company.issuePrefix };
|
|
}
|
|
|
|
async function createAgent(
|
|
request: APIRequestContext,
|
|
companyId: string,
|
|
name: string,
|
|
): Promise<Agent> {
|
|
return await json<Agent>(
|
|
await request.post(`/api/companies/${companyId}/agents`, {
|
|
data: {
|
|
name,
|
|
role: "qa",
|
|
title: "Connection intent fixture agent",
|
|
capabilities: "Exercises deterministic connection intent wiring.",
|
|
adapterType: "process",
|
|
adapterConfig: {
|
|
command: process.execPath,
|
|
args: ["--input-type=module", "-e", "process.exit(0)"],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function startFakeProvider() {
|
|
const captures: Array<{ method: string; toolName: string | null }> = [];
|
|
const server: Server = createServer(async (req, res) => {
|
|
const chunks: Buffer[] = [];
|
|
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
const payload = JSON.parse(
|
|
Buffer.concat(chunks).toString("utf8") || "{}",
|
|
) as {
|
|
id?: string | number;
|
|
method?: string;
|
|
params?: { name?: string };
|
|
};
|
|
captures.push({
|
|
method: String(payload.method ?? "<unknown>"),
|
|
toolName: payload.params?.name ?? null,
|
|
});
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
if (payload.method === "tools/list") {
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: payload.id ?? null,
|
|
result: {
|
|
tools: [
|
|
{
|
|
name: "notion:list_pages",
|
|
title: "List fixture pages",
|
|
description:
|
|
"Reads deterministic pages from the fake Notion provider.",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {},
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
if (payload.method === "tools/call") {
|
|
res.end(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: payload.id ?? null,
|
|
result: {
|
|
content: [{ type: "text", text: "Fixture page inventory" }],
|
|
},
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
res.end(
|
|
JSON.stringify({ jsonrpc: "2.0", id: payload.id ?? null, result: {} }),
|
|
);
|
|
});
|
|
const port = await listenOnFetchAllowedPort(server);
|
|
return {
|
|
url: `http://127.0.0.1:${port}/`,
|
|
captures,
|
|
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
|
};
|
|
}
|
|
|
|
function connectionAwareScript(connectionId: string) {
|
|
return `
|
|
const post = async (url, body, token = process.env.PAPERCLIP_RUNTIME_TOOLS_TOKEN) => {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { authorization: \`Bearer \${token}\`, "content-type": "application/json" },
|
|
body: JSON.stringify(body)
|
|
});
|
|
if (!response.ok) throw new Error(\`\${response.status}: \${await response.text()}\`);
|
|
return await response.json();
|
|
};
|
|
const search = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTIONS_SEARCH_URL, { query: "notion" });
|
|
const notion = search.results.find((result) => result.service === "notion");
|
|
if (!notion) throw new Error("Notion was not advertised");
|
|
if (notion.state !== "ready") {
|
|
const requested = await post(process.env.PAPERCLIP_RUNTIME_TOOLS_CONNECTION_REQUEST_URL, { service: notion.service });
|
|
if (requested.state !== "needs_user_action") throw new Error("Expected a user-action request");
|
|
console.log("waiting for connection intent");
|
|
process.exit(0);
|
|
}
|
|
const apiHeaders = { authorization: \`Bearer \${process.env.PAPERCLIP_API_KEY}\`, "content-type": "application/json" };
|
|
const sessionResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/sessions\`, {
|
|
method: "POST",
|
|
headers: apiHeaders,
|
|
body: JSON.stringify({ runId: process.env.PAPERCLIP_RUN_ID, ttlMs: 60000 })
|
|
});
|
|
if (!sessionResponse.ok) throw new Error(await sessionResponse.text());
|
|
const session = await sessionResponse.json();
|
|
const toolsResponse = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools\`, {
|
|
headers: { "x-paperclip-tool-gateway-token": session.token }
|
|
});
|
|
const tools = await toolsResponse.json();
|
|
const tool = tools.find((entry) => entry.connectionId === ${JSON.stringify(connectionId)} && entry.upstreamToolName === "notion:list_pages");
|
|
if (!tool) throw new Error("Continuation did not receive the installed Notion tool");
|
|
const call = await fetch(\`\${process.env.PAPERCLIP_API_URL}/api/tool-gateway/tools/call\`, {
|
|
method: "POST",
|
|
headers: { "x-paperclip-tool-gateway-token": session.token, "content-type": "application/json" },
|
|
body: JSON.stringify({ tool: tool.name, parameters: {} })
|
|
});
|
|
if (!call.ok) throw new Error(await call.text());
|
|
console.log(await call.text());
|
|
`;
|
|
}
|
|
|
|
function escapeRegExp(value: string) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
async function waitForAgentRun(
|
|
request: APIRequestContext,
|
|
companyId: string,
|
|
agentId: string,
|
|
) {
|
|
let terminalRun: { id: string; status: string } | null = null;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const runs = await json<Array<{ id: string; status: string }>>(
|
|
await request.get(
|
|
`/api/companies/${companyId}/heartbeat-runs?agentId=${agentId}&limit=10`,
|
|
),
|
|
);
|
|
terminalRun =
|
|
runs.find((run) => !["queued", "running"].includes(run.status)) ??
|
|
null;
|
|
return terminalRun?.status ?? null;
|
|
},
|
|
{ timeout: 45_000 },
|
|
)
|
|
.toBe("succeeded");
|
|
if (!terminalRun)
|
|
throw new Error("Agent run completed without a run receipt");
|
|
return terminalRun;
|
|
}
|
|
|
|
test("store setup and task connection intent share one fake provider through continuation", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
test.setTimeout(180_000);
|
|
const provider = await startFakeProvider();
|
|
try {
|
|
const seed = await newCompany(request);
|
|
const holder = await createAgent(
|
|
request,
|
|
seed.companyId,
|
|
"Existing access holder",
|
|
);
|
|
|
|
// Entry point one: connect and test the provider through the Connections store.
|
|
await page.goto(`/${seed.prefix}/apps`);
|
|
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();
|
|
await page
|
|
.getByPlaceholder("https://example.com/actions")
|
|
.fill(provider.url);
|
|
await page.getByRole("button", { name: "Continue" }).click();
|
|
await page.getByRole("button", { name: "Save and continue" }).click();
|
|
await page.getByRole("button", { name: /Check link/i }).click();
|
|
// A no-auth read-only provider can complete the access/install defaults in
|
|
// one commit. Other methods exercise the same intermediate steps in the
|
|
// shared-flow component suite.
|
|
await expect(page.getByRole("heading", { name: /is ready/i })).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
const connections = await json<{
|
|
connections: Array<{ id: string; name: string; config: Json }>;
|
|
}>(await request.get(`/api/companies/${seed.companyId}/tools/connections`));
|
|
expect(connections.connections).toHaveLength(1);
|
|
const connection = connections.connections[0]!;
|
|
const connectionId = connection.id;
|
|
await json(
|
|
await request.patch(`/api/tool-connections/${connectionId}`, {
|
|
data: {
|
|
config: {
|
|
...connection.config,
|
|
url: provider.url,
|
|
sourceTemplateKey: "notion",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
await json(
|
|
await request.put(`/api/tool-connections/${connectionId}/installs`, {
|
|
data: {
|
|
installs: [{ targetType: "agent", targetId: holder.id }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await page.goto(`/${seed.prefix}/apps/${connectionId}/permissions`);
|
|
const actionRow = page.locator("[data-action-id]").filter({ hasText: "List fixture pages" });
|
|
await actionRow.getByRole("button", { name: "Test", exact: true }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Test List fixture pages" }),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
await page.getByRole("button", { name: "Run", exact: true }).click();
|
|
await expect(page.getByText("Fixture page inventory")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
|
|
// Entry point two: a scripted agent requests Notion, then the same shared
|
|
// provider is reused from the task dialog and appears in the fresh run.
|
|
const scout = await createAgent(
|
|
request,
|
|
seed.companyId,
|
|
"Connection requester",
|
|
);
|
|
await json(
|
|
await request.patch(`/api/agents/${scout.id}`, {
|
|
data: {
|
|
adapterType: "process",
|
|
adapterConfig: {
|
|
command: process.execPath,
|
|
args: [
|
|
"--input-type=module",
|
|
"-e",
|
|
connectionAwareScript(connectionId),
|
|
],
|
|
},
|
|
replaceAdapterConfig: true,
|
|
},
|
|
}),
|
|
);
|
|
const issue = await json<{ id: string; identifier: string }>(
|
|
await request.post(`/api/companies/${seed.companyId}/issues`, {
|
|
data: {
|
|
title: "Read our Notion pages",
|
|
status: "in_progress",
|
|
assigneeAgentId: scout.id,
|
|
},
|
|
}),
|
|
);
|
|
// Assigning an in-progress task is the production wake path. Waiting for
|
|
// that run avoids creating a second artificial request from an explicit
|
|
// heartbeat invocation.
|
|
const firstRun = await waitForAgentRun(request, seed.companyId, scout.id);
|
|
|
|
const taskUrl = `/${seed.prefix}/issues/${issue.identifier}`;
|
|
await page.goto(taskUrl);
|
|
await expect(
|
|
page.getByText("Connection requester needs Notion"),
|
|
).toBeVisible({ timeout: 30_000 });
|
|
await page.getByRole("button", { name: "Connect / Use existing" }).click();
|
|
await expect(
|
|
page.getByRole("heading", { name: "Use an existing connection" }),
|
|
).toBeVisible();
|
|
await page
|
|
.getByRole("button", { name: new RegExp(escapeRegExp(connection.name)) })
|
|
.click();
|
|
|
|
await expect(page.getByText("Notion connected")).toBeVisible({
|
|
timeout: 30_000,
|
|
});
|
|
await expect(page).toHaveURL(new RegExp(`${taskUrl}$`));
|
|
await expect(
|
|
page
|
|
.getByTestId("connection-intent-focus-target")
|
|
.filter({ hasText: "Notion connected" }),
|
|
).toBeFocused();
|
|
expect(await page.locator("body").innerText()).not.toMatch(
|
|
/\/authorize\?|authorizationUrl/,
|
|
);
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const runs = await json<Array<{ id: string; status: string }>>(
|
|
await request.get(
|
|
`/api/companies/${seed.companyId}/heartbeat-runs?agentId=${scout.id}&limit=10`,
|
|
),
|
|
);
|
|
return runs.find((run) => run.id !== firstRun.id)?.status ?? null;
|
|
},
|
|
{ timeout: 45_000 },
|
|
)
|
|
.toBe("succeeded");
|
|
await expect
|
|
.poll(() =>
|
|
provider.captures.some(
|
|
(capture) =>
|
|
capture.method === "tools/call" &&
|
|
capture.toolName === "notion:list_pages",
|
|
),
|
|
)
|
|
.toBe(true);
|
|
|
|
const interactions = await json<Array<{ kind: string; status: string }>>(
|
|
await request.get(`/api/issues/${issue.id}/interactions`),
|
|
);
|
|
const connectionIntents = interactions.filter(
|
|
(interaction) => interaction.kind === "connection_intent",
|
|
);
|
|
expect(
|
|
connectionIntents.filter(
|
|
(interaction) => interaction.status === "accepted",
|
|
),
|
|
).toHaveLength(1);
|
|
expect(
|
|
connectionIntents.filter(
|
|
(interaction) => interaction.status === "pending",
|
|
),
|
|
).toHaveLength(0);
|
|
expect(
|
|
connectionIntents.every((interaction) =>
|
|
["accepted", "expired"].includes(interaction.status),
|
|
),
|
|
).toBe(true);
|
|
expect(holder.id).not.toBe(scout.id);
|
|
} finally {
|
|
await provider.close();
|
|
}
|
|
});
|