mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(slack): add threaded CEO conversations and live progress
This commit is contained in:
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"));
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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"]) => {
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in new issue
Block a user