diff --git a/doc/connections/SLACK-CEO-M2.md b/doc/connections/SLACK-CEO-M2.md index b69daeb2777..0696322a597 100644 --- a/doc/connections/SLACK-CEO-M2.md +++ b/doc/connections/SLACK-CEO-M2.md @@ -69,6 +69,10 @@ The live schema reviewed on 2026-09-22 uses `channel_id`, `oldest`, `latest`, add required `message_ts`, not `ts`. Both calls receive bounded time windows; thread roots must also be inside that window. Structural fixtures match these live schemas. The pilot caps thread page size at 100 even if Slack permits more. +The gateway sends fractional Slack timestamps even for whole-second boundaries +(`seconds.000000`). A live test showed integer-string bounds could return only a +parent with misleading no-more-messages metadata. Preserve caller fractional +precision without converting timestamps through floating-point formatting. The gateway enforces the configured channel, seven-day window, at most 200 requested message records and 30 thread calls per human turn. Failed calls @@ -98,5 +102,66 @@ The database tests use a simulated provider, not the normal Slack workspace. Real user OAuth, exact upstream tool schemas, bounded reads, second-request reuse and live restart recovery remain required before declaring live M2 acceptance. +### September 22 live follow-up + +After the timestamp correction and an isolated server restart, the normal +governed path returned 20 actual replies plus their parent using a saved grant. +Run `9dd42610-01f7-46ff-8889-44ac28bf9f88` settled TES-2 to Idle. Publication +`fa4a20e5-0be3-4be6-a646-1780890a7d18` reached Slack in one attempt. +A controlled signed replay of an already-processed DM message returned 200 with +no increase in tasks, runs, deliveries or publications. This is not a naturally +observed Slack retry. + +At 22:41–22:50 PDT, the user approved resetting only personal reads. The real +removal/reconnect path exposed and fixed retained-application uniqueness handling; +old credentials remain revoked, and foreign-owner applications cannot be adopted. +The pending request survived the pilot restart. The user approved fresh OAuth; +run `4e45ba40-7d64-46e8-8dd9-da927a0dc838` resumed the original request and read +five messages. Publication `8913ab75-0328-4581-a448-d8f9b07dc86c` reached Slack +in one attempt and TES-2 returned to Idle. A crash immediately AFTER OAuth +acceptance, before wake delivery, remains a separate live test. + +At that earlier checkpoint, 110 focused tests and direct server typechecking passed. M2b was planned in +[threaded research](../plans/2026-09-22-slack-ceo-threaded-research.md). Its v2 +permission definitions are tested but not admitted by existing v1 OAuth/runtime +entry points. Do not add scopes to the app until its upgrade path is ready. + +A subsequent backend checkpoint added a generated legacy-default thread-binding +migration and saved activation cutoff, exact originating-person/root checks, +and the shared-request outbox: v2 board requests must reach Slack before waking +the CEO. 227 focused tests and server/UI TypeScript checks pass. The new mode is +not activated; no live migration, new scopes or UI changes have been applied. +See the threaded-research plan for the remaining UI/privacy/research work and the +two broader integration failures also reproduced against committed code. + +### Current threaded-DM preview + +The narrow preview is now deployed on the isolated authenticated pilot after a +private database backup and migration 0284. The new **Enable threaded DMs** +button starts an explicit same-identity bot OAuth upgrade, adding only +`reactions:write`; user consent and exact validation must succeed before activation. +Personal reads and old conversation bindings are unchanged. New top-level DMs +after activation get one task/thread, a deduplicated eyes receipt and editable +working status. Both native and legacy ACPX runs can supply safe coarse progress; +tool updates are coalesced, and short runs may only show working then the answer. + +For new v2 bindings, **Reply via Slack** shares request and answer. The normal +Paperclip composer is still internal, disclosed in the banner. Default sharing, +human-only notes, explicit child-task linkage and historical screenshot research +remain unfinished. This is not full M2b acceptance. Focused verification includes +291 tests in 11 suites and 34 selected integration checks, plus server/UI/adapter +typechecks and the UI build. The bot upgrade has now completed: live status shows +active v2, reactions enabled and cutoff `1790145683.516000`. Real threaded Slack +acceptance began with a real post-upgrade DM. A callback channel-ID mismatch was +found and corrected (namespaced SDK ID versus bare provider ID); 38 targeted +checks and server typechecking pass. TES-4 now provides live proof of one eyes +receipt, one threaded working message, and an in-place final answer. Eyes are +removed on completion. Subsequent live checks verified same-thread continuity on +TES-4, a separate TES-5 root, labelled Paperclip request plus answer through Reply +via Slack, and “CEO is using tools…” followed by a final answer in the same thread. +The normal composer remains internal in this checkpoint. Next: Tasks-list +visibility for Idle conversations, shared-default v2 composer and isolated +human-only notes. No extra OAuth consent is needed for these UX fixes. + Implementation, coding-worker approval, artifacts, GitHub and merge are later milestones. Discovery guidance is not a security boundary for every CEO tool. diff --git a/doc/plans/2026-09-22-slack-ceo-threaded-research.md b/doc/plans/2026-09-22-slack-ceo-threaded-research.md new file mode 100644 index 00000000000..cb3634b365e --- /dev/null +++ b/doc/plans/2026-09-22-slack-ceo-threaded-research.md @@ -0,0 +1,364 @@ +# Slack CEO M2b: threaded, repository-backed research + +Status: narrow threaded-DM preview deployed and activated; live roots, follow-up, +explicit shared reply and coarse tool progress verified. Full +M2b remains incomplete. Continue `codex/slack-ceo-poc1`, +not the independent `codex/connection-capability-092226` checkout. Starting +commit: `91339784ce9a740c149b34f013a1e29e7e7febdc`. + +## Current checkpoint: threaded-DM preview + +The user's immediate request is to test a new Slack DM with a receipt, its own +thread and editable status. The isolated port-3210 pilot now runs this narrower +preview. Its database was backed up privately before applying migration 0284; +the main instance, old task history and personal read grant are unchanged. +The explicit bot upgrade completed successfully. A read-only live check confirms +active `ceo-dm-threaded-v2`, reactions enabled and fixed cutoff +`1790145683.516000`. Only new roots after that cutoff receive the preview behavior. + +`Enable threaded DMs` starts a browser-session-bound, ten-minute OAuth upgrade +from the five-scope bot profile to those same scopes plus `reactions:write`. +The callback verifies exact scopes, app/team, installer, bot, current linked +person/membership, configuration, credential fingerprint and runtime generation. +Only then does one transaction save credentials, enable reactions, advance the +generation and freeze the activation cutoff. Declined, stale or changed-identity +upgrades cannot activate v2. No personal `files:read` upgrade is included here. + +New roots after activation get separate tasks and threads. Existing roots keep +their original behavior; TES-2 is not migrated. Durable acceptance queues one +deduplicated eyes reaction. The publication worker posts/edits one status lane in +the exact thread. Native progress remains supported; the pilot's legacy ACPX +runner now emits a payload-free tool activity signal, coalesced at 20 seconds. +Short turns can go straight from working to the final answer. Never promise +individual grep/command names or expose raw arguments, reasoning or paths. + +For v2, explicit **Reply via Slack** shares the request before waking the CEO, +then shares the answer. The banner explicitly calls this a preview: normal +Paperclip composer messages still remain internal. Shared-default composer plus +isolated human-only notes, explicit child-task announcement/binding, historical +scan storage and screenshot inspection remain future M2b work. Their absence +does not prevent the narrow new-DM test, but this is not full M2b acceptance. + +Verification: 291 tests across 11 focused suites; 34 selected chat integration +checks; server/UI/adapter-utils TypeScript checks; token gates and UI build pass. +A separate 277-test selection also covered the full ACPX execution suite and +new coarse-event projection (counts overlap; do not sum them). The two broader +integration failures reproduced against committed HEAD remain noted below. +Live UI inspection confirmed the upgrade button and preview disclosure. OAuth +returned to the connection management page and the live endpoint is now v2. +Real reaction/thread/status acceptance is recorded below. +Earlier checkpoint paragraphs below are history, +not the current deployment/activation status. + +### Real-adapter admission correction and first live thread + +The first post-upgrade user messages failed before durable admission with HTTP +503: the real Chat SDK callback exposes `thread.channelId` as `slack:D…`, while +the v2 boundary/schema expects the bare provider `D…` ID. Initial service fixtures +used bare IDs and missed this seam. Admission now uses the existing canonical +provider-resource helper; v2 delivery persistence, lookup and task binding use the +same canonical ID. Legacy rows remain unchanged. Tests now assert the pinned +SDK's real callback shape and exercise namespaced channel IDs through upgrade, +receipt/progress, separate roots, same-task replies, dedupe and reconstruction. +38 targeted tests and server TypeScript checking passed. Restarted only the pilot. + +At 23:49 PDT, a harmless real Slack DM created TES-4, conversation +`1d7fb359-3e75-42ea-8387-d44a1fd68f3a`, root `1790146187.647289`. +Run `5b09a14c-068d-4acd-80b4-86ebb89198f2` succeeded. The eyes receipt was added +successfully at 23:49:49 and removed at completion, per the existing receipt +lifecycle. Publication `ee1a2bb7-6132-4b89-9948-77750a5ad13e` posted “CEO is +working…”; `2f128078-978a-40f1-aae0-d49aaa600783` changed the same Slack message +`1790146190.036889` to “Thread test passed.” Both had one successful attempt. +The real Slack UI showed one reply under the exact root, not a flat-DM answer. +No synthetic replay was used for this acceptance result. Earlier failed messages +were not admitted at the time of failure; one subsequently arrived as a provider +retry and created TES-5 after the admission correction. + +### Subsequent live checks and next UX work (September 23) + +- Same-thread follow-up stayed on TES-4 and correctly recalled the first request. + Run `096c8008-ee05-46df-b3b8-0ec60b859196` published once under the original root. +- A distinct root created TES-5, with its own conversation and reply. No manual + event replay was used to create either task. +- Explicit Reply via Slack on TES-4 shared the labelled human request and CEO + answer in the same thread. Run `e93225a4-6e5b-4f12-8f01-358c4f0dc6ec` succeeded; + request and final publications each had one successful attempt. +- User screenshots show the receipt, “CEO is using tools…” and its final answer + in the same thread during a real read-only channel request. The answer reported + 13 channel posts and 12 replies across three threads; this is reported coverage, + not an independent audit of those upstream reads. Attachments were not examined. + +Remaining immediate UX fixes: keep Idle Slack conversations discoverable in the +Tasks list without making them active work; route the normal composer through the +existing shared-request outbox for v2 tasks; add separately stored human-only +Internal notes. Never retroactively publish existing internal comments. Historical +channel reading, screenshots and explicit child-task thread linkage remain later +work; the narrow preview does not claim those capabilities. + +## Current evidence and immediate repair + +M1 works with a real CEO, Slack DM and canonical Paperclip task. M2 has a real +personal grant, channel reads, automatic continuation and durable final-answer +publication. The September 22 live recheck reproduced parent-only thread reads. +The gateway supplied whole-second timestamp strings. Normalizing whole seconds +to Slack's fractional `seconds.000000` format returned all 20 known replies +through the normal governed path after an isolated server restart. Preserve the +original precision of explicit fractional timestamps. No access limits change. + +Final verification run: `9dd42610-01f7-46ff-8889-44ac28bf9f88`; one published +reply `fa4a20e5-0be3-4be6-a646-1780890a7d18`, one attempt, existing grant, task Idle. +This proves saved-grant reuse after restart, NOT a crash at the accepted-OAuth +boundary. Fresh consent/restart and live duplicate delivery remain separate +acceptance checks; simulated test coverage is not live evidence. + +Subsequent controlled signed replay of a processed DM returned 200 and changed +none of the counts (3 issues, 31 runs, 26 publications, 6 deliveries). This tests +the live application's duplicate path with an existing message identity; it is +not a naturally occurring Slack retry. The durable duplicate counter became 1 +at `2026-09-23T05:26:31.310Z`. No additional message was submitted. + +Implemented foundation: exact versioned permission contracts in +`slack-ceo-permission-profiles.ts`, used by existing v1 bot/read validation with +unchanged scopes. v2 remains unavailable at current OAuth/runtime entry points. +110 tests across five files and direct server typechecking passed. + +### Fresh consent follow-up (22:41–22:50 PDT) + +With the user's explicit approval, removed only the personal read connection +through the normal UI; the bot remained active. This revealed a reconnect bug: +removal archives the empty application but preserves its unique name/key, and +`startSlackRead` tried to create the same application again. Fixed by validating +and reusing the same company's/person's exact pilot application, restoring only +its catalog container. Old connections/grants/profiles stay revoked or archived; +the replacement cannot read before new consent. Foreign-owner/type/source/key +collisions fail closed. Regression tests use real removal plus the public +service path, with no live DB repair. + +After the isolated server restart, the pending card survived. The user clicked +Allow on a fresh two-scope personal OAuth page. Intent +`1a8228c8-46fa-494d-ac52-95e6fc42b07b` was accepted; continuation run +`4e45ba40-7d64-46e8-8dd9-da927a0dc838` completed the original five-message test. +Publication `8913ab75-0328-4581-a448-d8f9b07dc86c` reached Slack in one attempt +(message `1790142571.581959`), and TES-2 returned to Idle. The restart was BEFORE +consent, not between accepted intent and wake delivery; that crash boundary +remains unverified live. + +Threading implementation now includes an opt-in adapter boundary in +`chat-slack-dm-threads.ts`/`chat-sdk-runtime.ts`. Signed-message tests verify two +DM roots remain distinct, replies and outbound answers use the original root, +and edits/deletions use the same identity. Invalid roots fail closed; the legacy +default is unchanged. 95 adapter/thread tests passed. This option is deliberately +not yet selected by endpoint configuration: durable binding/version admission, +task creation, shared/private composer isolation and scope upgrade must precede +activation. No new reactions/files permissions were requested. + +Combined verification after these changes: 207 tests across seven focused files +passed, direct server TypeScript checking passed, and `git diff --check` passed. +No full monorepo build or release/browser suite was run; this is an incremental +implementation checkpoint, not a PR-ready or M2b-complete claim. + +### Durable thread and shared-request backend checkpoint + +Generated migration `0284_amusing_bloodaxe.sql` adds a legacy-default binding +mode plus originating person/principal. Database constraints enforce DM/root +shape, matching channel, generation one and company-scoped principal ownership. +Existing rows are never promoted or republished. The migration was generated and +exercised in temporary test databases; it has not been applied to the live pilot. + +The runtime can consume a saved `slackDmThreading: { version: 2, since }` setup +record. No public activation/upgrade endpoint writes it yet. Upgrade must save +the cutoff once and advance `runtimeGeneration` under the credential/endpoint +lock. Root timestamps before the cutoff keep the pinned adapter's old identity, +including retries and replies; compare timestamps as integer microseconds, not +floating point. Admission rechecks mode under the endpoint lock. New threads +keep their task after completion/restart; exact company/endpoint/person/root +receipts fence wakeups and publications. Legacy DM fallback cannot match a v2 +conversation. Binding summaries now expose the persisted mode for future UI use. + +For v2 bindings, the existing board-reply service atomically stages the human +comment, a shared-request publication and a durable wake request. The request is +labelled “From Paperclip (you)” and sent by the bot under the exact root. The CEO +does not start until all transport parts have confirmed provider receipts. +Retries reuse the same comment/action/publication. Changed text, revoked identity +or retargeted bindings invalidate transport and execution. Legacy board replies +remain answer-only. Tests cover the actual publication worker with a simulated +provider, not only direct database status changes. + +Still required before live activation: wire the ordinary composer to this path; +store Internal notes separately from agent-readable task comments/history and +label them human-only; add the v2 upgrade/consent operation, receipts and editable +status, and explicit child-task announcement/binding. Merely skipping publication +of an ordinary comment is NOT internal-note isolation—the next shared run can +read the comment. No UI or live Slack permission change was made at this checkpoint. + +Verification: 217 tests across the seven focused OAuth/read/reply/adapter suites +and 10 threaded-pilot integration tests pass (227 total). The integration tests +also exercise multi-part request publication before wake and exact board binding +summaries. Server/UI TypeScript checks and migration safety/numbering checks pass. +`pnpm -r typecheck` stops at missing `cargo`; no full build claim. + +A broader chat integration run passed 1,028 tests and failed two existing cases: +“does not duplicate an inbound file when delivery recovery resumes after the task +mutation” and “materializes direct external-chat finals once and accepts the next +turn without agent bookkeeping writes.” Both fail identically in isolation and +with a read-only Vitest loader restoring committed `HEAD` versions of all modified +tracked TypeScript modules, including the original test file. The comparison did +not reset or modify the checkout. This is not a fully green repository baseline. +Run broad tests with `PAPERCLIP_SLACK_CEO_POC_ENABLED=false` and +`PAPERCLIP_SLACK_CEO_POC_READ_ENABLED=false`; dedicated pilot fixtures opt in. +Otherwise the isolated live configuration's environment defaults incorrectly +apply DM-only rules to unrelated Slack test fixtures. + +The running port-3210 authenticated pilot still reports healthy. No restart, +live migration, activation, permission expansion, commit, push or PR was performed +for this checkpoint. Current next coding step is the shared composer and isolated +human-only notes, not another OAuth reset. + +## User-approved behavior + +- One 👀 receipt after durable acceptance. Subsequent responses and one editable + status message belong under the original top-level DM message. +- Explicit new-task requests inside a thread create one separate announcement/ + thread and canonical task, with links between them. +- New threaded tasks mirror normal composer messages and CEO answers. An + Internal note option remains private, including from later shared run context. +- Read all history still available to the linked user in **only** the configured + public #papercuts channel, all replies, and inspect actual screenshot images. +- Compare each report to a pinned latest `origin/master` snapshot. Report + easy/difficult/unsure with author/date/source/reason; resolved reports separate. +- No implementation, coding delegation, PR, push or merge in this milestone. + +## Five steps and integration seams + +### 1. Contract, versioning and migration + +Update the authoritative user spec and this plan. Keep the isolated test10/TES +company, CEO, app, workspace, Tailscale origin and database. Current v1 tasks stay +private-by-default: never backfill previously internal notes into Slack. +Conversation sharing is versioned independently from OAuth permission profiles. +No authority is inherited just from an issue's parent or a prose source link. + +### 2. Threading, receipt, progress and shared composer + +Reuse `chat-channels.ts` durable ingress/actions/deliveries, `chat_conversations` +and `chat_message_links`, `chat-slack-receipts.ts`, the SDK adapter and existing +publication workers. Add a persisted conversation mode and immutable root; use +generated DB migrations if the schema needs fields. All lookups retain company, +endpoint, person, exact thread and session-generation constraints. + +- Normalize top-level DM `ts` versus reply `thread_ts` before delivery identity + and task lookup. Preserve root precision; reject cross-channel mismatches. +- Audit the SDK's DM flattening plus `reactionConversationThreadCondition` and + other DM-wide fallback paths. v2 must not route a missing thread to TES-2. +- Reuse the receipt outbox only after a v2 token verifies `reactions:write`. +- New-task creation is an idempotent server action bound to the linked human's + accepted request, not model-supplied identity. Separate root announcement is a + recoverable publication; hold Slack-dependent execution until linked. Do not + synthesize a fake inbound Slack event to satisfy authorization. +- Extend `slack-board-replies.ts` with explicit per-turn audience and routing; + retain v1 compatibility and precise wake/run/final-answer receipts. Wire the + normal composer to this path only for new shared bindings. Internal notes use + an isolated runtime context or are excluded from all shared runtime inputs. +- Extend `chat-run-publications.ts` and the existing safe progress map into an + editable status outbox keyed by conversation/generation/turn. Coalesce updates, + honor Retry-After, reject stale updates, always reconcile terminal states. + Render only allowlisted phase labels, never tool arguments or reasoning. + +### 3. Pinned repository assessment + +The pilot remote is `https://github.com/paperclipai/paperclip.git`. TES-3 is +currently projectless and has no Slack binding; don't relabel it as connected. +Use supported project APIs/UI to configure one Paperclip project. Resolve and +record master SHA at scan admission and inspect an isolated clean snapshot. +Audit the actual Codex/acpx adapter's enforceable read-only mode before assigning +the CEO repository execution. A project path or prompt saying read-only does not +remove shell/Git credentials. If unavailable, use a narrowly governed read-only +repository tool path; do not quietly enable a write-capable worker. + +Preflight: fetched master at `7944ed3d976d1a7cc26a2d0cee51f227f3542084` without +changing either checkout. The live CEO is `codex_local` with no explicit engine, +permissionMode or filesystemScope override. `codex-local/src/server/acp.ts` +rejects local confinement; the default ACP permission mode is approve-all. +The CLI argument builder also defaults to approval/sandbox bypass unless +explicit restrictions are supplied. Do not infer a read-only boundary from the +task's wording. Project attachment/snapshot execution are deliberately not yet +enabled. Prefer a task-scoped enforced policy, not a global CEO permission change. + +### 4. Resumable history and screenshot evidence + +Replace the current per-human-request 200-requested-record budget only for the +new historical scan path, not by globally disabling `slack-read-budget.ts`. +Create a durable scan with fixed cutoff, authority generation, cursors for channel +and every discovered root, deduped source records, image provenance, inspection +results, bounded batch leases, cancellation and retry schedule. Checkpoint pages +atomically; reconcile duplicate pages; retain partial coverage on errors. + +Gateway checks in `slack-read-profile.ts`, `slack-read-access.ts`, `tool-gateway.ts` +and continuation in `slack-read-intents.ts` still validate current company, +linked human, workspace, grant, CEO, task and original audience. Revocation stops +new batches/publications. A moved destination requires a new binding, not a scan +fingerprint rewrite. Provider tool schema drift fails closed. + +Screenshot ingestion requires an observed file ID from a governed channel/thread +read in this scan. Validate that provenance at fetch time, then use the existing +private attachment/vision pipeline with byte/pixel/MIME/decode limits and safe +provider downloads. No arbitrary URLs, token forwarding across redirects, bot +files grant, uploads, PDF/video or unrelated files. A file name/URL is not image +inspection. Missing image evidence means unsure for image-dependent issues. + +Report actual counts, inspected/uninspected screenshots, cursors/errors, +retention/access gaps and master SHA. Explicitly detect advertised replies versus +parent-only results. Full-history claims require complete coverage accounting, +not just a provider's single no-more-pages response. + +### 5. Versioned scopes and real consent + +Prepare exact profile definitions and tests first: + +- `ceo-dm-v1`: existing five bot scopes, unchanged. +- `ceo-dm-threaded-v2`: those five plus `reactions:write`. +- `slack-public-read-v1`: `channels:history`, `channels:read`, unchanged. +- `slack-public-research-v2`: those two plus personal `files:read`. + +Definitions do not enable features. Keep v1 entry points pinned to v1 until an +explicit upgrade API, manifest/validation/UI and runtime features are all wired. +Bind target profile/scopes in the OAuth transaction. Stage new credentials and +verify identity/scopes before swapping; failed/declined upgrades retain old +authority. Audit leases, stale callbacks and reconnect generation invalidation. +An active v1 bot currently cannot be reinstalled through the setup-only route; +do not relax that guard without implementing this upgrade contract. + +Only after fixture tests pass, confirm the app configuration change, preserve +both callbacks, and let the user approve each final OAuth screen. Do not add the +broad adapter scopes or change token rotation, workspace settings or tunnel. + +## Test gates + +1. v1 compatibility, exact scope sets, unknown profiles, stale OAuth target and + no automatic upgrades. Re-run existing read/identity/continuation tests. +2. Two roots/two tasks; same-thread continuity; explicit new task inside a thread; + duplicate event/client retry/restart create one task/root/reaction/final. +3. Shared composer request+answer; private notes never publish or leak through + subsequent shared context; audience and destination cannot mutate on retry. +4. Progress from real events, rate-limit/backoff, crash recovery, ambiguous send, + complete/cancel/error terminal dominance and no raw tool data. +5. Old channel pages, threads with pagination, screenshot-only reports, missing + images, parent/reply mismatch, provider drift, revoke-mid-scan and resumed + batches. Prevent cross-company/channel/file/identity access. +6. Real screenshot analysis and pinned repository comparison; no implementation. +7. Fresh real consent -> controlled accepted-intent restart -> one continuation, + then saved-grant reuse. Record fixture vs provider vs live-agent proof. + +M3 (approve and fix) starts only after these exit checks, explicit user direction, +coding-worker selection and a verified implementation approval boundary. + +## References + +- `doc/connections/SLACK-CEO-M2.md` and `SLACK-CEO-POC1.md` +- [Slack MCP](https://docs.slack.dev/ai/slack-mcp-server/) +- [Thread replies](https://docs.slack.dev/reference/methods/chat.postMessage/) +- [Editable messages](https://docs.slack.dev/reference/methods/chat.update/) +- [Reaction permission](https://docs.slack.dev/reference/methods/reactions.add/) +- [Personal file access](https://docs.slack.dev/reference/scopes/files.read/) + +These describe provider capabilities, not completed pilot functionality. diff --git a/packages/adapter-utils/src/acpx-engine/chat-progress.test.ts b/packages/adapter-utils/src/acpx-engine/chat-progress.test.ts new file mode 100644 index 00000000000..f548ac77e20 --- /dev/null +++ b/packages/adapter-utils/src/acpx-engine/chat-progress.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { createAcpxChatProgress } from "./chat-progress.js"; + +describe("ACPX coarse chat progress", () => { + it("emits only a closed tool signal and coalesces token-by-token updates", () => { + let time = 0; + const project = createAcpxChatProgress(() => time); + for (const type of ["text_delta", "status", "error", "done", "private command"]) expect(project(type)).toBeNull(); + expect(project("tool_call")).toEqual({ eventType: "chat.progress.using_tools", stream: "system", level: "info" }); + for (let i = 0; i < 100; i++) expect(project("tool_call")).toBeNull(); + time = 20_000; + expect(project("tool_call")).not.toBeNull(); + expect(createAcpxChatProgress(() => time)("tool_call")).not.toBeNull(); + }); +}); diff --git a/packages/adapter-utils/src/acpx-engine/chat-progress.ts b/packages/adapter-utils/src/acpx-engine/chat-progress.ts new file mode 100644 index 00000000000..db324caa0ec --- /dev/null +++ b/packages/adapter-utils/src/acpx-engine/chat-progress.ts @@ -0,0 +1,12 @@ +import type { AdapterRuntimeEvent } from "../types.js"; + +/** Only a closed activity label enters the local run-event table. Never accept + * a tool title, command, argument, output, or thought delta as display text. */ +export function createAcpxChatProgress(now: () => number = Date.now) { + let lastToolEventAt = -Infinity; + return (eventType: string): AdapterRuntimeEvent | null => { + if (eventType !== "tool_call" || now() - lastToolEventAt < 20_000) return null; + lastToolEventAt = now(); + return { eventType: "chat.progress.using_tools", stream: "system", level: "info" }; + }; +} diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 509a8dfb16d..eb15b1d2544 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1,4 +1,5 @@ import { cancellableSandboxStartup } from "./startup-cancellation.js"; +import { createAcpxChatProgress } from "./chat-progress.js"; import fs from "node:fs/promises"; import fsSync from "node:fs"; import os from "node:os"; @@ -4835,8 +4836,11 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { const stepEventRelay = async (): Promise => { const turn = activeTurn as AcpRuntimeTurn; const toolTitles = new Map(); + const chatProgress = createAcpxChatProgress(now); const drainEvents = (async (): Promise => { for await (const event of turn.events) { + const progress = chatProgress(event.type); + if (progress) await ctx.onEvent?.(progress); // ACPX currently flattens client-side filesystem/terminal receipts // into status text. They cannot establish complete action outcomes. if (event.type === "status" && /^(fs|terminal)\//.test(event.text)) incompleteToolInventory = true; diff --git a/packages/db/src/migrations/0284_amusing_bloodaxe.sql b/packages/db/src/migrations/0284_amusing_bloodaxe.sql new file mode 100644 index 00000000000..c14d15db15f --- /dev/null +++ b/packages/db/src/migrations/0284_amusing_bloodaxe.sql @@ -0,0 +1,11 @@ +ALTER TABLE "chat_conversations" ADD COLUMN "binding_mode" text DEFAULT 'legacy' NOT NULL;--> statement-breakpoint +ALTER TABLE "chat_conversations" ADD COLUMN "origin_principal_id" uuid;--> statement-breakpoint +ALTER TABLE "chat_conversations" ADD COLUMN "origin_user_id" text;--> statement-breakpoint +ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_origin_principal_fk" FOREIGN KEY ("company_id","origin_principal_id") REFERENCES "public"."chat_external_principals"("company_id","id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_binding_mode_check" CHECK ("chat_conversations"."binding_mode" in ('legacy', 'slack_dm_thread_v2'));--> statement-breakpoint +ALTER TABLE "chat_conversations" ADD CONSTRAINT "chat_conversations_slack_thread_binding_check" CHECK ("chat_conversations"."binding_mode" = 'legacy' or ( + "chat_conversations"."is_direct_message" and "chat_conversations"."session_generation" = 1 + and "chat_conversations"."origin_principal_id" is not null and "chat_conversations"."origin_user_id" is not null + and "chat_conversations"."external_conversation_id" ~ '^D[A-Z0-9]+$' + and "chat_conversations"."external_thread_id" ~ '^slack:D[A-Z0-9]+:[0-9]{1,12}[.][0-9]{1,6}$' + and split_part("chat_conversations"."external_thread_id", ':', 2) = "chat_conversations"."external_conversation_id")); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0279_snapshot.json b/packages/db/src/migrations/meta/0284_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0279_snapshot.json rename to packages/db/src/migrations/meta/0284_snapshot.json index 091efaf1ef8..badda291439 100644 --- a/packages/db/src/migrations/meta/0279_snapshot.json +++ b/packages/db/src/migrations/meta/0284_snapshot.json @@ -1,6 +1,6 @@ { - "id": "4770ad6d-2cdc-45c4-af89-c300fd134d91", - "prevId": "94dca593-bcb1-4e7e-8311-42e788a539c2", + "id": "8a038eb1-fe99-4de5-9608-3f4c7c2b53e5", + "prevId": "786cd4a9-366b-4934-be90-014e1ee15b51", "version": "7", "dialect": "postgresql", "tables": { @@ -2040,6 +2040,12 @@ "primaryKey": false, "notNull": false }, + "appearance": { + "name": "appearance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "status": { "name": "status", "type": "text", @@ -5669,6 +5675,25 @@ "notNull": true, "default": "''" }, + "binding_mode": { + "name": "binding_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'legacy'" + }, + "origin_principal_id": { + "name": "origin_principal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "origin_user_id": { + "name": "origin_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "session_generation": { "name": "session_generation", "type": "integer", @@ -5695,6 +5720,12 @@ "notNull": true, "default": false }, + "communication_guidance": { + "name": "communication_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, "state": { "name": "state", "type": "text", @@ -5819,6 +5850,21 @@ "onDelete": "restrict", "onUpdate": "no action" }, + "chat_conversations_origin_principal_fk": { + "name": "chat_conversations_origin_principal_fk", + "tableFrom": "chat_conversations", + "tableTo": "chat_external_principals", + "columnsFrom": [ + "company_id", + "origin_principal_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, "chat_conversations_company_issue_fk": { "name": "chat_conversations_company_issue_fk", "tableFrom": "chat_conversations", @@ -5881,6 +5927,14 @@ "chat_conversations_state_check": { "name": "chat_conversations_state_check", "value": "\"chat_conversations\".\"state\" in ('active', 'waiting', 'completed', 'unavailable', 'endpoint_removed')" + }, + "chat_conversations_binding_mode_check": { + "name": "chat_conversations_binding_mode_check", + "value": "\"chat_conversations\".\"binding_mode\" in ('legacy', 'slack_dm_thread_v2')" + }, + "chat_conversations_slack_thread_binding_check": { + "name": "chat_conversations_slack_thread_binding_check", + "value": "\"chat_conversations\".\"binding_mode\" = 'legacy' or (\n \"chat_conversations\".\"is_direct_message\" and \"chat_conversations\".\"session_generation\" = 1\n and \"chat_conversations\".\"origin_principal_id\" is not null and \"chat_conversations\".\"origin_user_id\" is not null\n and \"chat_conversations\".\"external_conversation_id\" ~ '^D[A-Z0-9]+$'\n and \"chat_conversations\".\"external_thread_id\" ~ '^slack:D[A-Z0-9]+:[0-9]{1,12}[.][0-9]{1,6}$'\n and split_part(\"chat_conversations\".\"external_thread_id\", ':', 2) = \"chat_conversations\".\"external_conversation_id\")" } }, "isRLSEnabled": false @@ -6605,6 +6659,13 @@ "primaryKey": false, "notNull": false }, + "communication_instructions": { + "name": "communication_instructions", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, "allow_direct_messages": { "name": "allow_direct_messages", "type": "boolean", @@ -8090,6 +8151,519 @@ }, "isRLSEnabled": false }, + "public.chat_github_configurations": { + "name": "chat_github_configurations", + "schema": "", + "columns": { + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "configuration": { + "name": "configuration", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_configurations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_configurations", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_github_config_revision_check": { + "name": "chat_github_config_revision_check", + "value": "\"chat_github_configurations\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.chat_github_registrations": { + "name": "chat_github_registrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trusted_origin": { + "name": "trusted_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_github_registration_state_uq": { + "name": "chat_github_registration_state_uq", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_registration_endpoint_idx": { + "name": "chat_github_registration_endpoint_idx", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_registrations", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_github_registration_status_check": { + "name": "chat_github_registration_status_check", + "value": "\"chat_github_registrations\".\"status\" in ('pending', 'exchanging', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.chat_github_reviews": { + "name": "chat_github_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pull_number": { + "name": "pull_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "configuration_revision": { + "name": "configuration_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "policy_snapshot": { + "name": "policy_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "assessment": { + "name": "assessment", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "conclusion": { + "name": "conclusion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_url": { + "name": "check_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_id": { + "name": "summary_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary_url": { + "name": "summary_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publication_receipts": { + "name": "publication_receipts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_github_reviews_delivery_uq": { + "name": "chat_github_reviews_delivery_uq", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_reviews_pull_idx": { + "name": "chat_github_reviews_pull_idx", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pull_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_github_reviews_task_idx": { + "name": "chat_github_reviews_task_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_github_reviews_run_id_heartbeat_runs_id_fk": { + "name": "chat_github_reviews_run_id_heartbeat_runs_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "heartbeat_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { + "name": "chat_github_reviews_company_id_endpoint_id_chat_endpoints_company_id_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "chat_endpoints", + "columnsFrom": [ + "company_id", + "endpoint_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk": { + "name": "chat_github_reviews_company_id_issue_id_issues_company_id_id_fk", + "tableFrom": "chat_github_reviews", + "tableTo": "issues", + "columnsFrom": [ + "company_id", + "issue_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_github_reviews_company_id_uq": { + "name": "chat_github_reviews_company_id_uq", + "nullsNotDistinct": false, + "columns": [ + "company_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "chat_github_review_pull_number_check": { + "name": "chat_github_review_pull_number_check", + "value": "\"chat_github_reviews\".\"pull_number\" > 0" + }, + "chat_github_review_state_check": { + "name": "chat_github_review_state_check", + "value": "\"chat_github_reviews\".\"state\" in ('queued', 'running', 'completed', 'incomplete', 'error', 'superseded', 'manual_required')" + } + }, + "isRLSEnabled": false + }, "public.chat_teams_file_transfers": { "name": "chat_teams_file_transfers", "schema": "", @@ -37410,6 +37984,26 @@ "notNull": true, "default": true }, + "setup_pending": { + "name": "setup_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_webhook_delivery": { + "name": "last_webhook_delivery", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "cron_expression": { "name": "cron_expression", "type": "text", @@ -37671,6 +38265,100 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.routine_webhook_test_receipts": { + "name": "routine_webhook_test_receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "delivery_key_hash": { + "name": "delivery_key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routine_webhook_test_receipts_delivery_uq": { + "name": "routine_webhook_test_receipts_delivery_uq", + "columns": [ + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delivery_key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routine_webhook_test_receipts_company_id_companies_id_fk": { + "name": "routine_webhook_test_receipts_company_id_companies_id_fk", + "tableFrom": "routine_webhook_test_receipts", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk": { + "name": "routine_webhook_test_receipts_trigger_id_routine_triggers_id_fk", + "tableFrom": "routine_webhook_test_receipts", + "tableTo": "routine_triggers", + "columnsFrom": [ + "trigger_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.routines": { "name": "routines", "schema": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 7a6ecc85f28..9a7076a356c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1975,6 +1975,13 @@ "when": 1790018362070, "tag": "0283_jittery_psynapse", "breakpoints": true + }, + { + "idx": 284, + "version": "7", + "when": 1790143464911, + "tag": "0284_amusing_bloodaxe", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_channels.ts b/packages/db/src/schema/chat_channels.ts index 6d81462828f..8752ac0ba4c 100644 --- a/packages/db/src/schema/chat_channels.ts +++ b/packages/db/src/schema/chat_channels.ts @@ -16,6 +16,7 @@ import { import type { ChatAdapterCapabilities, ChatConcurrencyPolicy, + ChatConversationBindingMode, ChatDeliveryState, ChatDeploymentMode, ChatEndpointSetupState, @@ -372,6 +373,10 @@ export const chatConversations = pgTable( .references(() => issues.id, { onDelete: "restrict" }), externalConversationId: text("external_conversation_id").notNull(), externalThreadId: text("external_thread_id").notNull().default(""), + // Immutable admission semantics; existing conversations remain legacy. + bindingMode: text("binding_mode").$type().notNull().default("legacy"), + originPrincipalId: uuid("origin_principal_id"), + originUserId: text("origin_user_id"), // Providers with linear conversations (DMs, Telegram groups, Teams group // chats) reuse one native thread id. A generation preserves the native id // used for replies while allowing completed Paperclip tasks to roll over. @@ -395,6 +400,13 @@ export const chatConversations = pgTable( "chat_conversations_state_check", sql`${table.state} in ('active', 'waiting', 'completed', 'unavailable', 'endpoint_removed')`, ), + check("chat_conversations_binding_mode_check", sql`${table.bindingMode} in ('legacy', 'slack_dm_thread_v2')`), + check("chat_conversations_slack_thread_binding_check", sql`${table.bindingMode} = 'legacy' or ( + ${table.isDirectMessage} and ${table.sessionGeneration} = 1 + and ${table.originPrincipalId} is not null and ${table.originUserId} is not null + and ${table.externalConversationId} ~ '^D[A-Z0-9]+$' + and ${table.externalThreadId} ~ '^slack:D[A-Z0-9]+:[0-9]{1,12}[.][0-9]{1,6}$' + and split_part(${table.externalThreadId}, ':', 2) = ${table.externalConversationId})`), index("chat_conversations_issue_idx").on(table.companyId, table.issueId), uniqueIndex("chat_conversations_thread_uq").on( table.endpointId, @@ -403,6 +415,11 @@ export const chatConversations = pgTable( table.sessionGeneration, ), unique("chat_conversations_company_id_uq").on(table.companyId, table.id), + foreignKey({ + columns: [table.companyId, table.originPrincipalId], + foreignColumns: [chatExternalPrincipals.companyId, chatExternalPrincipals.id], + name: "chat_conversations_origin_principal_fk", + }), foreignKey({ columns: [table.companyId, table.issueId], foreignColumns: [issues.companyId, issues.id], diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index d9809d90b85..9d3b5680214 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -189,7 +189,7 @@ export interface SlackAppConfiguration { export interface ChatEndpointSetupState { /** Safe deployment readiness, never OAuth credentials or transaction state. */ - slackOAuth?: { enabled: boolean; configured: boolean; callbackUrl: string | null; profile: "ceo-dm-v1"; missing: string[]; scopes: string[] }; + slackOAuth?: { enabled: boolean; configured: boolean; callbackUrl: string | null; profile: "ceo-dm-v1" | "ceo-dm-threaded-v2"; missing: string[]; scopes: string[] }; github?: { stage: "connect" | "install" | "repositories" | "verify" | "identity" | "behavior" | "test"; appSlug?: string; @@ -311,6 +311,8 @@ export interface ChatIdentityLink { revokedAt?: string | null; } +export type ChatConversationBindingMode = "legacy" | "slack_dm_thread_v2"; + export interface ChatConversation { id: string; companyId: string; @@ -321,6 +323,8 @@ export interface ChatConversation { issueTitle?: string | null; externalConversationId: string; externalThreadId: string; + /** Legacy rows never acquire shared-thread semantics implicitly. */ + bindingMode?: ChatConversationBindingMode; sessionGeneration: number; externalLabel: string; externalUrl?: string | null; @@ -472,8 +476,11 @@ export interface SlackReplyReceipt { } export interface ExternalChannelBindingSummary { + /** Persisted routing version; not itself permission to send. */ + bindingMode?: ChatConversationBindingMode; /** UI capability hint only; the reply API independently authorizes the signed-in person. */ slackReplyAvailable?: boolean; + slackThreadUpgradeAvailable?: boolean; endpointId: string; provider: ChatProvider; botLabel?: string | null; diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 9e10bbe5b0c..44d4a704726 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -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(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 }); diff --git a/server/src/__tests__/slack-board-replies.test.ts b/server/src/__tests__/slack-board-replies.test.ts index 9f2b6a9c952..23da8ab43f0 100644 --- a/server/src/__tests__/slack-board-replies.test.ts +++ b/server/src/__tests__/slack-board-replies.test.ts @@ -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>) { + 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(); + } + }); }); diff --git a/server/src/__tests__/slack-read-discovery.test.ts b/server/src/__tests__/slack-read-discovery.test.ts index 3d40dd5cc9f..4dfe757f5c7 100644 --- a/server/src/__tests__/slack-read-discovery.test.ts +++ b/server/src/__tests__/slack-read-discovery.test.ts @@ -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)); diff --git a/server/src/__tests__/slack-read-profile.test.ts b/server/src/__tests__/slack-read-profile.test.ts index 99116787596..1739da872a5 100644 --- a/server/src/__tests__/slack-read-profile.test.ts +++ b/server/src/__tests__/slack-read-profile.test.ts @@ -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" }); + }); }); diff --git a/server/src/routes/chat-channels.slack-oauth.test.ts b/server/src/routes/chat-channels.slack-oauth.test.ts index ac6b660a8ae..4cb16ddc14b 100644 --- a/server/src/routes/chat-channels.slack-oauth.test.ts +++ b/server/src/routes/chat-channels.slack-oauth.test.ts @@ -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")); diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index fcd5da8ff4b..649e0e628d8 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -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`); } }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 70bf411e508..61a93a2a15e 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -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", diff --git a/server/src/services/chat-channel-binding.ts b/server/src/services/chat-channel-binding.ts index 30e9d0adf17..bf0af1ccca2 100644 --- a/server/src/services/chat-channel-binding.ts +++ b/server/src/services/chat-channel-binding.ts @@ -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 } : {}), }; } diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 0693c0d42ec..fbb7de45fdf 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -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> = { 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[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, credentialLease: CredentialMutationLeaseGuard, actorUserId?: string | null, + commit?: (tx: DbTransaction) => Promise, ) { 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 { + 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["stage"]) => { diff --git a/server/src/services/chat-run-publications.ts b/server/src/services/chat-run-publications.ts index 97a55497295..dd722a8b7db 100644 --- a/server/src/services/chat-run-publications.ts +++ b/server/src/services/chat-run-publications.ts @@ -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"), ), ) diff --git a/server/src/services/chat-sdk-runtime.adapters.test.ts b/server/src/services/chat-sdk-runtime.adapters.test.ts index a174fe8710b..700c37bf508 100644 --- a/server/src/services/chat-sdk-runtime.adapters.test.ts +++ b/server/src/services/chat-sdk-runtime.adapters.test.ts @@ -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(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 }; 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}`, }), }), ); diff --git a/server/src/services/chat-sdk-runtime.ts b/server/src/services/chat-sdk-runtime.ts index f25007425ff..311b64d8a12 100644 --- a/server/src/services/chat-sdk-runtime.ts +++ b/server/src/services/chat-sdk-runtime.ts @@ -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< diff --git a/server/src/services/chat-slack-dm-threads.test.ts b/server/src/services/chat-slack-dm-threads.test.ts new file mode 100644 index 00000000000..ba800e7f2c2 --- /dev/null +++ b/server/src/services/chat-slack-dm-threads.test.ts @@ -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; + 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); + }); +}); diff --git a/server/src/services/chat-slack-dm-threads.ts b/server/src/services/chat-slack-dm-threads.ts new file mode 100644 index 00000000000..4f8a9ed05fd --- /dev/null +++ b/server/src/services/chat-slack-dm-threads.ts @@ -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 | 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; +} diff --git a/server/src/services/connection-intents.ts b/server/src/services/connection-intents.ts index e9568c6ccf3..2269bdae35c 100644 --- a/server/src/services/connection-intents.ts +++ b/server/src/services/connection-intents.ts @@ -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, { diff --git a/server/src/services/safe-native-chat-progress.ts b/server/src/services/safe-native-chat-progress.ts index 2f2a48c7849..d57cd175943 100644 --- a/server/src/services/safe-native-chat-progress.ts +++ b/server/src/services/safe-native-chat-progress.ts @@ -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 > = { + "chat.progress.using_tools": "using_tools", "workspace.ready": "preparing", "research.started": "researching", "research.progressed": "researching", diff --git a/server/src/services/slack-board-replies.ts b/server/src/services/slack-board-replies.ts index 488fc9504a7..a30c256e26e 100644 --- a/server/src/services/slack-board-replies.ts +++ b/server/src/services/slack-board-replies.ts @@ -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 { + 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 { + 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 } }); diff --git a/server/src/services/slack-bot-oauth.test.ts b/server/src/services/slack-bot-oauth.test.ts index 8512b22b14d..5b4699d558d 100644 --- a/server/src/services/slack-bot-oauth.test.ts +++ b/server/src/services/slack-bot-oauth.test.ts @@ -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().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])); diff --git a/server/src/services/slack-bot-oauth.ts b/server/src/services/slack-bot-oauth.ts index 0a99871e8da..c938cf48474 100644 --- a/server/src/services/slack-bot-oauth.ts +++ b/server/src/services/slack-bot-oauth.ts @@ -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 }; diff --git a/server/src/services/slack-ceo-permission-profiles.test.ts b/server/src/services/slack-ceo-permission-profiles.test.ts new file mode 100644 index 00000000000..4ca2199b7e8 --- /dev/null +++ b/server/src/services/slack-ceo-permission-profiles.test.ts @@ -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); + }); +}); diff --git a/server/src/services/slack-ceo-permission-profiles.ts b/server/src/services/slack-ceo-permission-profiles.ts new file mode 100644 index 00000000000..715ca53f711 --- /dev/null +++ b/server/src/services/slack-ceo-permission-profiles.ts @@ -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")); +} diff --git a/server/src/services/slack-read-profile.ts b/server/src/services/slack-read-profile.ts index 6dc1e5b74c6..647940a1bc6 100644 --- a/server/src/services/slack-read-profile.ts +++ b/server/src/services/slack-read-profile.ts @@ -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 { @@ -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) }; } diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index 4f8ed884428..c229485c528 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -71,6 +71,8 @@ export interface ChatConversation { export interface ExternalChannelBindingSummary { slackReplyAvailable?: boolean; + slackThreadUpgradeAvailable?: boolean; + bindingMode?: "legacy" | "slack_dm_thread_v2"; endpointId: string; provider: ChatProvider; botLabel?: string | null; @@ -220,6 +222,9 @@ export const chatEndpointsApi = { startSlackBotOAuth: (endpointId: string) => api.post<{ authorizationUrl: string; expiresAt: string }>( `/chat-endpoints/${endpointId}/slack/oauth/start`, { permissionProfile: "ceo-dm-v1" }, ), + startSlackThreadUpgrade: (endpointId: string) => api.post<{ authorizationUrl: string; expiresAt: string }>( + `/chat-endpoints/${endpointId}/slack/threading/upgrade`, { permissionProfile: "ceo-dm-threaded-v2" }, + ), setupTestStatus: (endpointId: string) => api.get<{ messageReceivedAt: string | null }>(`/chat-endpoints/${endpointId}/test-status`), requestIdentityAccess: (token: string) => api.post<{ status: "member" | "pending_approval" }>("/chat-identity-links/request-access", { token }), listResources: async (endpointId: string) => diff --git a/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx b/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx index 3eb8dc2fd8d..19a14c75de1 100644 --- a/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx +++ b/ui/src/components/chat/ExternallyConnectedTaskBanner.test.tsx @@ -14,6 +14,7 @@ const mockChatEndpointsApi = vi.hoisted(() => ({ getIssueBinding: vi.fn(), publishBoardMessage: vi.fn(), getPublicationBatchStatus: vi.fn(), + startSlackThreadUpgrade: vi.fn(), })); const pushToastMock = vi.hoisted(() => vi.fn()); const uploadAttachmentMock = vi.hoisted(() => vi.fn()); @@ -155,6 +156,28 @@ describe("ExternallyConnectedTaskBanner publication truth", () => { vi.restoreAllMocks(); }); + it("offers explicit threaded consent without changing the connection on render", async () => { + const binding = await mockChatEndpointsApi.getIssueBinding(); + mockChatEndpointsApi.getIssueBinding.mockResolvedValue({ ...binding, slackThreadUpgradeAvailable: true }); + mockChatEndpointsApi.startSlackThreadUpgrade.mockRejectedValue(new Error("Connection changed; reload before upgrading")); + await renderBanner(); + expect(container.textContent).toContain("Existing conversations stay unchanged"); + expect(mockChatEndpointsApi.startSlackThreadUpgrade).not.toHaveBeenCalled(); + await act(() => findButton(container, "Enable threaded DMs").click()); + await flushReact(); + expect(mockChatEndpointsApi.startSlackThreadUpgrade).toHaveBeenCalledOnce(); + expect(mockChatEndpointsApi.startSlackThreadUpgrade).toHaveBeenCalledWith("endpoint-1"); + expect(container.textContent).toContain("Connection changed; reload before upgrading"); + }); + + it("labels the threaded preview's remaining composer limitation", async () => { + const binding = await mockChatEndpointsApi.getIssueBinding(); + mockChatEndpointsApi.getIssueBinding.mockResolvedValue({ ...binding, bindingMode: "slack_dm_thread_v2" }); + await renderBanner(); + expect(container.textContent).toContain("normal Paperclip composer does not send messages to Slack yet"); + expect(container.textContent).not.toContain("Enable threaded DMs"); + }); + it("only reports success and clears the draft after confirmed publication", async () => { mockChatEndpointsApi.publishBoardMessage.mockResolvedValue({ id: "publication-1", diff --git a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx index 020fff966e7..0d853317455 100644 --- a/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx +++ b/ui/src/components/chat/ExternallyConnectedTaskBanner.tsx @@ -157,6 +157,16 @@ function ConnectedTaskComposer({ const { pushToast } = useToast(); const queryClient = useQueryClient(); const [composing, setComposing] = useState(false); + const upgradeThreads = useMutation({ + mutationFn: () => chatEndpointsApi.startSlackThreadUpgrade(binding.endpointId), + onSuccess: ({ authorizationUrl }) => { + const url = new URL(authorizationUrl); + if (url.origin !== "https://slack.com" || url.pathname !== "/oauth/v2/authorize" || url.username || url.password) { + throw new Error("Unexpected Slack consent destination"); + } + window.location.assign(url.href); + }, + }); const [body, setBody] = useState(""); const [selectedAttachmentIds, setSelectedAttachmentIds] = useState( [], @@ -523,7 +533,11 @@ function ConnectedTaskComposer({ - {binding.slackReplyAvailable && upgradeThreads.mutate()} />} + {binding.bindingMode === "slack_dm_thread_v2" &&

Thread preview: use Reply via Slack to share a request and the CEO’s answer in this thread. The normal Paperclip composer does not send messages to Slack yet.

} + {binding.slackReplyAvailable && } {composing && (
@@ -892,3 +906,15 @@ function ConnectedTaskComposer({ ); } + +export function SlackThreadUpgradeNotice({ pending = false, error = null, onUpgrade }: { + pending?: boolean; error?: string | null; onUpgrade: () => void; +}) { + return
+

Enable a separate task/thread for each new Slack DM, with an acknowledgement reaction and working status. Adds only the bot’s reactions permission. Existing conversations stay unchanged.

+ + {error &&

{error}

} +
; +} diff --git a/ui/src/components/chat/SlackReplyComposer.test.tsx b/ui/src/components/chat/SlackReplyComposer.test.tsx index 83fc18a3dcc..2e54e9384ec 100644 --- a/ui/src/components/chat/SlackReplyComposer.test.tsx +++ b/ui/src/components/chat/SlackReplyComposer.test.tsx @@ -21,10 +21,18 @@ describe("explicit Slack reply composer", () => { client.setQueryData(queryKeys.auth.session, { user: { id: "user" } }); }); afterEach(() => { flushSync(() => root.unmount()); container.remove(); client.clear(); localStorage.clear(); }); - async function render() { - await act(async () => { root.render(); }); + async function render(sharedThread = false) { + await act(async () => { root.render(); }); } const button = (text: string) => [...container.querySelectorAll("button")].find(b => b.textContent === text)!; + it("explains shared request delivery before execution in the new thread", async () => { + await render(true); + await act(async () => button("Reply via Slack").click()); + expect(container.textContent).toContain("Your request and the agent’s answer are shared"); + expect(container.textContent).toContain("starts after your request is delivered"); + expect(container.textContent).not.toContain("Only the agent’s answer"); + expect(chatEndpointsApi.requestSlackReply).not.toHaveBeenCalled(); + }); it("does not send anything on opening and explains the private default", async () => { await render(); await act(async () => button("Reply via Slack").click()); diff --git a/ui/src/components/chat/SlackReplyComposer.tsx b/ui/src/components/chat/SlackReplyComposer.tsx index f3cfb52fd14..0b380ca2e6e 100644 --- a/ui/src/components/chat/SlackReplyComposer.tsx +++ b/ui/src/components/chat/SlackReplyComposer.tsx @@ -7,8 +7,8 @@ import { Textarea } from "@/components/ui/textarea"; import { queryKeys } from "@/lib/queryKeys"; type Draft = { body: string; clientRequestId: string }; -export function SlackReplyComposer({ companyId, issueId, endpointId, conversationId, issueCacheRefs = [] }: { - companyId: string; issueId: string; endpointId: string; conversationId: string; issueCacheRefs?: string[]; +export function SlackReplyComposer({ companyId, issueId, endpointId, conversationId, issueCacheRefs = [], sharedThread = false }: { + companyId: string; issueId: string; endpointId: string; conversationId: string; issueCacheRefs?: string[]; sharedThread?: boolean; }) { const client = useQueryClient(); const session = useQuery({ queryKey: queryKeys.auth.session, queryFn: authApi.getSession }); @@ -54,8 +54,10 @@ export function SlackReplyComposer({ companyId, issueId, endpointId, conversatio return
{open &&
- -

Only the agent’s answer to this request is sent to Slack. Your request is saved in Paperclip. Ordinary task messages stay internal.

+ +

{sharedThread + ? "Your request and the agent’s answer are shared in the linked Slack thread. The agent starts after your request is delivered." + : "Only the agent’s answer to this request is sent to Slack. Your request is saved in Paperclip. Ordinary task messages stay internal."}