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