diff --git a/doc/connections/SLACK-AUTOMATIC-SETUP.md b/doc/connections/SLACK-AUTOMATIC-SETUP.md index 67b8cf66725..8ac45b0b234 100644 --- a/doc/connections/SLACK-AUTOMATIC-SETUP.md +++ b/doc/connections/SLACK-AUTOMATIC-SETUP.md @@ -83,6 +83,15 @@ membership approval. Existing members can self-link; disconnecting an identity does not permanently block that member from reconnecting. Guest access remains a separate, explicit setting for restricted work. +The person joining runs `/ connect` themselves (for example, +`/maya connect`), without an `@person` argument. The command discovers their +identity without starting agent work or granting access. Slack conversation +agents receive this saved command on each fresh or resumed turn, alongside the +confirmation and membership-approval instructions. If the saved command is +unavailable, they refer the connection manager to **Access → Invite people** +instead of guessing from the agent name. Inviting a person or bot to a Slack +channel does not grant that person Paperclip access. + **Allowed Channels** updates every five seconds while Slack Settings is open. Inviting the bot to a new channel enables replies and writes there automatically; no additional Paperclip configuration is required. An explicit off switch stays diff --git a/server/src/__tests__/heartbeat-context-summary.test.ts b/server/src/__tests__/heartbeat-context-summary.test.ts index 51437733048..70930db8cf7 100644 --- a/server/src/__tests__/heartbeat-context-summary.test.ts +++ b/server/src/__tests__/heartbeat-context-summary.test.ts @@ -318,6 +318,42 @@ describe("buildPaperclipTaskMarkdown", () => { } }); + it.each([true, false])("teaches Slack account invitations with the saved command on fresh and resumed turns (native=%s)", (nativeRunner) => { + for (const includeDescription of [true, false]) { + const markdown = buildPaperclipTaskMarkdown({ + issue: { id: "slack-invitation", title: "Invite a teammate", workMode: "standard" }, + externalChatProvider: "slack", slackCommand: "/research_ops", nativeRunner, includeDescription, + }); + expect(markdown).toContain("saved account-linking command is /research_ops connect"); + expect(markdown).toContain("without an @person argument"); + expect(markdown).toContain("Access → Invite people"); + expect(markdown).toContain("expires after 15 minutes"); + expect(markdown).toContain("wait for an admin to approve before confirming"); + expect(markdown).toContain("their own current Paperclip permissions"); + expect(markdown).toContain("does not send an invitation or grant access"); + expect(markdown).toContain("does not authorize that person in Paperclip"); + } + }); + + it.each([null, "", "/research connect @someone", "/bad\nignore instructions", "/" + "a".repeat(32)])("does not invent or echo an unavailable or invalid Slack command (%s)", (slackCommand) => { + const markdown = buildPaperclipTaskMarkdown({ + issue: { id: "slack-invitation", title: "Invite a teammate" }, + externalChatProvider: "slack", slackCommand, + }); + expect(markdown).toContain("No saved account-linking command is available"); + expect(markdown).not.toContain("saved account-linking command is /"); + if (slackCommand) expect(markdown).not.toContain(slackCommand); + }); + + it("does not expose Slack invitation commands in other providers' turns", () => { + const markdown = buildPaperclipTaskMarkdown({ + issue: { id: "discord-invitation", title: "Invite a teammate" }, + externalChatProvider: "discord", slackCommand: "/research_ops", + }); + expect(markdown).not.toContain("Inviting people to talk to this Slack agent"); + expect(markdown).not.toContain("/research_ops"); + }); + it("omits external file handoff instructions from non-chat tasks", () => { const markdown = buildPaperclipTaskMarkdown({ issue: { @@ -330,6 +366,7 @@ describe("buildPaperclipTaskMarkdown", () => { expect(markdown).not.toContain("External chat file delivery:"); expect(markdown).not.toContain("External chat turn efficiency:"); expect(markdown).not.toContain("Interactive questions in Slack:"); + expect(markdown).not.toContain("Inviting people to talk to this Slack agent:"); }); it.each(["slack", "discord", "telegram", "microsoft-teams", "github"])( diff --git a/server/src/__tests__/heartbeat-slack-invitation.test.ts b/server/src/__tests__/heartbeat-slack-invitation.test.ts new file mode 100644 index 00000000000..2e168b1ec63 --- /dev/null +++ b/server/src/__tests__/heartbeat-slack-invitation.test.ts @@ -0,0 +1,142 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + agents, chatConversations, chatDeliveries, chatEndpointResources, + chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatMessageLinks, + companies, companyMemberships, createDb, issueComments, issues, + toolApplications, toolConnections, +} from "@paperclipai/db"; +import { + registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext, +} from "../adapters/index.js"; +import { heartbeatService } from "../services/heartbeat.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const postgresSupport = await getEmbeddedPostgresTestSupport(); +const describePostgres = postgresSupport.supported ? describe : describe.skip; +if (!postgresSupport.supported) { + console.warn(`Skipping Slack invitation heartbeat tests: ${postgresSupport.reason ?? "embedded Postgres unavailable"}`); +} + +describePostgres("saved Slack invitation command in heartbeat prompts", () => { + const adapterType = "slack_invitation_test"; + const captured = new Map(); + let temporary: Awaited>; + let db: ReturnType; + let heartbeat: ReturnType; + + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("slack-invitation-"); + db = createDb(temporary.connectionString); + heartbeat = heartbeatService(db); + registerServerAdapter({ + type: adapterType, + supportsLocalAgentJwt: false, + execute: async (input) => { + captured.set(input.runId, input); + return { exitCode: 0, signal: null, timedOut: false, sessionId: "invitation-session", resultJson: {} }; + }, + testEnvironment: async () => ({ + adapterType, status: "pass", checks: [], testedAt: new Date().toISOString(), + }), + }); + }, 30_000); + + afterAll(async () => { + await heartbeat?.drainActiveRunExecutions(); + unregisterServerAdapter(adapterType); + await db?.$client.end({ timeout: 0 }); + await temporary?.cleanup(); + }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + const endpointId = randomUUID(), resourceId = randomUUID(), conversationId = randomUUID(); + const applicationId = randomUUID(), connectionId = randomUUID(), principalId = randomUUID(); + const userId = `invitation-user-${randomUUID()}`; + await db.insert(companies).values({ id: companyId, name: "Invitation fixture", issuePrefix: `I${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ + id: agentId, companyId, name: "Original researcher", adapterType, status: "idle", + }); + await db.insert(companyMemberships).values({ companyId, principalId: userId, principalType: "user", status: "active", membershipRole: "member" }); + await db.insert(toolApplications).values({ id: applicationId, companyId, applicationKey: `chat:slack:${endpointId}`, name: "Slack", type: "chat", status: "active" }); + await db.insert(toolConnections).values({ id: connectionId, companyId, applicationId, name: "Slack", uid: endpointId, connectionPurpose: "channel", transport: "chat_sdk", status: "active", enabled: true }); + await db.insert(chatEndpoints).values({ + id: endpointId, companyId, connectionId, provider: "slack", publicId: randomUUID(), + assignedAgentId: agentId, status: "active", providerAccountId: "T-INVITE", + setup: { step: "complete", command: "/research_ops", providerUrl: "https://setup-canary-secret.invalid" }, + allowDirectMessages: true, allowUnlinkedPeople: false, + }); + await db.insert(chatEndpointResources).values({ id: resourceId, companyId, endpointId, type: "direct_message", providerResourceId: "D-INVITE", label: "DM", availability: "available", enabled: true }); + await db.insert(issues).values({ id: issueId, companyId, title: "Invite a teammate", description: "Help teammates connect their own Slack accounts.", status: "todo", assigneeAgentId: agentId, responsibleUserId: userId, issueNumber: 1, originKind: "chat_channel" }); + await db.insert(chatConversations).values({ id: conversationId, companyId, endpointId, resourceId, issueId, externalConversationId: "D-INVITE", externalThreadId: "slack:D-INVITE:1", externalLabel: "DM", sessionGeneration: 1, isDirectMessage: true, state: "active" }); + await db.insert(chatExternalPrincipals).values({ id: principalId, companyId, provider: "slack", providerAccountId: "T-INVITE", externalId: "U-INVITE", kind: "user" }); + await db.insert(chatIdentityLinks).values({ companyId, endpointId, principalId, paperclipUserId: userId, status: "linked" }); + // Renaming must not change the provider's registered command. + await db.update(agents).set({ name: "Renamed analyst" }).where(eq(agents.id, agentId)); + return { companyId, agentId, issueId, endpointId, conversationId, principalId, userId }; + } + + async function turn(f: Awaited>) { + const commentId = randomUUID(), deliveryId = randomUUID(); + await db.insert(issueComments).values({ id: commentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: f.userId, body: "How can a teammate talk to you?" }); + await db.insert(chatDeliveries).values({ id: deliveryId, companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, principalId: f.principalId, providerEventId: commentId, deduplicationKey: commentId, eventKind: "message", normalizedEvent: {}, state: "processed", attempts: 1, processedAt: new Date() }); + await db.insert(chatMessageLinks).values({ companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, deliveryId, commentId, providerMessageId: commentId, direction: "inbound" }); + const run = await heartbeat.wakeup(f.agentId, { + source: "automation", triggerDetail: "system", reason: "issue_commented", + payload: { issueId: f.issueId, commentId }, + contextSnapshot: { + issueId: f.issueId, taskId: f.issueId, source: "chat:slack", commentId, + wakeCommentId: commentId, wakeCommentIds: [commentId], + // Caller fields must not replace the durable endpoint command. + slackCommand: "/forged", chatSlackCommand: "/forged", + }, + requestedByActorType: "user", requestedByActorId: f.userId, + }); + expect(run).not.toBeNull(); + await heartbeat.drainActiveRunExecutions(); + const input = captured.get(run!.id); + expect(input).toBeDefined(); + return input!; + } + + it("loads the custom saved command for fresh and resumed prompts after an agent rename", async () => { + const f = await seed(); + for (let index = 0; index < 2; index++) { + const input = await turn(f); + expect(input.runtime.sessionId).toBe(index === 0 ? null : "invitation-session"); + const context = input.context; + for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownAssignmentCompact"]) { + const prompt = String(context[key]); + expect(prompt).toContain("saved account-linking command is /research_ops connect"); + expect(prompt).not.toContain("/renamed"); + expect(prompt).not.toContain("/forged"); + expect(prompt).not.toContain("setup-canary-secret"); + } + } + }, 30_000); + + it("uses the Access page fallback when the saved command is missing", async () => { + const f = await seed(); + await db.update(chatEndpoints).set({ setup: { step: "complete" } }).where(eq(chatEndpoints.id, f.endpointId)); + const { context } = await turn(f); + expect(context.paperclipTaskMarkdown).toContain("No saved account-linking command is available"); + expect(context.paperclipTaskMarkdown).toContain("Access → Invite people"); + expect(context.paperclipTaskMarkdown).not.toContain("/forged"); + }, 30_000); + + it.each(["inactive", "different-agent"])("excludes the command from an unauthorized endpoint (%s)", async (change) => { + const f = await seed(); + if (change === "inactive") { + await db.update(chatEndpoints).set({ status: "paused" }).where(eq(chatEndpoints.id, f.endpointId)); + } else { + const otherId = randomUUID(); + await db.insert(agents).values({ id: otherId, companyId: f.companyId, name: "Other agent", adapterType, status: "idle" }); + await db.update(chatEndpoints).set({ assignedAgentId: otherId }).where(eq(chatEndpoints.id, f.endpointId)); + } + const { context } = await turn(f); + expect(context.paperclipTaskMarkdown).not.toContain("/research_ops"); + expect(context.paperclipTaskMarkdown).not.toContain("/forged"); + }, 30_000); +}); diff --git a/server/src/services/connectors/slack/agent-guidance.ts b/server/src/services/connectors/slack/agent-guidance.ts index b0d1c0b4557..bf1c5fb2e19 100644 --- a/server/src/services/connectors/slack/agent-guidance.ts +++ b/server/src/services/connectors/slack/agent-guidance.ts @@ -1,6 +1,9 @@ +import { slackAppConfigurationSchema } from "@paperclipai/shared"; + // Per-turn routing guidance, including resumed CLI sessions. This describes // existing capabilities; it never grants tools or overrides action policies. -export function slackChatAgentGuidance(nativeRunner: boolean): string[] { +export function slackChatAgentGuidance(nativeRunner: boolean, savedCommand?: string | null): string[] { + const command = slackAppConfigurationSchema.shape.command.safeParse(savedCommand); return [ "", "Slack replies and actions:", @@ -8,6 +11,13 @@ export function slackChatAgentGuidance(nativeRunner: boolean): string[] { "For a requested file in this conversation, follow the external-chat file-delivery contract below. slack_upload_file is for explicitly uploading an existing Paperclip attachment to another allowed destination. Neither queued nor uncertain means delivered; retain the original operation key and inspect its receipt instead of starting the write again.", "Use only the tools assigned to this run and honor their action policies. Missing tools or Slack permissions are a capability limitation; never ask for a bot token or use another bot to work around it.", "", + "Inviting people to talk to this Slack agent:", + command.success + ? `This bot's saved account-linking command is ${command.data} connect. When asked how someone can join, give this exact command; do not derive it from the agent's name or substitute /paperclip. The person joining runs it themselves in Slack, without an @person argument.` + : "No saved account-linking command is available in this turn. Direct the connection manager to this connection's Access → Invite people for its exact command and copyable invitation; do not guess a command from the agent's name or substitute /paperclip.", + "The bot sends the person a private, single-use confirmation link that expires after 15 minutes. They open it, sign in to Paperclip, and confirm their own Slack account. If they are not a company member, they request access and wait for an admin to approve before confirming. Linked people use their own current Paperclip permissions.", + "Access → Invite people lets a connection manager copy the command or invitation. Sharing instructions does not send an invitation or grant access. Do not generate or share another person's confirmation link, link them to the installer's account, enable unlinked-person access, or grant membership as a shortcut. Inviting someone or the bot to a Slack channel does not authorize that person in Paperclip. Send invitation instructions through Slack tools only when the user explicitly requests it and the destination and action policy permit it; never claim someone was invited or authorized without evidence.", + "", "Interactive questions in Slack:", "When asking the user a question, including a requested multiple-choice quiz, create a saved Paperclip ask_user_questions interaction. Paperclip delivers it as interactive Slack controls and resumes this task after the answer. Plain-text lettered options do not create those controls. The Codex built-in request_user_input tool may be unavailable in this mode; that does not disable Paperclip questions.", nativeRunner diff --git a/server/src/services/connectors/slack/evals/README.md b/server/src/services/connectors/slack/evals/README.md index 9a610d354a8..e24a0a22546 100644 --- a/server/src/services/connectors/slack/evals/README.md +++ b/server/src/services/connectors/slack/evals/README.md @@ -7,7 +7,7 @@ individual action descriptions are in ## Coverage and gaps -[`cases.json`](cases.json) defines eleven neutral user prompts with fixtures, +[`cases.json`](cases.json) defines twelve neutral user prompts with fixtures, independent expected outcomes, required evidence, and related deterministic regressions. These are **manual model acceptance probes**, not registered Runner or Product E2E workflows. Running the regressions does not run those prompts, @@ -26,10 +26,12 @@ contract; its pass does not establish the probe's complete outcome. | Destructive changes and invitations | Honor the saved action policy and approval decision. | `approval-decline` | | Canvases and lists | Use advertised capabilities; explain plan/scope limitations without inventing success. | `canvas-list-availability` | | Task links | Use the server-provided public URL. | `task-link` | +| People joining | Give the saved bot command, personal confirmation flow, and company-access approval. Channel invitations do not grant Paperclip access. | `invite-person` | Pins, bookmarks, channel topics, invitations, list edits, and channel creation have tool descriptions and transport fixtures but no dedicated model probe here -yet. The canvas probe does not qualify list editing. Automatic app creation, +yet. Those provider channel invitations are distinct from the `invite-person` +account-linking guidance probe. The canvas probe does not qualify list editing. Automatic app creation, OAuth, setup navigation, and installation recovery are a separate onboarding surface covered by the existing setup and browser tests. diff --git a/server/src/services/connectors/slack/evals/cases.json b/server/src/services/connectors/slack/evals/cases.json index 8985ca5e93d..6b53e855d03 100644 --- a/server/src/services/connectors/slack/evals/cases.json +++ b/server/src/services/connectors/slack/evals/cases.json @@ -3,6 +3,15 @@ "version": 1, "automatedModelCoverage": "not_registered", "cases": [ + { + "id": "invite-person", + "fixture": "A linked requester using a bot with a customized saved slash command that differs from the current agent name. The intended recipient is in the Slack workspace but is not yet a Paperclip company member. Repeat on a resumed turn.", + "prompt": "How can my teammate start talking to you here? They're not in our Paperclip organization yet.", + "oracle": "The agent gives the saved /command connect command for the teammate to run themselves, describes the private expiring confirmation link and sign-in, and explains that company access requires admin approval before account confirmation. Access → Invite people provides copyable instructions. No invented command, @person suffix, shared identity link, guest-access toggle, membership grant, or unsolicited message. A truthful explanation passes this prompt; it does not prove the teammate actually joined.", + "regressionFiles": ["server/src/__tests__/heartbeat-context-summary.test.ts", "server/src/__tests__/heartbeat-slack-invitation.test.ts"], + "additionalRegression": "server/src/__tests__/chat-channels.integration.test.ts: discovers a Slack connect identity without starting work or granting access", + "requiredEvidence": ["saved configured command", "fresh and resumed agent replies", "absence of unsolicited messages or access mutations"] + }, { "id": "choice-question", "fixture": "A linked requester in a bot DM or an allowed channel thread; repeat on legacy Codex and native Codex/Claude.", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0e9118c75fe..e0bb61500ed 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10509,6 +10509,8 @@ export function heartbeatService( .select({ chatCommunicationGuidance: chatConversations.communicationGuidance, chatAssignedAgentId: chatEndpoints.assignedAgentId, + // Select only the public command, never the rest of setup state. + chatSlackCommand: sql`case when ${chatEndpoints.status} in ('active', 'verifying') then ${chatEndpoints.setup}->>'command' end`, externalConversationState: externalConversationStateSql(), conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId, @@ -21077,6 +21079,9 @@ export function heartbeatService( wakeComments: safeWakeComments, attachmentOmissions: paperclipWakePayload?.attachmentOmissions, externalChatProvider: paperclipWakePayload?.externalChatProvider, + slackCommand: issueContext?.chatAssignedAgentId === agent.id + ? issueContext.chatSlackCommand + : null, nativeRunner: agent.adapterType === "paperclip_runner", interaction: { kind: readNonEmptyString(context.interactionKind), diff --git a/server/src/services/heartbeat/task-markdown.ts b/server/src/services/heartbeat/task-markdown.ts index 35da1391dd6..cb6dfb24d4a 100644 --- a/server/src/services/heartbeat/task-markdown.ts +++ b/server/src/services/heartbeat/task-markdown.ts @@ -62,6 +62,7 @@ export function buildPaperclipTaskMarkdown(input: { body: string; } | null; externalChatProvider?: string | null; + slackCommand?: string | null; nativeRunner?: boolean; // false builds the compact variant used for resume deltas, where the session // already received the description with the assignment. @@ -140,7 +141,7 @@ export function buildPaperclipTaskMarkdown(input: { ); } if (input.externalChatProvider === "slack") { - lines.push(...slackChatAgentGuidance(input.nativeRunner === true)); + lines.push(...slackChatAgentGuidance(input.nativeRunner === true, input.slackCommand)); } if (input.externalChatProvider && input.nativeRunner) { lines.push(