Files
PaperClipAI/tests/e2e/agentmail.spec.ts
DottaandPaperclip d66acb7ac1 feat: automate Slack bot app setup and installation (#15413)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat connectors give each agent a customer-owned bot and task-backed
conversations.
> - Manual Slack setup requires app creation and copying durable
credentials.
> - Operators need a shorter setup that an assisting agent can use
safely.
> - This pull request creates the app through Slack's Manifest API and
installs it through OAuth.
> - Durable registration state supports recovery without creating
another app.
> - A four-screen wizard, automatic avatar upload, and OAuth account
linking reduce setup work.
> - Connector settings and per-turn tool guidance support daily use
after installation.

## Linked Issues or Issue Description

**Subsystem affected**

Native Slack bot setup, company secret storage, chat connector
management, and agent tool guidance.

**Problem or motivation**

New Slack bots require manual app creation and copying a signing secret
and bot token. Interrupted setup can create duplicate apps. The setup
and management screens contain unnecessary controls. Agents also need
guidance for native questions, files, thread replies, and governed Slack
actions.

**Proposed solution**

Use a temporary app-configuration access token to create a
customer-owned app. Save durable secrets in the vault. Bind OAuth to the
initiating actor, company, endpoint, registration revision, scopes, and
configured origins. Preserve manual and existing-app recovery. Link the
installing user's account, send a welcome DM, and advance from saved
server evidence. Keep request-URL recovery instructions available if
automatic connection detection waits.

**Alternatives considered**

The Slack CLI adds installation requirements. Socket Mode changes
transport. A shared Paperclip-owned app changes app ownership. These
alternatives are outside this change.

**Roadmap alignment**

This extends existing chat connectors and secrets capabilities. Related
public work: #14037 and #13954 cover Slack MCP prerequisites and user
OAuth. No duplicate bot-registration PR was found.

## What Changed

- Share one reviewed manifest builder between automatic registration and
manual setup.
- Add replay-safe migration 0318 and company-bound registration state
with vault references and uncertain-creation recovery.
- Add registration, installation, callback, and resume APIs with
short-lived, single-use OAuth state.
- Save installation credentials before downstream checks and preserve
bot identity constraints.
- Reduce automatic setup to four screens. Keep advanced app details,
manual recovery, and existing-app setup.
- Upload the agent avatar with the Paperclip dark background. Link the
OAuth installer's account and send setup DMs.
- Show agent and connector-owner avatars. Simplify settings, access, and
conversation screens.
- Discover joined Slack channels and enable them by default. Start a
task from a bare mention and admit same-thread follow-ups.
- Refresh Slack tool guidance each turn. Add native-form, file,
approval, and delivery regressions plus manual model probe definitions
and sanitized acceptance records.
- Update deployment/database docs, OpenAPI, redaction, removal cleanup,
production Storybook stories, and provider browser tests.
- Merge current master and move the registration migration after its
latest migration without rewriting published commits.

The completed Slack success view intentionally has a single centered
**Done** action and no **Save & exit**, as explicitly requested by the
product owner. `DESIGN.md` records this exception; unfinished setup
steps retain the aligned wizard footer.

## Verification

- Passed after the master merge: repository typecheck, full build,
Storybook build, design-token gates, module-boundary gates, and
migration generation.
- Passed: all 352 focused Slack deterministic tests and all 14 affected
provider browser tests. Browser tests use controlled provider fixtures
and a separate throwaway instance.
- Passed on current head `c5d01e0e2`: the complete GitHub test matrix
(general server, chat, all workspaces, serialized server, and Runner),
all eight browser shards, typecheck/release registry, build, canary dry
run, security checks, and policy gates. There are 52 passing checks and
no pending or failing checks.
- Greptile completed on the exact current head with 5/5 and no
actionable findings or open review threads.
- Local repair verification passed 93 focused tests, including same-app
reinstall after revocation and rejection of consent started before
revocation, the AgentMail browser journey, and repository typecheck.
Local build and Storybook build also passed. The redundant local
full-suite rerun was stopped after the complete current-head CI matrix
passed.
- Real Slack setup and agent replies were exercised in the authorized
isolated test drive during the setup iteration.
- The ten additional model probes were attempted with legacy
`codex_local`, `gpt-5.6-sol`: five passed, two failed, and three were
partly verified. Native runtime is not qualified. See
`server/src/services/connectors/slack/evals/2026-10-08-acceptance.md`
for evidence and limits.
- Passing model probes cover native forms, downloaded file bytes, bare
mentions with thread replies, explicit posts/reactions, and saved
approval denial.
- The controlled uncertain-write probe found wrong delivery-check IDs.
The canvas fallback attempt used an invented tool name. Search
pagination/native search, a private-source denied-tool receipt, and
distinct board/webhook origins remain unqualified.

Reviewer path: enable Chat connectors, start Slack chat setup, select an
agent, enter an app-configuration access token, and approve Slack
installation. Send a message to the bot and confirm that setup advances
to success. Inspect settings and allowed channels. See
`doc/connections/SLACK-AUTOMATIC-SETUP.md` for deployment and recovery.

## Risks

- Slack app creation has no provider idempotency guarantee. A timeout
after dispatch stays uncertain until the operator checks Slack.
- OAuth needs a stable public HTTPS board origin. Webhook ingress may
use a separate configured HTTPS origin. Workspace policy can delay
installation.
- Migration 0318 can replay safely on instances that applied the earlier
development migration.
- OAuth installation now links the installer to the initiating Paperclip
user. Identity checks and company access rules still apply.
- Joined channels now enable bot responses by default. Linked-user
authorization and per-action approval rules still apply.
- Model behavior has the documented delivery-check and canvas fallback
failures. A passing CI run does not establish that every model probe
passed.
- Removing the connection does not delete the customer's Slack app. No
new first-party telemetry is added.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, code
execution, and browser verification. The runtime does not expose a more
specific authoring model ID or context-window size. The live bot probes
used OpenAI `gpt-5.6-sol` through `codex_local` in legacy mode.

## 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>
2026-10-08 06:33:31 -05:00

335 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from "node:crypto";
import { test, expect, type Route } from "@playwright/test";
const fulfill = (route: Route, body: unknown, status = 200) =>
route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
test("AgentMail setup and email work through the normal task conversation", async ({
page,
request,
}) => {
await page.setViewportSize({ width: 1440, height: 1080 });
const settings = await request.patch("/api/instance/settings/experimental", {
data: { enableChatConnectors: false },
});
expect(settings.ok()).toBeTruthy();
const created = await request.post("/api/companies", {
data: { name: `AgentMail browser ${Date.now()}` },
});
expect(created.ok()).toBeTruthy();
const company = await created.json();
const agentResponse = await request.post(
`/api/companies/${company.id}/agents`,
{
data: {
name: "Mail agent",
role: "qa",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
},
},
);
expect(agentResponse.ok()).toBeTruthy();
const agent = await agentResponse.json();
const taskResponse = await request.post(
`/api/companies/${company.id}/issues`,
{ data: { title: "Customer email", status: "backlog" } },
);
expect(taskResponse.ok()).toBeTruthy();
const task = await taskResponse.json();
const inbox = {
id: randomUUID(),
companyId: company.id,
connectionId: randomUUID(),
assignedAgentId: agent.id,
address: "agent@agentmail.to",
status: "active",
receiveMode: "websocket",
lastError: null,
lastSyncAt: new Date().toISOString(),
};
const organizationConnectionId = randomUUID();
let connected = false;
let reservedInbox: typeof inbox | null = null;
const setupRequests: { idempotencyKey: string; username?: string }[] = [];
const addressTakenError = "This email address is already in use. Choose a different address.";
const permissionError = "AgentMail did not allow Paperclip to create an inbox-scoped API key. Check your API key permissions and AgentMail account limits, then try again.";
const sends: any[] = [];
const conversationId = randomUUID();
const thread = {
conversationId,
issueId: task.id,
endpoint: inbox,
subject: "Customer email",
messages: [
{
id: randomUUID(),
providerMessageId: "incoming-message",
from: "Customer <customer@example.test>",
to: [inbox.address],
cc: ["visible@example.test"],
bcc: ["private@example.test"],
subject: "Customer email",
direction: "inbound",
text: "Can you help?",
fullText: "Can you help?\nEarlier quoted context",
commentId: null,
attachmentIds: [],
timestamp: new Date().toISOString(),
automatic: false,
},
],
publications: [] as any[],
};
await page.route("**/api/instance/settings/experimental", (route) =>
fulfill(route, { enableChatConnectors: false }),
);
await page.route(`**/api/companies/${company.id}/tools/connections`, route =>
fulfill(route, { connections: [{
id: organizationConnectionId, name: "AgentMail", status: "active", enabled: true,
config: { provider: "agentmail", emailCredential: true }, createdAt: "2026-09-30T14:00:00Z",
}] }),
);
await page.route(`**/api/companies/${company.id}/chat-endpoints`, route =>
fulfill(route, reservedInbox ? [{ ...reservedInbox, provider: "agentmail", assignedAgentName: agent.name,
providerAccountLabel: reservedInbox.address }] : []),
);
await page.route(`**/api/tool-connections/${inbox.connectionId}`, route =>
fulfill(route, { config: { credentialConnectionId: organizationConnectionId } }),
);
await page.route(`**/api/tool-connections/${organizationConnectionId}/grants`, route =>
fulfill(route, { grants: [{ status: "active", kind: "organization" }], capabilities: { canConfigure: true } }),
);
await page.route(`**/api/tool-connections/${organizationConnectionId}/installs`, route =>
fulfill(route, { installs: [{ targetType: "agent", targetId: agent.id }] }),
);
await page.route(`**/api/chat-endpoints/${inbox.id}/conversations`, route =>
fulfill(route, [{ id: conversationId, externalLabel: "Customer email", issueId: task.id,
issueTitle: task.title, issueIdentifier: task.identifier, state: "active" }]),
);
await page.route(`**/api/chat-endpoints/${inbox.id}/activity?*`, route =>
fulfill(route, { items: [{ id: randomUUID(), kind: "delivery", status: "processed",
summary: "Email received", createdAt: new Date().toISOString() }], nextCursor: null }),
);
await page.route("**/api/**/email/**", async (route) => {
const url = new URL(route.request().url()),
method = route.request().method();
if (url.pathname.endsWith("/connections") && method === "GET") return fulfill(route, [
{ id: organizationConnectionId, label: "AgentMail account key", scope: "organization", createdAt: "2026-09-30T14:00:00Z" },
{ id: inbox.connectionId, label: "AgentMail inbox key", scope: "inbox", createdAt: "2026-10-01T14:00:00Z" },
]);
if (url.pathname.endsWith("/connections") && method === "POST") {
expect(route.request().postDataJSON()).toMatchObject({ apiKey: "inbox-test-key", agentIds: [agent.id], allAgents: false });
return fulfill(route, { id: inbox.connectionId }, 201);
}
if (url.pathname.endsWith("/check-address")) {
const body = route.request().postDataJSON();
return fulfill(route, { address: `${body.username}@${body.domain}`, status: body.username === "taken" ? "taken" : "unknown" });
}
if (url.pathname.endsWith(`/connections/${inbox.connectionId}/inspect`))
return fulfill(route, {
scope: { scope_type: "inbox" }, inboxes: [{ inbox_id: inbox.address }], domains: [],
});
if (url.pathname.endsWith("/inspect"))
return fulfill(route, {
scope: { scope_type: "organization" },
inboxes: [{ inbox_id: inbox.address }],
domains: [
{
domain_id: "domain-id",
domain: "verified.example.test",
status: "VERIFIED",
},
],
});
if (url.pathname.endsWith("/inboxes") && method === "GET")
return fulfill(route, connected ? [inbox] : reservedInbox ? [reservedInbox] : []);
if (url.pathname.endsWith("/inboxes") && method === "POST") {
const body = route.request().postDataJSON();
expect(body.receiveMode).toBe("websocket");
expect(body.assignedAgentId).toBe(agent.id);
expect(body.credentialConnectionId).toBe(organizationConnectionId);
setupRequests.push(body);
if (setupRequests.length === 1) return fulfill(route, { error: addressTakenError, code: "agentmail_address_taken",
details: { field: "username", providerStatus: 403, operation: "create_inbox" } }, 409);
if (setupRequests.length === 2) {
reservedInbox = { ...inbox, id: body.idempotencyKey, address: `${body.username}@${body.domain}`, status: "draft" };
return fulfill(route, {
error: permissionError,
details: { code: "agentmail_request_failed", providerStatus: 403, operation: "create_inbox_key" },
}, 422);
}
connected = true;
return fulfill(route, inbox, 201);
}
if (url.pathname.endsWith(`/tasks/${task.id}`))
return fulfill(route, thread);
if (url.pathname.endsWith("/send")) {
const input = route.request().postDataJSON();
sends.push(input);
const publication = {
id: input.idempotencyKey,
issueId: input.parentIssueId ? randomUUID() : task.id,
conversationId,
outcome: "queued",
error: null,
providerMessageId: null,
};
thread.publications.push(publication);
return fulfill(route, publication, 202);
}
return fulfill(route, null);
});
await page.route(`**/api/chat-endpoints/${inbox.id}`, (route) =>
fulfill(route, {
...inbox,
provider: "agentmail",
setup: { step: "complete" },
capabilities: {},
assignedAgentName: agent.name,
botExternalId: inbox.address,
}),
);
await page.goto(`/${company.issuePrefix}/apps`);
await page.getByRole("button", { name: /^(Connect|Add connection) AgentMail$/ }).click();
await expect(
page.getByRole("heading", { name: "Give an agent an email address" }),
).toBeVisible();
await page.getByLabel("Agent", { exact: true }).click();
await page.getByPlaceholder("Filter agents").fill("Mail agent");
await expect(page.getByRole("button", { name: "Select Mail agent", exact: true }).locator('[data-slot="agent-avatar"]')).toBeVisible();
await page.getByRole("button", { name: "Select Mail agent", exact: true }).click();
await expect(page.locator('#email-agent [data-slot="agent-avatar"]')).toBeVisible();
await expect(page.getByLabel("API key", { exact: true })).toHaveValue(organizationConnectionId);
// A deliberately selected restricted key stays at credentials until the user
// chooses a usable account key or explicitly requests that existing inbox.
await page.getByLabel("API key", { exact: true }).selectOption(inbox.connectionId);
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page.getByText(`That key only connects ${inbox.address}.`, { exact: false })).toBeVisible();
await expect(page.getByLabel("Mail agent’s email address", { exact: true })).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath("agentmail-inbox-key-recovery.png"), fullPage: true });
await page.getByLabel("API key", { exact: true }).selectOption(organizationConnectionId);
await page.getByRole("button", { name: "Continue", exact: true }).click();
const addressField = page.getByLabel("Mail agent’s email address", { exact: true });
await expect(addressField).toBeEditable();
await expect(page.getByRole("heading", { name: "How it Works", exact: true })).toBeVisible();
await expect(page.getByLabel("Email domain", { exact: true })).toHaveValue("verified.example.test");
await page.getByLabel("Email domain", { exact: true }).selectOption("agentmail.to");
await expect(page.getByLabel("Email domain", { exact: true })).toHaveValue("agentmail.to");
await page.getByLabel("Email domain", { exact: true }).selectOption("verified.example.test");
await addressField.fill("taken");
await expect(page.getByRole("alert")).toHaveText(addressTakenError);
expect(setupRequests).toHaveLength(0);
await page.getByRole("button", { name: "taken-agent@verified.example.test", exact: true }).click();
await expect(addressField).toHaveValue("taken-agent");
await expect(page.getByRole("alert")).toHaveCount(0);
await addressField.fill("mail-agent");
await expect(page.getByRole("button", { name: "Review email address" })).toHaveCount(0);
await page.getByText("Advanced options", { exact: true }).click();
await expect(page.getByLabel("Email domain", { exact: true }).locator("option", { hasText: "verified.example.test" })).toHaveCount(1);
await page.getByRole("button", { name: "Review trust settings" }).click();
const trustDialog = page.getByRole("dialog");
await trustDialog.getByRole("combobox").first().selectOption("low_trust_review");
await trustDialog.getByRole("combobox").nth(1).selectOption("root_issue");
await trustDialog.getByRole("combobox").nth(2).selectOption(task.id);
await trustDialog.getByRole("button", { name: "Save trust settings" }).click();
await expect(page.getByText("Low-trust review configured")).toBeVisible();
await page.getByRole("button", { name: "Review trust settings" }).click();
await page.getByRole("dialog").getByRole("combobox").first().selectOption("standard");
await page.getByRole("button", { name: "Save trust settings" }).click();
await expect(trustDialog).not.toBeVisible();
const savedAgent = await (await request.get(`/api/agents/${agent.id}`)).json();
expect(savedAgent.permissions.authorizationPolicy).toEqual({});
await expect(page.getByRole("link", { name: "Set up allowlists ↗" })).toHaveAttribute(
"href", "https://docs.agentmail.to/knowledge-base/allowlists-blocklists");
await page.getByText("Advanced options", { exact: true }).click();
await page.getByRole("button", { name: "Create email address", exact: true }).click();
await expect(page.getByRole("alert")).toHaveText(addressTakenError);
await expect(addressField).toHaveAttribute("aria-invalid", "true");
await expect(page.getByRole("button", { name: "Create email address", exact: true })).toBeDisabled();
await page.screenshot({ path: test.info().outputPath("agentmail-address-taken.png"), fullPage: true });
await addressField.fill("mail-agent-free");
await expect(page.getByRole("alert")).toHaveCount(0);
await page.reload();
await expect(addressField).toHaveValue("mail-agent-free");
await page.getByRole("button", { name: "Create email address", exact: true }).click();
await expect(page.getByText(permissionError, { exact: true })).toBeVisible();
await expect(page.getByText("Internal server error", { exact: true })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Your agent’s email is ready" })).toHaveCount(0);
await page.screenshot({ path: test.info().outputPath("agentmail-permission-denied.png"), fullPage: true });
await page.getByRole("link", { name: "Connectors", exact: true }).click();
await page.getByRole("button", { name: "Finish setup", exact: true }).click();
await expect(page.getByText("mail-agent-free@verified.example.test", { exact: true })).toBeVisible();
await expect(addressField).toHaveCount(0);
await expect(page.getByRole("button", { name: "Choose a different address", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Finish connecting", exact: true }).click();
await expect(page.getByRole("heading", { name: "Your agent’s email is ready" })).toBeVisible();
expect(setupRequests.map(input => input.username)).toEqual(["mail-agent", "mail-agent-free", undefined]);
expect(setupRequests[2]).toMatchObject({ inboxId: "mail-agent-free@verified.example.test" });
expect(new Set(setupRequests.map(input => input.idempotencyKey)).size).toBe(1);
await page.getByRole("button", { name: "Email settings", exact: true }).click();
await expect(page.getByRole("heading", { name: inbox.address, exact: true })).toBeVisible();
const navigation = page.getByRole("navigation", { name: "Chat connection" });
await navigation.getByRole("link", { name: "Access", exact: true }).click();
await expect(page.getByText("Any human in the organization", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Reconnect inbox", exact: true })).toHaveCount(0);
await navigation.getByRole("link", { name: "Conversations", exact: true }).click();
await expect(page.getByRole("list", { name: "Conversations" })).toBeVisible();
await expect(page.getByRole("list", { name: "Conversations" }).locator(`a[href="/${company.issuePrefix}/issues/${task.id}"]`)).toHaveAttribute("href", `/${company.issuePrefix}/issues/${task.id}`);
await page.reload();
await expect(page.getByRole("list", { name: "Conversations" })).toBeVisible();
await navigation.getByRole("link", { name: "Activity", exact: true }).click();
await expect(page.getByText("Email received", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Connection activity", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("agentmail-management-tabs.png"), fullPage: true });
await navigation.getByRole("link", { name: "Settings", exact: true }).click();
await expect(page.getByRole("heading", { name: "Receiving email", exact: true })).toBeVisible();
const copyAddress = page.getByRole("button", { name: "Copy email address", exact: true });
await expect(copyAddress).toHaveText(inbox.address);
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await copyAddress.click();
await expect(page.getByRole("status").filter({ hasText: "Copied!" })).toHaveCSS("opacity", "1");
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(inbox.address);
await expect(page.getByRole("link", { name: "View inbox", exact: true })).toHaveAttribute(
"href", `https://console.agentmail.to/dashboard/inboxes/${encodeURIComponent(inbox.address)}`);
await page.screenshot({ path: test.info().outputPath("agentmail-settings-copy-inbox.png"), fullPage: true });
await expect(page.getByLabel("New API key", { exact: true })).not.toBeVisible();
await page.locator("summary").filter({ hasText: "Reconnect inbox" }).click();
await expect(page.getByLabel("New API key", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Reconnect inbox", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("agentmail-settings-reconnect.png"), fullPage: true });
await page.goto(`/${company.issuePrefix}/issues/${task.identifier}`);
const email = page.getByRole("article", { name: "Email received", exact: true });
await expect(email).toBeVisible();
await expect(email.getByText("Can you help?", { exact: true })).toBeVisible();
await expect(page.getByRole("button", {
name: /^(Internal comment|Email reply|Start email child task)$/,
})).toHaveCount(0);
await expect(email.getByText("Bcc: private@example.test")).not.toBeVisible();
await email.getByText("Email details", { exact: true }).click();
await expect(email.getByText("Bcc: private@example.test")).toBeVisible();
const composer = page.locator('[contenteditable="true"]').last();
await expect(composer).toBeEditable();
const instruction = "Please reply to the customer and confirm Friday delivery.";
await composer.fill(instruction);
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect.poll(async () => {
const comments = await (await request.get(`/api/issues/${task.id}/comments`)).json();
return comments.some((comment: { body: string }) => comment.body.includes(instruction));
}).toBe(true);
// Task instructions persist normally; only an explicit agent action sends mail.
expect(sends).toHaveLength(0);
await page.screenshot({
path: test.info().outputPath("email-task-conversation.png"),
fullPage: true,
});
});