feat(slack): add threaded CEO conversations and live progress

This commit is contained in:
Scott Tong committed 2026-09-23 00:12:02 -07:00
1 parent 91339784ce
commit fbcae3cd88
39 files changed
+2384 -74

No files matched your search

@@ -134,6 +134,7 @@ import * as discordQuestionForms from "../services/chat-discord-question-forms.j
import { issueService } from "../services/issues.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { getExternalChannelBindingSummary } from "../services/chat-channel-binding.js";
import { slackBoardRepliesService } from "../services/slack-board-replies.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { NativeChatAttachmentReadScope } from "../services/native-runtime/chat-attachment-read.js";
import { logActivity } from "../services/activity-log.js";
@@ -1244,7 +1245,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const token = { ok: true, app_id: "ATEST", team: { id: "TTEST" }, authed_user: { id: "UOWNER" }, access_token: "xoxb-synthetic-token", token_type: "bot", bot_user_id: botId, scope: SLACK_CEO_DM_SCOPES.join(",") };
const providerFetch = vi.fn<typeof fetch>(async input => {
if (String(input) === "https://slack.com/api/oauth.v2.access") return Response.json(token);
if (String(input) === "https://slack.com/api/auth.test") return Response.json({ ok: true, team_id: "TTEST", team: "Pilot", user_id: botId, user: `ceo-${botId}` }, { headers: { "x-oauth-scopes": SLACK_CEO_DM_SCOPES.join(",") } });
if (String(input) === "https://slack.com/api/auth.test") return Response.json({ ok: true, team_id: "TTEST", team: "Pilot", user_id: botId, user: `ceo-${botId}` }, { headers: { "x-oauth-scopes": token.scope } });
throw new Error(`Unexpected provider request: ${String(input)}`);
});
const services = createService(undefined, providerFetch);
@@ -1256,6 +1257,215 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
}
describe("Slack CEO DM-only OAuth pilot", () => {
async function activeLegacyFixture() {
const f = await slackCeoOAuthFixture();
await f.service.completeSlackBotOAuth(f.endpoint.id, { state: f.state, code: "code", actor: f.actor });
await f.service.handleWebhook(f.endpoint.publicId, "slack", signedSlackWebhookRequest({
url: `https://paperclip.example/api/chat-webhooks/${f.endpoint.publicId}/slack`, contentType: "application/json",
body: JSON.stringify({ type: "url_verification", challenge: "verified-challenge" }),
}));
await f.service.configure(f.endpoint.id, { action: "verify" }, "owner-user");
await f.service.finishSlackSetup(f.endpoint.id, "owner-user");
return f;
}
it.each(["declined", "wrong-scope", "wrong-installer", "revoked-link", "wrong-session", "stale-generation"])("retains the existing bot when a threaded upgrade fails: %s", async failure => {
const f = await activeLegacyFixture();
const [before] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.endpoint.connectionId));
const begin = await f.service.startSlackThreadUpgrade(f.endpoint.id, f.actor);
const state = new URL(begin.authorizationUrl).searchParams.get("state")!;
f.token.scope += ",reactions:write";
if (failure === "wrong-scope") f.token.scope += ",files:read";
if (failure === "wrong-installer") f.token.authed_user.id = "UOTHER";
if (failure === "revoked-link") await db.update(chatIdentityLinks).set({ status: "revoked", revokedAt: new Date() }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id));
if (failure === "stale-generation") {
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpoint.id));
await db.update(chatEndpoints).set({ setup: { ...endpoint.setup, runtimeGeneration: 999 } as any }).where(eq(chatEndpoints.id, f.endpoint.id));
}
await expect(f.service.completeSlackBotOAuth(f.endpoint.id, { state, code: "upgrade-code", error: failure === "declined" ? "access_denied" : null,
actor: failure === "wrong-session" ? { ...f.actor, sessionId: "another" } : f.actor })).rejects.toThrow();
const [after] = await db.select().from(toolConnections).where(eq(toolConnections.id, f.endpoint.connectionId));
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpoint.id));
expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs);
expect(after).toMatchObject({ enabled: true, status: "active" });
expect(endpoint).toMatchObject({ status: "active", capabilities: { reactions: false }, setup: { slackPermissionProfile: "ceo-dm-v1" } });
expect((endpoint.setup as any).slackDmThreading).toBeUndefined();
});
it.each(["native", "legacy"] as const)("upgrades once through fresh consent, acknowledges one root, and edits working progress in that thread: %s", async runtimeMode => {
const f = await activeLegacyFixture();
const begin = await f.service.startSlackThreadUpgrade(f.endpoint.id, f.actor);
const url = new URL(begin.authorizationUrl);
expect(url.searchParams.get("scope")?.split(",")).toEqual([...SLACK_CEO_DM_SCOPES, "reactions:write"]);
expect(url.searchParams.has("user_scope")).toBe(false);
f.token.scope += ",reactions:write";
const state = url.searchParams.get("state")!;
// Durable state survives service reconstruction, without a fresh grant.
await f.service.shutdown();
const restored = createService(undefined, f.providerFetch);
await restored.service.completeSlackBotOAuth(f.endpoint.id, { state, code: "upgrade-code", actor: f.actor });
const [upgraded] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpoint.id));
expect(upgraded).toMatchObject({ status: "active", capabilities: { reactions: true, files: false }, setup: { slackPermissionProfile: "ceo-dm-threaded-v2" } });
const cutoff = (upgraded.setup as any).slackDmThreading.since;
expect(cutoff).toMatch(/^\d+\.\d{6}$/);
await expect(restored.service.completeSlackBotOAuth(f.endpoint.id, { state, code: "upgrade-code", actor: f.actor })).rejects.toThrow();
await expect(restored.service.startSlackThreadUpgrade(f.endpoint.id, f.actor)).rejects.toThrow(/legacy pilot/);
await restored.service.handleWebhook(f.endpoint.publicId, "slack", signedSlackWebhookRequest({
url: `https://paperclip.example/api/chat-webhooks/${f.endpoint.publicId}/slack`, contentType: "application/json",
body: JSON.stringify({ type: "url_verification", challenge: "rebuild-runtime" }),
}));
const rootTs = `${Math.floor(Date.now() / 1000) + 1}.000001`;
// Match the real pinned SDK callback, not the bare provider channel id.
const root = makeThread({ channelId: "slack:D123", id: `slack:D123:${rootTs}`, isDM: true });
const incoming = { callbacks: restored.runtime.configurations.get(f.endpoint.id)!.callbacks, endpointId: f.endpoint.id,
thread: root.thread, message: makeMessage({ id: rootTs, userId: "UOWNER", text: "Test threaded progress" }), trigger: "direct_message" as const };
await deliverMessage(incoming);
await deliverMessage(incoming);
await restored.service.processPendingReceiptReactions();
const receipts = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "receipt_reaction")));
expect(receipts).toHaveLength(1);
expect(receipts[0].status).toBe("processed");
const [conversation] = await restored.service.listConversations(f.endpoint.id);
expect(conversation.bindingMode).toBe("slack_dm_thread_v2");
expect(conversation.externalConversationId).toBe("D123");
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.assignedAgentId, runtimeMode, status: "running",
contextSnapshot: await chatWakeContext({ endpointId: f.endpoint.id, issueId: conversation.issueId, provider: "slack", providerMessageId: rootTs }) });
await enqueueChatRunMilestones(db);
await restored.service.processPendingPublications();
const runtime = restored.runtime.get(f.endpoint.id)!;
expect(runtime.posts).toHaveLength(1);
expect(runtime.posts[0]).toMatchObject({ threadId: root.thread.id, text: "Maya is working…" });
await db.update(chatPublications).set({ createdAt: new Date(Date.now() - 25_000) }).where(eq(chatPublications.conversationId, conversation.id));
await db.insert(heartbeatRunEvents).values({ companyId: f.companyId, agentId: f.assignedAgentId, runId, seq: 1, eventType: runtimeMode === "native" ? "tool.execution.started" : "chat.progress.using_tools",
message: "PRIVATE COMMAND", payload: { command: "PRIVATE ARGUMENTS" } });
await enqueueChatRunMilestones(db);
await restored.service.processPendingPublications();
expect(runtime.posts).toHaveLength(1);
expect(runtime.edits.at(-1)).toMatchObject({ threadId: root.thread.id, text: "Maya is using tools…" });
expect(JSON.stringify([...runtime.posts, ...runtime.edits])).not.toContain("PRIVATE");
await db.update(heartbeatRuns).set({ status: "failed", errorCode: "test_error", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
await enqueueChatRunMilestones(db);
await restored.service.processPendingPublications();
expect(runtime.posts).toHaveLength(1);
expect(runtime.edits.at(-1)?.text).toContain("stopped before completing");
});
async function threadedFixture() {
const f = await slackCeoOAuthFixture();
await f.service.completeSlackBotOAuth(f.endpoint.id, { state: f.state, code: "code", actor: f.actor });
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpoint.id));
// Test-only activation. The production upgrade remains gated until the
// shared composer, private notes and scope-consent flow are complete.
await db.update(chatEndpoints).set({ setup: { ...endpoint.setup,
runtimeGeneration: Number((endpoint.setup as any).runtimeGeneration ?? 0) + 1,
slackDmThreading: { version: 2, since: "9000.000000" },
} as any }).where(eq(chatEndpoints.id, f.endpoint.id));
await f.service.handleWebhook(f.endpoint.publicId, "slack", signedSlackWebhookRequest({
url: `https://paperclip.example/api/chat-webhooks/${f.endpoint.publicId}/slack`, contentType: "application/json",
body: JSON.stringify({ type: "url_verification", challenge: "verified-challenge" }),
}));
await f.service.configure(f.endpoint.id, { action: "verify" }, "owner-user");
await f.service.finishSlackSetup(f.endpoint.id, "owner-user");
return f;
}
it("persists separate DM roots, resumes the same task in a reply, and deduplicates after reconstruction", async () => {
const f = await threadedFixture();
expect(f.runtime.configurations.get(f.endpoint.id)!.providerConfig).toMatchObject({
dmConversationMode: "message_threads", dmThreadingSince: "9000.000000",
});
const callbacks = f.runtime.configurations.get(f.endpoint.id)!.callbacks;
const root = makeThread({ channelId: "slack:D123", id: "slack:D123:9001.000001", isDM: true });
const second = makeThread({ channelId: "slack:D123", id: "slack:D123:9001.000002", isDM: true });
const firstInput = { callbacks, endpointId: f.endpoint.id, thread: root.thread,
message: makeMessage({ id: "9001.000001", userId: "UOWNER", text: "First task" }), trigger: "direct_message" as const };
await deliverMessage(firstInput);
await deliverMessage({ ...firstInput, thread: second.thread, message: makeMessage({ id: "9001.000002", userId: "UOWNER", text: "Second task" }) });
const [first] = await db.select().from(chatConversations).where(and(eq(chatConversations.endpointId, f.endpoint.id), eq(chatConversations.externalThreadId, root.thread.id)));
expect(first).toMatchObject({ bindingMode: "slack_dm_thread_v2", externalConversationId: "D123", originUserId: "owner-user", originPrincipalId: expect.any(String), sessionGeneration: 1 });
// Completing a turn must not roll a native thread into a different task.
await db.update(issues).set({ status: "done" }).where(eq(issues.id, first.issueId));
await db.update(chatConversations).set({ state: "completed" }).where(eq(chatConversations.id, first.id));
await deliverMessage({ ...firstInput, message: makeMessage({ id: "9002.000001", userId: "UOWNER", text: "Continue the first task" }) });
expect(f.wakeup).toHaveBeenCalledTimes(3);
expect(await f.service.listConversations(f.endpoint.id)).toHaveLength(2);
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, first.issueId))).toHaveLength(2);
await f.service.shutdown();
const restarted = createService(undefined, f.providerFetch);
await restarted.service.handleWebhook(f.endpoint.publicId, "slack", signedSlackWebhookRequest({
url: `https://paperclip.example/api/chat-webhooks/${f.endpoint.publicId}/slack`, contentType: "application/json",
body: JSON.stringify({ type: "url_verification", challenge: "still-here" }),
}));
const restartedCallbacks = restarted.runtime.configurations.get(f.endpoint.id)!.callbacks;
await deliverMessage({ ...firstInput, callbacks: restartedCallbacks });
expect(restarted.wakeup).not.toHaveBeenCalled();
await deliverMessage({ ...firstInput, callbacks: restartedCallbacks, message: makeMessage({ id: "9003.000001", userId: "UOWNER", text: "After restart" }) });
expect(restarted.wakeup).toHaveBeenCalledOnce();
expect(await restarted.service.listConversations(f.endpoint.id)).toHaveLength(2);
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, first.issueId))).toHaveLength(3);
});
it("keeps pre-upgrade flat messages legacy and denies mismatched thread ownership", async () => {
const f = await threadedFixture();
const callbacks = f.runtime.configurations.get(f.endpoint.id)!.callbacks;
const base = { callbacks, endpointId: f.endpoint.id, trigger: "direct_message" as const };
const old = { ...base, thread: makeThread({ channelId: "D123", id: "slack:D123:", isDM: true }).thread,
message: makeMessage({ id: "8999.999999", userId: "UOWNER", text: "Legacy private request" }) };
await deliverMessage(old);
await deliverMessage(old);
const current = { ...base, thread: makeThread({ channelId: "D123", id: "slack:D123:9001.000001", isDM: true }).thread,
message: makeMessage({ id: "9001.000001", userId: "UOWNER", text: "New thread" }) };
await deliverMessage(current);
const rows = await f.service.listConversations(f.endpoint.id);
expect(rows.map(row => row.bindingMode).sort()).toEqual(["legacy", "slack_dm_thread_v2"]);
const threaded = rows.find(row => row.bindingMode === "slack_dm_thread_v2")!;
await db.update(chatConversations).set({ originUserId: "different-user" }).where(eq(chatConversations.id, threaded.id));
await deliverMessage({ ...current, message: makeMessage({ id: "9002.000001", userId: "UOWNER", text: "Must not route to someone else's task" }) });
expect(f.wakeup).toHaveBeenCalledTimes(2);
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, threaded.issueId))).toHaveLength(1);
expect(await f.service.listConversations(f.endpoint.id)).toHaveLength(2);
// None of this profile's five scopes authorizes reactions or file reads.
expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "receipt_reaction")))).toHaveLength(0);
});
it.each(["short", "multipart"])("publishes the shared Paperclip request under the exact root before waking the CEO: %s", async (size) => {
const f = await threadedFixture();
const root = makeThread({ channelId: "D123", id: "slack:D123:9001.000001", isDM: true });
await deliverMessage({ callbacks: f.runtime.configurations.get(f.endpoint.id)!.callbacks, endpointId: f.endpoint.id,
thread: root.thread, message: makeMessage({ id: "9001.000001", userId: "UOWNER", text: "Initial task" }), trigger: "direct_message" });
const [conversation] = await f.service.listConversations(f.endpoint.id);
// Finish the simulated inbound wake so a new human turn is eligible.
await db.update(agentWakeupRequests).set({ status: "skipped" }).where(eq(agentWakeupRequests.companyId, f.companyId));
const replies = slackBoardRepliesService(db, { wakeup: receiptBackedWakeup(f.wakeup) });
const body = size === "short" ? "Shared follow-up" : "Evidence to discuss. ".repeat(1500);
const receipt = await replies.request({ companyId: f.companyId, endpointId: f.endpoint.id, conversationId: conversation.id, userId: "owner-user" }, body, randomUUID());
expect(receipt.status).toBe("queued");
expect(f.wakeup).toHaveBeenCalledTimes(1);
f.runtime.get(f.endpoint.id)!.postHook = async () => { expect(f.wakeup).toHaveBeenCalledTimes(1); };
await f.service.processPendingPublications();
let pending = await db.select().from(chatPublications).where(and(eq(chatPublications.companyId, f.companyId), eq(chatPublications.commentId, receipt.commentId)));
if (pending.some(row => row.state !== "published")) {
await replies.processPending();
expect(f.wakeup).toHaveBeenCalledTimes(1);
for (let attempt = 0; attempt < 4 && pending.some(row => row.state !== "published"); attempt++) {
await f.service.processPendingPublications();
pending = await db.select().from(chatPublications).where(and(eq(chatPublications.companyId, f.companyId), eq(chatPublications.commentId, receipt.commentId)));
}
}
const publications = await db.select().from(chatPublications).where(and(eq(chatPublications.companyId, f.companyId), eq(chatPublications.commentId, receipt.commentId)));
if (size === "short") expect(publications).toHaveLength(1);
else expect(publications.length).toBeGreaterThan(1);
for (const publication of publications) expect(publication).toMatchObject({ state: "published", providerMessageId: expect.any(String) });
const posts = f.runtime.get(f.endpoint.id)!.posts;
for (const post of posts) expect(post.threadId).toBe(root.thread.id);
expect(posts.map(post => post.text).join("")).toBe(`From Paperclip (you):\n\n${body.trimEnd()}`);
await replies.processPending();
await replies.processPending();
expect(f.wakeup).toHaveBeenCalledTimes(2);
expect((await getExternalChannelBindingSummary(db, f.companyId, conversation.issueId))?.bindingMode).toBe("slack_dm_thread_v2");
});
it("admits a linked DM once, and never requests optional files or receipt reactions", async () => {
const f = await slackCeoOAuthFixture();
await f.service.completeSlackBotOAuth(f.endpoint.id, { state: f.state, code: "code", actor: f.actor });
@@ -8,7 +8,7 @@ import {
toolApplications, toolConnections,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
import { slackBoardRepliesService, slackBoardReplyBinding, authorizeSlackBoardReplyWake } from "../services/slack-board-replies.js";
import { slackBoardRepliesService, slackBoardReplyBinding, authorizeSlackBoardReplyWake, authorizeSlackSharedRequestPublication } from "../services/slack-board-replies.js";
import { assertDurableChatWakeupRequest } from "../services/durable-chat-wakeup.js";
import type { IssueAssignmentWakeupDeps } from "../services/issue-assignment-wakeup.js";
import { issueService } from "../services/issues.js";
@@ -137,4 +137,90 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(authorizeSlackBoardReplyWake(db, { companyId: f.companyId, agentId: f.agentId, issueId: f.issueId,
wakeupRequestId: null, contextSnapshot: { source: "slack.board_reply" } })).rejects.toMatchObject({ status: 403 });
});
async function threadBinding(f: Awaited<ReturnType<typeof fixture>>) {
await db.update(chatConversations).set({ bindingMode: "slack_dm_thread_v2", externalThreadId: "slack:DTEST:9000.000001",
originPrincipalId: f.principalId, originUserId: f.userId }).where(eq(chatConversations.id, f.conversationId));
}
it("retains an exact threaded binding through board submission and rejects later root changes", async () => {
const f = await fixture();
await threadBinding(f);
await f.service.request(f.scope, "Answer in this thread", randomUUID());
expect(f.heartbeat.wakeup).not.toHaveBeenCalled();
await db.update(chatPublications).set({ state: "published", providerMessageId: "9001.000001" }).where(eq(chatPublications.companyId, f.companyId));
await f.service.processPending();
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId));
expect(await slackBoardReplyBinding(db, f.companyId, f.issueId, run.id)).toMatchObject({ conversationId: f.conversationId });
await db.update(chatConversations).set({ externalThreadId: "slack:DTEST:9000.000002" }).where(eq(chatConversations.id, f.conversationId));
expect(await slackBoardReplyBinding(db, f.companyId, f.issueId, run.id)).toBeNull();
});
it("atomically saves a shared request once, waits for delivery, then resumes once after reconstruction", async () => {
const f = await fixture(), key = randomUUID();
await threadBinding(f);
const first = await f.service.request(f.scope, "Shared from Paperclip", key);
const second = await f.service.request(f.scope, "Shared from Paperclip", key);
expect(first).toEqual(second);
const [publication] = await db.select().from(chatPublications).where(eq(chatPublications.companyId, f.companyId));
expect(publication.payload.text).toBe("From Paperclip (you):\n\nShared from Paperclip");
expect(publication.commentId).toBe(first.commentId);
expect(await authorizeSlackSharedRequestPublication(db, publication)).toBe(true);
expect(await authorizeSlackSharedRequestPublication(db, { ...publication, payload: { text: "Modified later" } })).toBe(false);
expect(f.heartbeat.wakeup).not.toHaveBeenCalled();
const restarted = slackBoardRepliesService(db, f.heartbeat);
for (const state of ["pending", "retry", "delivery_unknown", "failed"] as const) {
await db.update(chatPublications).set({ state }).where(eq(chatPublications.id, publication.id));
await restarted.processPending();
expect(f.heartbeat.wakeup).not.toHaveBeenCalled();
}
await db.update(chatPublications).set({ state: "published", providerMessageId: "9001.000001" }).where(eq(chatPublications.id, publication.id));
await restarted.processPending();
await restarted.processPending();
expect(f.heartbeat.wakeup).toHaveBeenCalledOnce();
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(1);
expect(await db.select().from(chatPublications).where(eq(chatPublications.companyId, f.companyId))).toHaveLength(1);
expect(await db.select().from(chatDeliveries).where(eq(chatDeliveries.companyId, f.companyId))).toHaveLength(1);
});
it("revocation before a shared send blocks both the request publication and the CEO wake", async () => {
const f = await fixture();
await threadBinding(f);
await f.service.request(f.scope, "Do not send after revocation", randomUUID());
const [publication] = await db.select().from(chatPublications).where(eq(chatPublications.companyId, f.companyId));
await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpointId));
expect(await authorizeSlackSharedRequestPublication(db, publication)).toBe(false);
await f.service.processPending();
expect(f.heartbeat.wakeup).not.toHaveBeenCalled();
expect((await db.select().from(chatActions).where(eq(chatActions.companyId, f.companyId)))[0].status).toBe("failed");
});
it("editing the saved request does not silently replace the text authorized for Slack", async () => {
const f = await fixture();
await threadBinding(f);
const result = await f.service.request(f.scope, "Original shared request", randomUUID());
await db.update(issueComments).set({ body: "Private replacement" }).where(eq(issueComments.id, result.commentId));
const [publication] = await db.select().from(chatPublications).where(eq(chatPublications.companyId, f.companyId));
expect(await authorizeSlackSharedRequestPublication(db, publication)).toBe(false);
await f.service.processPending();
expect(f.heartbeat.wakeup).not.toHaveBeenCalled();
});
it("does not authorize a threaded task merely from an existing linked identity and inbound receipt", async () => {
const f = await fixture();
await threadBinding(f);
await db.update(chatConversations).set({ originUserId: "another-user" }).where(eq(chatConversations.id, f.conversationId));
await expect(f.service.request(f.scope, "Do not start", randomUUID())).rejects.toMatchObject({ status: 403 });
expect(f.heartbeat.wakeup).not.toHaveBeenCalled();
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.issueId))).toHaveLength(0);
});
it("enforces thread shape, channel and tenant constraints in the database", async () => {
const f = await fixture(), other = await fixture();
await threadBinding(f);
for (const change of [{ externalThreadId: "slack:DOTHER:9000.000001" }, { sessionGeneration: 2 },
{ originPrincipalId: null }, { originPrincipalId: other.principalId }, { bindingMode: "unknown" as never }]) {
await expect(db.update(chatConversations).set(change).where(eq(chatConversations.id, f.conversationId))).rejects.toThrow();
}
});
});
@@ -67,6 +67,43 @@ const support = await getEmbeddedPostgresTestSupport();
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, interactionId: intents[0].id } }).where(eq(heartbeatRuns.id, f.runId));
expect((await f.service.ensureCapability(f.claims, { capability: "slack.read_channel" })).status).toBe("UNAVAILABLE");
});
it("starts fresh consent after removal without duplicating the retained application or reviving credentials", async () => {
const f = await fixture();
const access = toolAccessService(db);
const actor = { actorType: "user" as const, actorId: f.userId, sessionId: "fixture-session", actorSource: "session" as const };
const app = await access.createApplication(f.companyId, { name: "Slack public-channel read pilot", type: "mcp_http", ownerUserId: f.userId, metadata: { sourceTemplateKey: "slack" } });
await db.update(toolConnections).set({ applicationId: app.id }).where(eq(toolConnections.id, f.readConnection.id));
await access.archiveConnection(f.readConnection.id, f.companyId, actor);
const requested = await f.service.ensureCapability(f.claims, { capability: "slack.read_channel" });
expect(requested.status).toBe("AUTH_REQUIRED");
if (requested.status !== "AUTH_REQUIRED") throw new Error("Expected consent");
const result = await f.service.startSlackRead(requested.interactionId!, actor, "https://pilot.example.test/api/tools/oauth/callback");
expect(result.status).toBe("AUTH_REQUIRED");
const applications = await access.listApplications(f.companyId);
expect(applications.filter(item => item.name === app.name)).toHaveLength(1);
const connections = await access.listConnections(f.companyId);
const replacement = connections.find(item => item.applicationId === app.id && item.status !== "archived")!;
expect(replacement.id).not.toBe(f.readConnection.id);
expect(replacement.enabled).toBe(false);
expect(replacement.credentialSecretRefs).toEqual([]);
expect(connections.find(item => item.id === f.readConnection.id)).toMatchObject({ status: "archived", enabled: false });
expect(connections.find(item => item.transport === "chat_sdk")).toMatchObject({ status: "active", enabled: true });
const grants = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, replacement.id));
expect(grants.every(grant => grant.credentialSecretRefs.length === 0 && !grant.providerTenant?.slackReadPilot)).toBe(true);
await expect(resolveSlackReadGrant(db, replacement, f)).rejects.toMatchObject({ status: 403 });
expect((await access.listApplications(f.companyId)).find(item => item.id === app.id)).toMatchObject({ status: "active", archivedAt: null });
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, requested.interactionId!)))[0].status).toBe("pending");
});
it("never adopts a retained pilot application belonging to another person", async () => {
const f = await fixture();
const access = toolAccessService(db);
const actor = { actorType: "user" as const, actorId: f.userId, sessionId: "fixture-session", actorSource: "session" as const };
await access.createApplication(f.companyId, { name: "Slack public-channel read pilot", type: "mcp_http", ownerUserId: randomUUID(), metadata: { sourceTemplateKey: "slack" } });
await access.archiveConnection(f.readConnection.id, f.companyId, actor);
const requested = await f.service.ensureCapability(f.claims, { capability: "slack.read_channel" });
if (requested.status !== "AUTH_REQUIRED") throw new Error("Expected consent");
await expect(f.service.startSlackRead(requested.interactionId!, actor, "https://pilot.example.test/api/tools/oauth/callback")).rejects.toMatchObject({ status: 409 });
});
it("never externalizes a private Paperclip request", async () => {
const f = await fixture();
await db.delete(chatMessageLinks).where(eq(chatMessageLinks.commentId, f.commentId));
@@ -62,12 +62,12 @@ describe("Slack read pilot boundaries", () => {
const args = (parameters: unknown, toolName = "slack_read_channel", schema: unknown = readChannelSchema) => governedSlackReadArguments({ toolName, schema, parameters, channelId: "CAPPROVED", nowSeconds: 2_000_000 });
it("projects a fixed channel and window; repeated governance is stable", () => {
const projected = args({});
expect(projected).toEqual({ channel_id: "CAPPROVED", oldest: "1395200", latest: "2000000", limit: 30 });
expect(projected).toEqual({ channel_id: "CAPPROVED", oldest: "1395200.000000", latest: "2000000.000000", limit: 30 });
expect(args(projected)).toEqual(projected);
});
it("guides a clock-mismatched caller to the server window without widening it", () => {
expect(() => args({ latest: "2000027.72" })).toThrow("Omit oldest/latest");
expect(args({ limit: 100, response_format: "detailed" })).toMatchObject({ oldest: "1395200", latest: "2000000" });
expect(args({ limit: 100, response_format: "detailed" })).toMatchObject({ oldest: "1395200.000000", latest: "2000000.000000" });
});
it.each([
{ channel_id: "COTHER" }, { query: "in:anywhere" }, { channel: "COTHER" }, { limit: 201 }, { limit: -1 }, { limit: 1.5 },
@@ -83,10 +83,14 @@ describe("Slack read pilot boundaries", () => {
});
it("requires a recent thread root in the same channel", () => {
const projected = args({ message_ts: "1999999.123", response_format: "concise" }, "slack_read_thread", readThreadSchema);
expect(projected).toEqual({ message_ts: "1999999.123", response_format: "concise", channel_id: "CAPPROVED", limit: 30, oldest: "1395200", latest: "2000000" });
expect(projected).toEqual({ message_ts: "1999999.123", response_format: "concise", channel_id: "CAPPROVED", limit: 30, oldest: "1395200.000000", latest: "2000000.000000" });
expect(args(projected, "slack_read_thread", readThreadSchema)).toEqual(projected);
for (const parameters of [{ message_ts: "1395199.001" }, { message_ts: "2000001.001" }, { message_ts: "1999999" }, { ts: "1999999.123" }, {}]) {
expect(() => args(parameters, "slack_read_thread", readThreadSchema)).toThrow();
}
});
it("canonicalizes whole-second bounds for Slack without rounding fractional timestamps", () => {
expect(args({ oldest: "1395200", latest: "2000000" })).toMatchObject({ oldest: "1395200.000000", latest: "2000000.000000" });
expect(args({ oldest: "1999999.123456", latest: "1999999.999999" })).toMatchObject({ oldest: "1999999.123456", latest: "1999999.999999" });
});
});
@@ -10,15 +10,16 @@ import { chatChannelRoutes } from "./chat-channels.js";
const board: Request["actor"] = { type: "board", source: "session", userId: "owner", sessionId: "session", companyIds: ["company-a"], isInstanceAdmin: true };
function fixture(actor = board) {
const service = { get: vi.fn().mockResolvedValue({ id: "endpoint", companyId: "company-a" }), startSlackBotOAuth: vi.fn().mockResolvedValue({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=nonce" }), completeSlackBotOAuth: vi.fn().mockResolvedValue({ id: "endpoint" }) };
const upgrade = { ...service, startSlackThreadUpgrade: vi.fn().mockResolvedValue({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=upgrade" }) };
const limit = vi.fn().mockResolvedValue([{ issuePrefix: "TES" }]);
const where = vi.fn().mockReturnValue({ limit });
const db = { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where }) }) };
const app = express();
app.use(express.json());
app.use((req, _res, next) => { req.actor = actor; next(); });
app.use("/api", chatChannelRoutes(db as unknown as Db, { service: service as unknown as ChatChannelService, heartbeat: { wakeup: vi.fn() } }));
app.use("/api", chatChannelRoutes(db as unknown as Db, { service: upgrade as unknown as ChatChannelService, heartbeat: { wakeup: vi.fn() } }));
app.use(errorHandler);
return { app, service, db, limit };
return { app, service: upgrade, db, limit };
}
const path = "/api/chat-endpoints/endpoint/slack/oauth";
describe("Slack pilot OAuth route authority", () => {
@@ -30,6 +31,8 @@ describe("Slack pilot OAuth route authority", () => {
const f = fixture(actor);
await request(f.app).post(`${path}/start`).send({ permissionProfile: "ceo-dm-v1" }).expect(403);
await request(f.app).get(`${path}/callback`).query({ state: "nonce", code: "code" }).expect(403);
await request(f.app).post("/api/chat-endpoints/endpoint/slack/threading/upgrade").send({ permissionProfile: "ceo-dm-threaded-v2" }).expect(403);
expect(f.service.startSlackThreadUpgrade).not.toHaveBeenCalled();
await request(f.app).post("/api/chat-endpoints/endpoint/conversations/10000000-0000-4000-8000-000000000001/slack-replies")
.send({ body: "Reply hello", clientRequestId: "10000000-0000-4000-8000-000000000002" }).expect(403);
expect(f.service.startSlackBotOAuth).not.toHaveBeenCalled();
@@ -42,6 +45,17 @@ describe("Slack pilot OAuth route authority", () => {
await request(f.app).post(`${path}/start`).send({ permissionProfile: "ceo-dm-v1" }).expect(200);
expect(f.service.startSlackBotOAuth).toHaveBeenCalledWith("endpoint", { userId: "owner", sessionId: "session" });
});
it("admits only the exact upgrade profile and returns active bots to management", async () => {
const f = fixture();
const upgradePath = "/api/chat-endpoints/endpoint/slack/threading/upgrade";
await request(f.app).post(upgradePath).send({ permissionProfile: "ceo-dm-v1" }).expect(400);
await request(f.app).post(upgradePath).send({ permissionProfile: "ceo-dm-threaded-v2", scopes: ["files:read"] }).expect(400);
await request(f.app).post(upgradePath).send({ permissionProfile: "ceo-dm-threaded-v2" }).expect(200);
expect(f.service.startSlackThreadUpgrade).toHaveBeenCalledWith("endpoint", { userId: "owner", sessionId: "session" });
f.service.get.mockResolvedValue({ id: "endpoint", companyId: "company-a", status: "active" } as never);
const response = await request(f.app).get(`${path}/callback`).query({ state: "nonce", code: "code" }).expect(303);
expect(response.headers.location).toBe("/TES/apps/chat/endpoint/conversations");
});
it("uses an application-owned redirect and strips provider errors from URLs", async () => {
const f = fixture();
f.service.completeSlackBotOAuth.mockRejectedValueOnce(badRequest("Authorization denied"));
+13 -2
View File
@@ -181,6 +181,15 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
res.json(await service.startSlackBotOAuth(endpointId(req), actor));
});
router.post("/chat-endpoints/:endpointId/slack/threading/upgrade", validate(z.object({
permissionProfile: z.literal("ceo-dm-threaded-v2"),
}).strict()), async (req, res) => {
const actor = slackOAuthActor(req);
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.startSlackThreadUpgrade(endpointId(req), actor));
});
router.get("/chat-endpoints/:endpointId/slack/oauth/callback", async (req, res) => {
const actor = slackOAuthActor(req);
if (!(await assertEndpointManagementAccess(req, res))) return;
@@ -192,7 +201,9 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
const [company] = await db.select({ issuePrefix: companies.issuePrefix })
.from(companies).where(eq(companies.id, endpoint.companyId)).limit(1);
if (!company) throw notFound("Chat endpoint company not found");
const destination = `/${encodeURIComponent(company.issuePrefix)}/apps/chat/connect?provider=slack&purpose=chat&resume=${encodeURIComponent(endpointId(req))}&stage=credentials`;
const destination = endpoint.status === "active"
? `/${encodeURIComponent(company.issuePrefix)}/apps/chat/${encodeURIComponent(endpointId(req))}/conversations`
: `/${encodeURIComponent(company.issuePrefix)}/apps/chat/connect?provider=slack&purpose=chat&resume=${encodeURIComponent(endpointId(req))}&stage=credentials`;
try {
await service.completeSlackBotOAuth(endpointId(req), {
actor,
@@ -204,7 +215,7 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
} catch (error) {
if (!(error instanceof HttpError)) throw error;
// Never reflect the provider's error_description or OAuth code in UI URLs.
res.redirect(303, `${destination}&slackOauth=failed`);
res.redirect(303, `${destination}${destination.includes("?") ? "&" : "?"}slackOauth=failed`);
}
});
+8
View File
@@ -2695,6 +2695,14 @@ registry.registerPath({
responses: { 303: { description: "Redirect to the company-scoped connection wizard; success or safe error, no secrets in the destination." }, 403: r.forbidden, 404: r.notFound },
});
registry.registerPath({
method: "post", path: "/api/chat-endpoints/{endpointId}/slack/threading/upgrade", tags: ["chat-channels"],
summary: "Request consent for threaded pilot DMs and acknowledgement reactions",
description: "Same-person, session-bound upgrade of an active legacy pilot. Existing credentials stay active until the exact bot grant is verified and committed with the immutable threading cutoff. No channel/file permissions are added.",
request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(z.object({ permissionProfile: z.literal("ceo-dm-threaded-v2") }).strict()) },
responses: { 200: r.ok(z.object({ authorizationUrl: z.string(), expiresAt: z.string() })), 400: r.badRequest, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
});
registry.registerPath({
method: "post",
path: "/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
+6 -1
View File
@@ -1,4 +1,5 @@
import { and, desc, eq } from "drizzle-orm";
import { isSlackCeoBotProfile } from "./slack-ceo-permission-profiles.js";
import type { Db } from "@paperclipai/db";
import {
chatConversations,
@@ -70,10 +71,14 @@ export async function getExternalChannelBindingSummary(
externalLabel: row.conversation.externalLabel,
externalUrl,
conversationId: row.conversation.id,
bindingMode: row.conversation.bindingMode,
publicationState: row.publicationState ?? null,
assignedAgentLocked: true,
...(row.endpoint.provider === "slack" && row.endpoint.status === "active" && row.conversation.isDirectMessage &&
...(row.endpoint.provider === "slack" && row.endpoint.status === "active" &&
(row.endpoint.setup as { slackPermissionProfile?: string }).slackPermissionProfile === "ceo-dm-v1"
? { slackThreadUpgradeAvailable: true } : {}),
...(row.endpoint.provider === "slack" && row.endpoint.status === "active" && row.conversation.isDirectMessage &&
isSlackCeoBotProfile((row.endpoint.setup as { slackPermissionProfile?: string }).slackPermissionProfile)
? { slackReplyAvailable: true } : {}),
};
}
+185 -15
View File
@@ -1,5 +1,7 @@
import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js";
import { slackBoardRepliesService, slackBoardReplyBinding } from "./slack-board-replies.js";
import { slackBoardRepliesService, slackBoardReplyBinding, authorizeSlackSharedRequestPublication } from "./slack-board-replies.js";
import { slackDmThreadActivation, slackDmBindingMode, slackDmBindingOwnedBy, slackDmBindingReceipt, type SlackDmThreadActivation } from "./chat-slack-dm-threads.js";
import { isSlackCeoBotProfile, slackCeoBotScopes, SLACK_CEO_THREADED_PROFILE, type SlackCeoBotProfile } from "./slack-ceo-permission-profiles.js";
import { authorizeSlackReadIntent, slackReadContinuationBinding } from "./slack-read-intents.js";
import { SLACK_ADAPTER_BOT_SCOPES, SLACK_CEO_DM_SCOPES, SLACK_CEO_DM_PROFILE, slackBotOAuthStatus, slackBotOAuthConfig, slackBotOAuthBinding, slackBotAuthorizationUrl, exchangeSlackBotCode, assertSlackBotOAuthActor, slackDmPilotRequestAllowed } from "./slack-bot-oauth.js";
import { toolOauthStates } from "@paperclipai/db";
@@ -861,7 +863,7 @@ const INGRESS_REORDER_WINDOW_MS: Partial<Record<ChatProvider, number>> = {
type EndpointRow = typeof chatEndpoints.$inferSelect;
function isSlackDmPilot(endpoint: EndpointRow) {
return endpoint.provider === "slack" && (endpoint.setup as InternalSetupState).slackPermissionProfile === SLACK_CEO_DM_PROFILE;
return endpoint.provider === "slack" && isSlackCeoBotProfile((endpoint.setup as InternalSetupState).slackPermissionProfile);
}
type ResourceRow = typeof chatEndpointResources.$inferSelect;
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
@@ -869,7 +871,16 @@ type DbOrTransaction = Db | DbTransaction;
type SlackCallbackSurface = keyof ChatEndpointCallbackSurfaces;
type SlackCallbackObservation = { url: string; observedAt: string };
type InternalSetupState = ChatEndpointSetupState & {
slackPermissionProfile?: typeof SLACK_CEO_DM_PROFILE;
slackPermissionProfile?: SlackCeoBotProfile;
slackDmThreading?: SlackDmThreadActivation;
slackThreadUpgrade?: {
stateDigest: string;
configurationDigest: string;
generation: number;
credentials: string;
userId: string;
profile: typeof SLACK_CEO_THREADED_PROFILE;
};
slackBotOAuth?: {
version: 1;
stateDigest: string;
@@ -3398,7 +3409,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
credentialFingerprint: refsFingerprint,
generation,
localEpoch,
version: `${generation}:${refsFingerprint}:${localEpoch}`,
version: `${generation}:${refsFingerprint}:${localEpoch}:${isSlackDmPilot(record.endpoint) ? JSON.stringify(slackDmThreadActivation(record.endpoint.setup as InternalSetupState)) : "legacy"}`,
};
}
@@ -5871,7 +5882,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
row.assignedAgentName,
),
...(isSlackDmPilot(endpoint)
? { slackOAuth: slackBotOAuthStatus(getPublicBaseUrl(), endpoint.id) }
? { slackOAuth: slackBotOAuthStatus(getPublicBaseUrl(), endpoint.id, process.env, (endpoint.setup as InternalSetupState).slackPermissionProfile!) }
: {}),
...(endpoint.provider === "github"
? {
@@ -6211,7 +6222,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
.map((scope) => scope.trim())
.filter(Boolean),
);
const requiredScopes = endpoint && isSlackDmPilot(endpoint) ? SLACK_CEO_DM_SCOPES : REQUIRED_SLACK_BOT_SCOPES;
const requiredScopes = endpoint && isSlackDmPilot(endpoint) ? slackCeoBotScopes((endpoint.setup as InternalSetupState).slackPermissionProfile!) : REQUIRED_SLACK_BOT_SCOPES;
const missingScopes = requiredScopes.filter(
(scope) => !grantedScopes.has(scope),
);
@@ -6225,7 +6236,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
},
);
}
if (endpoint && isSlackDmPilot(endpoint) && [...grantedScopes].some(scope => !(SLACK_CEO_DM_SCOPES as readonly string[]).includes(scope))) {
if (endpoint && isSlackDmPilot(endpoint) && [...grantedScopes].some(scope => !(requiredScopes as readonly string[]).includes(scope))) {
throw unprocessable("This DM-only pilot cannot use a bot token with broader permissions");
}
return {
@@ -6531,6 +6542,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
credentials: Record<string, string>,
credentialLease: CredentialMutationLeaseGuard,
actorUserId?: string | null,
commit?: (tx: DbTransaction) => Promise<void>,
) {
await credentialLease.assertOwned();
const previousRefs = await db
@@ -6622,6 +6634,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
.update(toolConnections)
.set({ credentialSecretRefs: refs, updatedAt: new Date() })
.where(eq(toolConnections.id, endpoint.connectionId));
// Upgrade the verified profile and runtime fence atomically with vault refs.
await commit?.(tx);
await credentialLease.assertOwned(tx);
});
} catch (error) {
@@ -7605,6 +7619,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
provider: "slack",
userName,
nativeStreaming: endpoint.capabilities.nativeStreaming,
...(isSlackDmPilot(endpoint) && slackDmThreadActivation(endpoint.setup as InternalSetupState)
? { dmConversationMode: "message_threads", dmThreadingSince: slackDmThreadActivation(endpoint.setup as InternalSetupState)!.since }
: {}),
credentials: {
botToken: credentials.botToken,
signingSecret: credentials.signingSecret,
@@ -11634,6 +11651,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
agentId: input.endpoint.assignedAgentId,
commentId: input.commentId,
sessionGeneration: input.conversation.sessionGeneration,
...(input.conversation.bindingMode === "slack_dm_thread_v2" ? {
slackThreadBinding: slackDmBindingReceipt(input.conversation),
} : {}),
requestedByActorType: input.actorUserId ? "user" : "system",
requestedByActorId: input.actorUserId ?? input.principalId,
},
@@ -11779,6 +11799,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
: nonDirectDestinationAllowed(endpoint, resource)) ||
!authorization.allowed ||
authorization.userId !== expectedUserId ||
!slackDmBindingOwnedBy(conversation, {
companyId: action.companyId, endpointId: action.endpointId, principalId: action.principalId, userId: expectedUserId,
}) ||
payload.slackThreadBinding !== slackDmBindingReceipt(conversation) ||
(payload.requestedByActorType === "system" &&
payload.requestedByActorId !== action.principalId)
)
@@ -11835,6 +11859,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
"requestedByActorType",
"requestedByActorId",
].map((key) => action.payload[key]),
...(action.payload.slackThreadBinding === undefined ? [] : [action.payload.slackThreadBinding]),
]);
}
@@ -13635,6 +13660,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
tx: DbOrTransaction,
publication: typeof chatPublications.$inferSelect,
): Promise<boolean> {
const sharedRequest = await authorizeSlackSharedRequestPublication(tx as unknown as Db, publication);
if (sharedRequest !== null) return sharedRequest;
const notice = parseInboundWakePublicationKey(publication.idempotencyKey);
let runId = runIdFromMilestonePublication(publication);
if (!runId && publication.commentId && !notice) {
@@ -14512,6 +14539,16 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
slackSlashControl = false,
) {
if (isSlackDmPilot(endpoint) && (!thread.isDM || process.env.PAPERCLIP_SLACK_CEO_POC_ENABLED !== "true")) return;
const dmActivation = isSlackDmPilot(endpoint) ? slackDmThreadActivation(endpoint.setup as InternalSetupState) : null;
const bindingMode = dmActivation && !slackSlashControl
? slackDmBindingMode(dmActivation, { threadId: thread.id, channelId: canonicalProviderResourceId(endpoint.provider, thread), messageId: message.id })
: "legacy";
// Chat SDK exposes "slack:D…" as channelId, while our v2 binding contract
// stores the provider's bare D… id. Keep legacy rows untouched, and use the
// same canonical value for v2 admission, durability, lookup and recovery.
const externalConversationId = bindingMode === "slack_dm_thread_v2"
? canonicalProviderResourceId(endpoint.provider, thread)
: thread.channelId;
receiptReactionSupported = receiptReactionSupported && endpoint.capabilities.reactions;
if (
endpoint.provider === "telegram" &&
@@ -14719,7 +14756,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
label: thread.channel.name ?? thread.channelId,
},
conversation: {
externalConversationId: thread.channelId,
externalConversationId,
externalThreadId: thread.id,
label: thread.channel.name ?? thread.channelId,
isDirectMessage: thread.isDM,
@@ -15565,14 +15602,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
eq(chatConversations.endpointId, endpoint.id),
eq(
chatConversations.externalConversationId,
thread.channelId,
externalConversationId,
),
eq(chatConversations.externalThreadId, thread.id),
),
)
.orderBy(desc(chatConversations.sessionGeneration))
.then((rows) => rows[0] ?? null);
const isLinear = surfaceKind !== "native_thread";
const isLinear = surfaceKind !== "native_thread" && bindingMode !== "slack_dm_thread_v2";
let existingConversation: ConversationRow | null = latestConversation;
let existingIssue: typeof issues.$inferSelect | null =
existingConversation
@@ -15635,10 +15672,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
!principalResolution.linkedDenied &&
(principalResolution.userId !== null || guestSponsorAllowed);
const activationAllowed = addressed || existingConversation !== null;
const bindingAllowed = !latestConversation || (latestConversation.bindingMode === bindingMode && slackDmBindingOwnedBy(latestConversation, {
companyId: endpoint.companyId, endpointId: endpoint.id, principalId: principalResolution.principal.id, userId: principalResolution.userId,
}));
const allowed =
endpointAllowed &&
destinationAllowed &&
principalAllowed &&
bindingAllowed &&
activationAllowed;
const slackRootMessageId =
endpoint.provider === "slack"
@@ -15709,7 +15750,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
? endpoint.allowUnlinkedPeople
? "Endpoint sponsor can no longer authorize external guests"
: "External identity must be linked to a Paperclip account"
: "Message did not address the agent or an active task thread";
: !bindingAllowed
? "Slack thread ownership or routing mode changed"
: "Message did not address the agent or an active task thread";
await db
.update(chatDeliveries)
.set({
@@ -16056,6 +16099,12 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
taskEndpoint: EndpointRow,
taskUserId: string | null,
) => {
// The upgrade flow must also advance runtimeGeneration. Rechecking the
// saved cutoff under the endpoint lock prevents an in-flight old
// adapter callback from creating a binding in the new mode.
if (JSON.stringify(isSlackDmPilot(taskEndpoint) ? slackDmThreadActivation(taskEndpoint.setup as InternalSetupState) : null) !== JSON.stringify(dmActivation)) {
throw conflict("Slack DM threading changed; retry the delivery with its original identity");
}
let conversation = existingConversation;
if (!conversation) {
const sessionGeneration = isLinear
@@ -16089,8 +16138,11 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
endpointId: endpoint.id,
resourceId: resource.id,
issueId: issue.id,
externalConversationId: thread.channelId,
externalConversationId,
externalThreadId: thread.id,
bindingMode,
originPrincipalId: bindingMode === "slack_dm_thread_v2" ? principalResolution.principal.id : null,
originUserId: bindingMode === "slack_dm_thread_v2" ? taskUserId : null,
sessionGeneration,
externalLabel: resource.label,
providerUrl,
@@ -16110,7 +16162,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
.where(
and(
eq(chatConversations.endpointId, endpoint.id),
eq(chatConversations.externalConversationId, thread.channelId),
eq(chatConversations.externalConversationId, externalConversationId),
eq(chatConversations.externalThreadId, thread.id),
eq(chatConversations.sessionGeneration, sessionGeneration),
),
@@ -16121,6 +16173,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
throw conflict(
"Could not bind external conversation to a Paperclip task",
);
if (conversation.bindingMode !== bindingMode || !slackDmBindingOwnedBy(conversation, {
companyId: taskEndpoint.companyId, endpointId: taskEndpoint.id, principalId: principalResolution.principal.id, userId: taskUserId,
})) throw forbidden("Slack thread ownership changed");
const issue =
existingIssue?.id === conversation.issueId
@@ -18605,6 +18660,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
exact,
and(
eq(chatConversations.isDirectMessage, true),
eq(chatConversations.bindingMode, "legacy"),
eq(chatConversations.externalThreadId, `slack:${slackDmChannel}:`),
),
)
@@ -28512,6 +28568,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
externalUrl,
externalConversationId: conversation.externalConversationId,
externalThreadId: conversation.externalThreadId,
bindingMode: conversation.bindingMode,
sessionGeneration: conversation.sessionGeneration,
issueId: conversation.issueId,
issueIdentifier: issue?.identifier ?? null,
@@ -38182,7 +38239,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
return get(endpointId);
}
async function assertSlackPilot(endpoint: EndpointRow, userId: string) {
async function assertSlackPilot(endpoint: EndpointRow, userId: string, upgrade = false) {
const config = slackBotOAuthConfig(getPublicBaseUrl(), endpoint.id);
if (!isSlackDmPilot(endpoint) || endpoint.companyId !== config.COMPANY_ID ||
endpoint.assignedAgentId !== config.AGENT_ID || userId !== config.USER_ID ||
@@ -38196,12 +38253,121 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
ne(companyMemberships.membershipRole, "viewer"),
)).then(rows => rows[0]);
if (!membership) throw forbidden("An active non-viewer company membership is required");
if (!["draft", "attention", "verifying", "revoked"].includes(endpoint.status)) {
if (upgrade) {
if (endpoint.status !== "active" || endpoint.setup.step !== "complete" ||
(endpoint.setup as InternalSetupState).slackPermissionProfile !== SLACK_CEO_DM_PROFILE ||
slackDmThreadActivation(endpoint.setup as InternalSetupState)) {
throw conflict("Only a connected, legacy pilot can enable threaded DMs");
}
} else if (!["draft", "attention", "verifying", "revoked"].includes(endpoint.status)) {
throw conflict("Use this pilot's draft or unfinished connection; active and paused bots are not reinstalled");
}
return config;
}
/** Explicit upgrade: the old grant, installer proof and runtime remain live
* throughout consent. Profile/cutoff/credentials change in one transaction. */
async function startSlackThreadUpgrade(endpointId: string, actor: { userId: string; sessionId: string }) {
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
const config = await assertSlackPilot(record.endpoint, actor.userId, true);
if (!actor.sessionId) throw forbidden("Sign in through a Paperclip browser session first");
const state = randomBytes(32).toString("base64url");
const expiresAt = new Date(Date.now() + 10 * 60_000);
await withCredentialMutationLease(record.endpoint, async lease => {
await db.transaction(async tx => {
await lease.assertOwned(tx);
const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update");
if (!current || current.status !== "active" || runtimeGeneration(current.setup) !== runtimeGeneration(record.endpoint.setup) ||
(current.setup as InternalSetupState).slackPermissionProfile !== SLACK_CEO_DM_PROFILE) throw conflict("Connection changed; reload before upgrading");
const [connection] = await tx.select().from(toolConnections).where(eq(toolConnections.id, current.connectionId));
if (!connection?.enabled || connection.status !== "active") throw conflict("Reconnect the pilot before upgrading");
await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, current.companyId), eq(toolOauthStates.connectionId, current.connectionId)));
await tx.insert(toolOauthStates).values({ state, companyId: current.companyId, connectionId: current.connectionId,
codeVerifier: randomBytes(32).toString("base64url"), requestedScopes: [...slackCeoBotScopes(SLACK_CEO_THREADED_PROFILE)],
createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, expiresAt });
await tx.update(chatEndpoints).set({ setup: { ...current.setup, slackThreadUpgrade: {
profile: SLACK_CEO_THREADED_PROFILE, stateDigest: createHash("sha256").update(state).digest("hex"),
configurationDigest: slackBotOAuthBinding(config), generation: runtimeGeneration(current.setup),
credentials: credentialFingerprint(connection.credentialSecretRefs), userId: actor.userId,
} } as InternalSetupState, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "user", actorId: actor.userId,
action: "chat_endpoint.thread_upgrade_started", entityType: "tool_connection", entityId: current.connectionId,
details: { endpointId, profile: SLACK_CEO_THREADED_PROFILE } });
});
});
return { authorizationUrl: slackBotAuthorizationUrl(config, state, SLACK_CEO_THREADED_PROFILE), expiresAt: expiresAt.toISOString() };
}
async function completeSlackThreadUpgrade(endpointId: string, input: {
state: string; code?: string | null; error?: string | null; actor: { userId: string; sessionId: string };
}) {
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
const config = await assertSlackPilot(record.endpoint, input.actor.userId, true);
await withCredentialMutationLease(record.endpoint, async lease => {
const bound = await db.transaction(async tx => {
await lease.assertOwned(tx);
const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update");
const binding = (current?.setup as InternalSetupState | undefined)?.slackThreadUpgrade;
const [state] = await tx.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, input.state),
eq(toolOauthStates.companyId, config.COMPANY_ID), eq(toolOauthStates.connectionId, record.endpoint.connectionId))).for("update");
const [connection] = await tx.select().from(toolConnections).where(eq(toolConnections.id, record.endpoint.connectionId));
if (!current || !binding || !state || !connection?.enabled || connection.status !== "active" ||
current.status !== "active" || state.expiresAt <= new Date() || binding.profile !== SLACK_CEO_THREADED_PROFILE ||
binding.userId !== input.actor.userId || binding.generation !== runtimeGeneration(current.setup) ||
binding.credentials !== credentialFingerprint(connection.credentialSecretRefs) ||
binding.stateDigest !== createHash("sha256").update(input.state).digest("hex") ||
binding.configurationDigest !== slackBotOAuthBinding(config) ||
JSON.stringify(state.requestedScopes) !== JSON.stringify([...slackCeoBotScopes(SLACK_CEO_THREADED_PROFILE)])) {
throw badRequest("Slack upgrade expired or changed. Start the upgrade again.");
}
assertSlackBotOAuthActor(state, input.actor);
await tx.delete(toolOauthStates).where(eq(toolOauthStates.state, input.state));
return { endpoint: current, binding };
});
if (input.error || !input.code) throw badRequest("Slack upgrade was not approved. Your existing connection is unchanged.");
const installation = await exchangeSlackBotCode(config, input.code, fetchImpl, SLACK_CEO_THREADED_PROFILE);
const existingInstaller = (bound.endpoint.setup as InternalSetupState).slackBotOAuth?.installingUserId;
if (!existingInstaller || installation.installingUserId !== existingInstaller || installation.botUserId !== bound.endpoint.botExternalId) {
throw forbidden("Upgrade must retain the same Slack bot and linked person");
}
const credentials = { botToken: installation.botToken, signingSecret: config.SIGNING_SECRET };
const identity = await verifyCredentials("slack", credentials, { ...bound.endpoint,
setup: { ...bound.endpoint.setup, slackPermissionProfile: SLACK_CEO_THREADED_PROFILE } as InternalSetupState });
if (identity.providerAccountId !== config.TEAM_ID || identity.botExternalId !== installation.botUserId) throw forbidden("Slack upgrade identity changed");
await assertSlackPilot(bound.endpoint, input.actor.userId, true);
await persistCredentials(bound.endpoint, credentials, lease, input.actor.userId, async tx => {
const [current] = await tx.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update");
if (!current || current.status !== "active" || runtimeGeneration(current.setup) !== bound.binding.generation ||
JSON.stringify((current.setup as InternalSetupState).slackThreadUpgrade) !== JSON.stringify(bound.binding) ||
current.sponsorUserId !== input.actor.userId || current.assignedAgentId !== config.AGENT_ID) throw conflict("Connection changed during Slack upgrade");
const [link] = await tx.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks)
.innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, current.companyId)))
.innerJoin(companyMemberships, and(eq(companyMemberships.companyId, current.companyId), eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, input.actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer")))
.where(and(eq(chatIdentityLinks.companyId, current.companyId), eq(chatIdentityLinks.endpointId, endpointId),
eq(chatIdentityLinks.paperclipUserId, input.actor.userId), eq(chatIdentityLinks.status, "linked"), isNull(chatIdentityLinks.revokedAt),
eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, config.TEAM_ID),
eq(chatExternalPrincipals.externalId, installation.installingUserId), eq(chatExternalPrincipals.isBot, false))).for("update");
if (!link) throw forbidden("Linked identity or company access changed during Slack upgrade");
const now = new Date();
const setup: InternalSetupState = { ...current.setup, slackPermissionProfile: SLACK_CEO_THREADED_PROFILE,
slackDmThreading: { version: 2, since: `${Math.floor(now.getTime() / 1000)}.${String(now.getTime() % 1000).padStart(3, "0")}000` },
runtimeGeneration: bound.binding.generation + 1,
slackBotOAuth: { ...(current.setup as InternalSetupState).slackBotOAuth!, grantedScopes: installation.scopes },
};
delete setup.slackThreadUpgrade;
await tx.update(chatEndpoints).set({ setup, capabilities: { ...current.capabilities, reactions: true }, updatedAt: now }).where(eq(chatEndpoints.id, endpointId));
await logActivity(tx as unknown as Db, { companyId: current.companyId, actorType: "user", actorId: input.actor.userId,
action: "chat_endpoint.thread_upgrade_completed", entityType: "tool_connection", entityId: current.connectionId,
details: { endpointId, profile: SLACK_CEO_THREADED_PROFILE, since: setup.slackDmThreading!.since } });
});
});
await invalidateRuntime(endpointId);
return get(endpointId);
}
async function startSlackBotOAuth(endpointId: string, actor: { userId: string; sessionId: string }) {
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
@@ -38245,6 +38411,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
}) {
const record = await endpointRecord(endpointId);
if (!record) throw notFound("Chat endpoint not found");
if ((record.endpoint.setup as InternalSetupState).slackThreadUpgrade?.stateDigest === createHash("sha256").update(input.state).digest("hex")) {
return completeSlackThreadUpgrade(endpointId, input);
}
const config = await assertSlackPilot(record.endpoint, input.actor.userId);
await withCredentialMutationLease(record.endpoint, async lease => {
const bound = await db.transaction(async tx => {
@@ -38354,6 +38523,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
}
return {
startSlackThreadUpgrade,
startSlackBotOAuth,
completeSlackBotOAuth,
saveGitHubSetupProgress: async (endpointId: string, stage: NonNullable<ChatEndpointSetupState["github"]>["stage"]) => {
+6 -2
View File
@@ -223,7 +223,6 @@ async function enqueueSafeNativeChatProgress(
eq(heartbeatRuns.id, heartbeatRunEvents.runId),
eq(heartbeatRuns.companyId, heartbeatRunEvents.companyId),
eq(heartbeatRuns.agentId, heartbeatRunEvents.agentId),
eq(heartbeatRuns.runtimeMode, "native"),
eq(heartbeatRuns.status, "running"),
),
)
@@ -233,6 +232,11 @@ async function enqueueSafeNativeChatProgress(
eq(chatConversations.companyId, heartbeatRuns.companyId),
sql`${issueIdFromContext} = ${chatConversations.issueId}::text`,
inArray(chatConversations.state, ["active", "waiting"]),
or(eq(heartbeatRuns.runtimeMode, "native"), and(
eq(heartbeatRuns.runtimeMode, "legacy"),
eq(chatConversations.bindingMode, "slack_dm_thread_v2"),
eq(heartbeatRunEvents.eventType, "chat.progress.using_tools"),
)),
),
)
.innerJoin(
@@ -345,7 +349,7 @@ async function enqueueSafeNativeChatProgress(
eq(heartbeatRuns.id, row.runId),
eq(heartbeatRuns.companyId, row.companyId),
eq(heartbeatRuns.agentId, row.agentId),
eq(heartbeatRuns.runtimeMode, "native"),
inArray(heartbeatRuns.runtimeMode, ["native", "legacy"]),
eq(heartbeatRuns.status, "running"),
),
)
@@ -595,7 +595,57 @@ describe("Chat SDK published adapter integration", () => {
}
});
it("parses signed Slack message edits and deletes through the pinned adapter", async () => {
it("routes two signed DM roots separately and sends each answer under its original message", async () => {
const signingSecret = "slack-threading-fixture";
const onMessage = vi.fn<ChatSdkRuntimeCallbacks["onMessage"]>(async () => undefined);
const runtime = createChatSdkEndpointRuntime({
callbacks: { onMessage }, companyId: "thread-test-company", endpointId: "thread-test-endpoint",
persistence: memoryPersistence(), logger: "silent",
providerConfig: { provider: "slack", userName: "paperclip-agent", nativeStreaming: false,
dmConversationMode: "message_threads",
dmThreadingSince: "1790000000.000000",
credentials: { signingSecret, botToken: "xoxb-fixture", botUserId: "UBOT" } },
});
try {
await runtime.initialize();
const postMessage = vi.fn(async (_input: unknown) => ({ ok: true, channel: "D123", ts: "1790000002.000001" }));
const adapter = runtime.getProviderAdapter() as unknown as {
_client: { users: { info: (input: unknown) => Promise<unknown> }; chat: { postMessage: typeof postMessage } };
};
adapter._client.users.info = vi.fn(async () => ({ ok: true, user: { id: "UHUMAN", name: "human", is_bot: false, profile: { display_name: "Human" } } }));
adapter._client.chat.postMessage = postMessage;
for (const [index, message] of [
{ ts: "1790000000.000001", text: "First task" },
{ ts: "1790000000.000002", text: "Second task" },
{ ts: "1790000001.000001", thread_ts: "1790000000.000001", text: "Follow up on first task" },
].entries()) {
const response = await runtime.handleWebhook(signedSlackEventRequest(signingSecret, {
type: "event_callback", team_id: "TTEST", event_id: `EvThread${index}`,
event: { type: "message", channel: "D123", channel_type: "im", user: "UHUMAN", ...message },
}));
expect(response.status).toBe(200);
}
const roots = onMessage.mock.calls.map(([event]) => event.thread.id);
expect(roots).toEqual(["slack:D123:1790000000.000001", "slack:D123:1790000000.000002", "slack:D123:1790000000.000001"]);
// The SDK prefixes channel IDs too; service admission must canonicalize
// this field rather than compare it directly with a bare provider ID.
expect(onMessage.mock.calls.map(([event]) => event.thread.channelId)).toEqual([
"slack:D123", "slack:D123", "slack:D123",
]);
await runtime.thread(roots[0]).post("First answer");
await runtime.thread(roots[1]).post("Second answer");
expect(postMessage.mock.calls.map(([input]) => input)).toEqual([
expect.objectContaining({ channel: "D123", thread_ts: "1790000000.000001" }),
expect.objectContaining({ channel: "D123", thread_ts: "1790000000.000002" }),
]);
} finally { await runtime.shutdown(); }
});
it.each([
{ channel: "C-PAPERCLIP", channelType: "channel", mode: undefined, root: "1788.400", expectedRoot: "1788.400" },
{ channel: "D123", channelType: "im", mode: "message_threads" as const, root: undefined, expectedRoot: "1788.500" },
{ channel: "D123", channelType: "im", mode: "message_threads" as const, root: "1788.400", expectedRoot: "1788.400" },
])("parses signed Slack edits/deletes with consistent thread identity: $channel/$root", async ({ channel, channelType, mode, root, expectedRoot }) => {
const signingSecret = "slack-lifecycle-signing-secret";
const onMessageUpdated = vi.fn(async () => undefined);
const onMessageDeleted = vi.fn(async () => undefined);
@@ -611,6 +661,8 @@ describe("Chat SDK published adapter integration", () => {
persistence,
providerConfig: {
provider: "slack",
dmConversationMode: mode,
dmThreadingSince: mode ? "1788.000000" : undefined,
userName: "paperclip-agent",
credentials: {
botToken: "xoxb-test",
@@ -643,7 +695,7 @@ describe("Chat SDK published adapter integration", () => {
user: "U-OPERATOR",
text: "@paperclip-agent original request",
ts: "1788.500",
thread_ts: "1788.400",
thread_ts: root,
};
const edited = await runtime.handleWebhook(
@@ -654,8 +706,8 @@ describe("Chat SDK published adapter integration", () => {
event: {
type: "message",
subtype: "message_changed",
channel: "C-PAPERCLIP",
channel_type: "channel",
channel,
channel_type: channelType,
event_ts: "1788.600",
message: {
...previousMessage,
@@ -673,7 +725,7 @@ describe("Chat SDK published adapter integration", () => {
endpointId: "endpoint-slack-lifecycle-envelope",
provider: "slack",
thread: expect.objectContaining({
id: "slack:C-PAPERCLIP:1788.400",
id: `slack:${channel}:${expectedRoot}`,
}),
message: expect.objectContaining({
id: "1788.500",
@@ -694,8 +746,8 @@ describe("Chat SDK published adapter integration", () => {
event: {
type: "message",
subtype: "message_deleted",
channel: "C-PAPERCLIP",
channel_type: "channel",
channel,
channel_type: channelType,
deleted_ts: "1788.500",
event_ts: "1788.700",
previous_message: previousMessage,
@@ -709,9 +761,9 @@ describe("Chat SDK published adapter integration", () => {
endpointId: "endpoint-slack-lifecycle-envelope",
provider: "slack",
event: expect.objectContaining({
channelId: "C-PAPERCLIP",
channelId: channel,
messageId: "1788.500",
threadId: "slack:C-PAPERCLIP:1788.400",
threadId: `slack:${channel}:${expectedRoot}`,
}),
}),
);
+9
View File
@@ -30,6 +30,7 @@ import {
type TelegramRawMessage,
} from "@chat-adapter/telegram";
import { AsyncLocalStorage } from "node:async_hooks";
import { scopeSlackDmThreads } from "./chat-slack-dm-threads.js";
import {
Chat,
type ActionEvent,
@@ -146,6 +147,10 @@ interface ProviderConfigBase {
export interface ResolvedSlackChatConfig extends ProviderConfigBase {
provider: "slack";
/** Internal opt-in; endpoint admission remains disabled until v2 binding/sharing is wired. */
dmConversationMode?: "legacy" | "message_threads";
/** Durable upgrade cutoff. Earlier messages retain their legacy identity. */
dmThreadingSince?: string;
nativeStreaming?: boolean;
credentials: {
apiUrl?: string;
@@ -1376,6 +1381,10 @@ function createProviderAdapter(
},
};
const adapter = createSlackAdapter(adapterConfig);
if (config.dmConversationMode === "message_threads") {
if (!config.dmThreadingSince) throw new Error("Slack DM threading requires a saved activation cutoff");
scopeSlackDmThreads(adapter, config.dmThreadingSince);
}
(
adapter as unknown as SlackAdapterInternals
).paperclipFileUploadReceiptContext = new AsyncLocalStorage<
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { createSlackAdapter } from "@chat-adapter/slack";
import { scopeSlackDmThreads, slackDmThreadActivation, slackDmBindingMode, slackDmBindingOwnedBy } from "./chat-slack-dm-threads.js";
function adapter(threaded = true, since?: string) {
const instance = createSlackAdapter({ botToken: "xoxb-fixture", signingSecret: "fixture", mode: "webhook" });
if (threaded) scopeSlackDmThreads(instance, since);
return instance as unknown as {
threadIdForMessageEvent(event: Record<string, unknown>): string;
decodeThreadId(id: string): { channel: string; threadTs: string };
};
}
describe("Slack per-message DM thread identity against the pinned adapter", () => {
it("keeps the existing flat-DM default unchanged", () => {
expect(adapter(false).threadIdForMessageEvent({ channel: "D123", channel_type: "im", ts: "1790000000.000001" })).toBe("slack:D123:");
});
it("separates two roots, preserves precision, and routes follow-ups to the original root", () => {
const slack = adapter();
const first = slack.threadIdForMessageEvent({ channel: "D123", channel_type: "im", ts: "1790000000.000001" });
const second = slack.threadIdForMessageEvent({ channel: "D123", channel_type: "im", ts: "1790000000.000002" });
expect(first).not.toBe(second);
expect(first).toBe("slack:D123:1790000000.000001");
expect(slack.threadIdForMessageEvent({ channel: "D123", channel_type: "im", ts: "1790000001.000003", thread_ts: "1790000000.000001" })).toBe(first);
expect(slack.decodeThreadId(first)).toEqual({ channel: "D123", threadTs: "1790000000.000001" });
});
it("does not cross DM channels or change channel-message routing", () => {
const slack = adapter();
const event = { channel_type: "im", ts: "1790000000.000001" };
expect(slack.threadIdForMessageEvent({ ...event, channel: "D123" })).not.toBe(slack.threadIdForMessageEvent({ ...event, channel: "D456" }));
const channelEvent = { ...event, channel: "C123", channel_type: "channel" };
expect(slack.threadIdForMessageEvent(channelEvent)).toBe(adapter(false).threadIdForMessageEvent(channelEvent));
});
it.each([undefined, "", "not-a-timestamp", "1790000000", 1790000000.1, "1.2345678", "1.2:other"])("rejects invalid roots rather than falling back to a flat DM: %j", root => {
expect(() => adapter().threadIdForMessageEvent({ channel: "D123", channel_type: "im", ts: root })).toThrow("valid channel and thread root");
});
it.each([undefined, "", "C123", "D123:other"])("rejects invalid DM channels: %j", channel => {
expect(() => adapter().threadIdForMessageEvent({ channel, channel_type: "im", ts: "1.000001" })).toThrow("valid channel and thread root");
});
it("does not silently accept an adapter upgrade that removes the shared lifecycle seam", () => {
expect(() => scopeSlackDmThreads({} as never)).toThrowError(expect.objectContaining({ code: "CHAT_ADAPTER_COMPATIBILITY_ERROR" }));
});
it("keeps old roots/replies and replay identity unchanged across a saved cutoff", () => {
const slack = adapter(true, "1790000000.000002");
const legacy = adapter(false);
for (const event of [
{ channel: "D123", channel_type: "im", ts: "1790000000.000001" },
{ channel: "D123", channel_type: "im", ts: "1790000009.000009", thread_ts: "1790000000.000001" },
]) expect(slack.threadIdForMessageEvent(event)).toBe(legacy.threadIdForMessageEvent(event));
expect(slack.threadIdForMessageEvent({ channel: "D123", channel_type: "im", ts: "1790000000.000002" })).toBe("slack:D123:1790000000.000002");
});
it("requires an exact persisted activation and never derives one from the current time", () => {
expect(slackDmThreadActivation({})).toBeNull();
expect(slackDmThreadActivation({ slackDmThreading: { version: 2, since: "1790000000.000002" } })).toEqual({ version: 2, since: "1790000000.000002" });
for (const value of [null, {}, { version: 1, since: "1.1" }, { version: 2, since: "today" }]) {
expect(() => slackDmThreadActivation({ slackDmThreading: value })).toThrow("Invalid Slack DM threading activation");
}
});
it("rejects a flattened new message, cross-channel root or reply preceding its root", () => {
const activation = { version: 2 as const, since: "1790000000.000002" };
const input = { channelId: "D123", threadId: "slack:D123:1790000000.000002", messageId: "1790000000.000002" };
expect(slackDmBindingMode(activation, input)).toBe("slack_dm_thread_v2");
expect(slackDmBindingMode(activation, { ...input, threadId: "slack:D123:", messageId: "1790000000.000001" })).toBe("legacy");
expect(() => slackDmBindingMode(activation, { ...input, threadId: "slack:D123:" })).toThrow();
expect(() => slackDmBindingMode(activation, { ...input, channelId: "D456" })).toThrow();
expect(() => slackDmBindingMode(activation, { ...input, messageId: "1790000000.000001" })).toThrow();
});
it("binds new threads to the exact company, endpoint and originating person", () => {
const binding = { bindingMode: "slack_dm_thread_v2", companyId: "company", endpointId: "endpoint", isDirectMessage: true,
externalConversationId: "D123", externalThreadId: "slack:D123:1790000000.000002", sessionGeneration: 1,
originPrincipalId: "principal", originUserId: "user" };
const actor = { companyId: "company", endpointId: "endpoint", principalId: "principal", userId: "user" };
expect(slackDmBindingOwnedBy(binding, actor)).toBe(true);
for (const key of ["companyId", "endpointId", "principalId", "userId"] as const) expect(slackDmBindingOwnedBy(binding, { ...actor, [key]: "different" })).toBe(false);
expect(slackDmBindingOwnedBy({ ...binding, externalConversationId: "D456" }, actor)).toBe(false);
expect(slackDmBindingOwnedBy({ ...binding, sessionGeneration: 2 }, actor)).toBe(false);
expect(slackDmBindingOwnedBy({ ...binding, bindingMode: "unknown" }, actor)).toBe(false);
});
});
@@ -0,0 +1,114 @@
import type { Adapter } from "chat";
export type SlackDmThreadActivation = { version: 2; since: string };
function timestampMicros(value: unknown): bigint | null {
if (typeof value !== "string" || !/^\d{1,12}\.\d{1,6}$/.test(value)) return null;
const [seconds, fraction] = value.split(".");
return BigInt(seconds!) * 1_000_000n + BigInt(fraction!.padEnd(6, "0"));
}
/** Persisted once at explicit upgrade, not recomputed at process startup. */
export function slackDmThreadActivation(setup: { slackDmThreading?: unknown }): SlackDmThreadActivation | null {
if (setup.slackDmThreading === undefined) return null;
const value = setup.slackDmThreading as Partial<SlackDmThreadActivation> | null;
if (!value || value.version !== 2 || timestampMicros(value.since) === null) {
throw new Error("Invalid Slack DM threading activation");
}
return { version: 2, since: value.since! };
}
export function slackDmThreadRoot(threadId: string, channelId: string): string | null {
const match = /^slack:(D[A-Z0-9]+):(\d{1,12}\.\d{1,6})$/.exec(threadId);
return match?.[1] === channelId ? match[2]! : null;
}
export function slackDmBindingReceipt(binding: {
bindingMode: string; externalConversationId: string; externalThreadId: string;
originPrincipalId: string | null; originUserId: string | null;
}): string | undefined {
return binding.bindingMode === "legacy" ? undefined : JSON.stringify([
binding.bindingMode, binding.externalConversationId, binding.externalThreadId, binding.originPrincipalId, binding.originUserId,
]);
}
/** Admission must agree with the adapter before an issue can be created. */
export function slackDmBindingMode(activation: SlackDmThreadActivation | null, input: {
threadId: string; channelId: string; messageId: string;
}): "legacy" | "slack_dm_thread_v2" {
if (!activation) return "legacy";
const root = slackDmThreadRoot(input.threadId, input.channelId);
const messageMicros = timestampMicros(input.messageId);
const sinceMicros = timestampMicros(activation.since)!;
if (root) {
const rootMicros = timestampMicros(root)!;
if (messageMicros === null || messageMicros < rootMicros) throw new Error("Invalid Slack DM thread chronology");
return rootMicros >= sinceMicros ? "slack_dm_thread_v2" : "legacy";
}
if (input.threadId === `slack:${input.channelId}:` && messageMicros !== null && messageMicros < sinceMicros) return "legacy";
throw new Error("Slack DM message does not match the activated thread boundary");
}
/** A source link or matching issue ID never grants another person this thread. */
export function slackDmBindingOwnedBy(binding: {
bindingMode: string; companyId: string; endpointId: string; isDirectMessage: boolean;
externalConversationId: string; externalThreadId: string; sessionGeneration: number;
originPrincipalId: string | null; originUserId: string | null;
}, actor: { companyId: string; endpointId: string; principalId: string; userId: string | null }): boolean {
if (binding.companyId !== actor.companyId || binding.endpointId !== actor.endpointId) return false;
if (binding.bindingMode === "legacy") return true;
return binding.bindingMode === "slack_dm_thread_v2" && binding.isDirectMessage && binding.sessionGeneration === 1 &&
binding.originPrincipalId === actor.principalId && actor.userId !== null && binding.originUserId === actor.userId &&
slackDmThreadRoot(binding.externalThreadId, binding.externalConversationId) !== null;
}
export class SlackDmThreadCompatibilityError extends Error {
readonly code = "CHAT_ADAPTER_COMPATIBILITY_ERROR";
constructor() {
super("The pinned Slack adapter's DM thread identity contract is unavailable");
this.name = "SlackDmThreadCompatibilityError";
}
}
type SlackMessageIdentity = {
channel?: unknown;
channel_type?: unknown;
ts?: unknown;
thread_ts?: unknown;
};
/**
* Opt-in adapter seam only. Do not select this for legacy flat-DM bindings.
* The pinned adapter calls this same method for ingress, edits and deletions.
* Keeping it at that boundary prevents delivery deduplication from flattening
* independent requests together. This does not grant access or select a task.
*/
export function scopeSlackDmThreads(adapter: Adapter, since = "0.000000"): Adapter {
const sinceMicros = timestampMicros(since);
if (sinceMicros === null) throw new Error("Invalid Slack DM threading activation");
const slack = adapter as unknown as {
threadIdForMessageEvent?: (event: SlackMessageIdentity) => string;
encodeThreadId?: (identity: { channel: string; threadTs: string }) => string;
};
if (typeof slack.threadIdForMessageEvent !== "function" || typeof slack.encodeThreadId !== "function") {
throw new SlackDmThreadCompatibilityError();
}
const original = slack.threadIdForMessageEvent.bind(adapter);
const encode = slack.encodeThreadId.bind(adapter);
slack.threadIdForMessageEvent = (event) => {
if (event.channel_type !== "im") return original(event);
const root = event.thread_ts ?? event.ts;
if (typeof event.channel !== "string" || !/^D[A-Z0-9]+$/.test(event.channel) ||
typeof root !== "string" || !/^\d{1,12}\.\d{1,6}$/.test(root)) {
// No fallback to the flat DM: a malformed root must never reach another
// conversation's task. Preserve provider timestamp precision as text.
throw new Error("Slack DM message has no valid channel and thread root");
}
// A replay of an old top-level DM must retain its old delivery identity.
// Replies to pre-upgrade roots keep the pinned adapter's legacy behavior.
if (timestampMicros(root)! < sinceMicros) return original(event);
return encode({ channel: event.channel, threadTs: root });
};
return adapter;
}
+22 -1
View File
@@ -5,6 +5,7 @@ import { and, eq, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
toolApplications,
toolConnections,
toolCatalogEntries,
companyMemberships,
@@ -810,8 +811,28 @@ export function connectionIntentService(db: Db) {
isSlackReadProfile(item) && item.status !== "archived" && record(item.config?.slackReadPilot)?.fingerprint === authority.binding.fingerprint);
if (existing.length > 1) throw conflict("Resolve duplicate Slack read connections before authorizing");
if (existing[0]) return existing[0];
const app = await txAccess.createApplication(loaded.issue.companyId, { name: "Slack public-channel read pilot", type: "mcp_http",
// Removing a connection intentionally retains its application/audit history.
// Reuse only this person's exact pilot application, never its revoked grant.
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${`slack-read-app:${loaded.issue.companyId}`}))`);
const applications = await txAccess.listApplications(loaded.issue.companyId);
const priorApp = applications.find(item => item.applicationKey === "slack-public-channel-read-pilot" || item.name === "Slack public-channel read pilot");
if (priorApp && (priorApp.applicationKey !== "slack-public-channel-read-pilot" ||
priorApp.name !== "Slack public-channel read pilot" || priorApp.type !== "mcp_http" ||
!["active", "archived"].includes(priorApp.status) || priorApp.ownerUserId !== actor.actorId ||
priorApp.ownerAgentId || priorApp.pluginId || priorApp.metadata?.sourceTemplateKey !== "slack")) {
throw conflict("The existing Slack read pilot application has changed. Ask the setup operator to review it.");
}
const app = priorApp ?? await txAccess.createApplication(loaded.issue.companyId, { name: "Slack public-channel read pilot", type: "mcp_http",
ownerUserId: actor.actorId, metadata: { sourceTemplateKey: "slack" } });
if (priorApp?.status === "archived") {
// This restores the catalog container only. Connections, grants, tools,
// profiles and old OAuth transactions remain revoked/archived.
await tx.update(toolApplications).set({ status: "active", archivedAt: null, updatedAt: new Date() })
.where(and(eq(toolApplications.id, app.id), eq(toolApplications.companyId, loaded.issue.companyId)));
await logActivity(tx as unknown as Db, { companyId: loaded.issue.companyId, actorType: "user", actorId: actor.actorId!,
action: "tool_application.reactivated", entityType: "tool_application", entityId: app.id,
details: { reason: "slack_read_fresh_consent", interactionId } });
}
const config = { url: SLACK_READ_MCP_URL, slackReadPilot: authority.binding,
oauth: { provider: "slack", authorizationUrl: SLACK_READ_AUTH_URL, tokenUrl: SLACK_READ_TOKEN_URL, scopes: [...SLACK_READ_SCOPES] } };
return txAccess.createConnection(loaded.issue.companyId, {
@@ -1,4 +1,5 @@
export const SAFE_NATIVE_CHAT_PROGRESS_EVENT_TYPES = [
"chat.progress.using_tools",
"workspace.ready",
"research.started",
"research.progressed",
@@ -40,6 +41,7 @@ export type SafeNativeChatProgressPhase =
const SAFE_NATIVE_CHAT_PROGRESS_PHASES: Readonly<
Record<SafeNativeChatProgressEventType, SafeNativeChatProgressPhase>
> = {
"chat.progress.using_tools": "using_tools",
"workspace.ready": "preparing",
"research.started": "researching",
"research.progressed": "researching",
+81 -7
View File
@@ -3,7 +3,7 @@ import { and, eq, ne, sql } from "drizzle-orm";
import {
agents, agentWakeupRequests, chatActions, chatConversations, chatEndpoints,
chatIdentityLinks, chatExternalPrincipals, companyMemberships, heartbeatRuns,
issueComments, issues, toolConnections, type Db,
issueComments, issues, toolConnections, chatPublications, type Db,
} from "@paperclipai/db";
import { conflict, forbidden, HttpError, notFound } from "../errors.js";
import { issueService } from "./issues.js";
@@ -11,8 +11,13 @@ import { logActivity } from "./activity-log.js";
import { createDurableChatWakeupRequest, assertDurableChatWakeupReceipt } from "./durable-chat-wakeup.js";
import type { IssueAssignmentWakeupDeps } from "./issue-assignment-wakeup.js";
import { isAutomaticRecoverySuppressedByPauseHold } from "./recovery/pause-hold-guard.js";
import { slackDmBindingOwnedBy } from "./chat-slack-dm-threads.js";
import { isSlackCeoBotProfile } from "./slack-ceo-permission-profiles.js";
import { projectSafeChatPublication } from "./chat-publication-projection.js";
import { splitNativePublicationText } from "./chat-publication-text-parts.js";
const KIND = "slack_board_reply";
const SHARED_REQUEST_PREFIX = "slack-shared-request:";
export const SLACK_BOARD_REPLY_SOURCE = "slack.board_reply";
type Action = typeof chatActions.$inferSelect;
type Scope = { companyId: string; endpointId: string; conversationId: string; userId: string };
@@ -31,7 +36,7 @@ export async function authorizeSlackPilotConversation(db: Db, scope: Scope, rece
.from(toolConnections).where(and(eq(toolConnections.id, endpoint.connectionId), eq(toolConnections.companyId, scope.companyId)));
if (await isAutomaticRecoverySuppressedByPauseHold(db, scope.companyId, issue.id)) throw forbidden("This task is paused");
if (!connection?.enabled || connection.status !== "active" || endpoint.provider !== "slack" || endpoint.status !== "active" || !endpoint.allowDirectMessages ||
(endpoint.setup as { slackPermissionProfile?: string }).slackPermissionProfile !== "ceo-dm-v1" ||
!isSlackCeoBotProfile((endpoint.setup as { slackPermissionProfile?: string }).slackPermissionProfile) ||
endpoint.sponsorUserId !== scope.userId || !conversation.isDirectMessage ||
!["active", "waiting"].includes(conversation.state) || issue.originKind !== "chat_channel" ||
issue.assigneeAgentId !== endpoint.assignedAgentId || issue.hiddenAt || issue.assigneeUserId) {
@@ -49,9 +54,11 @@ export async function authorizeSlackPilotConversation(db: Db, scope: Scope, rece
and d.endpoint_id = ${endpoint.id} and d.conversation_id = ${conversation.id}
and d.principal_id = ${chatExternalPrincipals.id} and d.state = 'processed')`)).limit(1);
if (!identity || identity.principal.isBot || identity.principal.provider !== "slack" || identity.link.revokedAt) throw forbidden("Your linked Slack identity no longer owns this DM");
if (!slackDmBindingOwnedBy(conversation, { ...scope, principalId: identity.principal.id })) throw forbidden("Your linked Slack identity does not own this thread");
const fence = JSON.stringify([endpoint.connectionId, endpoint.providerAccountId, endpoint.botExternalId,
endpoint.assignedAgentId, conversation.externalConversationId, conversation.externalThreadId,
conversation.sessionGeneration, identity.link.id, identity.link.confirmedAt?.toISOString()]);
conversation.sessionGeneration, identity.link.id, identity.link.confirmedAt?.toISOString(),
...(conversation.bindingMode === "legacy" ? [] : [conversation.bindingMode, conversation.originPrincipalId, conversation.originUserId])]);
if (receipt && (receipt.kind !== KIND || receipt.payload.fence !== fence ||
receipt.payload.issueId !== issue.id || receipt.payload.agentId !== endpoint.assignedAgentId ||
receipt.principalId !== identity.principal.id)) throw forbidden("Slack reply authorization changed; submit a new request");
@@ -67,14 +74,66 @@ function scopeFor(action: Action): Scope {
async function authorizeAction(db: Db, action: Action) {
const current = await authorize(db, scopeFor(action), action);
const shared = current.conversation.bindingMode === "slack_dm_thread_v2";
if (shared !== (action.payload.audience === "shared_thread") ||
(shared && typeof action.payload.requestPublicationId !== "string")) throw forbidden("Slack request sharing mode changed");
const [comment] = await db.select().from(issueComments).where(and(
eq(issueComments.id, String(action.payload.commentId)), eq(issueComments.companyId, action.companyId),
eq(issueComments.issueId, current.issue.id), eq(issueComments.authorUserId, String(action.payload.userId)),
)).limit(1);
if (!comment || comment.deletedAt || comment.authorType !== "user") throw forbidden("Slack reply request was removed");
if (!comment || comment.deletedAt || comment.authorType !== "user" || comment.body !== action.payload.body) throw forbidden("Slack reply request was removed or changed");
return current;
}
/** Shared requests use the same durable publication worker as answers. Check
* the saved request's person/destination again at every provider send, including
* any transport fragments. Never infer authority from the outbox key alone. */
export async function authorizeSlackSharedRequestPublication(db: Db, publication: typeof chatPublications.$inferSelect): Promise<boolean | null> {
if (!publication.idempotencyKey.startsWith(SHARED_REQUEST_PREFIX)) return null;
const match = /^slack-shared-request:([0-9a-f-]{36})(?::slack-part:([1-9][0-9]*))?$/.exec(publication.idempotencyKey);
if (!match) return false;
const [action] = await db.select().from(chatActions).where(and(eq(chatActions.companyId, publication.companyId), eq(chatActions.id, match[1]))).limit(1);
if (!action || action.kind !== KIND || !["queued", "submitted"].includes(action.status) || action.payload.audience !== "shared_thread" ||
action.endpointId !== publication.endpointId || action.conversationId !== publication.conversationId ||
action.payload.issueId !== publication.issueId || action.payload.commentId !== publication.commentId ||
publication.payload.attachmentIds?.length || publication.payload.interactionId || publication.payload.card || publication.payload.progressState) return false;
const part = publication.payload.transportPart;
if (match[2] ? part?.batchId !== action.payload.requestPublicationId || part?.index !== Number(match[2])
: publication.id !== action.payload.requestPublicationId) return false;
const safeText = projectSafeChatPublication({ classification: "external", source: "explicit_board_send", text: `From Paperclip (you):\n\n${action.payload.body}` }).text;
const expectedParts = splitNativePublicationText("slack", safeText);
const index = match[2] ? Number(match[2]) : 0;
if (part) {
const expected = expectedParts[index];
if (!expected || part.index !== index || part.batchId !== action.payload.requestPublicationId || part.count !== expectedParts.length ||
part.mode !== "inline" || part.prefix !== expected.prefix || part.suffix !== expected.suffix || publication.payload.text !== expected.text) return false;
} else if (index !== 0 || publication.payload.text !== safeText) return false;
try {
await authorizeAction(db, action);
return true;
} catch (error) {
if (error instanceof HttpError && error.status < 500) return false;
throw error;
}
}
async function sharedRequestDelivered(db: Db, action: Action): Promise<boolean> {
if (action.payload.audience !== "shared_thread") return true;
const rows = await db.select().from(chatPublications).where(and(
eq(chatPublications.companyId, action.companyId), eq(chatPublications.endpointId, action.endpointId),
eq(chatPublications.conversationId, action.conversationId!), eq(chatPublications.commentId, String(action.payload.commentId)),
));
const root = rows.find(row => row.id === action.payload.requestPublicationId && row.idempotencyKey === `${SHARED_REQUEST_PREFIX}${action.id}`);
if (!root) return false;
const count = root.payload.transportPart?.count ?? 1;
const batch = rows.filter(row => row.id === root.id || row.payload.transportPart?.batchId === root.id);
if (batch.length !== count || new Set(batch.map(row => row.payload.transportPart?.index ?? 0)).size !== count) return false;
for (const row of batch) {
if (row.state !== "published" || !row.providerMessageId || !(await authorizeSlackSharedRequestPublication(db, row))) return false;
}
return true;
}
/** Called at run execution, not just at browser submission. A serialized source
* or guessed action ID is never sufficient authority. */
export async function authorizeSlackBoardReplyWake(db: Db, input: {
@@ -96,6 +155,7 @@ export async function authorizeSlackBoardReplyWake(db: Db, input: {
throw forbidden("Slack reply request does not match this run");
}
await authorizeAction(db, action);
if (!(await sharedRequestDelivered(db, action))) throw forbidden("Slack has not confirmed the shared request yet");
return action;
}
@@ -125,10 +185,16 @@ export function slackBoardRepliesService(db: Db, heartbeat: IssueAssignmentWakeu
for (const action of actions) {
try {
const current = await authorizeAction(db, action);
// A pending/uncertain/failed provider send never means the request was
// delivered. Keep this same receipt queued for transport recovery.
if (!(await sharedRequestDelivered(db, action))) continue;
const request = createDurableChatWakeupRequest({ id: action.id, companyId: action.companyId,
agentId: current.endpoint.assignedAgentId, issueId: current.issue.id, commentId: String(action.payload.commentId),
requestedByActorType: "user", requestedByActorId: String(action.payload.userId), requestedAt: action.createdAt,
authorize: async tx => { await authorizeAction(tx, action); },
authorize: async tx => {
await authorizeAction(tx, action);
if (!(await sharedRequestDelivered(tx, action))) throw forbidden("Slack has not confirmed the shared request yet");
},
});
await heartbeat.wakeup(request.agentId, { source: "assignment", triggerDetail: "system", reason: "Reply via Slack requested",
requestedByActorType: "user", requestedByActorId: request.requestedByActorId, durableChatRequest: request,
@@ -198,11 +264,19 @@ export function slackBoardRepliesService(db: Db, heartbeat: IssueAssignmentWakeu
status: "todo", actorUserId: scope.userId, companyGuard: scope.companyId,
}, tx);
}
const [created] = await tx.insert(chatActions).values({ id: randomUUID(), companyId: scope.companyId,
const shared = current.conversation.bindingMode === "slack_dm_thread_v2";
const requestId = randomUUID(), requestPublicationId = shared ? randomUUID() : null;
const [created] = await tx.insert(chatActions).values({ id: requestId, companyId: scope.companyId,
endpointId: scope.endpointId, conversationId: scope.conversationId, principalId: current.identity.principal.id,
kind: KIND, providerActionId: key, status: "queued", payload: { version: 1, userId: scope.userId,
issueId: issue.id, agentId: current.endpoint.assignedAgentId, commentId: comment.id, fence: current.fence, body },
issueId: issue.id, agentId: current.endpoint.assignedAgentId, commentId: comment.id, fence: current.fence, body,
...(shared ? { audience: "shared_thread", requestPublicationId } : {}) },
}).returning();
if (requestPublicationId) await tx.insert(chatPublications).values({
id: requestPublicationId, companyId: scope.companyId, endpointId: scope.endpointId, conversationId: scope.conversationId,
issueId: issue.id, commentId: comment.id, idempotencyKey: `${SHARED_REQUEST_PREFIX}${requestId}`, state: "pending",
payload: projectSafeChatPublication({ classification: "external", source: "explicit_board_send", text: `From Paperclip (you):\n\n${body}` }),
});
await logActivity(tx as unknown as Db, { companyId: scope.companyId, actorType: "user", actorId: scope.userId,
action: "chat.board_reply_requested", entityType: "issue", entityId: issue.id, issueId: issue.id,
details: { endpointId: scope.endpointId, conversationId: scope.conversationId, commentId: comment.id, requestId: created.id } });
@@ -9,6 +9,14 @@ function exchange(body: unknown, status = 200) {
}
describe("Slack CEO DM OAuth boundary", () => {
it("binds threaded scopes explicitly, never infers an upgrade from a token", async () => {
const profile = "ceo-dm-threaded-v2";
const upgraded = { ...installation, scope: `${installation.scope},reactions:write` };
const request = vi.fn<typeof globalThis.fetch>().mockImplementation(async () => Response.json(upgraded));
expect(new URL(slackBotAuthorizationUrl(config, "nonce", profile)).searchParams.get("scope")).toBe(upgraded.scope);
await expect(exchangeSlackBotCode(config, "code", request)).rejects.toThrow(/exactly/);
await expect(exchangeSlackBotCode(config, "code", request, profile)).resolves.toMatchObject({ scopes: [...SLACK_CEO_DM_SCOPES, "reactions:write"] });
});
it("is disabled by default and does not expose environment values", () => {
expect(slackBotOAuthStatus("http://localhost:3210", "id", {})).toMatchObject({ enabled: false, configured: false, callbackUrl: null });
const env = Object.fromEntries(Object.entries(config).map(([key, value]) => [`PAPERCLIP_SLACK_CEO_POC_${key}`, value]));
+9 -11
View File
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import { forbidden, unprocessable } from "../errors.js";
import { hasExactSlackCeoScopes, slackCeoBotScopes, type SlackCeoBotProfile, SLACK_CEO_DM_PROFILE, SLACK_CEO_DM_SCOPES } from "./slack-ceo-permission-profiles.js";
export { SLACK_CEO_DM_PROFILE, SLACK_CEO_DM_SCOPES } from "./slack-ceo-permission-profiles.js";
/** The existing adapter profile, not a least-privilege production profile. */
export const SLACK_ADAPTER_BOT_SCOPES = [
@@ -9,10 +11,6 @@ export const SLACK_ADAPTER_BOT_SCOPES = [
"reactions:read", "reactions:write", "users:read",
] as const;
/** DM reception, DM metadata, replies, author identity and private /connect. */
export const SLACK_CEO_DM_SCOPES = ["chat:write", "commands", "im:history", "im:read", "users:read"] as const;
export const SLACK_CEO_DM_PROFILE = "ceo-dm-v1" as const;
/** Called only after Slack's raw-body signature has been verified. */
export function slackDmPilotRequestAllowed(raw: string, contentType: string, appId: string | undefined, userId: string | undefined) {
try {
@@ -38,7 +36,7 @@ const PREFIX = "PAPERCLIP_SLACK_CEO_POC_";
const REQUIRED = ["COMPANY_ID", "USER_ID", "AGENT_ID", "APP_ID", "TEAM_ID", "CLIENT_ID", "CLIENT_SECRET", "SIGNING_SECRET"] as const;
export type SlackBotOAuthConfig = Record<(typeof REQUIRED)[number], string>;
export function slackBotOAuthStatus(publicBaseUrl: string | null, endpointId: string, env = process.env) {
export function slackBotOAuthStatus(publicBaseUrl: string | null, endpointId: string, env = process.env, profile: SlackCeoBotProfile = SLACK_CEO_DM_PROFILE) {
const enabled = env[`${PREFIX}ENABLED`] === "true";
let callbackUrl: string | null = null;
try {
@@ -55,8 +53,8 @@ export function slackBotOAuthStatus(publicBaseUrl: string | null, endpointId: st
...REQUIRED.filter((key) => !env[`${PREFIX}${key}`]?.trim()).map((key) => `${PREFIX}${key}`),
...(!callbackUrl ? ["PAPERCLIP_PUBLIC_URL (HTTPS)"] : []),
] : [],
profile: SLACK_CEO_DM_PROFILE,
scopes: [...SLACK_CEO_DM_SCOPES],
profile,
scopes: [...slackCeoBotScopes(profile)],
};
}
@@ -85,17 +83,17 @@ export function assertSlackBotOAuthActor(
}
}
export function slackBotAuthorizationUrl(config: SlackBotOAuthConfig & { callbackUrl: string }, state: string) {
export function slackBotAuthorizationUrl(config: SlackBotOAuthConfig & { callbackUrl: string }, state: string, profile: SlackCeoBotProfile = SLACK_CEO_DM_PROFILE) {
const url = new URL("https://slack.com/oauth/v2/authorize");
url.searchParams.set("client_id", config.CLIENT_ID);
url.searchParams.set("redirect_uri", config.callbackUrl);
url.searchParams.set("team", config.TEAM_ID);
url.searchParams.set("scope", SLACK_CEO_DM_SCOPES.join(","));
url.searchParams.set("scope", slackCeoBotScopes(profile).join(","));
url.searchParams.set("state", state);
return url.href;
}
export async function exchangeSlackBotCode(config: SlackBotOAuthConfig & { callbackUrl: string }, code: string, fetchImpl = globalThis.fetch) {
export async function exchangeSlackBotCode(config: SlackBotOAuthConfig & { callbackUrl: string }, code: string, fetchImpl = globalThis.fetch, profile: SlackCeoBotProfile = SLACK_CEO_DM_PROFILE) {
const response = await fetchImpl("https://slack.com/api/oauth.v2.access", {
method: "POST", redirect: "error", signal: AbortSignal.timeout(15_000),
headers: { "content-type": "application/x-www-form-urlencoded" },
@@ -118,7 +116,7 @@ export async function exchangeSlackBotCode(config: SlackBotOAuthConfig & { callb
// never silently turn off an administrator's token rotation requirement.
if (body.refresh_token || body.expires_in) throw unprocessable("This pilot does not yet support rotating Slack bot tokens. Keep your workspace policy unchanged.", { code: "slack_bot_rotation_unsupported" });
const scopes = typeof body.scope === "string" ? body.scope.split(/[ ,]+/).filter(Boolean) : [];
if (SLACK_CEO_DM_SCOPES.some((scope) => !scopes.includes(scope)) || scopes.some(scope => !(SLACK_CEO_DM_SCOPES as readonly string[]).includes(scope))) {
if (!hasExactSlackCeoScopes(profile, scopes)) {
throw unprocessable("This pilot needs exactly the DM-only permissions. Use a separate app without previous broader grants.", { code: "slack_bot_scope_profile_mismatch" });
}
return { botToken: body.access_token, botUserId: body.bot_user_id, installingUserId: user.id, scopes };
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
hasExactSlackCeoScopes, SLACK_CEO_DM_PROFILE, SLACK_CEO_DM_SCOPES,
SLACK_CEO_THREADED_PROFILE, SLACK_CEO_THREADED_SCOPES,
SLACK_READ_PROFILE, SLACK_READ_SCOPES,
SLACK_RESEARCH_PROFILE, SLACK_RESEARCH_SCOPES,
} from "./slack-ceo-permission-profiles.js";
import { slackBotAuthorizationUrl, slackBotOAuthStatus } from "./slack-bot-oauth.js";
import { hasSlackReadRuntimeScopes, isSlackReadProfile } from "./slack-read-profile.js";
describe("Slack CEO versioned permission contracts", () => {
const profiles = [
[SLACK_CEO_DM_PROFILE, SLACK_CEO_DM_SCOPES],
[SLACK_CEO_THREADED_PROFILE, SLACK_CEO_THREADED_SCOPES],
[SLACK_READ_PROFILE, SLACK_READ_SCOPES],
[SLACK_RESEARCH_PROFILE, SLACK_RESEARCH_SCOPES],
] as const;
it.each(profiles)("checks the exact scope set for %s", (profile, scopes) => {
expect(hasExactSlackCeoScopes(profile, scopes)).toBe(true);
expect(hasExactSlackCeoScopes(profile, [...scopes].reverse())).toBe(true);
expect(hasExactSlackCeoScopes(profile, [...scopes, scopes[0]])).toBe(true);
for (const omitted of scopes) {
expect(hasExactSlackCeoScopes(profile, scopes.filter(scope => scope !== omitted))).toBe(false);
}
for (const extra of ["admin", "files:write", "groups:history", "assistant:write"]) {
expect(hasExactSlackCeoScopes(profile, [...scopes, extra])).toBe(false);
}
});
it("adds only the two separately authorized permissions", () => {
expect(SLACK_CEO_THREADED_SCOPES).toEqual([...SLACK_CEO_DM_SCOPES, "reactions:write"]);
expect(SLACK_RESEARCH_SCOPES).toEqual([...SLACK_READ_SCOPES, "files:read"]);
expect(hasExactSlackCeoScopes(SLACK_CEO_DM_PROFILE, SLACK_CEO_THREADED_SCOPES)).toBe(false);
expect(hasExactSlackCeoScopes(SLACK_CEO_THREADED_PROFILE, SLACK_CEO_DM_SCOPES)).toBe(false);
expect(hasExactSlackCeoScopes(SLACK_READ_PROFILE, SLACK_RESEARCH_SCOPES)).toBe(false);
expect(hasExactSlackCeoScopes(SLACK_RESEARCH_PROFILE, SLACK_READ_SCOPES)).toBe(false);
});
it("never treats a bot grant as a personal grant or vice versa", () => {
expect(hasExactSlackCeoScopes(SLACK_RESEARCH_PROFILE, SLACK_CEO_THREADED_SCOPES)).toBe(false);
expect(hasExactSlackCeoScopes(SLACK_CEO_THREADED_PROFILE, SLACK_RESEARCH_SCOPES)).toBe(false);
});
it.each(["", "unknown", "__proto__", "constructor", "ceo-dm-v3"])("fails closed for profile %s", profile => {
expect(hasExactSlackCeoScopes(profile, SLACK_CEO_DM_SCOPES)).toBe(false);
});
it.each(profiles)("limits implicit identify to personal runtime checks for %s", (profile, scopes) => {
expect(hasExactSlackCeoScopes(profile, [...scopes, "identify"])).toBe(false);
expect(hasExactSlackCeoScopes(profile, [...scopes, "identify"], "runtime"))
.toBe(profile === SLACK_READ_PROFILE || profile === SLACK_RESEARCH_PROFILE);
});
it("does not activate or accept v2 through the existing v1 entry points", () => {
expect(slackBotOAuthStatus("https://pilot.example", "endpoint", {
PAPERCLIP_SLACK_CEO_POC_ENABLED: "true",
PAPERCLIP_SLACK_CEO_POC_BOT_PROFILE: SLACK_CEO_THREADED_PROFILE,
}).profile).toBe(SLACK_CEO_DM_PROFILE);
const url = new URL(slackBotAuthorizationUrl({
COMPANY_ID: "company", USER_ID: "user", AGENT_ID: "ceo", APP_ID: "ATEST",
TEAM_ID: "TTEST", CLIENT_ID: "client", CLIENT_SECRET: "synthetic-secret",
SIGNING_SECRET: "synthetic-signing", callbackUrl: "https://pilot.example/callback",
}, "nonce"));
expect(url.searchParams.get("scope")?.split(",")).toEqual(SLACK_CEO_DM_SCOPES);
expect(hasSlackReadRuntimeScopes(SLACK_RESEARCH_SCOPES)).toBe(false);
expect(isSlackReadProfile({ config: { slackReadPilot: { profile: SLACK_RESEARCH_PROFILE } } })).toBe(false);
});
});
@@ -0,0 +1,56 @@
/** Permission contracts, not feature switches. Threaded bot v2 requires the
* explicit, identity-bound upgrade transaction before runtime activation.
* Never infer a profile from the scopes in a token: bind the requested profile
* first, then verify that the granted scopes match it exactly. */
export const SLACK_CEO_DM_PROFILE = "ceo-dm-v1" as const;
export const SLACK_CEO_THREADED_PROFILE = "ceo-dm-threaded-v2" as const;
export const SLACK_READ_PROFILE = "slack-public-read-v1" as const;
export const SLACK_RESEARCH_PROFILE = "slack-public-research-v2" as const;
export const SLACK_CEO_DM_SCOPES = Object.freeze([
"chat:write", "commands", "im:history", "im:read", "users:read",
] as const);
export const SLACK_CEO_THREADED_SCOPES = Object.freeze([
...SLACK_CEO_DM_SCOPES, "reactions:write",
] as const);
export const SLACK_READ_SCOPES = Object.freeze([
"channels:history", "channels:read",
] as const);
export const SLACK_RESEARCH_SCOPES = Object.freeze([
...SLACK_READ_SCOPES, "files:read",
] as const);
const PROFILES = Object.freeze({
[SLACK_CEO_DM_PROFILE]: { purpose: "bot", scopes: SLACK_CEO_DM_SCOPES },
[SLACK_CEO_THREADED_PROFILE]: { purpose: "bot", scopes: SLACK_CEO_THREADED_SCOPES },
[SLACK_READ_PROFILE]: { purpose: "personal_read", scopes: SLACK_READ_SCOPES },
[SLACK_RESEARCH_PROFILE]: { purpose: "personal_read", scopes: SLACK_RESEARCH_SCOPES },
} as const);
export type SlackCeoPermissionProfile = keyof typeof PROFILES;
export type SlackCeoBotProfile = typeof SLACK_CEO_DM_PROFILE | typeof SLACK_CEO_THREADED_PROFILE;
export function isSlackCeoBotProfile(profile: unknown): profile is SlackCeoBotProfile {
return profile === SLACK_CEO_DM_PROFILE || profile === SLACK_CEO_THREADED_PROFILE;
}
export function slackCeoBotScopes(profile: SlackCeoBotProfile) {
if (!isSlackCeoBotProfile(profile)) throw new Error("Unknown Slack bot permission profile");
return PROFILES[profile].scopes;
}
/** The implicit identity-only scope is accepted only from a runtime identity
* check on a personal grant, not as an extra requested/returned OAuth scope. */
export function hasExactSlackCeoScopes(
profile: string,
scopes: readonly string[],
source: "oauth" | "runtime" = "oauth",
): boolean {
if (!Object.hasOwn(PROFILES, profile)) return false;
const expected = PROFILES[profile as SlackCeoPermissionProfile];
const granted = new Set(scopes);
return expected.scopes.every(scope => granted.has(scope)) &&
[...granted].every(scope =>
(expected.scopes as readonly string[]).includes(scope) ||
(source === "runtime" && expected.purpose === "personal_read" && scope === "identify"));
}
+12 -7
View File
@@ -3,9 +3,9 @@ import { and, eq } from "drizzle-orm";
import { chatConversations, chatEndpoints, type Db } from "@paperclipai/db";
import { forbidden, unprocessable } from "../errors.js";
import { authorizeSlackPilotConversation } from "./slack-board-replies.js";
import { hasExactSlackCeoScopes, SLACK_READ_PROFILE } from "./slack-ceo-permission-profiles.js";
export { SLACK_READ_PROFILE, SLACK_READ_SCOPES } from "./slack-ceo-permission-profiles.js";
export const SLACK_READ_PROFILE = "slack-public-read-v1" as const;
export const SLACK_READ_SCOPES = ["channels:history", "channels:read"] as const;
export const SLACK_READ_TOOLS = {
"slack.read_channel": "slack_read_channel",
"slack.read_thread": "slack_read_thread",
@@ -21,8 +21,7 @@ export const SLACK_READ_WINDOW_GUIDANCE = "Omit oldest/latest on the initial rea
* This is not an additional requested data scope. Missing/extra reads or any
* write scope still fail closed. */
export function hasSlackReadRuntimeScopes(scopes: readonly string[]) {
return SLACK_READ_SCOPES.every(scope => scopes.includes(scope)) &&
scopes.every(scope => scope === "identify" || (SLACK_READ_SCOPES as readonly string[]).includes(scope));
return hasExactSlackCeoScopes(SLACK_READ_PROFILE, scopes, "runtime");
}
export function object(value: unknown): Record<string, unknown> {
@@ -111,8 +110,7 @@ export function validateSlackReadTokenResponse(payload: unknown, expected: { app
(identity.app_id !== undefined && identity.app_id !== expected.appId)) {
throw forbidden("Slack authorized a different app than the configured pilot", { code: "slack_read_app_mismatch" });
}
const exactScopes = (candidate: string[]) => SLACK_READ_SCOPES.every(scope => candidate.includes(scope)) &&
candidate.every(scope => (SLACK_READ_SCOPES as readonly string[]).includes(scope));
const exactScopes = (candidate: string[]) => hasExactSlackCeoScopes(SLACK_READ_PROFILE, candidate);
if (!exactScopes(scopes) || (response.scope !== undefined && !exactScopes(String(response.scope).split(/[ ,]+/).filter(Boolean)))) {
throw unprocessable("Slack must grant exactly public-channel metadata and history access", { code: "slack_read_scope_mismatch" });
}
@@ -163,6 +161,13 @@ export function governedSlackReadArguments(input: {
!/^\d+\.\d+$/.test(String(args.message_ts)) || Number(args.message_ts) < oldest || Number(args.message_ts) > now)) {
throw forbidden("Only threads rooted within the seven-day scan are supported");
}
// Slack's MCP thread reader requires Slack ts strings even for whole-second
// boundaries. Integer strings can return only the parent with a misleading
// "no more messages" result. Preserve caller precision without float rounding.
const slackTimestamp = (value: unknown, fallback: number) => {
const text = value === undefined ? String(fallback) : String(value);
return text.includes(".") ? text : `${text}.000000`;
};
return { ...args, channel_id: input.channelId, limit: args.limit ?? 30,
oldest: args.oldest ?? String(oldest), latest: args.latest ?? String(now) };
oldest: slackTimestamp(args.oldest, oldest), latest: slackTimestamp(args.latest, now) };
}