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

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

No files matched your search

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