From 0d0e138dadd006a7e5597f519e888f4c470da497 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:26:54 -0500 Subject: [PATCH 1/4] refactor(chat): extract channel status, receipts, drafts and authority workflows (#15995) ## Thinking Path > - Paperclip manages AI agents and their work. > - Chat channels connect that work to provider conversations. > - Provider workflows still fill the shared service. > - Their authority and recovery rules need clear homes. > - This PR lands five reviewed extractions into channel folders. > - Shared authorization, state and worker lifetime stay explicit. ## Linked Issues or Issue Description Refs #15976, #15996, #15999, #16001 and #16002. This root PR now contains all five extractions from batch three. The four upper PRs were merged into their parents. The combined tree matches reviewed stack head `789e918b5af49ec1958076ce4d22d31006205302` exactly. ## What Changed - Extract Slack session status and accepted-file receipt recovery. - Extract Telegram publication drafts and Stop handling. - Extract Photon events and native responses. - Extract Teams file-transfer authority. - Reduce `chat-channels.ts` from 29,897 to 26,461 lines (3,436 lines, 11.5%). - Preserve function bodies, runtime-map identity, leases and transaction boundaries. ## Verification - All 117 selected public-service cases passed before and after their respective extractions. - All 30 moved declarations and remaining service statements preserve normalized syntax across the five slices. - Server typecheck, module boundaries and whitespace checks passed. - Cumulative `pnpm -r typecheck` and `pnpm build` passed. Combined-head CI and review are pending on `5de1b0b99d71049a72440e8c131bf4bb30bdf3f3`. - Fixtures exercise assembled services and disposable databases, not live third-party providers. - Cumulative local `pnpm test:run` on `789e918b5af49ec1958076ce4d22d31006205302` exited 1: 32,414 passed, 125 skipped, one failure. `company-skills-routes.test.ts:1691` (`stars, forks, and comments on skills through company-scoped endpoints`) failed with `socket hang up`; that test and its route are unchanged by this stack. The same 58-case suite then passed in isolation without code changes. The original command did not reach 111 later serialized suites; its failure is retained, so the full-local-test checklist remains unchecked. ```sh pnpm exec vitest run server/src/__tests__/chat-channels.integration.test.ts -t 'Slack session|Slack turns to Idle|signed Slack session stop|denies guest Slack stops' ``` - Combined-head CI attempt 1: five runners received a shutdown signal at 19:13:08 UTC (server shard 10/12, serialized shards 2/9 and 7/9, E2E shards 3/8 and 7/8). These jobs stopped during execution without a failing test assertion. [Original workflow](https://github.com/paperclipai/paperclip/actions/runs/38293566028). The affected jobs will receive one unchanged retry after the other jobs finish. ## Risks - Changes cross durable provider recovery and authority boundaries. The moved bodies preserve syntax, and 117 focused public-service cases passed before and after their extractions. - Shared workers, timers, runtime maps and generic authorization remain in the service. - The full local test run failed in an unchanged company-skills route test; its result and the unrun suites are recorded above. ## Model Used OpenAI Codex, GPT-6 family, with reasoning and repository tools. The exact runtime model ID and context-window size are not exposed in this session. No separate agent was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- ...-10-12-chat-channel-transports-refactor.md | 124 + server/src/services/chat-channels.ts | 3472 +---------------- .../chat-channels/imessage-photon/events.ts | 903 +++++ .../file-transfer-authority.ts | 1041 +++++ .../chat-channels/slack/file-receipts.ts | 1000 +++++ .../chat-channels/slack/session-status.ts | 408 ++ .../telegram/publication-drafts.ts | 461 +++ 7 files changed, 3955 insertions(+), 3454 deletions(-) create mode 100644 doc/plans/2026-10-12-chat-channel-transports-refactor.md create mode 100644 server/src/services/chat-channels/imessage-photon/events.ts create mode 100644 server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts create mode 100644 server/src/services/chat-channels/slack/file-receipts.ts create mode 100644 server/src/services/chat-channels/slack/session-status.ts create mode 100644 server/src/services/chat-channels/telegram/publication-drafts.ts diff --git a/doc/plans/2026-10-12-chat-channel-transports-refactor.md b/doc/plans/2026-10-12-chat-channel-transports-refactor.md new file mode 100644 index 00000000000..fdef6082391 --- /dev/null +++ b/doc/plans/2026-10-12-chat-channel-transports-refactor.md @@ -0,0 +1,124 @@ +# Chat channel transport workflows, batch three + +Base: `a2ac5c27157ab033a2e0e2731b1b8e35bf20e7b9`. The preceding stack +(#15976, #15979, #15981, #15985, #15987) is merged. The original service is +29,897 lines / 1,170,080 bytes. This batch creates five stacked PRs; merging is +separate work. + +| Order / boundary | Destination | Approximate lines moved / net reduction | Inputs and outputs | State and effects | Verification | +| --- | --- | --- | --- | --- | --- | +| 1. Slack session status | `chat-channels/slack/session-status.ts` | 342 / 330 | Endpoint lookup, credential lease, runtime fence, publication run identity, credentials and fetch; return processed count | Claim and settle existing durable status actions. Keep selected revision, attempt ownership and live test barrier. No worker or new state. | Existing public-service status, restart, rate-limit, concurrent selection, stop and Idle cases before/after. | +| 2. Slack accepted-file receipts | `chat-channels/slack/file-receipts.ts` | 900 / 840 | Runtime/credential authorization, publication eligibility and endpoint lookup; record/recover accepted upload receipts | Preserve same-attempt leases, exact receipt identities, publication links and transaction boundaries. Keep generic publication transport shared. | Existing upload recovery, competing retry deadline, revoked reach and no-duplicate-upload cases before/after. | +| 3. Telegram publication drafts | `chat-channels/telegram/publication-drafts.ts` | 394 / 370 | Current runtime and callback fence, source publication authority; reserve draft or handle stopped generation | Preserve runtime map identity, stop scope, draft revision and transactional invalidation. Keep maintenance and shared publication admission in their existing owners. | Existing durable private draft Stop, stale generation and publication recovery cases before/after. | +| 4. Photon incoming events and responses | `chat-channels/imessage-photon/events.ts` | 820 / 780 | Runtime callbacks, principal authorization, question delivery and shared inbound admission | Preserve receiver sequence/lease checks, native interaction binding, transaction ordering and deferred publication. No receiver/startup or credential-inspection ownership move. | Existing Photon public-service integration suite before/after. | +| 5. Teams file-transfer authority | `chat-channels/microsoft-teams/file-transfer-authority.ts` | 940 / 900 | Source proof, runtime/credential fence, principal authorization; return current recipient and immutable file authority | Keep source, recipient, Board intent and byte identity proofs together. Preserve map lifetimes and no-wait locks. Transport, maintenance cooldowns and cleanup stay with the publication worker. | Existing Teams current file authority, consent, composed Board transfer and stale-runtime cases before/after. | + +Use named, typed contracts and channel folders. Keep shared message admission, +provider startup/shutdown, generic publication transport and shared authorization +in the service. Do not introduce a mutable context bag or a runtime import back +to the service. Reconsider a candidate if its actual dependency contract proves +too broad. + +For each slice, inspect its execution path and callers, run baseline tests, move +mechanically, compare normalized syntax, rerun the same public entry-point tests, +and check server types and module boundaries. Finish CI and current-head review +before opening the next PR. Run broad local gates once at the cumulative handoff. +Record failures and skipped groups accurately; a retry does not erase a failed +run. Fixtures exercise assembled services and persistence, not live providers. + +The starting service function is 27,835 lines. Its 212 nested functions span +27,146 lines; 51 wiring/state/API statements span 435 lines. Imports span 423 +lines. The largest shared function remains `processMessage` at 2,166 lines. +Remap these measurements after the fifth extraction and stop at five PRs. + +## Slice 1: Slack session status + +- Service: 29,897 lines / 1,170,080 bytes → 29,553 lines / 1,156,944 bytes. +- Destination: 408 lines / 15,730 bytes. The durable claim, provider status call + and conditional settlement remain in one function. No state or worker moves. +- All six selected public-service cases passed before and after. They exercise + restart/rate-limit recovery, stale selection, Stop and publication-driven Idle. + Server typecheck, module boundaries, whitespace and normalized syntax parity + passed. The selection hook remains live through an options getter. +- Next: accepted-file receipt recovery. Keep the generic outbound publication + link writer shared and pass it as a typed callback to the Slack module. + +## Slice 2: Slack accepted-file receipts + +- Service: 29,553 lines / 1,156,944 bytes → 28,620 lines / 1,122,992 bytes. +- Destination: 1,000 lines / 36,807 bytes. Thirteen declarations move together: + receipt validation, recording, authorization, recovery, settlement and due-work + selection. The generic outbound-link writer remains shared. +- All nine selected public-service cases passed before and after. They cover + accepted upload recovery without reposting, original-attempt ownership, + competing retry deadlines, revoked reach and selected file publication. + Server typecheck, module boundaries and syntax parity passed. No state or + worker is added. Credential lease identity and all transactions are unchanged. +- Next: Telegram private draft reservation and stopped-generation callbacks, + retaining shared runtime-map identity and source-publication authorization. + +## Slice 3: Telegram publication drafts + +- Service: 28,620 lines / 1,122,992 bytes → 28,219 lines / 1,108,711 bytes. +- Destination: 461 lines / 16,860 bytes. Both functions preserve their original + bodies. Runtime maps remain the same per-service objects; the source authority + callback and credential guard are unchanged. +- All 34 durable private-draft Stop cases passed before and after, including + restart, bot-global identity allocation, concurrent Stop, transaction failure, + uncertain final delivery and retained stale callbacks. Server typecheck, + boundaries, whitespace and normalized syntax parity passed. +- Reassessment: Photon event handling can move without moving its receiver or + setup inspection. For Teams, extract source/recipient/file authority as a + coherent transaction-level API; keep transport protocol and scheduler lifetime + in the service to avoid passing unrelated runtime/worker dependencies. + +## Slice 4: Photon events + +- Service: 28,219 lines / 1,108,711 bytes → 27,398 lines / 1,079,439 bytes. +- Destination: 903 lines / 33,080 bytes. Provider failure, incoming event and + native response handling preserve all three function bodies. The receiver, + lease ownership, checkpoint callbacks and setup inspection remain in the + service; question delivery uses the original service object. +- All 18 Photon channel integration cases passed before and after. They use the + assembled Chat SDK runtime, provider fixture streams and a disposable database + to cover poll/confirmation responses, separate participant drafts, stale + controls, receiver lease fencing, groups, echoes, restart and continuation. + Server typecheck, boundaries, whitespace and syntax parity passed. +- Next: Teams file-transfer authority, with no transport or timer ownership move. + +## Slice 5: Teams file-transfer authority + +- Service: 27,398 lines / 1,079,439 bytes → 26,461 lines / 1,046,711 bytes. +- Destination: 1,041 lines / 36,532 bytes. Eleven declarations move into a + transaction-level authority factory exposing Board intent staging and fresh + authority derivation. It takes no database instance and starts no work. +- The original per-service runtime maps and retry-proof callbacks are passed + after the retry factory initializes. No-wait row locks, identity advisory + locks, source ordering and byte/recipient proof hashes preserve their bodies. +- All 50 selected Teams public-service cases passed before and after, including + exact recipient admission, retained source continuation, composed Board file + transfers, stale runtime callbacks and consent/file receipt recovery. Server + typecheck, module boundaries, whitespace and normalized syntax parity passed. + +## Reassessment after batch three + +The service falls from 29,897 lines / 1,170,080 bytes to 26,461 lines / +1,046,711 bytes: 3,436 lines (11.5%) and 123,369 bytes removed. The five new +modules total 3,813 lines / 139,009 bytes. All 117 selected cases passed before +and after their respective extractions. + +The service function spans 24,484 lines. Its 191 nested functions span 23,829 +lines; 54 state/wiring/API statements span 425 lines. Imports span 412 lines. +The largest remaining functions are `processMessage` (2,166 lines), +`handleAction` (1,074), `configureWithCredentialLease` (868), +`processSelectedPublication` (729), `handleModalSubmit` (708), `handleWebhook` +(685), `processLifecycleDelivery` (657), `applyProviderLifecycleEffect` (593), +and `ingestAttachments` (574). + +Future candidates include receipt-reaction recovery, provider-effect dispatch, +attachment ingestion and inbound delivery persistence/drain ownership. Teams +transport protocol and scheduling can be reconsidered after their lifetime +contract is explicit; this batch deliberately moved only authority proofs. +Do not move a large shared admission function wholesale merely to reduce LOC. +Stop this batch at five PRs. Cumulative local gates and current-head CI/review +results are recorded in the final PR; per-slice PRs retain their own evidence. diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 0cfbfa5fdbf..31338f7acc1 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,10 +1,15 @@ +import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; +import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; +import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; +import { createSlackFileReceipts } from "./chat-channels/slack/file-receipts.js"; +import { createSlackSessionStatus } from "./chat-channels/slack/session-status.js"; import { createDiscordCommands } from "./chat-channels/discord/commands.js"; import { createSlackTaskStart, slackSlashTaskRecoveryPayload, slackTaskStarterThreadId } from "./chat-channels/slack/task-start.js"; import { createGitHubWebhookIngress, GITHUB_RECOVERY_STATE_KEY, type GitHubIngressStageResult } from "./chat-channels/github/webhook-ingress.js"; import { createSlackSessionStop } from "./chat-channels/slack/session-stop.js"; import { createTelegramMaintenance } from "./chat-channels/telegram/maintenance.js"; import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence, InboundRuntimeContext } from "./chat-channels/runtime-types.js"; -import { createChatFailedRunRetry, type ChatFailedRetrySource as FailedChatRetrySource } from "./chat-channels/failed-run-retry.js"; +import { createChatFailedRunRetry } from "./chat-channels/failed-run-retry.js"; import { createChatPublicationReplacement } from "./chat-channels/publication-replacement.js"; import { createChatIdentityLinks } from "./chat-channels/identity-links.js"; import { createChatResourceReconciliation } from "./chat-channels/resource-reconciliation.js"; @@ -107,16 +112,16 @@ import { runtimeCanonicalOrigin } from "./cloud-runtime-identity.js"; import { takePhotonCompanion } from "./photon/attachments.js"; import { writePhotonCheckpoint } from "./photon/receiver.js"; import { PhotonState } from "./photon/state.js"; -import { nativeSha256 } from "./native-runtime/canonical.js"; + import { HEIF_CONTENT_TYPES, photonHeifPreview, validatePhotonImage } from "./photon/media.js"; import { projectSafeChatPublicationText } from "./chat-publication-projection.js"; -import { PhotonAnswerValidationError, nativePhotonInteraction, publishPhotonPrompt, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "./photon/interactions.js"; -import { validateNativeQuestionResponseInput } from "./native-runtime/native-question-bridge.js"; -import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared"; -import { PhotonCloudClient, PhotonError, photonFailure, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; -import { PhotonChatAdapter, photonThreadId, photonReplyReference } from "./photon/adapter.js"; +import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "./photon/interactions.js"; + + +import { PhotonCloudClient, PhotonError, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; +import { PhotonChatAdapter, photonReplyReference } from "./photon/adapter.js"; import { photonChannelConfigurationSchema } from "@paperclipai/shared"; -import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage"; + import { defaultSlackAppConfiguration } from "@paperclipai/shared"; import { createHash, @@ -221,9 +226,7 @@ import { } from "../attachment-types.js"; import { isUniqueViolation } from "../db-errors.js"; import { - bindTeamsPersonalRecipient, deriveTeamsPersonalRecipient, - parseTeamsPersonalRecipient, } from "./chat-teams-personal-recipient.js"; import { teamsFileTransferService, @@ -253,15 +256,8 @@ import { } from "./chat-telegram-media-intake.js"; import { normalizeTelegramRichMessage } from "./chat-telegram-rich-intake.js"; import { - TELEGRAM_DRAFT_ACTION_KIND, - TELEGRAM_DRAFT_STOPPED_REASON, isTelegramDraftStopped, - parseTelegramDraftBinding, - telegramDraftTextSha256, - telegramGenerationStoppedReceipt, - telegramPrivateDraftDestination, type TelegramDraftControl, - type TelegramGenerationStoppedProof, } from "./chat-telegram-draft-stop.js"; @@ -354,11 +350,8 @@ import { } from "./chat-discord-question-forms.js"; import { parseSlackSessionStop, - setSlackSessionStatus, - slackSessionStatusForPublication, } from "./chat-slack-sessions.js"; import { - slackSessionSyncPayload, stageSlackSessionSync, } from "./chat-slack-session-outbox.js"; import { @@ -859,9 +852,6 @@ const REACTION_LINK_MAX_AGE_MS = 2 * 60_000; const DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS = 5 * 60_000; const DELIVERY_LEASE_TTL_MS = 90_000; const DELIVERY_DRAIN_LIMIT = 100; -const SLACK_FILE_RECEIPT_MAX_AGE_MS = 24 * 60 * 60_000; -const SLACK_FILE_RECEIPT_MAX_ATTEMPTS = 12; -const SLACK_FILE_RECEIPT_STALE_MS = 60_000; const ORPHAN_FOLLOW_UP_GRACE_MS = 5_000; const ORPHAN_FOLLOW_UP_MAX_ATTEMPTS = 12; const PUBLICATION_ENDPOINT_CONCURRENCY = 4; @@ -1082,81 +1072,6 @@ type ProviderEffectPayload = { credentialFingerprint: string; }; -type SlackFileUploadReceiptPayload = { - version: 1; - publicationId: string; - publicationAttempt: number; - threadId: string; - fileIds: string[]; - botExternalId: string; - runtimeGeneration: number; - credentialFingerprint: string; -}; - -function slackFileUploadReceiptPayload( - payload: Record, -): SlackFileUploadReceiptPayload | null { - if ( - payload.version !== 1 || - typeof payload.publicationId !== "string" || - !isUuidLike(payload.publicationId) || - typeof payload.publicationAttempt !== "number" || - !Number.isSafeInteger(payload.publicationAttempt) || - payload.publicationAttempt < 1 || - typeof payload.threadId !== "string" || - payload.threadId.length < 1 || - payload.threadId.length > 2_048 || - !payload.threadId.startsWith("slack:") || - typeof payload.botExternalId !== "string" || - payload.botExternalId.length < 1 || - payload.botExternalId.length > 255 || - typeof payload.runtimeGeneration !== "number" || - !Number.isSafeInteger(payload.runtimeGeneration) || - payload.runtimeGeneration < 0 || - typeof payload.credentialFingerprint !== "string" || - !/^[a-f0-9]{64}$/.test(payload.credentialFingerprint) || - !Array.isArray(payload.fileIds) || - payload.fileIds.length < 1 || - payload.fileIds.length > 20 || - payload.fileIds.some( - (fileId) => - typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId), - ) || - new Set(payload.fileIds).size !== payload.fileIds.length - ) { - return null; - } - return payload as SlackFileUploadReceiptPayload; -} - -function operatorConfirmedSlackFileReceipt( - result: Record | null, - payload: SlackFileUploadReceiptPayload, -): boolean { - return ( - result?.operatorConfirmedPublicationId === payload.publicationId && - result.operatorConfirmedAttempt === payload.publicationAttempt && - typeof result.operatorConfirmedAt === "string" && - Number.isFinite(Date.parse(result.operatorConfirmedAt)) - ); -} - -function sameSlackFileUploadReceipt( - left: SlackFileUploadReceiptPayload, - right: SlackFileUploadReceiptPayload, -): boolean { - return ( - left.publicationId === right.publicationId && - left.publicationAttempt === right.publicationAttempt && - left.threadId === right.threadId && - left.botExternalId === right.botExternalId && - left.runtimeGeneration === right.runtimeGeneration && - left.credentialFingerprint === right.credentialFingerprint && - left.fileIds.length === right.fileIds.length && - left.fileIds.every((fileId, index) => fileId === right.fileIds[index]) - ); -} - function isSlackReceiptReactionAlreadyApplied(error: unknown): boolean { const pending: Array<{ depth: number; value: unknown }> = [ { depth: 0, value: error }, @@ -5348,6 +5263,10 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } const withCredentialMutationLease = chatCredentialMutationLease(db, options); + const { handlePhotonFailure, handlePhotonEvent } = createPhotonEvents(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage }); + const { handleTelegramGenerationStopped, reserveTelegramPublicationDraft } = createTelegramPublicationDrafts(db, { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication }); + const { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt } = createSlackFileReceipts(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink }); + const { processPendingSlackSessionSyncs } = createSlackSessionStatus(db, { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runIdFromMilestonePublication, resolveCredentials, fetchImpl, redactError, get slackSessionSyncSelectionBarrier() { return options.slackSessionSyncSelectionBarrier; } }); const { reconcileDiscordCommands, handleDiscordNativeCommand } = createDiscordCommands(db, { endpointRecord, runtimeContextForRecord, runtimeGeneration, credentialFingerprint, resolveCredentialRefs, fetchImpl, withCredentialMutationLease, runtimeCallbackRecord, ensurePrincipal, runtimeCallbackEndpoint, nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization, getDiscordGatewayOwnership: (endpointId) => discordGatewayOwnerships.get(endpointId), discordGatewayRuntimeIsCurrent, stageAuthorizedTaskControlPublication, scheduleMessageProcessing, processPendingPublications }); const { latestSlackDmControlThreadId, finalizeSlackTaskStartFailure, admitConfirmedSlackTaskStart, processSlackTaskStart, processPendingSlackTaskStarts } = createSlackTaskStart(db, { endpointRecord, runtimeGeneration, credentialFingerprint, invalidateRuntime, runtimeFor, processMessage, runtimeContextForRecord, redactError, withCredentialMutationLease, sponsorAllowsGuest, nonDirectDestinationAllowed, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, get slackTaskAdmissionClaimBarrier() { return options.slackTaskAdmissionClaimBarrier; }, maxErrorText: MAX_ERROR_TEXT }); const { stageGitHubWebhookIngress, githubIngressAcceptedResponse, processGitHubWebhookIngress, processFailedGitHubWebhookDeliveries, processPendingGitHubWebhookIngress } = createGitHubWebhookIngress(db, { endpointRecord, runtimeContextForRecord, resolveCredentialRefs, runtimeGeneration, credentialFingerprint, getWebhookPublicBaseUrl, redactError, handleWebhook, runtimeCallbackEndpoint, fetchImpl, persistence, withCredentialMutationLease, isShuttingDown: () => shuttingDown, get githubWebhookReplayBarrier() { return options.githubWebhookReplayBarrier; }, supportedWebhookEvents: SUPPORTED_GITHUB_WEBHOOK_EVENTS }); @@ -6294,829 +6213,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return inspection; } - async function handlePhotonFailure( - endpointId: string, - error: unknown, - context: RuntimeContext, - ) { - if (error instanceof PhotonError && error.code === "attachment_not_ready") - return; - error = photonFailure(error); - const fatal = - error instanceof PhotonError && - [ - "credentials", - "line_unavailable", - "history_gap", - "invalid_response", - ].includes(error.code); - const message = - error instanceof PhotonError - ? error.message - : "Photon connection interrupted; reconnecting"; - await db.transaction(async (tx) => { - const endpoint = await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!endpoint) return; - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: message, - lastError: message, - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, endpointId)); - if (fatal) - await tx - .update(toolConnections) - .set({ - enabled: false, - healthStatus: "error", - healthMessage: message, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - }); - // Retirement is queued outside the receiver callback to avoid self-joining. - void invalidateRuntime(endpointId).catch(() => undefined); - } - - async function processPhotonResponse( - endpoint: EndpointRow, - event: PhotonEvent, - threadId: string, - adapter: PhotonChatAdapter, - context: RuntimeContext, - ): Promise { - const command = - event.type === "message.received" - ? photonResponseCommand(event.message.content.text ?? "") - : null; - const replyGuid = - event.type === "message.received" ? event.message.replyTargetGuid : null; - const prompt = - event.type === "poll.changed" - ? await adapter.state.read( - `poll-message:${event.pollMessageGuid}`, - ) - : replyGuid - ? ((await adapter.state.read( - `prompt-message:${replyGuid}`, - )) ?? - (await adapter.state.read( - `poll-message:${replyGuid}`, - ))) - : null; - const reference = command?.reference ?? prompt?.reference; - if (!reference) { - if ( - !event.isFromMe && - (replyGuid || - (event.type === "poll.changed" && - (event.delta.type === "voted" || event.delta.type === "unvoted"))) - ) { - // A vote can beat the local poll-binding write. Keep it behind the - // checkpoint while a publication in this chat is in flight. - const pending = await db - .select({ id: chatPublications.id }) - .from(chatPublications) - .innerJoin( - chatConversations, - eq(chatConversations.id, chatPublications.conversationId), - ) - .where( - and( - eq(chatPublications.endpointId, endpoint.id), - eq(chatConversations.externalThreadId, threadId), - inArray(chatPublications.state, [ - "pending", - "retry", - "streaming", - "delivery_unknown", - ]), - isNotNull(sql`${chatPublications.payload}->>'interactionId'`), - ), - ) - .limit(1); - if (pending.length) - throw new PhotonError( - "attachment_not_ready", - "Waiting for Photon poll binding", - ); - } - return ( - event.type === "poll.changed" || - (event.type === "message.received" && - /^\/(answer|submit)\b/i.test(event.message.content.text ?? "")) - ); - } - const action = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, endpoint.companyId), - eq(chatActions.endpointId, endpoint.id), - eq(chatActions.kind, "photon_interaction"), - eq(chatActions.providerActionId, `photon:${reference}`), - ), - ) - .then((rows) => rows[0]); - if (!action || action.status !== "issued" || !action.conversationId) - return true; - const binding = action.payload as unknown as PhotonInteractionBinding; - if ( - binding.version !== 1 || - binding.reference !== reference || - !Number.isFinite(Date.parse(binding.expiresAt)) || - Date.parse(binding.expiresAt) <= Date.now() - ) - return true; - const conversation = await db - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, action.conversationId), - ), - ) - .then((rows) => rows[0]); - if ( - !conversation || - !["active", "waiting"].includes(conversation.state) || - conversation.sessionGeneration !== binding.sessionGeneration || - conversation.externalThreadId !== threadId - ) - return true; - const publication = await db - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.id, binding.publicationId), - eq(chatPublications.conversationId, conversation.id), - ), - ) - .then((rows) => rows[0]); - if ( - !publication || - publication.payload.interactionId !== binding.interactionId || - new Date(event.occurredAt).getTime() < action.createdAt.getTime() - ) - return true; - if ( - ["pending", "retry", "streaming", "delivery_unknown"].includes( - publication.state, - ) - ) - throw new PhotonError( - "attachment_not_ready", - "Waiting for Photon prompt publication", - ); - if (publication.state !== "published") return true; - const interaction = ( - await issueThreadInteractionService(db).listForIssue(conversation.issueId) - ).find((candidate) => candidate.id === binding.interactionId); - if ( - !interaction || - interaction.status !== "pending" || - interaction.companyId !== endpoint.companyId || - interaction.createdByAgentId !== endpoint.assignedAgentId || - !nativePhotonInteraction(interaction) - ) - return true; - const actor = - event.type === "message.received" ? event.message.sender : event.actor; - if (event.isFromMe || !actor?.address || actor.service !== "iMessage") - return true; - const chat = await adapter.chatInfo(threadId); - if ( - chat.isArchived || - !chat.participants.some( - (person) => - person.address === actor.address && person.service === actor.service, - ) - ) - return true; - const principal = await ensurePrincipal( - endpoint, - { - userId: `${actor.service}:${actor.address}`, - userName: actor.address, - fullName: actor.address, - isMe: false, - isBot: false, - }, - event, - ); - if ( - !principal.userId || - principal.linkedDenied || - principal.principal.kind !== "user" || - principal.principal.isBot - ) - return true; - const assertCurrent = async () => - db.transaction((tx) => - requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }), - ); - try { - await assertCurrent(); - } catch (error) { - if (isExternalActionAuthorizationChange(error)) return true; - throw error; - } - const draftKey = `draft:${reference}:${principal.principal.id}:${principal.userId}`; - let draft = (await adapter.state.read(draftKey)) ?? { - schema: 1, - interactionId: interaction.id, - principalId: principal.principal.id, - userId: principal.userId, - answers: [], - lastSequence: 0, - }; - if (draft.lastSequence > event.sequence) return true; - const questionIndex = command?.questionIndex ?? prompt?.questionIndex ?? 0; - let answerValue = - command?.value ?? - (event.type === "message.received" - ? (event.message.content.text ?? "") - : ""); - let pollChoice: string | undefined; - if (event.type === "poll.changed") { - if (!prompt || event.pollMessageGuid !== prompt.pollMessageGuid) - return true; - if (event.delta.type !== "voted" && event.delta.type !== "unvoted") - return true; - pollChoice = prompt.options[event.delta.optionIdentifier]; - if (!pollChoice) return true; - if (event.delta.type === "unvoted") { - const questionId = - interaction.kind === "ask_user_questions" - ? interaction.payload.questions[questionIndex]?.id - : null; - await adapter.state.update(draftKey, () => ({ - ...draft, - answers: draft.answers.filter( - (answer) => - answer.questionId !== questionId || - !answer.optionIds.includes(pollChoice!), - ), - decision: draft.decision === pollChoice ? undefined : draft.decision, - lastSequence: event.sequence, - })); - return true; - } - } - const enqueueResponse = async (key: string, text: string) => { - await assertCurrent(); - await db.transaction(async (tx) => { - await tx - .insert(chatActions) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - kind: "photon_response_notice", - providerActionId: `photon-notice:${key}`, - payload: { - reference, - questionIndex, - publicationId: binding.publicationId, - }, - status: "processed", - }) - .onConflictDoNothing(); - await notifyChatPublicationWork(tx); - await tx - .insert(chatPublications) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - issueId: conversation.issueId, - idempotencyKey: key, - payload: { - ...publication.payload, - card: undefined, - text: projectSafeChatPublicationText(text), - }, - state: "pending", - }) - .onConflictDoNothing(); - }); - scheduleMessageProcessing(() => - processPendingPublications().then(() => undefined), - ); - }; - const notice = (text: string) => - enqueueResponse(`photon-response:${reference}:${event.sequence}`, text); - try { - if (interaction.kind === "ask_user_questions") { - if (command?.command !== "submit") { - let answer: AskUserQuestionsAnswer; - if (pollChoice) { - const question = interaction.payload.questions[questionIndex]; - if ( - !question || - !question.options.some((option) => option.id === pollChoice) - ) - return true; - answer = { questionId: question.id, optionIds: [pollChoice] }; - } else - answer = parsePhotonQuestionAnswer( - interaction, - questionIndex, - answerValue, - ); - draft = { - ...draft, - answers: [ - ...draft.answers.filter( - (entry) => entry.questionId !== answer.questionId, - ), - answer, - ], - lastSequence: event.sequence, - }; - } - const answers = interaction.payload.questions.flatMap((question) => - draft.answers.filter((answer) => answer.questionId === question.id), - ); - if ( - interaction.payload.questions.length > 1 && - command?.command !== "submit" - ) { - await adapter.state.update(draftKey, () => draft); - const next = interaction.payload.questions.findIndex( - (question) => - !answers.some((answer) => answer.questionId === question.id), - ); - if (next >= 0) - await enqueueResponse( - `photon-question:${reference}:${next}`, - "Input needed", - ); - else - await notice( - `Your answers are saved. Send /submit ${reference} to submit them.`, - ); - return true; - } - await validateNativeQuestionResponseInput(interaction, { answers }); - // Canonical service rechecks required fields, options, audience, and - // pending -> answered atomically with the response-delivery receipt. - const issue = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0]); - if (!issue) return true; - const answered = await issueThreadInteractionService( - db, - ).answerQuestions( - issue, - interaction.id, - { answers }, - { userId: principal.userId }, - { - beforeResolveInTransaction: async (tx) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }); - const current = await tx - .select() - .from(chatConversations) - .where(eq(chatConversations.id, conversation.id)) - .then((rows) => rows[0]); - if ( - current?.sessionGeneration !== binding.sessionGeneration || - Date.parse(binding.expiresAt) <= Date.now() - ) - throw forbidden( - "Photon prompt expired or its task generation changed", - ); - }, - afterResolveInTransaction: async (tx, resolved) => { - const claimed = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processed", - result: { - code: "photon_question_answered", - interactionId: resolved.id, - interactionStatus: resolved.status, - answersSha256: nativeSha256( - (resolved as AskUserQuestionsInteraction).result?.answers, - ), - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!claimed.length) - throw forbidden("Photon prompt has already been resolved"); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: principal.userId!, - action: "issue.thread_interaction_answered", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - provider: endpoint.provider, - endpointId: endpoint.id, - conversationId: conversation.id, - interactionId: resolved.id, - }, - }); - }, - }, - ); - scheduleMessageProcessing(async () => { - await questionResponses.deliver(answered.id); - await processPendingPublications(); - }); - } else { - if (command?.command === "submit") return true; - const matched = /^(accept|reject|1|2)(?:\s+([\s\S]+))?$/i.exec( - answerValue.trim(), - ); - const decision = - pollChoice === "accept" || pollChoice === "reject" - ? pollChoice - : matched - ? /^(accept|1)$/i.test(matched[1]) - ? "accept" - : "reject" - : draft.decision; - if (!decision) - throw new PhotonAnswerValidationError( - `Send /answer ${reference} Accept or /answer ${reference} Reject .`, - ); - const reason = - matched?.[2]?.trim() ?? - (draft.decision === "reject" && !pollChoice - ? answerValue.trim() - : ""); - if ( - decision === "reject" && - interaction.payload.rejectRequiresReason && - !reason - ) { - await adapter.state.update(draftKey, () => ({ - ...draft, - decision, - lastSequence: event.sequence, - })); - await notice( - `Give a rejection reason with /answer ${reference} Reject .`, - ); - return true; - } - const issue = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .then((rows) => rows[0]); - if (!issue) return true; - const mutation = { - beforeResolveInTransaction: async (tx: DbTransaction) => { - await requireCurrentExternalActionAuthorization(tx, { - conversationId: conversation.id, - endpointId: endpoint.id, - expectedUserId: principal.userId!, - principalId: principal.principal.id, - runtimeContext: context, - }); - const current = await tx - .select() - .from(chatConversations) - .where(eq(chatConversations.id, conversation.id)) - .then((rows) => rows[0]); - if ( - current?.sessionGeneration !== binding.sessionGeneration || - Date.parse(binding.expiresAt) <= Date.now() - ) - throw forbidden( - "Photon prompt expired or its task generation changed", - ); - }, - afterResolveInTransaction: async ( - tx: DbTransaction, - resolved: IssueThreadInteraction, - ) => { - const claimed = await tx - .update(chatActions) - .set({ - principalId: principal.principal.id, - status: "processed", - result: { - code: "photon_confirmation_resolved", - interactionId: resolved.id, - interactionStatus: resolved.status, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.status, "issued"), - ), - ) - .returning({ id: chatActions.id }); - if (!claimed.length) - throw forbidden("Photon prompt has already been resolved"); - await logActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: principal.userId!, - action: - decision === "accept" - ? "issue.thread_interaction_accepted" - : "issue.thread_interaction_rejected", - entityType: "issue", - entityId: issue.id, - details: { - source: "external_chat", - provider: endpoint.provider, - endpointId: endpoint.id, - conversationId: conversation.id, - interactionId: resolved.id, - }, - }); - }, - }; - if (decision === "accept") - await issueThreadInteractionService(db).acceptInteraction( - issue, - interaction.id, - {}, - { userId: principal.userId }, - mutation, - ); - else - await issueThreadInteractionService(db).rejectInteraction( - issue, - interaction.id, - { reason }, - { userId: principal.userId }, - mutation, - ); - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - } - } catch (error) { - if (error instanceof PhotonError) throw error; - if (isExternalActionAuthorizationChange(error)) return true; - if (error instanceof HttpError && [403, 404, 409].includes(error.status)) - return true; - // Only validator errors become correction copy. Infrastructure errors - // leave the checkpoint unchanged and retry through durable recovery. - if ( - (error instanceof HttpError && error.status === 422) || - error instanceof PhotonAnswerValidationError - ) { - const missingIndex = interaction.kind === "ask_user_questions" && command?.command === "submit" - ? interaction.payload.questions.findIndex((question) => - question.required !== false && !draft.answers.some((answer) => answer.questionId === question.id)) - : -1; - await notice( - `${redactError(error)} Send /answer ${reference}.${(missingIndex >= 0 ? missingIndex : questionIndex) + 1} to correct it.`, - ); - return true; - } - throw error; - } - return true; - } - - async function handlePhotonEvent( - endpointId: string, - event: PhotonEvent, - context: RuntimeContext, - ) { - const record = await runtimeCallbackRecord(endpointId, context, [ - "verifying", - "active", - "attention", - ]); - if (!record) - throw new Error("Photon receiver no longer owns this endpoint"); - const adapter = context.endpointRuntime!.getProviderAdapter(); - if (!(adapter instanceof PhotonChatAdapter)) - throw new Error("Photon adapter unavailable"); - if (adapter.authentication.identity.allocation === "shared" && event.type === "group.changed") return; - if ( - event.type === "group.changed" && - (event.change.type === "participantRemoved" || - event.change.type === "participantLeft") && - event.change.participant.address === record.endpoint.botExternalId - ) { - const removedThreadId = photonThreadId({ - lineId: adapter.authentication.identity.lineId, - chatGuid: event.chatGuid, - isGroup: true, - }); - await db.transaction(async (tx) => { - if ( - !(await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - ])) - ) - throw new Error("Photon receiver is no longer current"); - if ( - ( - await renewDiscordGatewayOwnershipForMessageAdmission( - tx, - endpointId, - context, - ) - ).kind !== "owned" - ) - throw new Error("Photon receiver lease was replaced"); - // Removal can be the first retained event after activation. Preserve a - // tombstone even when the group has never been discovered locally. - await tx - .insert(chatEndpointResources) - .values({ - companyId: record.endpoint.companyId, - endpointId, - providerResourceId: removedThreadId, - type: "group_chat", - label: "Unavailable iMessage group", - enabled: false, - availability: "unavailable", - metadata: { photonRemoved: true }, - }) - .onConflictDoUpdate({ - target: [ - chatEndpointResources.endpointId, - chatEndpointResources.type, - chatEndpointResources.providerResourceId, - ], - set: { - availability: "unavailable", - metadata: sql`jsonb_set(${chatEndpointResources.metadata}, '{photonRemoved}', 'true'::jsonb)`, - updatedAt: new Date(), - }, - }); - }); - await adapter.endTyping(removedThreadId); - return; - } - const groupIdentity = photonThreadId({ lineId: adapter.authentication.identity.lineId, chatGuid: event.chatGuid, isGroup: true }); - const removedGroup = await db.select({ metadata: chatEndpointResources.metadata }).from(chatEndpointResources).where(and(eq(chatEndpointResources.companyId, record.endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), eq(chatEndpointResources.providerResourceId, groupIdentity))).limit(1).then((rows) => rows[0]); - const readded = event.type === "group.changed" && event.change.type === "participantAdded" && event.change.participant.address === record.endpoint.botExternalId; - // Late events from a removed chat must still advance recovery without a - // now-forbidden chat lookup holding every other conversation behind them. - if (removedGroup?.metadata.photonRemoved === true && !readded) return; - const chat = await adapter.client.chats - .get(event.chatGuid) - .catch((error) => { - throw photonFailure(error); - }); - if (chat.guid !== event.chatGuid || chat.service !== "iMessage" || (chat.isGroup && adapter.authentication.identity.allocation === "shared")) return; - const threadId = photonThreadId({ - lineId: adapter.authentication.identity.lineId, - chatGuid: chat.guid, - isGroup: chat.isGroup, - }); - const removed = - event.type === "group.changed" && - (event.change.type === "participantRemoved" || - event.change.type === "participantLeft") && - event.change.participant.address === record.endpoint.botExternalId; - const available = await db.transaction(async (tx) => { - if ( - !(await runtimeCallbackEndpoint(tx, endpointId, context, [ - "verifying", - "active", - ])) - ) - throw new Error("Photon receiver is no longer current"); - const ownership = await renewDiscordGatewayOwnershipForMessageAdmission( - tx, - endpointId, - context, - ); - if (ownership.kind !== "owned") - throw new Error("Photon receiver lease was replaced"); - const existing = await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, endpointId), - eq(chatEndpointResources.providerResourceId, threadId), - eq( - chatEndpointResources.type, - chat.isGroup ? "group_chat" : "direct_message", - ), - ), - ) - .then((rows) => rows[0]); - const added = - event.type === "group.changed" && - event.change.type === "participantAdded" && - event.change.participant.address === record.endpoint.botExternalId; - const stillRemoved = - removed || (existing?.metadata.photonRemoved === true && !added); - const availability = - stillRemoved || chat.isArchived ? "unavailable" : "available"; - const metadata = { - photonRemoved: stillRemoved, - participants: chat.participants - .slice(0, 256) - .map((p) => ({ address: p.address, service: p.service })), - }; - const label = ( - chat.displayName || chat.participants.map((p) => p.address).join(", ") - ).slice(0, 512); - await tx - .insert(chatEndpointResources) - .values({ - companyId: record.endpoint.companyId, - endpointId, - providerResourceId: threadId, - type: chat.isGroup ? "group_chat" : "direct_message", - label, - enabled: !chat.isGroup, - availability, - metadata, - }) - .onConflictDoUpdate({ - target: [ - chatEndpointResources.endpointId, - chatEndpointResources.type, - chatEndpointResources.providerResourceId, - ], - set: { label, availability, metadata, updatedAt: new Date() }, - }); - return availability === "available"; - }); - if (!available) return; - if ( - !event.isFromMe && - (await processPhotonResponse( - record.endpoint, - event, - threadId, - adapter, - context, - )) - ) - return; - if (event.type === "message.received" && !event.isFromMe) { - await handleSdkMessage( - { - provider: "imessage-photon", - endpointId, - thread: context.endpointRuntime!.thread(threadId), - message: adapter.normalize(event.message, chat), - trigger: chat.isGroup ? "unaddressed_message" : "direct_message", - }, - context, - ); - } - } - async function configure( endpointId: string, input: ConfigureChatEndpointInput, @@ -9956,6 +9052,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { wakeup: (...args) => options.heartbeat.wakeup(...args), staleProcessingMs: DELIVERY_PROCESSING_STALE_MS, }); + const { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority } = createTeamsFileAuthority({ runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds }); async function authorizeRetainedChatSourcePublication( tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect, @@ -23216,943 +22313,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - // Native Teams file consent is authority for one personal recipient and one - // immutable file, not authority inferred from the SDK's latest route cache. - const teamsBoardFileIntentKind = "teams_board_file_intent"; - const teamsFileProofHash = (value: unknown) => - createHash("sha256") - .update( - JSON.stringify(value, (_key, entry) => - entry && typeof entry === "object" && !Array.isArray(entry) - ? Object.fromEntries( - Object.keys(entry) - .sort() - .map((key) => [key, entry[key]]), - ) - : entry, - ), - ) - .digest("hex"); - - async function teamsFileCurrentScope( - tx: DbTransaction, - supplied: typeof chatPublications.$inferSelect, - fence?: LifecycleRuntimeFence, - ) { - const [endpoint] = await tx - .select() - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.companyId, supplied.companyId), - eq(chatEndpoints.id, supplied.endpointId), - ), - ) - .for("no key update", { noWait: true }); - if ( - !endpoint || - endpoint.provider !== "microsoft-teams" || - endpoint.status !== "active" || - !endpoint.allowDirectMessages || - !isUuidLike(endpoint.providerAccountId) || - !isUuidLike(endpoint.botExternalId) - ) - return null; - const [connection] = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share", { noWait: true }); - if (!connection?.enabled || connection.status !== "active") return null; - const currentFence = { - generation: runtimeGeneration(endpoint.setup), - credentialFingerprint: credentialFingerprint( - connection.credentialSecretRefs, - ), - }; - const instance = runtime.get(endpoint.id); - const registration = instance - ? runtimeContexts.get(instance as object) - : null; - if ( - !instance || - !registration || - registration.generation !== currentFence.generation || - registration.credentialFingerprint !== - currentFence.credentialFingerprint || - registration.localEpoch !== localRuntimeEpoch(endpoint.id) || - runtimeVersions.get(endpoint.id) !== registration.version || - (fence && - (fence.generation !== currentFence.generation || - fence.credentialFingerprint !== currentFence.credentialFingerprint)) - ) - return null; - const [conversation] = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, supplied.conversationId), - eq(chatConversations.issueId, supplied.issueId), - ), - ) - .for("update", { noWait: true }); - if ( - !conversation?.isDirectMessage || - !["active", "waiting"].includes(conversation.state) - ) - return null; - const [superseded] = await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.externalThreadId, conversation.externalThreadId), - gt( - chatConversations.sessionGeneration, - conversation.sessionGeneration, - ), - ), - ) - .limit(1); - if (superseded) return null; - const [issue] = await tx - .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId }) - .from(issues) - .where( - and( - eq(issues.companyId, endpoint.companyId), - eq(issues.id, conversation.issueId), - ), - ) - .for("share", { noWait: true }); - if (!issue || issue.assigneeAgentId !== endpoint.assignedAgentId) - return null; - // The conversation lock serializes rollover. The endpoint lock serializes - // reach/credential changes; no lock here waits behind inverse Board order. - if (conversation.resourceId) - await tx - .select({ id: chatEndpointResources.id }) - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - eq(chatEndpointResources.id, conversation.resourceId), - ), - ) - .for("update", { noWait: true }); - const [publication] = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.issueId, conversation.issueId), - eq(chatPublications.id, supplied.id), - ), - ) - .for("update", { noWait: true }); - const ids = publication?.payload.attachmentIds; - if ( - !publication?.commentId || - !Array.isArray(ids) || - ids.length !== 1 || - !isUuidLike(ids[0]) || - publication.payload.progressState !== undefined || - publication.payload.interactionId || - publication.payload.card || - publication.idempotencyKey.startsWith("control:") || - publication.idempotencyKey.startsWith("wake:") - ) - return null; - const [comment] = await tx - .select() - .from(issueComments) - .where( - and( - eq(issueComments.companyId, endpoint.companyId), - eq(issueComments.issueId, conversation.issueId), - eq(issueComments.id, publication.commentId), - isNull(issueComments.deletedAt), - ), - ) - .for("share", { noWait: true }); - const [file] = await tx - .select({ attachment: issueAttachments, asset: assets }) - .from(issueAttachments) - .innerJoin( - assets, - and( - eq(assets.id, issueAttachments.assetId), - eq(assets.companyId, endpoint.companyId), - ), - ) - .where( - and( - eq(issueAttachments.companyId, endpoint.companyId), - eq(issueAttachments.issueId, conversation.issueId), - eq(issueAttachments.issueCommentId, publication.commentId), - eq(issueAttachments.id, ids[0]), - ), - ) - .for("share", { noWait: true }); - const filename = - file?.asset.originalFilename ?? - (file ? `attachment-${file.attachment.id}` : ""); - if ( - !comment || - comment.updatedAt.getTime() !== comment.createdAt.getTime() || - !file || - !Number.isSafeInteger(file.asset.byteSize) || - file.asset.byteSize <= 0 || - file.asset.byteSize > MAX_ATTACHMENT_BYTES || - file.asset.byteSize >= 60 * 1024 * 1024 || - !/^[a-f0-9]{64}$/.test(file.asset.sha256) || - !file.asset.objectKey.trim() || - !isAllowedContentType(file.asset.contentType) || - !filename || - filename.length > 255 || - filename !== filename.trim() || - filename.endsWith(".") || - /[<>:"/\\|?*\x00-\x1f\x7f]/.test(filename) - ) - return null; - const byteIdentity = { - attachmentId: file.attachment.id, - assetId: file.asset.id, - sha256: file.asset.sha256, - byteSize: file.asset.byteSize, - filename, - contentType: file.asset.contentType, - originatingRunId: file.attachment.originatingRunId, - }; - return { - endpoint, - conversation, - publication, - comment, - file, - filename, - currentFence, - byteIdentity, - instance, - }; - } - - async function teamsFilePrincipalAuthorization( - tx: DbTransaction, - endpoint: EndpointRow, - principalId: string, - ) { - const identity = await tx.execute( - sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0)) as locked`, - ); - if (!identity[0]?.locked) - throw new NativeChatReviewPresentationContentionError(); - const [link] = await tx - .select() - .from(chatIdentityLinks) - .where( - and( - eq(chatIdentityLinks.companyId, endpoint.companyId), - eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.principalId, principalId), - ), - ) - .for("update", { noWait: true }); - const userId = - link?.status === "linked" ? link.paperclipUserId : endpoint.sponsorUserId; - if (userId) - await tx - .select() - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, endpoint.companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, userId), - ), - ) - .for("update", { noWait: true }); - // All rows and the identity advisory key used by the common authorization - // helper are already held; the helper cannot introduce an inverse wait. - return lockCurrentPrincipalAuthorization(tx, endpoint, principalId); - } - - async function teamsFileSourceBinding( - tx: DbTransaction, - scope: NonNullable>>, - actionId: string, - ) { - const { endpoint, conversation } = scope; - const [action] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, endpoint.companyId), - eq(chatActions.endpointId, endpoint.id), - eq(chatActions.conversationId, conversation.id), - eq(chatActions.id, actionId), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - ), - ) - .for("update", { noWait: true }); - if ( - !action?.deliveryId || - !action.principalId || - action.payload.version !== 1 || - action.payload.issueId !== conversation.issueId || - action.payload.agentId !== endpoint.assignedAgentId || - action.payload.sessionGeneration !== conversation.sessionGeneration || - typeof action.payload.commentId !== "string" || - !isUuidLike(action.payload.commentId) - ) - return null; - const [delivery] = await tx - .select() - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.conversationId, conversation.id), - eq(chatDeliveries.id, action.deliveryId), - eq(chatDeliveries.principalId, action.principalId), - eq(chatDeliveries.state, "processed"), - inArray(chatDeliveries.eventKind, [ - "direct_message", - "mention", - "message", - ]), - ), - ) - .for("update", { noWait: true }); - const originalFence = delivery ? lifecycleRuntimeFence(delivery) : null; - const [principal] = await tx - .select() - .from(chatExternalPrincipals) - .where( - and( - eq(chatExternalPrincipals.companyId, endpoint.companyId), - eq(chatExternalPrincipals.id, action.principalId), - eq(chatExternalPrincipals.provider, "microsoft-teams"), - eq( - chatExternalPrincipals.providerAccountId, - endpoint.providerAccountId!, - ), - eq(chatExternalPrincipals.kind, "user"), - eq(chatExternalPrincipals.isBot, false), - ), - ) - .for("share", { noWait: true }); - if (!delivery || !originalFence || !principal) return null; - const admission = { - companyId: endpoint.companyId, - endpointId: endpoint.id, - runtimeGeneration: originalFence.generation, - credentialFingerprint: originalFence.credentialFingerprint, - tenantId: endpoint.providerAccountId!, - botAppId: endpoint.botExternalId!, - providerEventId: delivery.providerEventId, - threadId: conversation.externalThreadId, - isDirectMessage: true, - }; - const parsed = parseTeamsPersonalRecipient( - delivery.normalizedEvent.teamsPersonalRecipient, - admission, - ); - const binding = parsed - ? bindTeamsPersonalRecipient(parsed, { - admission, - deliveryId: delivery.id, - principalId: principal.id, - conversationId: conversation.id, - conversationGeneration: conversation.sessionGeneration, - externalPrincipalId: principal.externalId, - }) - : null; - const originalConversation = delivery.normalizedEvent.conversation as - | Record - | undefined; - const originalMessage = delivery.normalizedEvent.message as - | Record - | undefined; - const originalPrincipal = delivery.normalizedEvent.principal as - | Record - | undefined; - if ( - !binding || - originalConversation?.isDirectMessage !== true || - typeof originalConversation.externalThreadId !== "string" || - canonicalTeamsThreadId(originalConversation.externalThreadId) !== - canonicalTeamsThreadId(conversation.externalThreadId) || - originalMessage?.providerMessageId !== - binding.recipient.providerActivityId || - originalPrincipal?.externalId !== principal.externalId - ) - return null; - const [source] = await tx - .select({ comment: issueComments, link: chatMessageLinks }) - .from(chatMessageLinks) - .innerJoin( - issueComments, - and( - eq(issueComments.id, chatMessageLinks.commentId), - eq(issueComments.companyId, endpoint.companyId), - eq(issueComments.issueId, conversation.issueId), - ), - ) - .where( - and( - eq(chatMessageLinks.companyId, endpoint.companyId), - eq(chatMessageLinks.endpointId, endpoint.id), - eq(chatMessageLinks.conversationId, conversation.id), - eq(chatMessageLinks.deliveryId, delivery.id), - eq(chatMessageLinks.commentId, action.payload.commentId), - eq(chatMessageLinks.direction, "inbound"), - ), - ) - .for("share", { noWait: true }); - const expectedUserId = - action.payload.requestedByActorType === "user" - ? action.payload.requestedByActorId - : null; - if ( - !source || - source.link.providerMessageId !== binding.recipient.providerActivityId || - source.comment.deletedAt || - source.comment.createdAt > scope.comment.createdAt || - source.comment.updatedAt.getTime() !== - source.comment.createdAt.getTime() || - source.comment.authorUserId !== expectedUserId || - !["user", "system"].includes( - String(action.payload.requestedByActorType), - ) || - (expectedUserId === null && - action.payload.requestedByActorId !== principal.id) - ) - return null; - const [lifecycle] = await tx - .select({ id: chatDeliveries.id }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - or( - isNull(chatDeliveries.conversationId), - eq(chatDeliveries.conversationId, conversation.id), - ), - ne(chatDeliveries.state, "filtered"), - inArray(chatDeliveries.eventKind, [ - "message_updated", - "message_deleted", - "message_restored", - ]), - sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(originalFence)}::jsonb`, - sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`, - ), - ) - .limit(1); - if (lifecycle) return null; - const authorization = await teamsFilePrincipalAuthorization( - tx, - endpoint, - principal.id, - ); - if (!authorization.allowed || authorization.userId !== expectedUserId) - return null; - const [receipt] = await tx - .select() - .from(agentWakeupRequests) - .where( - and( - eq(agentWakeupRequests.companyId, endpoint.companyId), - eq(agentWakeupRequests.id, action.id), - eq(agentWakeupRequests.agentId, endpoint.assignedAgentId), - ), - ) - .for("share", { noWait: true }); - if (!receipt || ["skipped", "cancelled"].includes(receipt.status)) - return null; - try { - assertFailedRetryReceipt( - createDurableChatWakeupRequest({ - id: action.id, - companyId: endpoint.companyId, - agentId: endpoint.assignedAgentId, - issueId: conversation.issueId, - commentId: source.comment.id, - requestedByActorType: action.payload.requestedByActorType as - | "user" - | "system", - requestedByActorId: String(action.payload.requestedByActorId), - requestedAt: action.createdAt, - authorize: async () => {}, - }), - receipt, - ); - } catch (error) { - if ( - error instanceof HttpError && - (error.details as { code?: string } | undefined)?.code === - "chat_failed_run_retry_not_authorized" - ) - return null; - throw error; - } - return { - binding, - authorization, - action, - sourceFacts: { - actionId: action.id, - deliveryId: delivery.id, - commentId: source.comment.id, - principalId: principal.id, - scope: inboundWakeScope(action), - originalFence, - bodySha256: createHash("sha256") - .update(source.comment.body) - .digest("hex"), - receiptId: receipt.id, - recipientBindingSha256: teamsFileProofHash(binding), - }, - }; - } - - async function teamsBoardOriginBinding( - tx: DbTransaction, - scope: NonNullable>>, - ) { - // Pick the task's first admitted source, never the latest DM actor. If the - // durable task contains different recipient principals, consent is ambiguous. - const principals = await tx - .selectDistinct({ id: chatActions.principalId }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, - ), - ) - .limit(2); - if (principals.length !== 1 || !principals[0]?.id) return null; - const [origin] = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "processed"), - sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, - ), - ) - .orderBy(asc(chatActions.createdAt), asc(chatActions.id)) - .limit(1); - return origin ? teamsFileSourceBinding(tx, scope, origin.id) : null; - } - - async function teamsBoardAuthorIsCurrent( - tx: DbTransaction, - companyId: string, - userId: string, - ) { - const [membership] = await tx - .select() - .from(companyMemberships) - .where( - and( - eq(companyMemberships.companyId, companyId), - eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, userId), - ), - ) - .for("share", { noWait: true }); - return ( - membership?.status === "active" && membership.membershipRole !== "viewer" - ); - } - - function teamsBoardFileIntentPayload( - scope: NonNullable>>, - source: NonNullable>>, - userId: string, - ) { - return { - schema: "paperclip.teams.board-file-intent.v1", - publicationId: scope.publication.id, - issueId: scope.conversation.issueId, - commentId: scope.comment.id, - attachmentId: scope.file.attachment.id, - userId, - conversationGeneration: scope.conversation.sessionGeneration, - sourceActionId: source.action.id, - sourceScopeSha256: teamsFileProofHash(source.sourceFacts), - recipientBindingSha256: teamsFileProofHash(source.binding), - byteIdentitySha256: teamsFileProofHash(scope.byteIdentity), - commentBodySha256: createHash("sha256") - .update(scope.comment.body) - .digest("hex"), - }; - } - - function teamsFileRuntimeStillCurrent( - scope: NonNullable>>, - ) { - const registration = runtimeContexts.get(scope.instance as object); - return ( - runtime.get(scope.endpoint.id) === scope.instance && - registration?.generation === scope.currentFence.generation && - registration.credentialFingerprint === - scope.currentFence.credentialFingerprint && - registration.localEpoch === localRuntimeEpoch(scope.endpoint.id) && - runtimeVersions.get(scope.endpoint.id) === registration.version - ); - } - - async function stageTeamsBoardFileIntent( - tx: DbTransaction, - publication: typeof chatPublications.$inferSelect, - userId: string, - conversation: ConversationRow, - ): Promise { - if ( - !publication.idempotencyKey.startsWith("explicit-board:") || - !conversation.isDirectMessage - ) - return; - const scope = await teamsFileCurrentScope(tx, publication); - if ( - !scope || - scope.conversation.id !== conversation.id || - scope.conversation.sessionGeneration !== conversation.sessionGeneration || - scope.comment.authorUserId !== userId || - scope.comment.authorAgentId || - scope.comment.createdByRunId || - !(await teamsBoardAuthorIsCurrent(tx, scope.endpoint.companyId, userId)) - ) - return; - const source = await teamsBoardOriginBinding(tx, scope); - if (!source || !teamsFileRuntimeStillCurrent(scope)) return; - const key = `${teamsBoardFileIntentKind}:${publication.id}`; - const payload = teamsBoardFileIntentPayload(scope, source, userId); - const [existing] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.providerActionId, key), - ), - ) - .for("update", { noWait: true }); - if (existing) { - if ( - existing.kind !== teamsBoardFileIntentKind || - existing.status !== "processed" || - existing.deliveryId !== source.binding.deliveryId || - existing.principalId !== source.binding.principalId || - existing.conversationId !== scope.conversation.id || - teamsFileProofHash(existing.payload) !== teamsFileProofHash(payload) - ) - throw conflict("The Teams file recipient intent has changed", { - code: "chat_file_transfer_authority_changed", - }); - return; - } - await tx.insert(chatActions).values({ - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - deliveryId: source.binding.deliveryId, - principalId: source.binding.principalId, - kind: teamsBoardFileIntentKind, - providerActionId: key, - status: "processed", - payload, - }); - } - - async function deriveTeamsFileTransferAuthority( - tx: DbTransaction, - publication: typeof chatPublications.$inferSelect, - fence: LifecycleRuntimeFence, - continuingTeamsFileTransfer?: NonNullable< - NonNullable< - Parameters[1]["committedResponse"] - >["continuingTeamsFileTransfer"] - >, - ): Promise { - const scope = await teamsFileCurrentScope(tx, publication, fence); - if (!scope) return null; - let source: NonNullable>>; - let causal: Record; - if (isExplicitOperatorPublication(scope.publication)) { - if ( - !scope.publication.idempotencyKey.startsWith("explicit-board:") || - !scope.comment.authorUserId || - scope.comment.authorAgentId || - scope.comment.createdByRunId || - !(await teamsBoardAuthorIsCurrent( - tx, - scope.endpoint.companyId, - scope.comment.authorUserId, - )) - ) - return null; - const [intent] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.endpointId, scope.endpoint.id), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, teamsBoardFileIntentKind), - eq( - chatActions.providerActionId, - `${teamsBoardFileIntentKind}:${scope.publication.id}`, - ), - eq(chatActions.status, "processed"), - ), - ) - .for("share", { noWait: true }); - if ( - !intent || - typeof intent.payload.sourceActionId !== "string" || - !isUuidLike(intent.payload.sourceActionId) - ) - return null; - const bound = await teamsBoardOriginBinding(tx, scope); - if ( - !bound || - bound.action.id !== intent.payload.sourceActionId || - intent.deliveryId !== bound.binding.deliveryId || - intent.principalId !== bound.binding.principalId - ) - return null; - const currentPayload = teamsBoardFileIntentPayload( - scope, - bound, - scope.comment.authorUserId, - ); - if ( - teamsFileProofHash(intent.payload) !== - teamsFileProofHash(currentPayload) - ) - return null; - source = bound; - causal = { - kind: "explicit_board_send", - intentId: intent.id, - ...currentPayload, - }; - } else { - const runId = scope.comment.createdByRunId; - if ( - !runId || - scope.comment.authorAgentId !== scope.endpoint.assignedAgentId || - scope.file.attachment.originatingRunId !== runId - ) - return null; - const [run] = await tx - .select() - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, scope.endpoint.companyId), - eq(heartbeatRuns.id, runId), - eq(heartbeatRuns.agentId, scope.endpoint.assignedAgentId), - eq(heartbeatRuns.nativeIssueId, scope.conversation.issueId), - eq(heartbeatRuns.runtimeMode, "native"), - eq(heartbeatRuns.status, "succeeded"), - isNotNull(heartbeatRuns.finishedAt), - ), - ) - .for("share", { noWait: true }); - const marker = run?.resultJson?.nativeCommittedChatResponse as - | Record - | undefined; - if ( - !run || - !marker || - marker.schema !== "paperclip.native_committed_chat_response.v1" || - typeof marker.resultId !== "string" || - !isUuidLike(marker.resultId) || - typeof marker.decisionId !== "string" || - !isUuidLike(marker.decisionId) || - typeof marker.canonicalSha256 !== "string" || - !/^(?:sha256:)?[a-f0-9]{64}$/.test(marker.canonicalSha256) - ) - return null; - // Pre-lock the exact causal source and actor rows before the existing - // complete native publication proof invokes its common authorization. - const originActions = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.companyId, scope.endpoint.companyId), - eq(chatActions.conversationId, scope.conversation.id), - eq(chatActions.kind, "inbound_wakeup"), - inArray( - sql`${chatActions.payload}->>'commentId'`, - retryCommentIds(run.contextSnapshot ?? {}), - ), - ), - ); - const bindings: Array< - NonNullable>> - > = []; - for (const action of originActions) { - const binding = await teamsFileSourceBinding(tx, scope, action.id); - if (!binding) return null; - bindings.push(binding); - } - let proved: FailedChatRetrySource; - try { - proved = await failedChatRetrySource(tx, { - companyId: scope.endpoint.companyId, - issueId: scope.conversation.issueId, - agentId: run.agentId, - failedRunId: run.id, - publication: true, - committedResponse: { - resultId: marker.resultId, - canonicalSha256: marker.canonicalSha256, - decisionId: marker.decisionId, - continuingTeamsFileTransfer, - }, - }); - } catch (error) { - if ( - error instanceof HttpError && - (error.details as { code?: string } | undefined)?.code === - "chat_failed_run_retry_not_authorized" - ) - return null; - throw error; - } - if ( - proved.provider !== "microsoft-teams" || - proved.endpointId !== scope.endpoint.id || - proved.conversationId !== scope.conversation.id || - proved.sessionGeneration !== scope.conversation.sessionGeneration || - bindings.length !== proved.sources.length - ) - return null; - const ordered = proved.sources.map((entry) => - bindings.find((binding) => binding.action.id === entry.actionId), - ); - const first = ordered[0]; - if ( - !first || - ordered.some( - (entry) => - !entry || - entry.binding.principalId !== proved.principalId || - entry.binding.recipient.providerConversationId !== - first.binding.recipient.providerConversationId || - entry.binding.recipient.providerUserId !== - first.binding.recipient.providerUserId || - entry.binding.recipient.aadObjectId !== - first.binding.recipient.aadObjectId, - ) - ) - return null; - if ( - run.resultJson?.finalizationReasonCode === - "governed_response_waiting" && - !(await authorizeNativeChatReviewPresentation( - tx as unknown as Db, - { - companyId: scope.endpoint.companyId, - issueId: scope.conversation.issueId, - runId: run.id, - resultJson: run.resultJson, - destination: { - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - }, - }, - "nonblocking", - )) - ) - return null; - source = first; - causal = { - kind: "native_committed_response", - runId: run.id, - resultId: marker.resultId, - canonicalSha256: marker.canonicalSha256, - decisionId: marker.decisionId, - sourceScopeSha256: proved.sourceScopeSha256, - sources: ordered.map((entry) => entry!.sourceFacts), - }; - } - // No mutable stage, attempt, receipt, route URL or provider token enters - // this digest. The next authorization derives these facts afresh. - const sourceDigest = teamsFileProofHash({ - schema: "paperclip.teams.file-source.v1", - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - conversationGeneration: scope.conversation.sessionGeneration, - issueId: scope.conversation.issueId, - publicationId: scope.publication.id, - commentId: scope.comment.id, - causal, - byteIdentity: scope.byteIdentity, - }); - if (!teamsFileRuntimeStillCurrent(scope)) return null; - return { - companyId: scope.endpoint.companyId, - endpointId: scope.endpoint.id, - conversationId: scope.conversation.id, - issueId: scope.conversation.issueId, - publicationId: scope.publication.id, - commentId: scope.comment.id, - attachmentId: scope.file.attachment.id, - principalId: source.binding.principalId, - authorizedUserId: source.authorization.userId, - runtimeGeneration: scope.currentFence.generation, - credentialFingerprint: scope.currentFence.credentialFingerprint, - conversationGeneration: scope.conversation.sessionGeneration, - sourceDigest, - tenantId: source.binding.recipient.tenantId, - botAppId: source.binding.recipient.botAppId, - aadObjectId: source.binding.recipient.aadObjectId, - providerConversationId: source.binding.recipient.providerConversationId, - providerUserId: source.binding.recipient.providerUserId, - sha256: scope.file.asset.sha256, - byteSize: scope.file.asset.byteSize, - filename: scope.filename, - }; - } - function teamsTransferProtocol( input: { credentialLease?: CredentialMutationLeaseGuard; @@ -24630,148 +22790,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }; } - async function recordSlackFileUploadReceipt(input: { - credentialLease: CredentialMutationLeaseGuard; - conversation: ConversationRow; - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - receipt: SlackFileUploadAcceptedReceipt; - runtimeContext: LifecycleRuntimeFence; - }): Promise { - const publicationAttempt = input.publication.attempts + 1; - const botExternalId = input.endpoint.botExternalId; - const expectedThreadId = `slack:${input.receipt.channelId}:${input.receipt.threadTs ?? ""}`; - if ( - input.endpoint.provider !== "slack" || - !botExternalId || - input.receipt.version !== 1 || - expectedThreadId !== input.conversation.externalThreadId || - input.receipt.fileIds.length !== - (input.publication.payload.attachmentIds?.length ?? 0) - ) { - throw new Error( - "Slack file upload receipt did not match its publication", - ); - } - const payload: SlackFileUploadReceiptPayload = { - version: 1, - publicationId: input.publication.id, - publicationAttempt, - threadId: expectedThreadId, - fileIds: [...input.receipt.fileIds], - botExternalId, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }; - if (!slackFileUploadReceiptPayload(payload)) { - throw new Error("Slack file upload receipt was malformed"); - } - const providerActionId = `slack-file-receipt:${input.publication.id}:${publicationAttempt}`; - const recordedAt = new Date(); - return db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.publication.id), - eq(chatPublications.companyId, input.publication.companyId), - eq(chatPublications.endpointId, input.publication.endpointId), - eq( - chatPublications.conversationId, - input.publication.conversationId, - ), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = publication - ? await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - input.runtimeContext, - ["verifying", "active"], - ) - : null; - const conversation = endpoint - ? await tx - .select({ - externalThreadId: chatConversations.externalThreadId, - id: chatConversations.id, - }) - .from(chatConversations) - .where( - and( - eq(chatConversations.id, input.conversation.id), - eq(chatConversations.companyId, input.publication.companyId), - eq(chatConversations.endpointId, input.endpoint.id), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - if ( - !publication || - endpoint?.provider !== "slack" || - endpoint.botExternalId !== botExternalId || - conversation?.externalThreadId !== expectedThreadId - ) { - throw new Error( - "Slack file upload ownership changed before receipt persistence", - ); - } - await notifyChatPublicationWork(tx); - const inserted = await tx - .insert(chatActions) - .values({ - companyId: input.publication.companyId, - endpointId: input.publication.endpointId, - conversationId: input.publication.conversationId, - kind: "slack_file_upload_receipt", - providerActionId, - payload, - status: "received", - result: { code: "slack_file_upload_identity_pending", attempts: 0 }, - createdAt: recordedAt, - updatedAt: recordedAt, - }) - .onConflictDoNothing() - .returning({ id: chatActions.id }); - if (inserted[0]) { - await input.credentialLease.assertOwned(tx); - return inserted[0].id; - } - const existing = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.publication.endpointId), - eq(chatActions.providerActionId, providerActionId), - eq(chatActions.kind, "slack_file_upload_receipt"), - sql`${chatActions.payload} = ${JSON.stringify(payload)}::jsonb`, - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!existing) { - throw new Error( - "Slack file upload receipt conflicted with durable state", - ); - } - await input.credentialLease.assertOwned(tx); - return existing.id; - }); - } - async function recordExactOutboundPublicationLink( tx: DbOrTransaction, input: { @@ -24813,447 +22831,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .then((rows) => rows.length > 0); } - async function stageSlackFilePublicationSuccess( - tx: DbTransaction, - input: { - committedAt: Date; - endpoint: EndpointRow; - publication: typeof chatPublications.$inferSelect; - providerMessageId: string; - runtimeContext: LifecycleRuntimeFence; - stageSessionSync: boolean; - }, - ): Promise { - if ( - !(await recordExactOutboundPublicationLink(tx, { - publication: input.publication, - providerMessageId: input.providerMessageId, - })) - ) { - throw new Error( - "Slack file upload message identity conflicts with another publication", - ); - } - await tx - .update(chatEndpoints) - .set({ - lastPublicationAt: input.committedAt, - updatedAt: input.committedAt, - }) - .where( - and( - eq(chatEndpoints.id, input.endpoint.id), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${input.runtimeContext.generation}`, - ), - ); - if (input.stageSessionSync) { - await stageSlackSessionSync(tx, { - companyId: input.publication.companyId, - endpointId: input.publication.endpointId, - conversationId: input.publication.conversationId, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - }); - } - } - - async function handleTelegramGenerationStopped( - callback: ChatSdkCallbackEvent, - context: RuntimeContext, - ): Promise { - const receipt = telegramGenerationStoppedReceipt(callback.event); - if (callback.provider !== "telegram" || !receipt) return; - const record = await runtimeCallbackRecord(callback.endpointId, context, [ - "verifying", - "active", - ]); - if ( - record?.endpoint.provider !== "telegram" || - record.endpoint.botExternalId !== receipt.botUserId - ) - return; - const candidates = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, record.endpoint.companyId), - eq(chatActions.endpointId, callback.endpointId), - eq(chatActions.kind, TELEGRAM_DRAFT_ACTION_KIND), - sql`${chatActions.payload}->>'botUserId' = ${receipt.botUserId}`, - sql`${chatActions.payload}->>'draftId' = ${String(receipt.draftId)}`, - ), - ) - .limit(2); - if (candidates.length !== 1) return; - const candidate = candidates[0]!; - const binding = parseTelegramDraftBinding(candidate.payload); - if ( - !binding || - binding.chatId !== receipt.chatId || - binding.messageThreadId !== receipt.messageThreadId || - binding.runtimeGeneration !== context.generation || - binding.credentialFingerprint !== context.credentialFingerprint - ) - return; - await db.transaction(async (tx) => { - // The sender holds a renewable credential lease during draft HTTP. Do - // not acquire that lease here: a Stop can arrive inside the first RPC. - // Both Stop and final-send use the publication→endpoint→draft lock order. - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, record.endpoint.companyId), - eq(chatPublications.endpointId, callback.endpointId), - eq(chatPublications.conversationId, binding.conversationId), - eq(chatPublications.id, binding.publicationId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = await runtimeCallbackEndpoint( - tx, - callback.endpointId, - context, - ["verifying", "active"], - ); - if ( - !publication || - !endpoint || - endpoint.provider !== "telegram" || - endpoint.botExternalId !== receipt.botUserId || - runtime.get(endpoint.id) !== context.endpointRuntime || - publication.attempts !== binding.publicationAttempt || - telegramDraftTextSha256( - renderPublicationTransportText(publication.payload), - ) !== binding.textSha256 - ) - return; - const connection = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share") - .then((rows) => rows[0] ?? null); - if ( - !connection?.enabled || - connection.status !== "active" || - credentialFingerprint(connection.credentialSecretRefs) !== - binding.credentialFingerprint - ) - return; - const conversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, binding.conversationId), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !conversation || - !conversation.isDirectMessage || - conversation.sessionGeneration !== binding.sessionGeneration || - conversation.externalThreadId !== - `telegram:${binding.chatId}${binding.messageThreadId === null ? "" : `:${binding.messageThreadId}`}` - ) - return; - const action = await tx - .select() - .from(chatActions) - .where(eq(chatActions.id, candidate.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !action || - JSON.stringify(action.payload) !== JSON.stringify(candidate.payload) - ) - return; - // final_sending is an irreversible local I/O claim, not a delivery - // receipt. A late Stop must not claim that an in-flight final was undone. - if ( - action.result?.phase !== "drafting" || - action.status !== "processing" || - !["streaming", "delivery_unknown"].includes(publication.state) - ) - return; - const stoppedAt = new Date(); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - phase: "stopped", - updateId: receipt.updateId, - stoppedAt: stoppedAt.toISOString(), - }, - updatedAt: stoppedAt, - }) - .where(eq(chatActions.id, action.id)); - await notifyChatPublicationWork(tx); - await tx - .update(chatPublications) - .set({ - state: "cancelled", - nextAttemptAt: null, - redactedError: TELEGRAM_DRAFT_STOPPED_REASON, - updatedAt: stoppedAt, - }) - .where(eq(chatPublications.id, publication.id)); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: "telegram_draft_presentation_stopped", - attempts: publication.attempts, - }, - updatedAt: stoppedAt, - }) - .where( - and( - eq(chatActions.companyId, publication.companyId), - eq(chatActions.endpointId, publication.endpointId), - eq( - chatActions.providerActionId, - `task-control-authorization:${publication.id}`, - ), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'attempts' = ${String(publication.attempts)}`, - ), - ); - }); - } - - async function reserveTelegramPublicationDraft(input: { - endpoint: EndpointRow; - conversation: ConversationRow; - publication: typeof chatPublications.$inferSelect; - text: string; - credentialLease: CredentialMutationLeaseGuard; - runtimeContext: RuntimeContext; - }): Promise<{ actionId: string; control: TelegramDraftControl }> { - const endpointRuntime = await runtimeFor(input.endpoint); - const registeredContext = runtimeContexts.get(endpointRuntime as object); - if ( - !registeredContext || - registeredContext.generation !== input.runtimeContext.generation || - registeredContext.credentialFingerprint !== - input.runtimeContext.credentialFingerprint || - registeredContext.version !== input.runtimeContext.version - ) - throw new Error("Telegram draft runtime authority changed"); - const draftRuntimeContext = { ...input.runtimeContext, endpointRuntime }; - const destination = telegramPrivateDraftDestination( - input.conversation.externalThreadId, - ); - if ( - !destination || - !input.conversation.isDirectMessage || - !input.endpoint.botExternalId - ) - throw new Error("Telegram private draft destination is invalid"); - const bindingBase = { - version: 1 as const, - publicationId: input.publication.id, - publicationAttempt: input.publication.attempts + 1, - conversationId: input.conversation.id, - sessionGeneration: input.conversation.sessionGeneration, - runtimeGeneration: input.runtimeContext.generation, - credentialFingerprint: input.runtimeContext.credentialFingerprint, - botUserId: input.endpoint.botExternalId, - ...destination, - textSha256: telegramDraftTextSha256(input.text), - }; - const reserved = await db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.publication.id), - eq(chatPublications.state, "streaming"), - eq(chatPublications.attempts, bindingBase.publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - if (!publication) - throw new Error("Telegram draft publication ownership changed"); - const providerActionId = `telegram-draft-publication:${publication.id}:${publication.attempts}`; - const existing = await tx - .select({ id: chatActions.id }) - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, input.endpoint.id), - eq(chatActions.providerActionId, providerActionId), - ), - ) - .limit(1); - // A second invocation of the same attempt cannot create another draft or - // replay an uncertain final. A new explicit publication attempt gets a - // fresh, never-before-used bot-global ID. - if (existing.length) - throw new Error("Telegram draft attempt already exists"); - // NO CYCLE and no company/endpoint ownership: rollback, endpoint deletion - // and bot re-binding cannot recycle a provider-visible draft identity. - // Exhaustion is a refusal, never a fallback to a process-local/random ID. - const allocated = await tx.execute( - sql`select nextval('chat_telegram_draft_ids')::integer as "draftId"`, - ); - const binding = parseTelegramDraftBinding({ - ...bindingBase, - draftId: allocated[0]?.draftId, - }); - if (!binding) - throw new Error("Telegram draft identity allocation was invalid"); - const [action] = await tx - .insert(chatActions) - .values({ - companyId: input.endpoint.companyId, - endpointId: input.endpoint.id, - conversationId: input.conversation.id, - kind: TELEGRAM_DRAFT_ACTION_KIND, - providerActionId, - payload: binding, - status: "issued", - result: { phase: "reserved" }, - }) - .returning(); - return { action: action!, binding }; - }); - const before = (final: boolean): Promise => - db.transaction(async (tx) => { - await input.credentialLease.assertOwned(tx); - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, input.endpoint.companyId), - eq(chatPublications.id, input.publication.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const endpoint = await runtimeCallbackEndpoint( - tx, - input.endpoint.id, - draftRuntimeContext, - ["verifying", "active"], - ); - if ( - !endpoint || - endpoint.provider !== "telegram" || - !endpoint.allowDirectMessages || - endpoint.botExternalId !== reserved.binding.botUserId || - runtime.get(endpoint.id) !== endpointRuntime - ) - throw new Error("Telegram draft endpoint authority changed"); - const connection = await tx - .select() - .from(toolConnections) - .where( - and( - eq(toolConnections.companyId, endpoint.companyId), - eq(toolConnections.id, endpoint.connectionId), - ), - ) - .for("share") - .then((rows) => rows[0] ?? null); - if ( - !connection?.enabled || - connection.status !== "active" || - credentialFingerprint(connection.credentialSecretRefs) !== - reserved.binding.credentialFingerprint - ) - throw new Error("Telegram draft credential authority changed"); - const conversation = await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, endpoint.companyId), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.id, input.conversation.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const action = await tx - .select() - .from(chatActions) - .where(eq(chatActions.id, reserved.action.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if ( - !action || - JSON.stringify(action.payload) !== - JSON.stringify(reserved.action.payload) || - !publication || - publication.attempts !== reserved.binding.publicationAttempt - ) - throw new Error("Telegram draft attempt authority changed"); - if ( - action.status === "cancelled" && - action.result?.phase === "stopped" && - publication.state === "cancelled" - ) - return false; - if ( - publication.state !== "streaming" || - !conversation || - telegramDraftTextSha256( - renderPublicationTransportText(publication.payload), - ) !== reserved.binding.textSha256 || - conversation.sessionGeneration !== - reserved.binding.sessionGeneration || - conversation.externalThreadId !== - input.conversation.externalThreadId || - !["active", "waiting", "completed"].includes(conversation.state) || - !["reserved", "drafting"].includes(String(action.result?.phase)) || - !["issued", "processing"].includes(action.status) || - !(await authorizeRetainedChatSourcePublication(tx, publication)) - ) - throw new Error("Telegram draft source authority changed"); - await tx - .update(chatActions) - .set({ - status: "processing", - result: { - phase: final ? "final_sending" : "drafting", - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, action.id)); - await input.credentialLease.assertOwned(tx); - return true; - }); - return { - actionId: reserved.action.id, - control: { - version: 1, - draftId: reserved.binding.draftId, - beforeDraft: () => before(false), - beforeFinal: () => before(true), - }, - }; - } - async function postSafePublication(input: { endpoint: EndpointRow; conversation: ConversationRow; @@ -27056,967 +24633,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return result; } - async function lockSlackFileReceiptAuthorization( - tx: DbTransaction, - input: { - actionId: string; - payload: SlackFileUploadReceiptPayload; - }, - ): Promise< - | { - action: typeof chatActions.$inferSelect; - conversation: ConversationRow; - endpoint: EndpointRow; - mode: "delivery_unknown" | "operator_confirmed"; - publication: typeof chatPublications.$inferSelect; - } - | { action: typeof chatActions.$inferSelect; mode: "defer" | "settled" } - | null - > { - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.id, input.payload.publicationId), - eq(chatPublications.attempts, input.payload.publicationAttempt), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null); - const action = publication - ? await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.id, input.actionId), - eq(chatActions.companyId, publication.companyId), - eq(chatActions.endpointId, publication.endpointId), - eq(chatActions.conversationId, publication.conversationId), - eq(chatActions.kind, "slack_file_upload_receipt"), - eq( - chatActions.providerActionId, - `slack-file-receipt:${publication.id}:${publication.attempts}`, - ), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const currentPayload = action - ? slackFileUploadReceiptPayload(action.payload) - : null; - if ( - !publication || - !action || - !publication.payload.attachmentIds?.length || - publication.payload.card || - publication.payload.interactionId || - publication.idempotencyKey.startsWith("control:") || - !currentPayload || - !sameSlackFileUploadReceipt(currentPayload, input.payload) - ) { - return null; - } - if (publication.state === "streaming") { - return { action, mode: "defer" }; - } - if (publication.state === "published" && publication.providerMessageId) { - const exactLink = await tx - .select({ id: chatMessageLinks.id }) - .from(chatMessageLinks) - .where( - and( - eq(chatMessageLinks.companyId, publication.companyId), - eq(chatMessageLinks.endpointId, publication.endpointId), - eq(chatMessageLinks.conversationId, publication.conversationId), - eq(chatMessageLinks.publicationId, publication.id), - publication.commentId - ? eq(chatMessageLinks.commentId, publication.commentId) - : isNull(chatMessageLinks.commentId), - eq( - chatMessageLinks.providerMessageId, - publication.providerMessageId, - ), - eq(chatMessageLinks.direction, "outbound"), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - return exactLink ? { action, mode: "settled" } : null; - } - const mode = - publication.state === "delivery_unknown" - ? "delivery_unknown" - : publication.state === "published" && - !publication.providerMessageId && - operatorConfirmedSlackFileReceipt(action.result, input.payload) - ? "operator_confirmed" - : null; - if (!mode) return null; - const endpoint = await runtimeCallbackEndpoint( - tx, - publication.endpointId, - { - generation: input.payload.runtimeGeneration, - credentialFingerprint: input.payload.credentialFingerprint, - }, - ["verifying", "active"], - ); - const conversation = endpoint - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.id, publication.conversationId), - eq(chatConversations.companyId, publication.companyId), - eq(chatConversations.endpointId, publication.endpointId), - eq(chatConversations.externalThreadId, input.payload.threadId), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - const resource = - endpoint && conversation?.resourceId && !conversation.isDirectMessage - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.id, conversation.resourceId), - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - ), - ) - .for("no key update") - .then((rows) => rows[0] ?? null) - : null; - if ( - endpoint?.provider !== "slack" || - endpoint.botExternalId !== input.payload.botExternalId || - !conversation || - !(conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource)) - ) { - return null; - } - return { action, conversation, endpoint, mode, publication }; - } - - async function processPendingSlackFileUploadReceipts(limit = 25) { - const selectedAt = new Date(); - const actions = await db - .select() - .from(chatActions) - .where( - slackReceiptEligibility(selectedAt), - ) - .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) - .limit(limit); - let processed = 0; - for (const selected of actions) { - const payload = slackFileUploadReceiptPayload(selected.payload); - if (!payload) { - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { - code: "slack_file_upload_receipt_payload_invalid", - retryable: false, - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.kind, selected.kind), - eq(chatActions.status, selected.status), - sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, - selected.result === null - ? isNull(chatActions.result) - : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, - ), - ); - }); - continue; - } - const record = await endpointRecord(selected.endpointId); - if (!record) { - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_file_upload_receipt_endpoint_removed" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.kind, selected.kind), - eq(chatActions.status, selected.status), - sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, - selected.result === null - ? isNull(chatActions.result) - : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, - ), - ); - }); - continue; - } - if (["paused", "attention"].includes(record.endpoint.status)) continue; - const ownerToken = randomUUID(); - let attempt = 1; - try { - await withCredentialMutationLease(record.endpoint, async (lease) => { - const claim = await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const locked = await lockSlackFileReceiptAuthorization(tx, { - actionId: selected.id, - payload, - }); - const decisionAt = new Date(); - const priorAttempts = - typeof locked?.action.result?.attempts === "number" && - Number.isSafeInteger(locked.action.result.attempts) - ? locked.action.result.attempts - : 0; - attempt = priorAttempts + 1; - const retryAt = - typeof locked?.action.result?.retryAt === "string" - ? Date.parse(locked.action.result.retryAt) - : Number.NaN; - const eligible = locked - ? locked.action.status === "received" || - (locked.action.status === "failed" && - locked.action.result?.retryable === true && - Number.isFinite(retryAt) && - retryAt <= decisionAt.getTime()) || - (locked.action.status === "processing" && - locked.action.updatedAt.getTime() <= - decisionAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS) - : false; - if (locked && !eligible) return null; - if (locked?.mode === "defer") { - if (locked.action.status === "processing") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "received", - result: { - code: "slack_file_upload_identity_pending", - attempts: priorAttempts, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, locked.action.id)); - } - return null; - } - if (locked?.mode === "settled") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "processed", - result: { - code: "slack_file_upload_identity_confirmed", - attempts: priorAttempts, - }, - updatedAt: new Date(), - }) - .where(eq(chatActions.id, locked.action.id)); - return null; - } - if ( - !locked || - decisionAt.getTime() - - (locked?.action.createdAt ?? selected.createdAt).getTime() > - SLACK_FILE_RECEIPT_MAX_AGE_MS || - attempt > SLACK_FILE_RECEIPT_MAX_ATTEMPTS - ) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - code: locked - ? "slack_file_upload_receipt_expired" - : "slack_file_upload_receipt_authorization_changed", - attempts: priorAttempts, - }, - updatedAt: decisionAt, - }) - .where( - and( - eq(chatActions.id, selected.id), - inArray(chatActions.status, [ - "received", - "failed", - "processing", - ]), - ), - ); - return null; - } - if ( - !["received", "failed", "processing"].includes( - locked.action.status, - ) - ) { - return null; - } - await notifyChatPublicationWork(tx); - const [claimed] = await tx - .update(chatActions) - .set({ - status: "processing", - result: { - attempts: attempt, - ownerToken, - ...(locked.mode === "operator_confirmed" - ? { - operatorConfirmedPublicationId: - locked.action.result?.operatorConfirmedPublicationId, - operatorConfirmedAttempt: - locked.action.result?.operatorConfirmedAttempt, - operatorConfirmedAt: - locked.action.result?.operatorConfirmedAt, - } - : {}), - }, - updatedAt: decisionAt, - }) - .where( - and( - eq(chatActions.id, locked.action.id), - eq(chatActions.status, locked.action.status), - ), - ) - .returning({ id: chatActions.id }); - await lease.assertOwned(tx); - return claimed ? locked : null; - }); - if (!claim || !("endpoint" in claim)) return; - processed += 1; - const endpointRuntime = await runtimeFor(claim.endpoint); - await lease.assertOwned(); - const providerMessageId = - await endpointRuntime.resolveSlackFileUploadReceipt( - payload.threadId, - payload.fileIds, - ); - if (!providerMessageId) { - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "failed", - result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( - { - attempts: attempt, - code: "slack_file_upload_identity_pending", - retryable: true, - retryAt: new Date( - Date.now() + Math.min(60_000, 1_000 * 2 ** attempt), - ).toISOString(), - }, - )}::jsonb`, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - return; - } - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const locked = await lockSlackFileReceiptAuthorization(tx, { - actionId: selected.id, - payload, - }); - if ( - !locked || - locked.mode === "defer" || - locked.mode === "settled" || - !("publication" in locked) || - locked.action.status !== "processing" || - locked.action.result?.ownerToken !== ownerToken - ) { - if (!locked) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { - attempts: attempt, - code: "slack_file_upload_receipt_authorization_changed", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } else if (locked.mode === "defer") { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "received", - result: { - attempts: attempt, - code: "slack_file_upload_identity_pending", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } else { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: - locked.mode === "settled" ? "processed" : "cancelled", - result: { - attempts: attempt, - code: - locked.mode === "settled" - ? "slack_file_upload_identity_confirmed" - : "slack_file_upload_receipt_superseded", - }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - } - return; - } - const committedAt = new Date(); - if ( - !(await recordExactOutboundPublicationLink(tx, { - publication: locked.publication, - providerMessageId, - })) - ) { - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "failed", - result: { - attempts: attempt, - code: "slack_file_upload_receipt_message_conflict", - retryable: false, - }, - updatedAt: committedAt, - }) - .where(eq(chatActions.id, selected.id)); - return; - } - if (locked.mode === "delivery_unknown") { - await notifyChatPublicationWork(tx); - const [settled] = await tx - .update(chatPublications) - .set({ - state: "published", - providerMessageId, - publishedAt: committedAt, - nextAttemptAt: null, - redactedError: null, - updatedAt: committedAt, - }) - .where( - and( - eq(chatPublications.id, locked.publication.id), - eq(chatPublications.state, "delivery_unknown"), - eq(chatPublications.attempts, payload.publicationAttempt), - ), - ) - .returning({ id: chatPublications.id }); - if (!settled) { - throw new Error( - "Slack file upload publication changed before receipt commit", - ); - } - await stageSlackFilePublicationSuccess(tx, { - committedAt, - endpoint: locked.endpoint, - publication: locked.publication, - providerMessageId, - runtimeContext: { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - stageSessionSync: - !isExplicitOperatorPublication(locked.publication) && - !locked.publication.idempotencyKey.startsWith( - "control:status:", - ), - }); - } else { - await notifyChatPublicationWork(tx); - const [enriched] = await tx - .update(chatPublications) - .set({ providerMessageId }) - .where( - and( - eq(chatPublications.id, locked.publication.id), - eq(chatPublications.state, "published"), - eq(chatPublications.attempts, payload.publicationAttempt), - isNull(chatPublications.providerMessageId), - ), - ) - .returning({ id: chatPublications.id }); - if (!enriched) { - throw new Error( - "Slack file upload publication changed before identity enrichment", - ); - } - } - await notifyChatPublicationWork(tx); - await tx - .update(chatActions) - .set({ - status: "processed", - result: { - attempts: attempt, - code: "slack_file_upload_identity_confirmed", - }, - updatedAt: committedAt, - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - await lease.assertOwned(tx); - }); - }); - } catch (error) { - const disposition = classifyChatPublicationError(error, attempt); - const retryable = - disposition.kind === "retry" || - disposition.kind === "delivery_unknown"; - const retryMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 8)); - await db.transaction(async (tx) => { - await notifyChatPublicationWork(tx); - return tx - .update(chatActions) - .set({ - status: retryable ? "failed" : "cancelled", - result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( - { - attempts: attempt, - code: retryable - ? "slack_file_upload_receipt_lookup_retry" - : "slack_file_upload_receipt_lookup_rejected", - retryable, - ...(retryable - ? { retryAt: new Date(Date.now() + retryMs).toISOString() } - : {}), - }, - )}::jsonb`, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, selected.id), - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - } - } - return processed; - } - - async function processPendingSlackSessionSyncs( - limit = 25, - onlyActionId?: string, - ) { - const now = new Date(); - const actions = await db - .select({ action: chatActions }) - .from(chatActions) - .leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId))) - .where(and( - onlyActionId ? eq(chatActions.id, onlyActionId) : undefined, - dueChatAction("slack_session_sync", now.getTime()), - )) - .orderBy(asc(chatActions.updatedAt)) - .limit(limit) - .then(rows => rows.map(row => row.action)); - await options.slackSessionSyncSelectionBarrier?.(); - for (const action of actions) { - const payload = slackSessionSyncPayload(action.payload); - if (!payload || !action.conversationId) { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: "failed", - result: { code: "slack_session_payload_invalid" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.id, action.id), - eq(chatActions.updatedAt, action.updatedAt), - ), - ); - }); - continue; - } - const revisionWhere = and( - eq(chatActions.id, action.id), - sql`(${chatActions.payload}->>'revision')::bigint = ${payload.revision}`, - ); - const selectedSnapshotWhere = and( - revisionWhere, - eq(chatActions.status, action.status), - eq(chatActions.updatedAt, action.updatedAt), - ); - const record = await endpointRecord(action.endpointId); - if (record && ["paused", "attention"].includes(record.endpoint.status)) continue; - if (!record || record.endpoint.provider !== "slack") { - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_endpoint_unavailable" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - }); - continue; - } - const attempts = - (typeof action.result?.attempts === "number" - ? action.result.attempts - : 0) + 1; - const ownerToken = randomUUID(); - let ownsAttempt = false; - try { - await withCredentialMutationLease(record.endpoint, async (lease) => { - const claim = await db.transaction(async (tx) => { - await lease.assertOwned(tx); - const endpoint = await runtimeCallbackEndpoint( - tx, - action.endpointId, - { - generation: payload.runtimeGeneration, - credentialFingerprint: payload.credentialFingerprint, - }, - ["active", "verifying"], - ); - const conversation = endpoint - ? await tx - .select() - .from(chatConversations) - .where( - and( - eq(chatConversations.id, action.conversationId!), - eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.companyId, endpoint.companyId), - inArray(chatConversations.state, [ - "active", - "waiting", - "completed", - ]), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const resource = - endpoint && conversation?.resourceId - ? await tx - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.id, conversation.resourceId), - eq(chatEndpointResources.companyId, endpoint.companyId), - eq(chatEndpointResources.endpointId, endpoint.id), - ), - ) - .for("update") - .then((rows) => rows[0] ?? null) - : null; - const superseded = conversation - ? await tx - .select({ id: chatConversations.id }) - .from(chatConversations) - .where( - and( - eq(chatConversations.companyId, conversation.companyId), - eq(chatConversations.endpointId, conversation.endpointId), - eq( - chatConversations.externalThreadId, - conversation.externalThreadId, - ), - gt( - chatConversations.sessionGeneration, - conversation.sessionGeneration, - ), - ), - ) - .limit(1) - .then((rows) => rows.length > 0) - : false; - if ( - !endpoint || - !conversation || - superseded || - !(conversation.isDirectMessage - ? endpoint.allowDirectMessages - : nonDirectDestinationAllowed(endpoint, resource)) - ) { - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_destination_changed" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - return null; - } - // Recompute at dispatch: a delayed processing retry must not - // overwrite a newer final response or a later question card. - const publication = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq(chatPublications.conversationId, conversation.id), - eq(chatPublications.state, "published"), - sql`${chatPublications.idempotencyKey} not like 'explicit%'`, - sql`${chatPublications.idempotencyKey} not like 'control:status:%'`, - sql`${chatPublications.idempotencyKey} not like 'wake:%'`, - ), - ) - .orderBy( - desc(chatPublications.publishedAt), - desc(chatPublications.createdAt), - desc(chatPublications.id), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - if (!publication) { - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: "cancelled", - result: { code: "slack_session_no_published_state" }, - updatedAt: new Date(), - }) - .where(selectedSnapshotWhere); - return null; - } - let status = slackSessionStatusForPublication( - publication.payload, - conversation.state === "completed", - ); - const runId = runIdFromMilestonePublication(publication); - if (status === "processing" && runId) { - const run = await tx - .select({ status: heartbeatRuns.status }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.id, runId), - eq(heartbeatRuns.companyId, endpoint.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), - ), - ) - .then((rows) => rows[0] ?? null); - // Stop may finish before its confirmation publication. Never - // resurrect processing from that run's earlier working receipt. - if (!run || !["queued", "running"].includes(run.status)) - status = "active"; - } - await notifyChatActionWork(tx, "slack_session_sync"); - const claimed = await tx - .update(chatActions) - .set({ - status: "processing", - result: { attempts, ownerToken }, - updatedAt: new Date(), - }) - .where( - and( - selectedSnapshotWhere, - or( - and( - eq(chatActions.status, "received"), - sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${new Date().toISOString()}::timestamptz)`, - ), - and( - eq(chatActions.status, "processing"), - lte(chatActions.updatedAt, new Date(Date.now() - 60_000)), - ), - ), - ), - ) - .returning({ id: chatActions.id }); - await lease.assertOwned(tx); - return claimed.length ? { endpoint, conversation, status } : null; - }); - if (!claim) return; - ownsAttempt = true; - const credentials = await resolveCredentials(claim.endpoint); - await lease.assertOwned(); - const outcome = await setSlackSessionStatus({ - botToken: credentials.botToken, - threadId: claim.conversation.externalThreadId, - status: claim.status, - fetch: fetchImpl, - }); - await db.transaction(async (tx) => { - await lease.assertOwned(tx); - // Refresh long work before Slack's one-hour processing timeout. - // Unsupported sessions settle until a new publication restages - // them; completed threads must not create perpetual polling work. - const retryMs = - outcome === "updated" && claim.status === "processing" - ? 30 * 60_000 - : null; - await notifyChatActionWork(tx, "slack_session_sync"); - await tx - .update(chatActions) - .set({ - status: retryMs === null ? "processed" : "received", - result: { - attempts, - outcome, - sessionStatus: claim.status, - ...(retryMs === null - ? {} - : { - retryAt: new Date(Date.now() + retryMs).toISOString(), - }), - }, - updatedAt: new Date(), - }) - .where( - and( - revisionWhere, - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ), - ); - }); - }); - } catch (error) { - const disposition = classifyChatPublicationError(error, attempts); - // Unlike posting a message, repeating setStatus cannot duplicate any - // content. Ambiguous transport/receipt failures remain retryable, but - // a definite scope/auth/destination rejection waits for new activity. - const retryable = - disposition.kind === "retry" || - disposition.kind === "delivery_unknown"; - const retryMs = - disposition.kind === "retry" - ? disposition.retryAfterMs - : Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attempts, 8)); - await db.transaction(async (tx) => { - await notifyChatActionWork(tx, "slack_session_sync"); - return tx - .update(chatActions) - .set({ - status: retryable ? "received" : "failed", - result: { - attempts, - code: retryable - ? "slack_session_sync_retry" - : "slack_session_sync_rejected", - ...(retryable - ? { retryAt: new Date(Date.now() + retryMs).toISOString() } - : {}), - }, - updatedAt: new Date(), - }) - .where( - and( - revisionWhere, - ownsAttempt - ? and( - eq(chatActions.status, "processing"), - sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, - ) - : and( - eq(chatActions.status, "received"), - eq(chatActions.updatedAt, action.updatedAt), - ), - ), - ); - }); - logger.warn( - { - endpointId: action.endpointId, - actionId: action.id, - retryable, - error: redactError(error), - }, - "Slack session status update did not complete; message delivery is unchanged", - ); - } - } - return actions.length; - } - async function withPublicationAttemptLease( publication: typeof chatPublications.$inferSelect, operation: (lease: CredentialMutationLeaseGuard) => Promise, @@ -28958,50 +25574,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - function slackReceiptEligibility(selectedAt?: Date) { - return and( - eq(chatActions.kind, "slack_file_upload_receipt"), - notExists( - db - .select({ id: chatEndpoints.id }) - .from(chatEndpoints) - .where( - and( - eq(chatEndpoints.id, chatActions.endpointId), - inArray(chatEndpoints.status, ["paused", "attention"]), - ), - ), - ), - // The normal publication owner is still polling Slack. Receipt - // recovery must neither contend with it nor let its not-yet-due - // action occupy the bounded recovery page. - notExists( - db - .select({ id: chatPublications.id }) - .from(chatPublications) - .where( - and( - sql`${chatPublications.id}::text = ${chatActions.payload}->>'publicationId'`, - sql`${chatPublications.attempts}::text = ${chatActions.payload}->>'publicationAttempt'`, - eq(chatPublications.state, "streaming"), - ), - ), - ), - or( - eq(chatActions.status, "received"), - and( - eq(chatActions.status, "failed"), - sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`, - selectedAt ? sql`(${chatActions.result}->>'retryAt')::timestamptz <= ${selectedAt.toISOString()}::timestamptz` : sql`${chatActions.result}->>'retryAt' is not null`, - ), - and( - eq(chatActions.status, "processing"), - selectedAt ? lte(chatActions.updatedAt, new Date(selectedAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)) : undefined, - ), - ), - ); - } - function publicationEligibility({ now, busyEndpointIds = [], coolingTeamsPublicationIds = [], attemptedIds = [] }: { now?: Date; busyEndpointIds?: string[]; coolingTeamsPublicationIds?: string[]; attemptedIds?: string[] } = {}) { const earlierPublication = alias(chatPublications, "earlier_chat_publications"); return and( @@ -29425,14 +25997,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return next; } - async function nextSlackReceiptAt(): Promise { - const [row] = await db.select({ at: sql`min(case - when ${chatActions.status} = 'processing' then ${chatActions.updatedAt} + ${SLACK_FILE_RECEIPT_STALE_MS} * interval '1 millisecond' - when ${chatActions.status} = 'failed' then (${chatActions.result}->>'retryAt')::timestamptz - else now() end)` }).from(chatActions).where(slackReceiptEligibility()); - return row?.at == null ? null : new Date(row.at).getTime(); - } - async function nextPublicationAt(): Promise { const cooldowns = Object.fromEntries([...teamsFileRetryAfter].map(([id, at]) => [id, new Date(at).toISOString()])); const [queued, streaming] = await Promise.all([ diff --git a/server/src/services/chat-channels/imessage-photon/events.ts b/server/src/services/chat-channels/imessage-photon/events.ts new file mode 100644 index 00000000000..14a914287fd --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/events.ts @@ -0,0 +1,903 @@ +import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { nativeSha256 } from "../../native-runtime/canonical.js"; + +import { projectSafeChatPublicationText } from "../../chat-publication-projection.js"; +import { PhotonAnswerValidationError, nativePhotonInteraction, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "../../photon/interactions.js"; +import { validateNativeQuestionResponseInput } from "../../native-runtime/native-question-bridge.js"; +import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared"; +import { PhotonError, photonFailure } from "../../photon/cloud.js"; +import { PhotonChatAdapter, photonThreadId } from "../../photon/adapter.js"; + +import type { LiveEvent as PhotonEvent } from "@photon-ai/advanced-imessage"; + +import { + and, + eq, + inArray, + isNotNull, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatPublications, + issues, + toolConnections, +} from "@paperclipai/db"; + +import { + HttpError, + forbidden, +} from "../../../errors.js"; + +import { + type ChatSdkMessageCallbackEvent, +} from "../../chat-sdk-runtime.js"; + +import { + logActivity, +} from "../../activity-log.js"; + +import { issueThreadInteractionService } from "../../issue-thread-interactions.js"; +import { + questionResponseDeliveryService, +} from "../../question-response-delivery.js"; + +import type { + Author, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type PhotonEventsOptions = { + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + invalidateRuntime: (endpointId: string) => Promise; + ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: Pick; userId: string | null; linkedDenied: boolean }>; + requireCurrentExternalActionAuthorization: (tx: DbTransaction, input: { conversationId: string; endpointId: string; expectedUserId: string; principalId: string; runtimeContext: LifecycleRuntimeFence }) => Promise; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + scheduleMessageProcessing: (task: () => Promise) => void; + processPendingPublications: () => Promise; + questionResponses: Pick, "deliver">; + redactError: (error: unknown) => string; + renewDiscordGatewayOwnershipForMessageAdmission: (tx: DbTransaction, endpointId: string, context: RuntimeContext) => Promise<{ kind: "owned" | "lost" }>; + handleSdkMessage: (event: ChatSdkMessageCallbackEvent, runtimeContext?: RuntimeContext, messageOptions?: { receiptReactionSupported?: boolean; slackSlashControl?: boolean }) => Promise; +}; + +export function createPhotonEvents(db: Db, options: PhotonEventsOptions) { + const { runtimeCallbackRecord, runtimeCallbackEndpoint, invalidateRuntime, ensurePrincipal, requireCurrentExternalActionAuthorization, isExternalActionAuthorizationChange, scheduleMessageProcessing, processPendingPublications, questionResponses, redactError, renewDiscordGatewayOwnershipForMessageAdmission, handleSdkMessage } = options; + + + async function handlePhotonFailure( + endpointId: string, + error: unknown, + context: RuntimeContext, + ) { + if (error instanceof PhotonError && error.code === "attachment_not_ready") + return; + error = photonFailure(error); + const fatal = + error instanceof PhotonError && + [ + "credentials", + "line_unavailable", + "history_gap", + "invalid_response", + ].includes(error.code); + const message = + error instanceof PhotonError + ? error.message + : "Photon connection interrupted; reconnecting"; + await db.transaction(async (tx) => { + const endpoint = await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!endpoint) return; + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: message, + lastError: message, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, endpointId)); + if (fatal) + await tx + .update(toolConnections) + .set({ + enabled: false, + healthStatus: "error", + healthMessage: message, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + }); + // Retirement is queued outside the receiver callback to avoid self-joining. + void invalidateRuntime(endpointId).catch(() => undefined); + } + + async function processPhotonResponse( + endpoint: EndpointRow, + event: PhotonEvent, + threadId: string, + adapter: PhotonChatAdapter, + context: RuntimeContext, + ): Promise { + const command = + event.type === "message.received" + ? photonResponseCommand(event.message.content.text ?? "") + : null; + const replyGuid = + event.type === "message.received" ? event.message.replyTargetGuid : null; + const prompt = + event.type === "poll.changed" + ? await adapter.state.read( + `poll-message:${event.pollMessageGuid}`, + ) + : replyGuid + ? ((await adapter.state.read( + `prompt-message:${replyGuid}`, + )) ?? + (await adapter.state.read( + `poll-message:${replyGuid}`, + ))) + : null; + const reference = command?.reference ?? prompt?.reference; + if (!reference) { + if ( + !event.isFromMe && + (replyGuid || + (event.type === "poll.changed" && + (event.delta.type === "voted" || event.delta.type === "unvoted"))) + ) { + // A vote can beat the local poll-binding write. Keep it behind the + // checkpoint while a publication in this chat is in flight. + const pending = await db + .select({ id: chatPublications.id }) + .from(chatPublications) + .innerJoin( + chatConversations, + eq(chatConversations.id, chatPublications.conversationId), + ) + .where( + and( + eq(chatPublications.endpointId, endpoint.id), + eq(chatConversations.externalThreadId, threadId), + inArray(chatPublications.state, [ + "pending", + "retry", + "streaming", + "delivery_unknown", + ]), + isNotNull(sql`${chatPublications.payload}->>'interactionId'`), + ), + ) + .limit(1); + if (pending.length) + throw new PhotonError( + "attachment_not_ready", + "Waiting for Photon poll binding", + ); + } + return ( + event.type === "poll.changed" || + (event.type === "message.received" && + /^\/(answer|submit)\b/i.test(event.message.content.text ?? "")) + ); + } + const action = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, endpoint.companyId), + eq(chatActions.endpointId, endpoint.id), + eq(chatActions.kind, "photon_interaction"), + eq(chatActions.providerActionId, `photon:${reference}`), + ), + ) + .then((rows) => rows[0]); + if (!action || action.status !== "issued" || !action.conversationId) + return true; + const binding = action.payload as unknown as PhotonInteractionBinding; + if ( + binding.version !== 1 || + binding.reference !== reference || + !Number.isFinite(Date.parse(binding.expiresAt)) || + Date.parse(binding.expiresAt) <= Date.now() + ) + return true; + const conversation = await db + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, action.conversationId), + ), + ) + .then((rows) => rows[0]); + if ( + !conversation || + !["active", "waiting"].includes(conversation.state) || + conversation.sessionGeneration !== binding.sessionGeneration || + conversation.externalThreadId !== threadId + ) + return true; + const publication = await db + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.id, binding.publicationId), + eq(chatPublications.conversationId, conversation.id), + ), + ) + .then((rows) => rows[0]); + if ( + !publication || + publication.payload.interactionId !== binding.interactionId || + new Date(event.occurredAt).getTime() < action.createdAt.getTime() + ) + return true; + if ( + ["pending", "retry", "streaming", "delivery_unknown"].includes( + publication.state, + ) + ) + throw new PhotonError( + "attachment_not_ready", + "Waiting for Photon prompt publication", + ); + if (publication.state !== "published") return true; + const interaction = ( + await issueThreadInteractionService(db).listForIssue(conversation.issueId) + ).find((candidate) => candidate.id === binding.interactionId); + if ( + !interaction || + interaction.status !== "pending" || + interaction.companyId !== endpoint.companyId || + interaction.createdByAgentId !== endpoint.assignedAgentId || + !nativePhotonInteraction(interaction) + ) + return true; + const actor = + event.type === "message.received" ? event.message.sender : event.actor; + if (event.isFromMe || !actor?.address || actor.service !== "iMessage") + return true; + const chat = await adapter.chatInfo(threadId); + if ( + chat.isArchived || + !chat.participants.some( + (person) => + person.address === actor.address && person.service === actor.service, + ) + ) + return true; + const principal = await ensurePrincipal( + endpoint, + { + userId: `${actor.service}:${actor.address}`, + userName: actor.address, + fullName: actor.address, + isMe: false, + isBot: false, + }, + event, + ); + if ( + !principal.userId || + principal.linkedDenied || + principal.principal.kind !== "user" || + principal.principal.isBot + ) + return true; + const assertCurrent = async () => + db.transaction((tx) => + requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }), + ); + try { + await assertCurrent(); + } catch (error) { + if (isExternalActionAuthorizationChange(error)) return true; + throw error; + } + const draftKey = `draft:${reference}:${principal.principal.id}:${principal.userId}`; + let draft = (await adapter.state.read(draftKey)) ?? { + schema: 1, + interactionId: interaction.id, + principalId: principal.principal.id, + userId: principal.userId, + answers: [], + lastSequence: 0, + }; + if (draft.lastSequence > event.sequence) return true; + const questionIndex = command?.questionIndex ?? prompt?.questionIndex ?? 0; + let answerValue = + command?.value ?? + (event.type === "message.received" + ? (event.message.content.text ?? "") + : ""); + let pollChoice: string | undefined; + if (event.type === "poll.changed") { + if (!prompt || event.pollMessageGuid !== prompt.pollMessageGuid) + return true; + if (event.delta.type !== "voted" && event.delta.type !== "unvoted") + return true; + pollChoice = prompt.options[event.delta.optionIdentifier]; + if (!pollChoice) return true; + if (event.delta.type === "unvoted") { + const questionId = + interaction.kind === "ask_user_questions" + ? interaction.payload.questions[questionIndex]?.id + : null; + await adapter.state.update(draftKey, () => ({ + ...draft, + answers: draft.answers.filter( + (answer) => + answer.questionId !== questionId || + !answer.optionIds.includes(pollChoice!), + ), + decision: draft.decision === pollChoice ? undefined : draft.decision, + lastSequence: event.sequence, + })); + return true; + } + } + const enqueueResponse = async (key: string, text: string) => { + await assertCurrent(); + await db.transaction(async (tx) => { + await tx + .insert(chatActions) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: conversation.id, + kind: "photon_response_notice", + providerActionId: `photon-notice:${key}`, + payload: { + reference, + questionIndex, + publicationId: binding.publicationId, + }, + status: "processed", + }) + .onConflictDoNothing(); + await notifyChatPublicationWork(tx); + await tx + .insert(chatPublications) + .values({ + companyId: endpoint.companyId, + endpointId: endpoint.id, + conversationId: conversation.id, + issueId: conversation.issueId, + idempotencyKey: key, + payload: { + ...publication.payload, + card: undefined, + text: projectSafeChatPublicationText(text), + }, + state: "pending", + }) + .onConflictDoNothing(); + }); + scheduleMessageProcessing(() => + processPendingPublications().then(() => undefined), + ); + }; + const notice = (text: string) => + enqueueResponse(`photon-response:${reference}:${event.sequence}`, text); + try { + if (interaction.kind === "ask_user_questions") { + if (command?.command !== "submit") { + let answer: AskUserQuestionsAnswer; + if (pollChoice) { + const question = interaction.payload.questions[questionIndex]; + if ( + !question || + !question.options.some((option) => option.id === pollChoice) + ) + return true; + answer = { questionId: question.id, optionIds: [pollChoice] }; + } else + answer = parsePhotonQuestionAnswer( + interaction, + questionIndex, + answerValue, + ); + draft = { + ...draft, + answers: [ + ...draft.answers.filter( + (entry) => entry.questionId !== answer.questionId, + ), + answer, + ], + lastSequence: event.sequence, + }; + } + const answers = interaction.payload.questions.flatMap((question) => + draft.answers.filter((answer) => answer.questionId === question.id), + ); + if ( + interaction.payload.questions.length > 1 && + command?.command !== "submit" + ) { + await adapter.state.update(draftKey, () => draft); + const next = interaction.payload.questions.findIndex( + (question) => + !answers.some((answer) => answer.questionId === question.id), + ); + if (next >= 0) + await enqueueResponse( + `photon-question:${reference}:${next}`, + "Input needed", + ); + else + await notice( + `Your answers are saved. Send /submit ${reference} to submit them.`, + ); + return true; + } + await validateNativeQuestionResponseInput(interaction, { answers }); + // Canonical service rechecks required fields, options, audience, and + // pending -> answered atomically with the response-delivery receipt. + const issue = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0]); + if (!issue) return true; + const answered = await issueThreadInteractionService( + db, + ).answerQuestions( + issue, + interaction.id, + { answers }, + { userId: principal.userId }, + { + beforeResolveInTransaction: async (tx) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }); + const current = await tx + .select() + .from(chatConversations) + .where(eq(chatConversations.id, conversation.id)) + .then((rows) => rows[0]); + if ( + current?.sessionGeneration !== binding.sessionGeneration || + Date.parse(binding.expiresAt) <= Date.now() + ) + throw forbidden( + "Photon prompt expired or its task generation changed", + ); + }, + afterResolveInTransaction: async (tx, resolved) => { + const claimed = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processed", + result: { + code: "photon_question_answered", + interactionId: resolved.id, + interactionStatus: resolved.status, + answersSha256: nativeSha256( + (resolved as AskUserQuestionsInteraction).result?.answers, + ), + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!claimed.length) + throw forbidden("Photon prompt has already been resolved"); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: principal.userId!, + action: "issue.thread_interaction_answered", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + provider: endpoint.provider, + endpointId: endpoint.id, + conversationId: conversation.id, + interactionId: resolved.id, + }, + }); + }, + }, + ); + scheduleMessageProcessing(async () => { + await questionResponses.deliver(answered.id); + await processPendingPublications(); + }); + } else { + if (command?.command === "submit") return true; + const matched = /^(accept|reject|1|2)(?:\s+([\s\S]+))?$/i.exec( + answerValue.trim(), + ); + const decision = + pollChoice === "accept" || pollChoice === "reject" + ? pollChoice + : matched + ? /^(accept|1)$/i.test(matched[1]) + ? "accept" + : "reject" + : draft.decision; + if (!decision) + throw new PhotonAnswerValidationError( + `Send /answer ${reference} Accept or /answer ${reference} Reject .`, + ); + const reason = + matched?.[2]?.trim() ?? + (draft.decision === "reject" && !pollChoice + ? answerValue.trim() + : ""); + if ( + decision === "reject" && + interaction.payload.rejectRequiresReason && + !reason + ) { + await adapter.state.update(draftKey, () => ({ + ...draft, + decision, + lastSequence: event.sequence, + })); + await notice( + `Give a rejection reason with /answer ${reference} Reject .`, + ); + return true; + } + const issue = await db + .select() + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .then((rows) => rows[0]); + if (!issue) return true; + const mutation = { + beforeResolveInTransaction: async (tx: DbTransaction) => { + await requireCurrentExternalActionAuthorization(tx, { + conversationId: conversation.id, + endpointId: endpoint.id, + expectedUserId: principal.userId!, + principalId: principal.principal.id, + runtimeContext: context, + }); + const current = await tx + .select() + .from(chatConversations) + .where(eq(chatConversations.id, conversation.id)) + .then((rows) => rows[0]); + if ( + current?.sessionGeneration !== binding.sessionGeneration || + Date.parse(binding.expiresAt) <= Date.now() + ) + throw forbidden( + "Photon prompt expired or its task generation changed", + ); + }, + afterResolveInTransaction: async ( + tx: DbTransaction, + resolved: IssueThreadInteraction, + ) => { + const claimed = await tx + .update(chatActions) + .set({ + principalId: principal.principal.id, + status: "processed", + result: { + code: "photon_confirmation_resolved", + interactionId: resolved.id, + interactionStatus: resolved.status, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.status, "issued"), + ), + ) + .returning({ id: chatActions.id }); + if (!claimed.length) + throw forbidden("Photon prompt has already been resolved"); + await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: principal.userId!, + action: + decision === "accept" + ? "issue.thread_interaction_accepted" + : "issue.thread_interaction_rejected", + entityType: "issue", + entityId: issue.id, + details: { + source: "external_chat", + provider: endpoint.provider, + endpointId: endpoint.id, + conversationId: conversation.id, + interactionId: resolved.id, + }, + }); + }, + }; + if (decision === "accept") + await issueThreadInteractionService(db).acceptInteraction( + issue, + interaction.id, + {}, + { userId: principal.userId }, + mutation, + ); + else + await issueThreadInteractionService(db).rejectInteraction( + issue, + interaction.id, + { reason }, + { userId: principal.userId }, + mutation, + ); + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + } + } catch (error) { + if (error instanceof PhotonError) throw error; + if (isExternalActionAuthorizationChange(error)) return true; + if (error instanceof HttpError && [403, 404, 409].includes(error.status)) + return true; + // Only validator errors become correction copy. Infrastructure errors + // leave the checkpoint unchanged and retry through durable recovery. + if ( + (error instanceof HttpError && error.status === 422) || + error instanceof PhotonAnswerValidationError + ) { + const missingIndex = interaction.kind === "ask_user_questions" && command?.command === "submit" + ? interaction.payload.questions.findIndex((question) => + question.required !== false && !draft.answers.some((answer) => answer.questionId === question.id)) + : -1; + await notice( + `${redactError(error)} Send /answer ${reference}.${(missingIndex >= 0 ? missingIndex : questionIndex) + 1} to correct it.`, + ); + return true; + } + throw error; + } + return true; + } + + async function handlePhotonEvent( + endpointId: string, + event: PhotonEvent, + context: RuntimeContext, + ) { + const record = await runtimeCallbackRecord(endpointId, context, [ + "verifying", + "active", + "attention", + ]); + if (!record) + throw new Error("Photon receiver no longer owns this endpoint"); + const adapter = context.endpointRuntime!.getProviderAdapter(); + if (!(adapter instanceof PhotonChatAdapter)) + throw new Error("Photon adapter unavailable"); + if (adapter.authentication.identity.allocation === "shared" && event.type === "group.changed") return; + if ( + event.type === "group.changed" && + (event.change.type === "participantRemoved" || + event.change.type === "participantLeft") && + event.change.participant.address === record.endpoint.botExternalId + ) { + const removedThreadId = photonThreadId({ + lineId: adapter.authentication.identity.lineId, + chatGuid: event.chatGuid, + isGroup: true, + }); + await db.transaction(async (tx) => { + if ( + !(await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + ])) + ) + throw new Error("Photon receiver is no longer current"); + if ( + ( + await renewDiscordGatewayOwnershipForMessageAdmission( + tx, + endpointId, + context, + ) + ).kind !== "owned" + ) + throw new Error("Photon receiver lease was replaced"); + // Removal can be the first retained event after activation. Preserve a + // tombstone even when the group has never been discovered locally. + await tx + .insert(chatEndpointResources) + .values({ + companyId: record.endpoint.companyId, + endpointId, + providerResourceId: removedThreadId, + type: "group_chat", + label: "Unavailable iMessage group", + enabled: false, + availability: "unavailable", + metadata: { photonRemoved: true }, + }) + .onConflictDoUpdate({ + target: [ + chatEndpointResources.endpointId, + chatEndpointResources.type, + chatEndpointResources.providerResourceId, + ], + set: { + availability: "unavailable", + metadata: sql`jsonb_set(${chatEndpointResources.metadata}, '{photonRemoved}', 'true'::jsonb)`, + updatedAt: new Date(), + }, + }); + }); + await adapter.endTyping(removedThreadId); + return; + } + const groupIdentity = photonThreadId({ lineId: adapter.authentication.identity.lineId, chatGuid: event.chatGuid, isGroup: true }); + const removedGroup = await db.select({ metadata: chatEndpointResources.metadata }).from(chatEndpointResources).where(and(eq(chatEndpointResources.companyId, record.endpoint.companyId), eq(chatEndpointResources.endpointId, endpointId), eq(chatEndpointResources.providerResourceId, groupIdentity))).limit(1).then((rows) => rows[0]); + const readded = event.type === "group.changed" && event.change.type === "participantAdded" && event.change.participant.address === record.endpoint.botExternalId; + // Late events from a removed chat must still advance recovery without a + // now-forbidden chat lookup holding every other conversation behind them. + if (removedGroup?.metadata.photonRemoved === true && !readded) return; + const chat = await adapter.client.chats + .get(event.chatGuid) + .catch((error) => { + throw photonFailure(error); + }); + if (chat.guid !== event.chatGuid || chat.service !== "iMessage" || (chat.isGroup && adapter.authentication.identity.allocation === "shared")) return; + const threadId = photonThreadId({ + lineId: adapter.authentication.identity.lineId, + chatGuid: chat.guid, + isGroup: chat.isGroup, + }); + const removed = + event.type === "group.changed" && + (event.change.type === "participantRemoved" || + event.change.type === "participantLeft") && + event.change.participant.address === record.endpoint.botExternalId; + const available = await db.transaction(async (tx) => { + if ( + !(await runtimeCallbackEndpoint(tx, endpointId, context, [ + "verifying", + "active", + ])) + ) + throw new Error("Photon receiver is no longer current"); + const ownership = await renewDiscordGatewayOwnershipForMessageAdmission( + tx, + endpointId, + context, + ); + if (ownership.kind !== "owned") + throw new Error("Photon receiver lease was replaced"); + const existing = await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, endpointId), + eq(chatEndpointResources.providerResourceId, threadId), + eq( + chatEndpointResources.type, + chat.isGroup ? "group_chat" : "direct_message", + ), + ), + ) + .then((rows) => rows[0]); + const added = + event.type === "group.changed" && + event.change.type === "participantAdded" && + event.change.participant.address === record.endpoint.botExternalId; + const stillRemoved = + removed || (existing?.metadata.photonRemoved === true && !added); + const availability = + stillRemoved || chat.isArchived ? "unavailable" : "available"; + const metadata = { + photonRemoved: stillRemoved, + participants: chat.participants + .slice(0, 256) + .map((p) => ({ address: p.address, service: p.service })), + }; + const label = ( + chat.displayName || chat.participants.map((p) => p.address).join(", ") + ).slice(0, 512); + await tx + .insert(chatEndpointResources) + .values({ + companyId: record.endpoint.companyId, + endpointId, + providerResourceId: threadId, + type: chat.isGroup ? "group_chat" : "direct_message", + label, + enabled: !chat.isGroup, + availability, + metadata, + }) + .onConflictDoUpdate({ + target: [ + chatEndpointResources.endpointId, + chatEndpointResources.type, + chatEndpointResources.providerResourceId, + ], + set: { label, availability, metadata, updatedAt: new Date() }, + }); + return availability === "available"; + }); + if (!available) return; + if ( + !event.isFromMe && + (await processPhotonResponse( + record.endpoint, + event, + threadId, + adapter, + context, + )) + ) + return; + if (event.type === "message.received" && !event.isFromMe) { + await handleSdkMessage( + { + provider: "imessage-photon", + endpointId, + thread: context.endpointRuntime!.thread(threadId), + message: adapter.normalize(event.message, chat), + trigger: chat.isGroup ? "unaddressed_message" : "direct_message", + }, + context, + ); + } + } + return { handlePhotonFailure, handlePhotonEvent }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts b/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts new file mode 100644 index 00000000000..ff58df90f2f --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/file-transfer-authority.ts @@ -0,0 +1,1041 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; +import { createChatFailedRunRetry, type ChatFailedRetrySource as FailedChatRetrySource } from "../../chat-channels/failed-run-retry.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + asc, + eq, + gt, + inArray, + isNotNull, + isNull, + ne, + or, + sql, +} from "drizzle-orm"; + +import { + canonicalTeamsThreadId, +} from "../../chat-control-chronology.js"; + +import type { Db } from "@paperclipai/db"; +import { + createDurableChatWakeupRequest, +} from "../../durable-chat-wakeup.js"; +import { + agentWakeupRequests, + assets, + chatActions, + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + chatExternalPrincipals, + chatIdentityLinks, + chatMessageLinks, + chatPublications, + companyMemberships, + heartbeatRuns, + issueComments, + issueAttachments, + issues, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; +import { + isUuidLike, +} from "@paperclipai/shared"; +import { + isAllowedContentType, + MAX_ATTACHMENT_BYTES, +} from "../../../attachment-types.js"; + +import { + bindTeamsPersonalRecipient, + parseTeamsPersonalRecipient, +} from "../../chat-teams-personal-recipient.js"; +import { + type TeamsFileTransferAuthority, +} from "../../chat-teams-file-transfers.js"; + +import { + HttpError, + conflict, +} from "../../../errors.js"; + +import { + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { + authorizeNativeChatReviewPresentation, + NativeChatReviewPresentationContentionError, +} from "../../native-runtime/native-chat-review-presentation.js"; + +type ConversationRow = typeof chatConversations.$inferSelect; +type TeamsFileAuthorityOptions = { + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + runtime: Pick; + runtimeContexts: Pick, "get">; + localRuntimeEpoch: (endpointId: string) => number; + runtimeVersions: ReadonlyMap; + lockCurrentPrincipalAuthorization: (tx: DbOrTransaction, endpoint: EndpointRow, principalId: string) => Promise<{ allowed: boolean; userId: string | null }>; + lifecycleRuntimeFence: (delivery: typeof chatDeliveries.$inferSelect) => LifecycleRuntimeFence | null; + assertFailedRetryReceipt: ReturnType["assertFailedRetryReceipt"]; + inboundWakeScope: (action: typeof chatActions.$inferSelect) => string; + failedChatRetrySource: ReturnType["failedChatRetrySource"]; + isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean; + retryCommentIds: ReturnType["retryCommentIds"]; +}; + +export function createTeamsFileAuthority(options: TeamsFileAuthorityOptions) { + const { runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds } = options; + + + // Native Teams file consent is authority for one personal recipient and one + // immutable file, not authority inferred from the SDK's latest route cache. + const teamsBoardFileIntentKind = "teams_board_file_intent"; + const teamsFileProofHash = (value: unknown) => + createHash("sha256") + .update( + JSON.stringify(value, (_key, entry) => + entry && typeof entry === "object" && !Array.isArray(entry) + ? Object.fromEntries( + Object.keys(entry) + .sort() + .map((key) => [key, entry[key]]), + ) + : entry, + ), + ) + .digest("hex"); + + async function teamsFileCurrentScope( + tx: DbTransaction, + supplied: typeof chatPublications.$inferSelect, + fence?: LifecycleRuntimeFence, + ) { + const [endpoint] = await tx + .select() + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.companyId, supplied.companyId), + eq(chatEndpoints.id, supplied.endpointId), + ), + ) + .for("no key update", { noWait: true }); + if ( + !endpoint || + endpoint.provider !== "microsoft-teams" || + endpoint.status !== "active" || + !endpoint.allowDirectMessages || + !isUuidLike(endpoint.providerAccountId) || + !isUuidLike(endpoint.botExternalId) + ) + return null; + const [connection] = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share", { noWait: true }); + if (!connection?.enabled || connection.status !== "active") return null; + const currentFence = { + generation: runtimeGeneration(endpoint.setup), + credentialFingerprint: credentialFingerprint( + connection.credentialSecretRefs, + ), + }; + const instance = runtime.get(endpoint.id); + const registration = instance + ? runtimeContexts.get(instance as object) + : null; + if ( + !instance || + !registration || + registration.generation !== currentFence.generation || + registration.credentialFingerprint !== + currentFence.credentialFingerprint || + registration.localEpoch !== localRuntimeEpoch(endpoint.id) || + runtimeVersions.get(endpoint.id) !== registration.version || + (fence && + (fence.generation !== currentFence.generation || + fence.credentialFingerprint !== currentFence.credentialFingerprint)) + ) + return null; + const [conversation] = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, supplied.conversationId), + eq(chatConversations.issueId, supplied.issueId), + ), + ) + .for("update", { noWait: true }); + if ( + !conversation?.isDirectMessage || + !["active", "waiting"].includes(conversation.state) + ) + return null; + const [superseded] = await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.externalThreadId, conversation.externalThreadId), + gt( + chatConversations.sessionGeneration, + conversation.sessionGeneration, + ), + ), + ) + .limit(1); + if (superseded) return null; + const [issue] = await tx + .select({ id: issues.id, assigneeAgentId: issues.assigneeAgentId }) + .from(issues) + .where( + and( + eq(issues.companyId, endpoint.companyId), + eq(issues.id, conversation.issueId), + ), + ) + .for("share", { noWait: true }); + if (!issue || issue.assigneeAgentId !== endpoint.assignedAgentId) + return null; + // The conversation lock serializes rollover. The endpoint lock serializes + // reach/credential changes; no lock here waits behind inverse Board order. + if (conversation.resourceId) + await tx + .select({ id: chatEndpointResources.id }) + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + eq(chatEndpointResources.id, conversation.resourceId), + ), + ) + .for("update", { noWait: true }); + const [publication] = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.issueId, conversation.issueId), + eq(chatPublications.id, supplied.id), + ), + ) + .for("update", { noWait: true }); + const ids = publication?.payload.attachmentIds; + if ( + !publication?.commentId || + !Array.isArray(ids) || + ids.length !== 1 || + !isUuidLike(ids[0]) || + publication.payload.progressState !== undefined || + publication.payload.interactionId || + publication.payload.card || + publication.idempotencyKey.startsWith("control:") || + publication.idempotencyKey.startsWith("wake:") + ) + return null; + const [comment] = await tx + .select() + .from(issueComments) + .where( + and( + eq(issueComments.companyId, endpoint.companyId), + eq(issueComments.issueId, conversation.issueId), + eq(issueComments.id, publication.commentId), + isNull(issueComments.deletedAt), + ), + ) + .for("share", { noWait: true }); + const [file] = await tx + .select({ attachment: issueAttachments, asset: assets }) + .from(issueAttachments) + .innerJoin( + assets, + and( + eq(assets.id, issueAttachments.assetId), + eq(assets.companyId, endpoint.companyId), + ), + ) + .where( + and( + eq(issueAttachments.companyId, endpoint.companyId), + eq(issueAttachments.issueId, conversation.issueId), + eq(issueAttachments.issueCommentId, publication.commentId), + eq(issueAttachments.id, ids[0]), + ), + ) + .for("share", { noWait: true }); + const filename = + file?.asset.originalFilename ?? + (file ? `attachment-${file.attachment.id}` : ""); + if ( + !comment || + comment.updatedAt.getTime() !== comment.createdAt.getTime() || + !file || + !Number.isSafeInteger(file.asset.byteSize) || + file.asset.byteSize <= 0 || + file.asset.byteSize > MAX_ATTACHMENT_BYTES || + file.asset.byteSize >= 60 * 1024 * 1024 || + !/^[a-f0-9]{64}$/.test(file.asset.sha256) || + !file.asset.objectKey.trim() || + !isAllowedContentType(file.asset.contentType) || + !filename || + filename.length > 255 || + filename !== filename.trim() || + filename.endsWith(".") || + /[<>:"/\\|?*\x00-\x1f\x7f]/.test(filename) + ) + return null; + const byteIdentity = { + attachmentId: file.attachment.id, + assetId: file.asset.id, + sha256: file.asset.sha256, + byteSize: file.asset.byteSize, + filename, + contentType: file.asset.contentType, + originatingRunId: file.attachment.originatingRunId, + }; + return { + endpoint, + conversation, + publication, + comment, + file, + filename, + currentFence, + byteIdentity, + instance, + }; + } + + async function teamsFilePrincipalAuthorization( + tx: DbTransaction, + endpoint: EndpointRow, + principalId: string, + ) { + const identity = await tx.execute( + sql`select pg_try_advisory_xact_lock(hashtextextended(${`chat-identity:${endpoint.companyId}:${principalId}`}, 0)) as locked`, + ); + if (!identity[0]?.locked) + throw new NativeChatReviewPresentationContentionError(); + const [link] = await tx + .select() + .from(chatIdentityLinks) + .where( + and( + eq(chatIdentityLinks.companyId, endpoint.companyId), + eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.principalId, principalId), + ), + ) + .for("update", { noWait: true }); + const userId = + link?.status === "linked" ? link.paperclipUserId : endpoint.sponsorUserId; + if (userId) + await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, endpoint.companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + ), + ) + .for("update", { noWait: true }); + // All rows and the identity advisory key used by the common authorization + // helper are already held; the helper cannot introduce an inverse wait. + return lockCurrentPrincipalAuthorization(tx, endpoint, principalId); + } + + async function teamsFileSourceBinding( + tx: DbTransaction, + scope: NonNullable>>, + actionId: string, + ) { + const { endpoint, conversation } = scope; + const [action] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, endpoint.companyId), + eq(chatActions.endpointId, endpoint.id), + eq(chatActions.conversationId, conversation.id), + eq(chatActions.id, actionId), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + ), + ) + .for("update", { noWait: true }); + if ( + !action?.deliveryId || + !action.principalId || + action.payload.version !== 1 || + action.payload.issueId !== conversation.issueId || + action.payload.agentId !== endpoint.assignedAgentId || + action.payload.sessionGeneration !== conversation.sessionGeneration || + typeof action.payload.commentId !== "string" || + !isUuidLike(action.payload.commentId) + ) + return null; + const [delivery] = await tx + .select() + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.conversationId, conversation.id), + eq(chatDeliveries.id, action.deliveryId), + eq(chatDeliveries.principalId, action.principalId), + eq(chatDeliveries.state, "processed"), + inArray(chatDeliveries.eventKind, [ + "direct_message", + "mention", + "message", + ]), + ), + ) + .for("update", { noWait: true }); + const originalFence = delivery ? lifecycleRuntimeFence(delivery) : null; + const [principal] = await tx + .select() + .from(chatExternalPrincipals) + .where( + and( + eq(chatExternalPrincipals.companyId, endpoint.companyId), + eq(chatExternalPrincipals.id, action.principalId), + eq(chatExternalPrincipals.provider, "microsoft-teams"), + eq( + chatExternalPrincipals.providerAccountId, + endpoint.providerAccountId!, + ), + eq(chatExternalPrincipals.kind, "user"), + eq(chatExternalPrincipals.isBot, false), + ), + ) + .for("share", { noWait: true }); + if (!delivery || !originalFence || !principal) return null; + const admission = { + companyId: endpoint.companyId, + endpointId: endpoint.id, + runtimeGeneration: originalFence.generation, + credentialFingerprint: originalFence.credentialFingerprint, + tenantId: endpoint.providerAccountId!, + botAppId: endpoint.botExternalId!, + providerEventId: delivery.providerEventId, + threadId: conversation.externalThreadId, + isDirectMessage: true, + }; + const parsed = parseTeamsPersonalRecipient( + delivery.normalizedEvent.teamsPersonalRecipient, + admission, + ); + const binding = parsed + ? bindTeamsPersonalRecipient(parsed, { + admission, + deliveryId: delivery.id, + principalId: principal.id, + conversationId: conversation.id, + conversationGeneration: conversation.sessionGeneration, + externalPrincipalId: principal.externalId, + }) + : null; + const originalConversation = delivery.normalizedEvent.conversation as + | Record + | undefined; + const originalMessage = delivery.normalizedEvent.message as + | Record + | undefined; + const originalPrincipal = delivery.normalizedEvent.principal as + | Record + | undefined; + if ( + !binding || + originalConversation?.isDirectMessage !== true || + typeof originalConversation.externalThreadId !== "string" || + canonicalTeamsThreadId(originalConversation.externalThreadId) !== + canonicalTeamsThreadId(conversation.externalThreadId) || + originalMessage?.providerMessageId !== + binding.recipient.providerActivityId || + originalPrincipal?.externalId !== principal.externalId + ) + return null; + const [source] = await tx + .select({ comment: issueComments, link: chatMessageLinks }) + .from(chatMessageLinks) + .innerJoin( + issueComments, + and( + eq(issueComments.id, chatMessageLinks.commentId), + eq(issueComments.companyId, endpoint.companyId), + eq(issueComments.issueId, conversation.issueId), + ), + ) + .where( + and( + eq(chatMessageLinks.companyId, endpoint.companyId), + eq(chatMessageLinks.endpointId, endpoint.id), + eq(chatMessageLinks.conversationId, conversation.id), + eq(chatMessageLinks.deliveryId, delivery.id), + eq(chatMessageLinks.commentId, action.payload.commentId), + eq(chatMessageLinks.direction, "inbound"), + ), + ) + .for("share", { noWait: true }); + const expectedUserId = + action.payload.requestedByActorType === "user" + ? action.payload.requestedByActorId + : null; + if ( + !source || + source.link.providerMessageId !== binding.recipient.providerActivityId || + source.comment.deletedAt || + source.comment.createdAt > scope.comment.createdAt || + source.comment.updatedAt.getTime() !== + source.comment.createdAt.getTime() || + source.comment.authorUserId !== expectedUserId || + !["user", "system"].includes( + String(action.payload.requestedByActorType), + ) || + (expectedUserId === null && + action.payload.requestedByActorId !== principal.id) + ) + return null; + const [lifecycle] = await tx + .select({ id: chatDeliveries.id }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + or( + isNull(chatDeliveries.conversationId), + eq(chatDeliveries.conversationId, conversation.id), + ), + ne(chatDeliveries.state, "filtered"), + inArray(chatDeliveries.eventKind, [ + "message_updated", + "message_deleted", + "message_restored", + ]), + sql`${chatDeliveries.normalizedEvent}->'runtimeContext' = ${JSON.stringify(originalFence)}::jsonb`, + sql`${chatDeliveries.normalizedEvent}->'message'->>'targetProviderEventId' = ${delivery.providerEventId}`, + ), + ) + .limit(1); + if (lifecycle) return null; + const authorization = await teamsFilePrincipalAuthorization( + tx, + endpoint, + principal.id, + ); + if (!authorization.allowed || authorization.userId !== expectedUserId) + return null; + const [receipt] = await tx + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, endpoint.companyId), + eq(agentWakeupRequests.id, action.id), + eq(agentWakeupRequests.agentId, endpoint.assignedAgentId), + ), + ) + .for("share", { noWait: true }); + if (!receipt || ["skipped", "cancelled"].includes(receipt.status)) + return null; + try { + assertFailedRetryReceipt( + createDurableChatWakeupRequest({ + id: action.id, + companyId: endpoint.companyId, + agentId: endpoint.assignedAgentId, + issueId: conversation.issueId, + commentId: source.comment.id, + requestedByActorType: action.payload.requestedByActorType as + | "user" + | "system", + requestedByActorId: String(action.payload.requestedByActorId), + requestedAt: action.createdAt, + authorize: async () => {}, + }), + receipt, + ); + } catch (error) { + if ( + error instanceof HttpError && + (error.details as { code?: string } | undefined)?.code === + "chat_failed_run_retry_not_authorized" + ) + return null; + throw error; + } + return { + binding, + authorization, + action, + sourceFacts: { + actionId: action.id, + deliveryId: delivery.id, + commentId: source.comment.id, + principalId: principal.id, + scope: inboundWakeScope(action), + originalFence, + bodySha256: createHash("sha256") + .update(source.comment.body) + .digest("hex"), + receiptId: receipt.id, + recipientBindingSha256: teamsFileProofHash(binding), + }, + }; + } + + async function teamsBoardOriginBinding( + tx: DbTransaction, + scope: NonNullable>>, + ) { + // Pick the task's first admitted source, never the latest DM actor. If the + // durable task contains different recipient principals, consent is ambiguous. + const principals = await tx + .selectDistinct({ id: chatActions.principalId }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, + ), + ) + .limit(2); + if (principals.length !== 1 || !principals[0]?.id) return null; + const [origin] = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "processed"), + sql`${chatActions.payload}->>'sessionGeneration' = ${String(scope.conversation.sessionGeneration)}`, + ), + ) + .orderBy(asc(chatActions.createdAt), asc(chatActions.id)) + .limit(1); + return origin ? teamsFileSourceBinding(tx, scope, origin.id) : null; + } + + async function teamsBoardAuthorIsCurrent( + tx: DbTransaction, + companyId: string, + userId: string, + ) { + const [membership] = await tx + .select() + .from(companyMemberships) + .where( + and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), + ), + ) + .for("share", { noWait: true }); + return ( + membership?.status === "active" && membership.membershipRole !== "viewer" + ); + } + + function teamsBoardFileIntentPayload( + scope: NonNullable>>, + source: NonNullable>>, + userId: string, + ) { + return { + schema: "paperclip.teams.board-file-intent.v1", + publicationId: scope.publication.id, + issueId: scope.conversation.issueId, + commentId: scope.comment.id, + attachmentId: scope.file.attachment.id, + userId, + conversationGeneration: scope.conversation.sessionGeneration, + sourceActionId: source.action.id, + sourceScopeSha256: teamsFileProofHash(source.sourceFacts), + recipientBindingSha256: teamsFileProofHash(source.binding), + byteIdentitySha256: teamsFileProofHash(scope.byteIdentity), + commentBodySha256: createHash("sha256") + .update(scope.comment.body) + .digest("hex"), + }; + } + + function teamsFileRuntimeStillCurrent( + scope: NonNullable>>, + ) { + const registration = runtimeContexts.get(scope.instance as object); + return ( + runtime.get(scope.endpoint.id) === scope.instance && + registration?.generation === scope.currentFence.generation && + registration.credentialFingerprint === + scope.currentFence.credentialFingerprint && + registration.localEpoch === localRuntimeEpoch(scope.endpoint.id) && + runtimeVersions.get(scope.endpoint.id) === registration.version + ); + } + + async function stageTeamsBoardFileIntent( + tx: DbTransaction, + publication: typeof chatPublications.$inferSelect, + userId: string, + conversation: ConversationRow, + ): Promise { + if ( + !publication.idempotencyKey.startsWith("explicit-board:") || + !conversation.isDirectMessage + ) + return; + const scope = await teamsFileCurrentScope(tx, publication); + if ( + !scope || + scope.conversation.id !== conversation.id || + scope.conversation.sessionGeneration !== conversation.sessionGeneration || + scope.comment.authorUserId !== userId || + scope.comment.authorAgentId || + scope.comment.createdByRunId || + !(await teamsBoardAuthorIsCurrent(tx, scope.endpoint.companyId, userId)) + ) + return; + const source = await teamsBoardOriginBinding(tx, scope); + if (!source || !teamsFileRuntimeStillCurrent(scope)) return; + const key = `${teamsBoardFileIntentKind}:${publication.id}`; + const payload = teamsBoardFileIntentPayload(scope, source, userId); + const [existing] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.providerActionId, key), + ), + ) + .for("update", { noWait: true }); + if (existing) { + if ( + existing.kind !== teamsBoardFileIntentKind || + existing.status !== "processed" || + existing.deliveryId !== source.binding.deliveryId || + existing.principalId !== source.binding.principalId || + existing.conversationId !== scope.conversation.id || + teamsFileProofHash(existing.payload) !== teamsFileProofHash(payload) + ) + throw conflict("The Teams file recipient intent has changed", { + code: "chat_file_transfer_authority_changed", + }); + return; + } + await tx.insert(chatActions).values({ + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + deliveryId: source.binding.deliveryId, + principalId: source.binding.principalId, + kind: teamsBoardFileIntentKind, + providerActionId: key, + status: "processed", + payload, + }); + } + + async function deriveTeamsFileTransferAuthority( + tx: DbTransaction, + publication: typeof chatPublications.$inferSelect, + fence: LifecycleRuntimeFence, + continuingTeamsFileTransfer?: NonNullable< + NonNullable< + Parameters[1]["committedResponse"] + >["continuingTeamsFileTransfer"] + >, + ): Promise { + const scope = await teamsFileCurrentScope(tx, publication, fence); + if (!scope) return null; + let source: NonNullable>>; + let causal: Record; + if (isExplicitOperatorPublication(scope.publication)) { + if ( + !scope.publication.idempotencyKey.startsWith("explicit-board:") || + !scope.comment.authorUserId || + scope.comment.authorAgentId || + scope.comment.createdByRunId || + !(await teamsBoardAuthorIsCurrent( + tx, + scope.endpoint.companyId, + scope.comment.authorUserId, + )) + ) + return null; + const [intent] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.endpointId, scope.endpoint.id), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, teamsBoardFileIntentKind), + eq( + chatActions.providerActionId, + `${teamsBoardFileIntentKind}:${scope.publication.id}`, + ), + eq(chatActions.status, "processed"), + ), + ) + .for("share", { noWait: true }); + if ( + !intent || + typeof intent.payload.sourceActionId !== "string" || + !isUuidLike(intent.payload.sourceActionId) + ) + return null; + const bound = await teamsBoardOriginBinding(tx, scope); + if ( + !bound || + bound.action.id !== intent.payload.sourceActionId || + intent.deliveryId !== bound.binding.deliveryId || + intent.principalId !== bound.binding.principalId + ) + return null; + const currentPayload = teamsBoardFileIntentPayload( + scope, + bound, + scope.comment.authorUserId, + ); + if ( + teamsFileProofHash(intent.payload) !== + teamsFileProofHash(currentPayload) + ) + return null; + source = bound; + causal = { + kind: "explicit_board_send", + intentId: intent.id, + ...currentPayload, + }; + } else { + const runId = scope.comment.createdByRunId; + if ( + !runId || + scope.comment.authorAgentId !== scope.endpoint.assignedAgentId || + scope.file.attachment.originatingRunId !== runId + ) + return null; + const [run] = await tx + .select() + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, scope.endpoint.companyId), + eq(heartbeatRuns.id, runId), + eq(heartbeatRuns.agentId, scope.endpoint.assignedAgentId), + eq(heartbeatRuns.nativeIssueId, scope.conversation.issueId), + eq(heartbeatRuns.runtimeMode, "native"), + eq(heartbeatRuns.status, "succeeded"), + isNotNull(heartbeatRuns.finishedAt), + ), + ) + .for("share", { noWait: true }); + const marker = run?.resultJson?.nativeCommittedChatResponse as + | Record + | undefined; + if ( + !run || + !marker || + marker.schema !== "paperclip.native_committed_chat_response.v1" || + typeof marker.resultId !== "string" || + !isUuidLike(marker.resultId) || + typeof marker.decisionId !== "string" || + !isUuidLike(marker.decisionId) || + typeof marker.canonicalSha256 !== "string" || + !/^(?:sha256:)?[a-f0-9]{64}$/.test(marker.canonicalSha256) + ) + return null; + // Pre-lock the exact causal source and actor rows before the existing + // complete native publication proof invokes its common authorization. + const originActions = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.companyId, scope.endpoint.companyId), + eq(chatActions.conversationId, scope.conversation.id), + eq(chatActions.kind, "inbound_wakeup"), + inArray( + sql`${chatActions.payload}->>'commentId'`, + retryCommentIds(run.contextSnapshot ?? {}), + ), + ), + ); + const bindings: Array< + NonNullable>> + > = []; + for (const action of originActions) { + const binding = await teamsFileSourceBinding(tx, scope, action.id); + if (!binding) return null; + bindings.push(binding); + } + let proved: FailedChatRetrySource; + try { + proved = await failedChatRetrySource(tx, { + companyId: scope.endpoint.companyId, + issueId: scope.conversation.issueId, + agentId: run.agentId, + failedRunId: run.id, + publication: true, + committedResponse: { + resultId: marker.resultId, + canonicalSha256: marker.canonicalSha256, + decisionId: marker.decisionId, + continuingTeamsFileTransfer, + }, + }); + } catch (error) { + if ( + error instanceof HttpError && + (error.details as { code?: string } | undefined)?.code === + "chat_failed_run_retry_not_authorized" + ) + return null; + throw error; + } + if ( + proved.provider !== "microsoft-teams" || + proved.endpointId !== scope.endpoint.id || + proved.conversationId !== scope.conversation.id || + proved.sessionGeneration !== scope.conversation.sessionGeneration || + bindings.length !== proved.sources.length + ) + return null; + const ordered = proved.sources.map((entry) => + bindings.find((binding) => binding.action.id === entry.actionId), + ); + const first = ordered[0]; + if ( + !first || + ordered.some( + (entry) => + !entry || + entry.binding.principalId !== proved.principalId || + entry.binding.recipient.providerConversationId !== + first.binding.recipient.providerConversationId || + entry.binding.recipient.providerUserId !== + first.binding.recipient.providerUserId || + entry.binding.recipient.aadObjectId !== + first.binding.recipient.aadObjectId, + ) + ) + return null; + if ( + run.resultJson?.finalizationReasonCode === + "governed_response_waiting" && + !(await authorizeNativeChatReviewPresentation( + tx as unknown as Db, + { + companyId: scope.endpoint.companyId, + issueId: scope.conversation.issueId, + runId: run.id, + resultJson: run.resultJson, + destination: { + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + }, + }, + "nonblocking", + )) + ) + return null; + source = first; + causal = { + kind: "native_committed_response", + runId: run.id, + resultId: marker.resultId, + canonicalSha256: marker.canonicalSha256, + decisionId: marker.decisionId, + sourceScopeSha256: proved.sourceScopeSha256, + sources: ordered.map((entry) => entry!.sourceFacts), + }; + } + // No mutable stage, attempt, receipt, route URL or provider token enters + // this digest. The next authorization derives these facts afresh. + const sourceDigest = teamsFileProofHash({ + schema: "paperclip.teams.file-source.v1", + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + conversationGeneration: scope.conversation.sessionGeneration, + issueId: scope.conversation.issueId, + publicationId: scope.publication.id, + commentId: scope.comment.id, + causal, + byteIdentity: scope.byteIdentity, + }); + if (!teamsFileRuntimeStillCurrent(scope)) return null; + return { + companyId: scope.endpoint.companyId, + endpointId: scope.endpoint.id, + conversationId: scope.conversation.id, + issueId: scope.conversation.issueId, + publicationId: scope.publication.id, + commentId: scope.comment.id, + attachmentId: scope.file.attachment.id, + principalId: source.binding.principalId, + authorizedUserId: source.authorization.userId, + runtimeGeneration: scope.currentFence.generation, + credentialFingerprint: scope.currentFence.credentialFingerprint, + conversationGeneration: scope.conversation.sessionGeneration, + sourceDigest, + tenantId: source.binding.recipient.tenantId, + botAppId: source.binding.recipient.botAppId, + aadObjectId: source.binding.recipient.aadObjectId, + providerConversationId: source.binding.recipient.providerConversationId, + providerUserId: source.binding.recipient.providerUserId, + sha256: scope.file.asset.sha256, + byteSize: scope.file.asset.byteSize, + filename: scope.filename, + }; + } + return { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority }; +} diff --git a/server/src/services/chat-channels/slack/file-receipts.ts b/server/src/services/chat-channels/slack/file-receipts.ts new file mode 100644 index 00000000000..a0cc84c2852 --- /dev/null +++ b/server/src/services/chat-channels/slack/file-receipts.ts @@ -0,0 +1,1000 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + randomUUID, +} from "node:crypto"; +import { + and, + asc, + eq, + inArray, + isNull, + lte, + notExists, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatMessageLinks, + chatPublications, +} from "@paperclipai/db"; + +import { + isUuidLike, +} from "@paperclipai/shared"; + +import { + type SlackFileUploadAcceptedReceipt, +} from "../../chat-sdk-runtime.js"; + +import { + stageSlackSessionSync, +} from "../../chat-slack-session-outbox.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type SlackFileUploadReceiptPayload = { + version: 1; + publicationId: string; + publicationAttempt: number; + threadId: string; + fileIds: string[]; + botExternalId: string; + runtimeGeneration: number; + credentialFingerprint: string; +}; + +function slackFileUploadReceiptPayload( + payload: Record, +): SlackFileUploadReceiptPayload | null { + if ( + payload.version !== 1 || + typeof payload.publicationId !== "string" || + !isUuidLike(payload.publicationId) || + typeof payload.publicationAttempt !== "number" || + !Number.isSafeInteger(payload.publicationAttempt) || + payload.publicationAttempt < 1 || + typeof payload.threadId !== "string" || + payload.threadId.length < 1 || + payload.threadId.length > 2_048 || + !payload.threadId.startsWith("slack:") || + typeof payload.botExternalId !== "string" || + payload.botExternalId.length < 1 || + payload.botExternalId.length > 255 || + typeof payload.runtimeGeneration !== "number" || + !Number.isSafeInteger(payload.runtimeGeneration) || + payload.runtimeGeneration < 0 || + typeof payload.credentialFingerprint !== "string" || + !/^[a-f0-9]{64}$/.test(payload.credentialFingerprint) || + !Array.isArray(payload.fileIds) || + payload.fileIds.length < 1 || + payload.fileIds.length > 20 || + payload.fileIds.some( + (fileId) => + typeof fileId !== "string" || !/^F[A-Z0-9]{1,254}$/.test(fileId), + ) || + new Set(payload.fileIds).size !== payload.fileIds.length + ) { + return null; + } + return payload as SlackFileUploadReceiptPayload; +} + +function operatorConfirmedSlackFileReceipt( + result: Record | null, + payload: SlackFileUploadReceiptPayload, +): boolean { + return ( + result?.operatorConfirmedPublicationId === payload.publicationId && + result.operatorConfirmedAttempt === payload.publicationAttempt && + typeof result.operatorConfirmedAt === "string" && + Number.isFinite(Date.parse(result.operatorConfirmedAt)) + ); +} + +function sameSlackFileUploadReceipt( + left: SlackFileUploadReceiptPayload, + right: SlackFileUploadReceiptPayload, +): boolean { + return ( + left.publicationId === right.publicationId && + left.publicationAttempt === right.publicationAttempt && + left.threadId === right.threadId && + left.botExternalId === right.botExternalId && + left.runtimeGeneration === right.runtimeGeneration && + left.credentialFingerprint === right.credentialFingerprint && + left.fileIds.length === right.fileIds.length && + left.fileIds.every((fileId, index) => fileId === right.fileIds[index]) + ); +} +const SLACK_FILE_RECEIPT_MAX_AGE_MS = 24 * 60 * 60_000; +const SLACK_FILE_RECEIPT_MAX_ATTEMPTS = 12; +const SLACK_FILE_RECEIPT_STALE_MS = 60_000; +type ConversationRow = typeof chatConversations.$inferSelect; +type SlackFileReceiptsOptions = { + endpointRecord: (id: string) => Promise; + withCredentialMutationLease: ReturnType; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + runtimeFor: (endpoint: EndpointRow) => Promise; + isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean; + recordExactOutboundPublicationLink: (tx: DbOrTransaction, input: { publication: typeof chatPublications.$inferSelect; providerMessageId: string }) => Promise; +}; + +export function createSlackFileReceipts(db: Db, options: SlackFileReceiptsOptions) { + const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runtimeFor, isExplicitOperatorPublication, recordExactOutboundPublicationLink } = options; + + + async function recordSlackFileUploadReceipt(input: { + credentialLease: CredentialMutationLeaseGuard; + conversation: ConversationRow; + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + receipt: SlackFileUploadAcceptedReceipt; + runtimeContext: LifecycleRuntimeFence; + }): Promise { + const publicationAttempt = input.publication.attempts + 1; + const botExternalId = input.endpoint.botExternalId; + const expectedThreadId = `slack:${input.receipt.channelId}:${input.receipt.threadTs ?? ""}`; + if ( + input.endpoint.provider !== "slack" || + !botExternalId || + input.receipt.version !== 1 || + expectedThreadId !== input.conversation.externalThreadId || + input.receipt.fileIds.length !== + (input.publication.payload.attachmentIds?.length ?? 0) + ) { + throw new Error( + "Slack file upload receipt did not match its publication", + ); + } + const payload: SlackFileUploadReceiptPayload = { + version: 1, + publicationId: input.publication.id, + publicationAttempt, + threadId: expectedThreadId, + fileIds: [...input.receipt.fileIds], + botExternalId, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }; + if (!slackFileUploadReceiptPayload(payload)) { + throw new Error("Slack file upload receipt was malformed"); + } + const providerActionId = `slack-file-receipt:${input.publication.id}:${publicationAttempt}`; + const recordedAt = new Date(); + return db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.publication.id), + eq(chatPublications.companyId, input.publication.companyId), + eq(chatPublications.endpointId, input.publication.endpointId), + eq( + chatPublications.conversationId, + input.publication.conversationId, + ), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = publication + ? await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + input.runtimeContext, + ["verifying", "active"], + ) + : null; + const conversation = endpoint + ? await tx + .select({ + externalThreadId: chatConversations.externalThreadId, + id: chatConversations.id, + }) + .from(chatConversations) + .where( + and( + eq(chatConversations.id, input.conversation.id), + eq(chatConversations.companyId, input.publication.companyId), + eq(chatConversations.endpointId, input.endpoint.id), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + if ( + !publication || + endpoint?.provider !== "slack" || + endpoint.botExternalId !== botExternalId || + conversation?.externalThreadId !== expectedThreadId + ) { + throw new Error( + "Slack file upload ownership changed before receipt persistence", + ); + } + await notifyChatPublicationWork(tx); + const inserted = await tx + .insert(chatActions) + .values({ + companyId: input.publication.companyId, + endpointId: input.publication.endpointId, + conversationId: input.publication.conversationId, + kind: "slack_file_upload_receipt", + providerActionId, + payload, + status: "received", + result: { code: "slack_file_upload_identity_pending", attempts: 0 }, + createdAt: recordedAt, + updatedAt: recordedAt, + }) + .onConflictDoNothing() + .returning({ id: chatActions.id }); + if (inserted[0]) { + await input.credentialLease.assertOwned(tx); + return inserted[0].id; + } + const existing = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.publication.endpointId), + eq(chatActions.providerActionId, providerActionId), + eq(chatActions.kind, "slack_file_upload_receipt"), + sql`${chatActions.payload} = ${JSON.stringify(payload)}::jsonb`, + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!existing) { + throw new Error( + "Slack file upload receipt conflicted with durable state", + ); + } + await input.credentialLease.assertOwned(tx); + return existing.id; + }); + } + + async function stageSlackFilePublicationSuccess( + tx: DbTransaction, + input: { + committedAt: Date; + endpoint: EndpointRow; + publication: typeof chatPublications.$inferSelect; + providerMessageId: string; + runtimeContext: LifecycleRuntimeFence; + stageSessionSync: boolean; + }, + ): Promise { + if ( + !(await recordExactOutboundPublicationLink(tx, { + publication: input.publication, + providerMessageId: input.providerMessageId, + })) + ) { + throw new Error( + "Slack file upload message identity conflicts with another publication", + ); + } + await tx + .update(chatEndpoints) + .set({ + lastPublicationAt: input.committedAt, + updatedAt: input.committedAt, + }) + .where( + and( + eq(chatEndpoints.id, input.endpoint.id), + inArray(chatEndpoints.status, ["verifying", "active"]), + sql`coalesce((${chatEndpoints.setup}->>'runtimeGeneration')::integer, 0) = ${input.runtimeContext.generation}`, + ), + ); + if (input.stageSessionSync) { + await stageSlackSessionSync(tx, { + companyId: input.publication.companyId, + endpointId: input.publication.endpointId, + conversationId: input.publication.conversationId, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + }); + } + } + + async function lockSlackFileReceiptAuthorization( + tx: DbTransaction, + input: { + actionId: string; + payload: SlackFileUploadReceiptPayload; + }, + ): Promise< + | { + action: typeof chatActions.$inferSelect; + conversation: ConversationRow; + endpoint: EndpointRow; + mode: "delivery_unknown" | "operator_confirmed"; + publication: typeof chatPublications.$inferSelect; + } + | { action: typeof chatActions.$inferSelect; mode: "defer" | "settled" } + | null + > { + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.payload.publicationId), + eq(chatPublications.attempts, input.payload.publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const action = publication + ? await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.id, input.actionId), + eq(chatActions.companyId, publication.companyId), + eq(chatActions.endpointId, publication.endpointId), + eq(chatActions.conversationId, publication.conversationId), + eq(chatActions.kind, "slack_file_upload_receipt"), + eq( + chatActions.providerActionId, + `slack-file-receipt:${publication.id}:${publication.attempts}`, + ), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const currentPayload = action + ? slackFileUploadReceiptPayload(action.payload) + : null; + if ( + !publication || + !action || + !publication.payload.attachmentIds?.length || + publication.payload.card || + publication.payload.interactionId || + publication.idempotencyKey.startsWith("control:") || + !currentPayload || + !sameSlackFileUploadReceipt(currentPayload, input.payload) + ) { + return null; + } + if (publication.state === "streaming") { + return { action, mode: "defer" }; + } + if (publication.state === "published" && publication.providerMessageId) { + const exactLink = await tx + .select({ id: chatMessageLinks.id }) + .from(chatMessageLinks) + .where( + and( + eq(chatMessageLinks.companyId, publication.companyId), + eq(chatMessageLinks.endpointId, publication.endpointId), + eq(chatMessageLinks.conversationId, publication.conversationId), + eq(chatMessageLinks.publicationId, publication.id), + publication.commentId + ? eq(chatMessageLinks.commentId, publication.commentId) + : isNull(chatMessageLinks.commentId), + eq( + chatMessageLinks.providerMessageId, + publication.providerMessageId, + ), + eq(chatMessageLinks.direction, "outbound"), + ), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + return exactLink ? { action, mode: "settled" } : null; + } + const mode = + publication.state === "delivery_unknown" + ? "delivery_unknown" + : publication.state === "published" && + !publication.providerMessageId && + operatorConfirmedSlackFileReceipt(action.result, input.payload) + ? "operator_confirmed" + : null; + if (!mode) return null; + const endpoint = await runtimeCallbackEndpoint( + tx, + publication.endpointId, + { + generation: input.payload.runtimeGeneration, + credentialFingerprint: input.payload.credentialFingerprint, + }, + ["verifying", "active"], + ); + const conversation = endpoint + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.id, publication.conversationId), + eq(chatConversations.companyId, publication.companyId), + eq(chatConversations.endpointId, publication.endpointId), + eq(chatConversations.externalThreadId, input.payload.threadId), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + const resource = + endpoint && conversation?.resourceId && !conversation.isDirectMessage + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.id, conversation.resourceId), + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + ), + ) + .for("no key update") + .then((rows) => rows[0] ?? null) + : null; + if ( + endpoint?.provider !== "slack" || + endpoint.botExternalId !== input.payload.botExternalId || + !conversation || + !(conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource)) + ) { + return null; + } + return { action, conversation, endpoint, mode, publication }; + } + + async function processPendingSlackFileUploadReceipts(limit = 25) { + const selectedAt = new Date(); + const actions = await db + .select() + .from(chatActions) + .where( + slackReceiptEligibility(selectedAt), + ) + .orderBy(asc(chatActions.updatedAt), asc(chatActions.id)) + .limit(limit); + let processed = 0; + for (const selected of actions) { + const payload = slackFileUploadReceiptPayload(selected.payload); + if (!payload) { + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { + code: "slack_file_upload_receipt_payload_invalid", + retryable: false, + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.kind, selected.kind), + eq(chatActions.status, selected.status), + sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, + selected.result === null + ? isNull(chatActions.result) + : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, + ), + ); + }); + continue; + } + const record = await endpointRecord(selected.endpointId); + if (!record) { + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_file_upload_receipt_endpoint_removed" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.kind, selected.kind), + eq(chatActions.status, selected.status), + sql`${chatActions.payload} = ${JSON.stringify(selected.payload)}::jsonb`, + selected.result === null + ? isNull(chatActions.result) + : sql`${chatActions.result} = ${JSON.stringify(selected.result)}::jsonb`, + ), + ); + }); + continue; + } + if (["paused", "attention"].includes(record.endpoint.status)) continue; + const ownerToken = randomUUID(); + let attempt = 1; + try { + await withCredentialMutationLease(record.endpoint, async (lease) => { + const claim = await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const locked = await lockSlackFileReceiptAuthorization(tx, { + actionId: selected.id, + payload, + }); + const decisionAt = new Date(); + const priorAttempts = + typeof locked?.action.result?.attempts === "number" && + Number.isSafeInteger(locked.action.result.attempts) + ? locked.action.result.attempts + : 0; + attempt = priorAttempts + 1; + const retryAt = + typeof locked?.action.result?.retryAt === "string" + ? Date.parse(locked.action.result.retryAt) + : Number.NaN; + const eligible = locked + ? locked.action.status === "received" || + (locked.action.status === "failed" && + locked.action.result?.retryable === true && + Number.isFinite(retryAt) && + retryAt <= decisionAt.getTime()) || + (locked.action.status === "processing" && + locked.action.updatedAt.getTime() <= + decisionAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS) + : false; + if (locked && !eligible) return null; + if (locked?.mode === "defer") { + if (locked.action.status === "processing") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "received", + result: { + code: "slack_file_upload_identity_pending", + attempts: priorAttempts, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, locked.action.id)); + } + return null; + } + if (locked?.mode === "settled") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "processed", + result: { + code: "slack_file_upload_identity_confirmed", + attempts: priorAttempts, + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, locked.action.id)); + return null; + } + if ( + !locked || + decisionAt.getTime() - + (locked?.action.createdAt ?? selected.createdAt).getTime() > + SLACK_FILE_RECEIPT_MAX_AGE_MS || + attempt > SLACK_FILE_RECEIPT_MAX_ATTEMPTS + ) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: locked + ? "slack_file_upload_receipt_expired" + : "slack_file_upload_receipt_authorization_changed", + attempts: priorAttempts, + }, + updatedAt: decisionAt, + }) + .where( + and( + eq(chatActions.id, selected.id), + inArray(chatActions.status, [ + "received", + "failed", + "processing", + ]), + ), + ); + return null; + } + if ( + !["received", "failed", "processing"].includes( + locked.action.status, + ) + ) { + return null; + } + await notifyChatPublicationWork(tx); + const [claimed] = await tx + .update(chatActions) + .set({ + status: "processing", + result: { + attempts: attempt, + ownerToken, + ...(locked.mode === "operator_confirmed" + ? { + operatorConfirmedPublicationId: + locked.action.result?.operatorConfirmedPublicationId, + operatorConfirmedAttempt: + locked.action.result?.operatorConfirmedAttempt, + operatorConfirmedAt: + locked.action.result?.operatorConfirmedAt, + } + : {}), + }, + updatedAt: decisionAt, + }) + .where( + and( + eq(chatActions.id, locked.action.id), + eq(chatActions.status, locked.action.status), + ), + ) + .returning({ id: chatActions.id }); + await lease.assertOwned(tx); + return claimed ? locked : null; + }); + if (!claim || !("endpoint" in claim)) return; + processed += 1; + const endpointRuntime = await runtimeFor(claim.endpoint); + await lease.assertOwned(); + const providerMessageId = + await endpointRuntime.resolveSlackFileUploadReceipt( + payload.threadId, + payload.fileIds, + ); + if (!providerMessageId) { + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "failed", + result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( + { + attempts: attempt, + code: "slack_file_upload_identity_pending", + retryable: true, + retryAt: new Date( + Date.now() + Math.min(60_000, 1_000 * 2 ** attempt), + ).toISOString(), + }, + )}::jsonb`, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + return; + } + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const locked = await lockSlackFileReceiptAuthorization(tx, { + actionId: selected.id, + payload, + }); + if ( + !locked || + locked.mode === "defer" || + locked.mode === "settled" || + !("publication" in locked) || + locked.action.status !== "processing" || + locked.action.result?.ownerToken !== ownerToken + ) { + if (!locked) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + attempts: attempt, + code: "slack_file_upload_receipt_authorization_changed", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } else if (locked.mode === "defer") { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "received", + result: { + attempts: attempt, + code: "slack_file_upload_identity_pending", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } else { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: + locked.mode === "settled" ? "processed" : "cancelled", + result: { + attempts: attempt, + code: + locked.mode === "settled" + ? "slack_file_upload_identity_confirmed" + : "slack_file_upload_receipt_superseded", + }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + } + return; + } + const committedAt = new Date(); + if ( + !(await recordExactOutboundPublicationLink(tx, { + publication: locked.publication, + providerMessageId, + })) + ) { + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "failed", + result: { + attempts: attempt, + code: "slack_file_upload_receipt_message_conflict", + retryable: false, + }, + updatedAt: committedAt, + }) + .where(eq(chatActions.id, selected.id)); + return; + } + if (locked.mode === "delivery_unknown") { + await notifyChatPublicationWork(tx); + const [settled] = await tx + .update(chatPublications) + .set({ + state: "published", + providerMessageId, + publishedAt: committedAt, + nextAttemptAt: null, + redactedError: null, + updatedAt: committedAt, + }) + .where( + and( + eq(chatPublications.id, locked.publication.id), + eq(chatPublications.state, "delivery_unknown"), + eq(chatPublications.attempts, payload.publicationAttempt), + ), + ) + .returning({ id: chatPublications.id }); + if (!settled) { + throw new Error( + "Slack file upload publication changed before receipt commit", + ); + } + await stageSlackFilePublicationSuccess(tx, { + committedAt, + endpoint: locked.endpoint, + publication: locked.publication, + providerMessageId, + runtimeContext: { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + stageSessionSync: + !isExplicitOperatorPublication(locked.publication) && + !locked.publication.idempotencyKey.startsWith( + "control:status:", + ), + }); + } else { + await notifyChatPublicationWork(tx); + const [enriched] = await tx + .update(chatPublications) + .set({ providerMessageId }) + .where( + and( + eq(chatPublications.id, locked.publication.id), + eq(chatPublications.state, "published"), + eq(chatPublications.attempts, payload.publicationAttempt), + isNull(chatPublications.providerMessageId), + ), + ) + .returning({ id: chatPublications.id }); + if (!enriched) { + throw new Error( + "Slack file upload publication changed before identity enrichment", + ); + } + } + await notifyChatPublicationWork(tx); + await tx + .update(chatActions) + .set({ + status: "processed", + result: { + attempts: attempt, + code: "slack_file_upload_identity_confirmed", + }, + updatedAt: committedAt, + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + await lease.assertOwned(tx); + }); + }); + } catch (error) { + const disposition = classifyChatPublicationError(error, attempt); + const retryable = + disposition.kind === "retry" || + disposition.kind === "delivery_unknown"; + const retryMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : Math.min(60_000, 1_000 * 2 ** Math.min(attempt, 8)); + await db.transaction(async (tx) => { + await notifyChatPublicationWork(tx); + return tx + .update(chatActions) + .set({ + status: retryable ? "failed" : "cancelled", + result: sql`coalesce(${chatActions.result}, '{}'::jsonb) || ${JSON.stringify( + { + attempts: attempt, + code: retryable + ? "slack_file_upload_receipt_lookup_retry" + : "slack_file_upload_receipt_lookup_rejected", + retryable, + ...(retryable + ? { retryAt: new Date(Date.now() + retryMs).toISOString() } + : {}), + }, + )}::jsonb`, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, selected.id), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + } + } + return processed; + } + + function slackReceiptEligibility(selectedAt?: Date) { + return and( + eq(chatActions.kind, "slack_file_upload_receipt"), + notExists( + db + .select({ id: chatEndpoints.id }) + .from(chatEndpoints) + .where( + and( + eq(chatEndpoints.id, chatActions.endpointId), + inArray(chatEndpoints.status, ["paused", "attention"]), + ), + ), + ), + // The normal publication owner is still polling Slack. Receipt + // recovery must neither contend with it nor let its not-yet-due + // action occupy the bounded recovery page. + notExists( + db + .select({ id: chatPublications.id }) + .from(chatPublications) + .where( + and( + sql`${chatPublications.id}::text = ${chatActions.payload}->>'publicationId'`, + sql`${chatPublications.attempts}::text = ${chatActions.payload}->>'publicationAttempt'`, + eq(chatPublications.state, "streaming"), + ), + ), + ), + or( + eq(chatActions.status, "received"), + and( + eq(chatActions.status, "failed"), + sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'true'`, + selectedAt ? sql`(${chatActions.result}->>'retryAt')::timestamptz <= ${selectedAt.toISOString()}::timestamptz` : sql`${chatActions.result}->>'retryAt' is not null`, + ), + and( + eq(chatActions.status, "processing"), + selectedAt ? lte(chatActions.updatedAt, new Date(selectedAt.getTime() - SLACK_FILE_RECEIPT_STALE_MS)) : undefined, + ), + ), + ); + } + + async function nextSlackReceiptAt(): Promise { + const [row] = await db.select({ at: sql`min(case + when ${chatActions.status} = 'processing' then ${chatActions.updatedAt} + ${SLACK_FILE_RECEIPT_STALE_MS} * interval '1 millisecond' + when ${chatActions.status} = 'failed' then (${chatActions.result}->>'retryAt')::timestamptz + else now() end)` }).from(chatActions).where(slackReceiptEligibility()); + return row?.at == null ? null : new Date(row.at).getTime(); + } + return { recordSlackFileUploadReceipt, stageSlackFilePublicationSuccess, processPendingSlackFileUploadReceipts, nextSlackReceiptAt }; +} diff --git a/server/src/services/chat-channels/slack/session-status.ts b/server/src/services/chat-channels/slack/session-status.ts new file mode 100644 index 00000000000..ccdf67e8412 --- /dev/null +++ b/server/src/services/chat-channels/slack/session-status.ts @@ -0,0 +1,408 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { dueChatAction } from "../../chat-action-work.js"; + +import { notifyChatActionWork } from "../../chat-work-notifications.js"; + +import { chatCredentialMutationLease } from "../../chat-credential-mutation-lease.js"; + +import { + randomUUID, +} from "node:crypto"; +import { + and, + asc, + desc, + eq, + gt, + inArray, + lte, + or, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatEndpointResources, + chatEndpoints, + chatPublications, + heartbeatRuns, +} from "@paperclipai/db"; + +import { logger } from "../../../middleware/logger.js"; + +import { + setSlackSessionStatus, + slackSessionStatusForPublication, +} from "../../chat-slack-sessions.js"; +import { + slackSessionSyncPayload, +} from "../../chat-slack-session-outbox.js"; + +import { classifyChatPublicationError } from "../../chat-publication-errors.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type SlackSessionStatusOptions = { + endpointRecord: (id: string) => Promise; + withCredentialMutationLease: ReturnType; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + runIdFromMilestonePublication: (publication: typeof chatPublications.$inferSelect) => string | null; + resolveCredentials: (endpoint: EndpointRow, database?: DbOrTransaction) => Promise>; + fetchImpl: typeof globalThis.fetch; + redactError: (error: unknown) => string; + slackSessionSyncSelectionBarrier?: () => Promise; +}; + +export function createSlackSessionStatus(db: Db, options: SlackSessionStatusOptions) { + const { endpointRecord, withCredentialMutationLease, runtimeCallbackEndpoint, nonDirectDestinationAllowed, runIdFromMilestonePublication, resolveCredentials, fetchImpl, redactError } = options; + + + async function processPendingSlackSessionSyncs( + limit = 25, + onlyActionId?: string, + ) { + const now = new Date(); + const actions = await db + .select({ action: chatActions }) + .from(chatActions) + .leftJoin(chatEndpoints, and(eq(chatEndpoints.id, chatActions.endpointId), eq(chatEndpoints.companyId, chatActions.companyId))) + .where(and( + onlyActionId ? eq(chatActions.id, onlyActionId) : undefined, + dueChatAction("slack_session_sync", now.getTime()), + )) + .orderBy(asc(chatActions.updatedAt)) + .limit(limit) + .then(rows => rows.map(row => row.action)); + await options.slackSessionSyncSelectionBarrier?.(); + for (const action of actions) { + const payload = slackSessionSyncPayload(action.payload); + if (!payload || !action.conversationId) { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: "failed", + result: { code: "slack_session_payload_invalid" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.id, action.id), + eq(chatActions.updatedAt, action.updatedAt), + ), + ); + }); + continue; + } + const revisionWhere = and( + eq(chatActions.id, action.id), + sql`(${chatActions.payload}->>'revision')::bigint = ${payload.revision}`, + ); + const selectedSnapshotWhere = and( + revisionWhere, + eq(chatActions.status, action.status), + eq(chatActions.updatedAt, action.updatedAt), + ); + const record = await endpointRecord(action.endpointId); + if (record && ["paused", "attention"].includes(record.endpoint.status)) continue; + if (!record || record.endpoint.provider !== "slack") { + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_endpoint_unavailable" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + }); + continue; + } + const attempts = + (typeof action.result?.attempts === "number" + ? action.result.attempts + : 0) + 1; + const ownerToken = randomUUID(); + let ownsAttempt = false; + try { + await withCredentialMutationLease(record.endpoint, async (lease) => { + const claim = await db.transaction(async (tx) => { + await lease.assertOwned(tx); + const endpoint = await runtimeCallbackEndpoint( + tx, + action.endpointId, + { + generation: payload.runtimeGeneration, + credentialFingerprint: payload.credentialFingerprint, + }, + ["active", "verifying"], + ); + const conversation = endpoint + ? await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.id, action.conversationId!), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.companyId, endpoint.companyId), + inArray(chatConversations.state, [ + "active", + "waiting", + "completed", + ]), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const resource = + endpoint && conversation?.resourceId + ? await tx + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.id, conversation.resourceId), + eq(chatEndpointResources.companyId, endpoint.companyId), + eq(chatEndpointResources.endpointId, endpoint.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null) + : null; + const superseded = conversation + ? await tx + .select({ id: chatConversations.id }) + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, conversation.companyId), + eq(chatConversations.endpointId, conversation.endpointId), + eq( + chatConversations.externalThreadId, + conversation.externalThreadId, + ), + gt( + chatConversations.sessionGeneration, + conversation.sessionGeneration, + ), + ), + ) + .limit(1) + .then((rows) => rows.length > 0) + : false; + if ( + !endpoint || + !conversation || + superseded || + !(conversation.isDirectMessage + ? endpoint.allowDirectMessages + : nonDirectDestinationAllowed(endpoint, resource)) + ) { + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_destination_changed" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + return null; + } + // Recompute at dispatch: a delayed processing retry must not + // overwrite a newer final response or a later question card. + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq(chatPublications.conversationId, conversation.id), + eq(chatPublications.state, "published"), + sql`${chatPublications.idempotencyKey} not like 'explicit%'`, + sql`${chatPublications.idempotencyKey} not like 'control:status:%'`, + sql`${chatPublications.idempotencyKey} not like 'wake:%'`, + ), + ) + .orderBy( + desc(chatPublications.publishedAt), + desc(chatPublications.createdAt), + desc(chatPublications.id), + ) + .limit(1) + .then((rows) => rows[0] ?? null); + if (!publication) { + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { code: "slack_session_no_published_state" }, + updatedAt: new Date(), + }) + .where(selectedSnapshotWhere); + return null; + } + let status = slackSessionStatusForPublication( + publication.payload, + conversation.state === "completed", + ); + const runId = runIdFromMilestonePublication(publication); + if (status === "processing" && runId) { + const run = await tx + .select({ status: heartbeatRuns.status }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, runId), + eq(heartbeatRuns.companyId, endpoint.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), + ), + ) + .then((rows) => rows[0] ?? null); + // Stop may finish before its confirmation publication. Never + // resurrect processing from that run's earlier working receipt. + if (!run || !["queued", "running"].includes(run.status)) + status = "active"; + } + await notifyChatActionWork(tx, "slack_session_sync"); + const claimed = await tx + .update(chatActions) + .set({ + status: "processing", + result: { attempts, ownerToken }, + updatedAt: new Date(), + }) + .where( + and( + selectedSnapshotWhere, + or( + and( + eq(chatActions.status, "received"), + sql`(${chatActions.result}->>'retryAt' is null or (${chatActions.result}->>'retryAt')::timestamptz <= ${new Date().toISOString()}::timestamptz)`, + ), + and( + eq(chatActions.status, "processing"), + lte(chatActions.updatedAt, new Date(Date.now() - 60_000)), + ), + ), + ), + ) + .returning({ id: chatActions.id }); + await lease.assertOwned(tx); + return claimed.length ? { endpoint, conversation, status } : null; + }); + if (!claim) return; + ownsAttempt = true; + const credentials = await resolveCredentials(claim.endpoint); + await lease.assertOwned(); + const outcome = await setSlackSessionStatus({ + botToken: credentials.botToken, + threadId: claim.conversation.externalThreadId, + status: claim.status, + fetch: fetchImpl, + }); + await db.transaction(async (tx) => { + await lease.assertOwned(tx); + // Refresh long work before Slack's one-hour processing timeout. + // Unsupported sessions settle until a new publication restages + // them; completed threads must not create perpetual polling work. + const retryMs = + outcome === "updated" && claim.status === "processing" + ? 30 * 60_000 + : null; + await notifyChatActionWork(tx, "slack_session_sync"); + await tx + .update(chatActions) + .set({ + status: retryMs === null ? "processed" : "received", + result: { + attempts, + outcome, + sessionStatus: claim.status, + ...(retryMs === null + ? {} + : { + retryAt: new Date(Date.now() + retryMs).toISOString(), + }), + }, + updatedAt: new Date(), + }) + .where( + and( + revisionWhere, + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ), + ); + }); + }); + } catch (error) { + const disposition = classifyChatPublicationError(error, attempts); + // Unlike posting a message, repeating setStatus cannot duplicate any + // content. Ambiguous transport/receipt failures remain retryable, but + // a definite scope/auth/destination rejection waits for new activity. + const retryable = + disposition.kind === "retry" || + disposition.kind === "delivery_unknown"; + const retryMs = + disposition.kind === "retry" + ? disposition.retryAfterMs + : Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attempts, 8)); + await db.transaction(async (tx) => { + await notifyChatActionWork(tx, "slack_session_sync"); + return tx + .update(chatActions) + .set({ + status: retryable ? "received" : "failed", + result: { + attempts, + code: retryable + ? "slack_session_sync_retry" + : "slack_session_sync_rejected", + ...(retryable + ? { retryAt: new Date(Date.now() + retryMs).toISOString() } + : {}), + }, + updatedAt: new Date(), + }) + .where( + and( + revisionWhere, + ownsAttempt + ? and( + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'ownerToken' = ${ownerToken}`, + ) + : and( + eq(chatActions.status, "received"), + eq(chatActions.updatedAt, action.updatedAt), + ), + ), + ); + }); + logger.warn( + { + endpointId: action.endpointId, + actionId: action.id, + retryable, + error: redactError(error), + }, + "Slack session status update did not complete; message delivery is unchanged", + ); + } + } + return actions.length; + } + return { processPendingSlackSessionSyncs }; +} diff --git a/server/src/services/chat-channels/telegram/publication-drafts.ts b/server/src/services/chat-channels/telegram/publication-drafts.ts new file mode 100644 index 00000000000..c469155c736 --- /dev/null +++ b/server/src/services/chat-channels/telegram/publication-drafts.ts @@ -0,0 +1,461 @@ +import type { EndpointRow, DbTransaction, DbOrTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + and, + eq, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatPublications, + toolConnections, +} from "@paperclipai/db"; +import type { + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + TELEGRAM_DRAFT_ACTION_KIND, + TELEGRAM_DRAFT_STOPPED_REASON, + parseTelegramDraftBinding, + telegramDraftTextSha256, + telegramGenerationStoppedReceipt, + telegramPrivateDraftDestination, + type TelegramDraftControl, + type TelegramGenerationStoppedProof, +} from "../../chat-telegram-draft-stop.js"; + +import { + type ChatSdkCallbackEvent, + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { + renderPublicationTransportText, +} from "../../chat-publication-text-parts.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type ConversationRow = typeof chatConversations.$inferSelect; +type TelegramPublicationDraftsOptions = { + runtimeCallbackRecord: (endpointId: string, context: RuntimeContext, statuses: EndpointRow["status"][]) => Promise; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + runtimeFor: (endpoint: EndpointRow) => Promise; + runtime: Pick; + runtimeContexts: WeakMap; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + authorizeRetainedChatSourcePublication: (tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect) => Promise; +}; + +export function createTelegramPublicationDrafts(db: Db, options: TelegramPublicationDraftsOptions) { + const { runtimeCallbackRecord, runtimeCallbackEndpoint, runtimeFor, runtime, runtimeContexts, credentialFingerprint, authorizeRetainedChatSourcePublication } = options; + + + async function handleTelegramGenerationStopped( + callback: ChatSdkCallbackEvent, + context: RuntimeContext, + ): Promise { + const receipt = telegramGenerationStoppedReceipt(callback.event); + if (callback.provider !== "telegram" || !receipt) return; + const record = await runtimeCallbackRecord(callback.endpointId, context, [ + "verifying", + "active", + ]); + if ( + record?.endpoint.provider !== "telegram" || + record.endpoint.botExternalId !== receipt.botUserId + ) + return; + const candidates = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, callback.endpointId), + eq(chatActions.kind, TELEGRAM_DRAFT_ACTION_KIND), + sql`${chatActions.payload}->>'botUserId' = ${receipt.botUserId}`, + sql`${chatActions.payload}->>'draftId' = ${String(receipt.draftId)}`, + ), + ) + .limit(2); + if (candidates.length !== 1) return; + const candidate = candidates[0]!; + const binding = parseTelegramDraftBinding(candidate.payload); + if ( + !binding || + binding.chatId !== receipt.chatId || + binding.messageThreadId !== receipt.messageThreadId || + binding.runtimeGeneration !== context.generation || + binding.credentialFingerprint !== context.credentialFingerprint + ) + return; + await db.transaction(async (tx) => { + // The sender holds a renewable credential lease during draft HTTP. Do + // not acquire that lease here: a Stop can arrive inside the first RPC. + // Both Stop and final-send use the publication→endpoint→draft lock order. + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, record.endpoint.companyId), + eq(chatPublications.endpointId, callback.endpointId), + eq(chatPublications.conversationId, binding.conversationId), + eq(chatPublications.id, binding.publicationId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = await runtimeCallbackEndpoint( + tx, + callback.endpointId, + context, + ["verifying", "active"], + ); + if ( + !publication || + !endpoint || + endpoint.provider !== "telegram" || + endpoint.botExternalId !== receipt.botUserId || + runtime.get(endpoint.id) !== context.endpointRuntime || + publication.attempts !== binding.publicationAttempt || + telegramDraftTextSha256( + renderPublicationTransportText(publication.payload), + ) !== binding.textSha256 + ) + return; + const connection = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share") + .then((rows) => rows[0] ?? null); + if ( + !connection?.enabled || + connection.status !== "active" || + credentialFingerprint(connection.credentialSecretRefs) !== + binding.credentialFingerprint + ) + return; + const conversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, binding.conversationId), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !conversation || + !conversation.isDirectMessage || + conversation.sessionGeneration !== binding.sessionGeneration || + conversation.externalThreadId !== + `telegram:${binding.chatId}${binding.messageThreadId === null ? "" : `:${binding.messageThreadId}`}` + ) + return; + const action = await tx + .select() + .from(chatActions) + .where(eq(chatActions.id, candidate.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !action || + JSON.stringify(action.payload) !== JSON.stringify(candidate.payload) + ) + return; + // final_sending is an irreversible local I/O claim, not a delivery + // receipt. A late Stop must not claim that an in-flight final was undone. + if ( + action.result?.phase !== "drafting" || + action.status !== "processing" || + !["streaming", "delivery_unknown"].includes(publication.state) + ) + return; + const stoppedAt = new Date(); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + phase: "stopped", + updateId: receipt.updateId, + stoppedAt: stoppedAt.toISOString(), + }, + updatedAt: stoppedAt, + }) + .where(eq(chatActions.id, action.id)); + await notifyChatPublicationWork(tx); + await tx + .update(chatPublications) + .set({ + state: "cancelled", + nextAttemptAt: null, + redactedError: TELEGRAM_DRAFT_STOPPED_REASON, + updatedAt: stoppedAt, + }) + .where(eq(chatPublications.id, publication.id)); + await tx + .update(chatActions) + .set({ + status: "cancelled", + result: { + code: "telegram_draft_presentation_stopped", + attempts: publication.attempts, + }, + updatedAt: stoppedAt, + }) + .where( + and( + eq(chatActions.companyId, publication.companyId), + eq(chatActions.endpointId, publication.endpointId), + eq( + chatActions.providerActionId, + `task-control-authorization:${publication.id}`, + ), + eq(chatActions.status, "processing"), + sql`${chatActions.result}->>'attempts' = ${String(publication.attempts)}`, + ), + ); + }); + } + + async function reserveTelegramPublicationDraft(input: { + endpoint: EndpointRow; + conversation: ConversationRow; + publication: typeof chatPublications.$inferSelect; + text: string; + credentialLease: CredentialMutationLeaseGuard; + runtimeContext: RuntimeContext; + }): Promise<{ actionId: string; control: TelegramDraftControl }> { + const endpointRuntime = await runtimeFor(input.endpoint); + const registeredContext = runtimeContexts.get(endpointRuntime as object); + if ( + !registeredContext || + registeredContext.generation !== input.runtimeContext.generation || + registeredContext.credentialFingerprint !== + input.runtimeContext.credentialFingerprint || + registeredContext.version !== input.runtimeContext.version + ) + throw new Error("Telegram draft runtime authority changed"); + const draftRuntimeContext = { ...input.runtimeContext, endpointRuntime }; + const destination = telegramPrivateDraftDestination( + input.conversation.externalThreadId, + ); + if ( + !destination || + !input.conversation.isDirectMessage || + !input.endpoint.botExternalId + ) + throw new Error("Telegram private draft destination is invalid"); + const bindingBase = { + version: 1 as const, + publicationId: input.publication.id, + publicationAttempt: input.publication.attempts + 1, + conversationId: input.conversation.id, + sessionGeneration: input.conversation.sessionGeneration, + runtimeGeneration: input.runtimeContext.generation, + credentialFingerprint: input.runtimeContext.credentialFingerprint, + botUserId: input.endpoint.botExternalId, + ...destination, + textSha256: telegramDraftTextSha256(input.text), + }; + const reserved = await db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.id, input.publication.id), + eq(chatPublications.state, "streaming"), + eq(chatPublications.attempts, bindingBase.publicationAttempt), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + if (!publication) + throw new Error("Telegram draft publication ownership changed"); + const providerActionId = `telegram-draft-publication:${publication.id}:${publication.attempts}`; + const existing = await tx + .select({ id: chatActions.id }) + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, input.endpoint.id), + eq(chatActions.providerActionId, providerActionId), + ), + ) + .limit(1); + // A second invocation of the same attempt cannot create another draft or + // replay an uncertain final. A new explicit publication attempt gets a + // fresh, never-before-used bot-global ID. + if (existing.length) + throw new Error("Telegram draft attempt already exists"); + // NO CYCLE and no company/endpoint ownership: rollback, endpoint deletion + // and bot re-binding cannot recycle a provider-visible draft identity. + // Exhaustion is a refusal, never a fallback to a process-local/random ID. + const allocated = await tx.execute( + sql`select nextval('chat_telegram_draft_ids')::integer as "draftId"`, + ); + const binding = parseTelegramDraftBinding({ + ...bindingBase, + draftId: allocated[0]?.draftId, + }); + if (!binding) + throw new Error("Telegram draft identity allocation was invalid"); + const [action] = await tx + .insert(chatActions) + .values({ + companyId: input.endpoint.companyId, + endpointId: input.endpoint.id, + conversationId: input.conversation.id, + kind: TELEGRAM_DRAFT_ACTION_KIND, + providerActionId, + payload: binding, + status: "issued", + result: { phase: "reserved" }, + }) + .returning(); + return { action: action!, binding }; + }); + const before = (final: boolean): Promise => + db.transaction(async (tx) => { + await input.credentialLease.assertOwned(tx); + const publication = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, input.endpoint.companyId), + eq(chatPublications.id, input.publication.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const endpoint = await runtimeCallbackEndpoint( + tx, + input.endpoint.id, + draftRuntimeContext, + ["verifying", "active"], + ); + if ( + !endpoint || + endpoint.provider !== "telegram" || + !endpoint.allowDirectMessages || + endpoint.botExternalId !== reserved.binding.botUserId || + runtime.get(endpoint.id) !== endpointRuntime + ) + throw new Error("Telegram draft endpoint authority changed"); + const connection = await tx + .select() + .from(toolConnections) + .where( + and( + eq(toolConnections.companyId, endpoint.companyId), + eq(toolConnections.id, endpoint.connectionId), + ), + ) + .for("share") + .then((rows) => rows[0] ?? null); + if ( + !connection?.enabled || + connection.status !== "active" || + credentialFingerprint(connection.credentialSecretRefs) !== + reserved.binding.credentialFingerprint + ) + throw new Error("Telegram draft credential authority changed"); + const conversation = await tx + .select() + .from(chatConversations) + .where( + and( + eq(chatConversations.companyId, endpoint.companyId), + eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.id, input.conversation.id), + ), + ) + .for("update") + .then((rows) => rows[0] ?? null); + const action = await tx + .select() + .from(chatActions) + .where(eq(chatActions.id, reserved.action.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if ( + !action || + JSON.stringify(action.payload) !== + JSON.stringify(reserved.action.payload) || + !publication || + publication.attempts !== reserved.binding.publicationAttempt + ) + throw new Error("Telegram draft attempt authority changed"); + if ( + action.status === "cancelled" && + action.result?.phase === "stopped" && + publication.state === "cancelled" + ) + return false; + if ( + publication.state !== "streaming" || + !conversation || + telegramDraftTextSha256( + renderPublicationTransportText(publication.payload), + ) !== reserved.binding.textSha256 || + conversation.sessionGeneration !== + reserved.binding.sessionGeneration || + conversation.externalThreadId !== + input.conversation.externalThreadId || + !["active", "waiting", "completed"].includes(conversation.state) || + !["reserved", "drafting"].includes(String(action.result?.phase)) || + !["issued", "processing"].includes(action.status) || + !(await authorizeRetainedChatSourcePublication(tx, publication)) + ) + throw new Error("Telegram draft source authority changed"); + await tx + .update(chatActions) + .set({ + status: "processing", + result: { + phase: final ? "final_sending" : "drafting", + }, + updatedAt: new Date(), + }) + .where(eq(chatActions.id, action.id)); + await input.credentialLease.assertOwned(tx); + return true; + }); + return { + actionId: reserved.action.id, + control: { + version: 1, + draftId: reserved.binding.draftId, + beforeDraft: () => before(false), + beforeFinal: () => before(true), + }, + }; + } + return { handleTelegramGenerationStopped, reserveTelegramPublicationDraft }; +} From 4b766b872993045a2e787404f0475503683e5ded Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 12 Oct 2026 12:33:59 -0700 Subject: [PATCH 2/4] fix(acpx): record turn activity at the moment a stop fires (#15962) Record stop-time ACP turn diagnostics (last-event age, oldest pending tool age, pending tool kinds) so wall-clock timeout events show whether the turn was stalled. Diagnostics only. Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 5.5 --- doc/observability.md | 11 ++ .../src/acpx-engine/constants.ts | 7 ++ .../src/acpx-engine/execute.test.ts | 113 ++++++++++++++++++ .../adapter-utils/src/acpx-engine/execute.ts | 64 +++++++++- .../__tests__/run-failure-diagnostics.test.ts | 20 +++- .../src/services/run-failure-diagnostics.ts | 17 ++- 6 files changed, 226 insertions(+), 6 deletions(-) diff --git a/doc/observability.md b/doc/observability.md index cb6b75c1af3..807ad61c80a 100644 --- a/doc/observability.md +++ b/doc/observability.md @@ -638,6 +638,17 @@ pending. Recent events do not prove useful progress. These fields contain only numbers and a boolean, never tool names, IDs, arguments, or event content, and do not change the execution timeout, cancellation, or recovery policy. +A stopped turn (wall-clock timeout or operator stop) also records +`acpLastEventAgeAtStopMs` and `acpOldestPendingToolAgeAtStopMs`, measured at the +moment the stop fired. A cancel can make the agent emit closing events, so the +finalization age alone cannot separate a turn that went silent long before its +deadline from one that was still working. For a stopped turn, pending tools +means the tools pending when the stop fired, so a tool the cancel closes still +counts; `acpPendingToolCount` keeps its finalization-time meaning. When tools are +pending, `acpPendingToolKinds` lists their ACP tool kinds as a sorted, comma-separated +string drawn only from the closed ACP `ToolKind` enum plus `unknown`; the +server drops the field if any other value appears. + ACP and legacy OpenCode results retain the adapter's resolved wall-clock timeout as `adapterExecutionTimeout` in the instance run result. Finalization uses it for `effectiveTimeoutSec`, `timeoutSource`, and `timeoutConfigured`. Sources are diff --git a/packages/adapter-utils/src/acpx-engine/constants.ts b/packages/adapter-utils/src/acpx-engine/constants.ts index 663d19ec243..1c37dc958cd 100644 --- a/packages/adapter-utils/src/acpx-engine/constants.ts +++ b/packages/adapter-utils/src/acpx-engine/constants.ts @@ -26,6 +26,13 @@ export const ACPX_HANDSHAKE_TRANSPORT_POLL_MS = 250; // much smaller than the whole-adapter execution timeout. export const ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS = 30_000; +// The closed ACP `ToolKind` enum. Run diagnostics may report these labels for +// pending tool calls; tool titles, names, and inputs never leave the run. +// Any other reported kind is collapsed to "unknown". +export const ACP_TOOL_KINDS = [ + "read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other", +] as const; + export const ACPX_ADAPTER_AGENT_IDS = { claude_local: "claude", codex_local: "codex", diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 78fd4ebfcf3..095996c1f83 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -33,6 +33,7 @@ import { parseGeminiVersionParts, referencedSourceContentSignature, rewriteGeminiAcpFlagForVersion, + summarizeAcpStopActivity, summarizeAcpxTurnUsage, type AcpxEngineExecutorOptions, } from "./execute.js"; @@ -3400,6 +3401,118 @@ describe("ACP activity diagnostics", () => { expect(currentNow).toBeGreaterThan(10_000); }, ); + + it("snapshots a silent turn's activity when the wall-clock timeout fires, before cancel events", async () => { + const root = await makeTempRoot(); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + let currentNow = 1000; + let releaseTurn: (() => void) | null = null; + const turnCancelled = new Promise((resolve) => { releaseTurn = resolve; }); + const execute = createAcpxEngineExecutor({ + now: () => currentNow, + createRuntime: () => ({ + ...buildRuntime(), + startTurn: () => ({ + events: (async function* () { + yield { type: "tool_call", toolCallId: "private-id", title: "private command", kind: "execute", status: "in_progress" }; + // The agent goes silent with the tool still pending. + currentNow = 9000; + await turnCancelled; + // The cancel makes the agent emit a closing event, so the + // finalize-time age alone would make this turn look active. + currentNow = 9500; + yield { type: "status", text: "private closing status" }; + })(), + result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })), + cancel: async () => { releaseTurn?.(); }, + }), + }) as never, + }); + const result = await execute({ + runId: "silent-timeout", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, timeoutSec: 1 }, + context: {}, onMeta: async () => {}, onLog: async () => {}, + } as never); + expect(result.timedOut).toBe(true); + expect(result.resultJson).toMatchObject({ + acpObservedEventCount: 2, + acpLastEventAgeMs: 0, + acpPendingToolCount: 1, + acpLastEventAgeAtStopMs: 8000, + acpOldestPendingToolAgeAtStopMs: 8000, + acpPendingToolKinds: "execute", + }); + const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp"))); + expect(JSON.stringify(diagnostics)).not.toContain("private"); + }, 15_000); + + it("keeps the stalled tool's stop-time age when the cancel closes that tool", async () => { + const root = await makeTempRoot(); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + let currentNow = 1000; + let releaseTurn: (() => void) | null = null; + const turnCancelled = new Promise((resolve) => { releaseTurn = resolve; }); + const execute = createAcpxEngineExecutor({ + now: () => currentNow, + createRuntime: () => ({ + ...buildRuntime(), + startTurn: () => ({ + events: (async function* () { + yield { type: "tool_call", toolCallId: "private-id", title: "private command", kind: "execute", status: "in_progress" }; + currentNow = 9000; + await turnCancelled; + // The cancel closes the stalled tool before finalization runs. + currentNow = 9500; + yield { type: "tool_call", tag: "tool_call_update", toolCallId: "private-id", status: "cancelled" }; + })(), + result: turnCancelled.then(() => ({ status: "cancelled", stopReason: "cancelled" })), + cancel: async () => { releaseTurn?.(); }, + }), + }) as never, + }); + const result = await execute({ + runId: "cancel-closes-tool", agent: { id: "agent-1", companyId: "company-1" }, runtime: {}, + config: { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir: path.join(root, "state"), cwd, timeoutSec: 1 }, + context: {}, onMeta: async () => {}, onLog: async () => {}, + } as never); + expect(result.timedOut).toBe(true); + expect(result.resultJson).toMatchObject({ + acpPendingToolCount: 0, + acpLastEventAgeAtStopMs: 8000, + acpOldestPendingToolAgeAtStopMs: 8000, + acpPendingToolKinds: "execute", + }); + const diagnostics = Object.fromEntries(Object.entries(result.resultJson ?? {}).filter(([key]) => key.startsWith("acp"))); + expect(JSON.stringify(diagnostics)).not.toContain("private"); + }, 15_000); +}); + +describe("summarizeAcpStopActivity", () => { + it("reports nothing for an unstopped turn with no pending tools", () => { + expect(summarizeAcpStopActivity(null, 1000, [])).toEqual({}); + }); + + it("reports pending kinds without a stop, and collapses unknown kinds", () => { + expect(summarizeAcpStopActivity(null, 1000, [ + { kind: "fetch", firstSeenAt: 10 }, { kind: "private_tool_name" }, {}, { kind: "fetch" }, + ])).toEqual({ acpPendingToolKinds: "fetch,unknown" }); + }); + + it("measures ages from the stop and ignores tools first seen after it", () => { + expect(summarizeAcpStopActivity(10_000, 4000, [ + { kind: "execute", firstSeenAt: 3000 }, { kind: "read", firstSeenAt: 12_000 }, + ])).toEqual({ + acpLastEventAgeAtStopMs: 6000, + acpOldestPendingToolAgeAtStopMs: 7000, + acpPendingToolKinds: "execute,read", + }); + }); + + it("omits the event age when no event preceded the stop", () => { + expect(summarizeAcpStopActivity(10_000, null, [])).toEqual({}); + }); }); describe("summarizeAcpxTurnUsage", () => { diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index e84c6014991..01ae1020b8d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -105,6 +105,7 @@ import { type AcpRuntimeUsageCost, } from "acpx/runtime"; import { + ACP_TOOL_KINDS, ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS, ACPX_HANDSHAKE_TIMEOUT_MS, ACPX_HANDSHAKE_TRANSPORT_POLL_MS, @@ -3249,6 +3250,46 @@ async function emitRuntimeEvent( } } +function isPendingAcpTool(tool: { status?: string }): boolean { + return tool.status !== "completed" && tool.status !== "failed" && tool.status !== "cancelled"; +} + +/** + * Activity as of the moment the turn's stop fired, for a stopped turn only. + * Separates a turn that went silent long before its deadline from one that + * was still working. Reports only ages and closed ACP tool-kind labels. + */ +export function summarizeAcpStopActivity( + stopRequestedAt: number | null, + lastEventBeforeStopAt: number | null, + pendingTools: ReadonlyArray<{ kind?: string; firstSeenAt?: number }>, +): { acpLastEventAgeAtStopMs?: number; acpOldestPendingToolAgeAtStopMs?: number; acpPendingToolKinds?: string } { + const summary: ReturnType = {}; + if (pendingTools.length > 0) { + const knownKinds: readonly string[] = ACP_TOOL_KINDS; + summary.acpPendingToolKinds = uniqueSorted( + pendingTools.map((tool) => (tool.kind && knownKinds.includes(tool.kind) ? tool.kind : "unknown")), + ).join(","); + } + if (stopRequestedAt === null) return summary; + const age = (since: number) => { + const value = stopRequestedAt - since; + return Number.isSafeInteger(value) && value >= 0 ? value : undefined; + }; + if (lastEventBeforeStopAt !== null) { + const value = age(lastEventBeforeStopAt); + if (value !== undefined) summary.acpLastEventAgeAtStopMs = value; + } + const pendingSince = pendingTools + .map((tool) => tool.firstSeenAt) + .filter((at): at is number => typeof at === "number" && at <= stopRequestedAt); + if (pendingSince.length > 0) { + const value = age(Math.min(...pendingSince)); + if (value !== undefined) summary.acpOldestPendingToolAgeAtStopMs = value; + } + return summary; +} + function resultErrorMessage(result: AcpRuntimeTurnResult): string | null { if (result.status !== "failed") return null; return result.error.message; @@ -4046,7 +4087,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { let runtimeStopConfirmed = false; let safeInterruptedSession = false; let preserveInterruptedSession = false; - const interruptionTools = new Map(); + const interruptionTools = new Map(); let incompleteToolInventory = false; try { await ctx.onCancellationReady?.(); @@ -4759,6 +4800,14 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { let observedUsage: Pick = { usageComplete: false }; let lastRuntimeEventAt: number | null = null; let observedRuntimeEvents = 0; + // Activity as of the moment the turn's stop (wall-clock timeout or + // operator stop) fired. The cancel can make the agent emit closing + // events (including updates that close the stalled tool), so the + // finalize-time age and pending set alone cannot tell a silent stall + // from live work. + let turnStopRequestedAt: number | null = null; + let lastRuntimeEventBeforeStopAt: number | null = null; + let pendingToolsAtStop: Array<{ kind?: string; firstSeenAt?: number }> | null = null; let turnDispatchAttempted = false; // The turn-local state the sequence steps share. `promptBuild` sets the // prompt, `preTurnUsage` sets the pre-turn status, `turnStart` sets the @@ -4846,6 +4895,11 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { }; const stepTurnStart = (signal: AbortSignal, startTimeoutMs: number | undefined): StartedTurn => { ctx.signal?.throwIfAborted(); + signal.addEventListener("abort", () => { + if (turnStopRequestedAt !== null) return; + turnStopRequestedAt = now(); + pendingToolsAtStop = [...interruptionTools.values()].filter(isPendingAcpTool); + }, { once: true }); // After entering startTurn, a thrown error cannot prove the provider // received no work. Only failures before this boundary are free to settle. turnDispatchAttempted = true; @@ -4939,6 +4993,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { const drainEvents = (async (): Promise => { for await (const event of turn.events) { lastRuntimeEventAt = now(); + if (turnStopRequestedAt === null) lastRuntimeEventBeforeStopAt = lastRuntimeEventAt; observedRuntimeEvents += 1; // ACPX currently flattens client-side filesystem/terminal receipts // into status text. They cannot establish complete action outcomes. @@ -4950,6 +5005,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { interruptionTools.set(event.toolCallId, { kind: event.kind ?? previous?.kind, status: event.status ?? previous?.status, + firstSeenAt: previous?.firstSeenAt ?? lastRuntimeEventAt, }); } } @@ -5004,14 +5060,14 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { // Capture runtime activity before usage reads, error logs, or cleanup // make an otherwise silent turn look active at its execution deadline. const lastEventAgeMs = lastRuntimeEventAt === null ? undefined : now() - lastRuntimeEventAt; + const pendingTools = [...interruptionTools.values()].filter(isPendingAcpTool); const activityDiagnostics = { acpObservedEventCount: observedRuntimeEvents, ...(lastEventAgeMs !== undefined && Number.isSafeInteger(lastEventAgeMs) && lastEventAgeMs >= 0 ? { acpLastEventAgeMs: lastEventAgeMs } : {}), - acpPendingToolCount: [...interruptionTools.values()].filter( - (tool) => tool.status !== "completed" && tool.status !== "failed" && tool.status !== "cancelled", - ).length, + acpPendingToolCount: pendingTools.length, acpToolInventoryComplete: !incompleteToolInventory, + ...summarizeAcpStopActivity(turnStopRequestedAt, lastRuntimeEventBeforeStopAt, pendingToolsAtStop ?? pendingTools), }; if (input.kind === "terminal") { const terminal = input.terminal; diff --git a/server/src/services/__tests__/run-failure-diagnostics.test.ts b/server/src/services/__tests__/run-failure-diagnostics.test.ts index 731c569e0e9..e75e043311c 100644 --- a/server/src/services/__tests__/run-failure-diagnostics.test.ts +++ b/server/src/services/__tests__/run-failure-diagnostics.test.ts @@ -323,11 +323,29 @@ describe("run failure diagnostics", () => { expect(JSON.stringify(result)).not.toContain("private"); }); + it("includes stop-time ACP activity and closed pending tool-kind labels", () => { + const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: { + acpLastEventAgeMs: 700, acpLastEventAgeAtStopMs: 14_000_000, acpOldestPendingToolAgeAtStopMs: 14_100_000, + acpPendingToolKinds: "execute,unknown", + } }), {})); + expect(result.execution).toEqual({ + acpLastEventAgeMs: 700, acpLastEventAgeAtStopMs: 14_000_000, acpOldestPendingToolAgeAtStopMs: 14_100_000, + acpPendingToolKinds: "execute,unknown", + }); + }); + + it.each(["execute,private command", "", "execute,", "Execute", ["execute"], 1])( + "omits pending tool kinds that are not closed labels (%j)", (value) => { + const result = collectRunFailureDiagnostics(run({ resultJson: { acpPendingToolKinds: value } }), {}); + expect(result.execution).toEqual({}); + }, + ); + it.each([null, -1, Infinity, NaN, 1.5, Number.MAX_SAFE_INTEGER + 1, "private", {}])( "omits invalid ACP activity fields (%j)", (value) => { const result = collectRunFailureDiagnostics(run({ resultJson: { acpLastEventAgeMs: value, acpObservedEventCount: value, acpPendingToolCount: value, - acpToolInventoryComplete: value, + acpToolInventoryComplete: value, acpLastEventAgeAtStopMs: value, acpOldestPendingToolAgeAtStopMs: value, } }), {}); expect(result.execution).toEqual({}); }, diff --git a/server/src/services/run-failure-diagnostics.ts b/server/src/services/run-failure-diagnostics.ts index 7a14c7e3d84..ae9614a09af 100644 --- a/server/src/services/run-failure-diagnostics.ts +++ b/server/src/services/run-failure-diagnostics.ts @@ -6,6 +6,7 @@ import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js"; import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared"; import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction"; import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics"; +import { ACP_TOOL_KINDS } from "@paperclipai/adapter-utils/acpx-engine/constants"; import { redactCurrentUserText } from "../log-redaction.js"; import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js"; import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js"; @@ -41,6 +42,15 @@ export interface RunFailureDiagnostics { truncatedFields: string[]; } +const ACP_PENDING_TOOL_KIND_LABELS: ReadonlySet = new Set([...ACP_TOOL_KINDS, "unknown"]); + +/** Closed ACP tool-kind labels only; any other content drops the whole field. */ +function readAcpPendingToolKinds(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0 || value.length > 200) return null; + const kinds = value.split(","); + return kinds.every((kind) => ACP_PENDING_TOOL_KIND_LABELS.has(kind)) ? value : null; +} + function read(value: unknown, key: string): unknown { if (!value || typeof value !== "object") return undefined; try { @@ -197,10 +207,15 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport "mode", "stopReason", "timeoutFired", "timeoutSource", "timeoutConfigured", "effectiveTimeoutSec", "errorFamily", ])); - for (const field of ["acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount"]) { + for (const field of [ + "acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount", + "acpLastEventAgeAtStopMs", "acpOldestPendingToolAgeAtStopMs", + ]) { const value = read(result, field); if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) execution[field] = value; } + const pendingToolKinds = readAcpPendingToolKinds(read(result, "acpPendingToolKinds")); + if (pendingToolKinds) execution.acpPendingToolKinds = pendingToolKinds; const toolInventoryComplete = read(result, "acpToolInventoryComplete"); if (typeof toolInventoryComplete === "boolean") execution.acpToolInventoryComplete = toolInventoryComplete; const restoreFailure = read(result, "workspaceRestoreFailure"); From 7d9b93aee2b79b2f035c5838741ce0b94168ec83 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:52:28 -0500 Subject: [PATCH 3/4] docs: establish paired feature and agent access maps (#16025) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Contributors use the feature map to understand and verify product capabilities. > - Agents need APIs and usable entry points within their permission boundaries. > - Product recipes and agent access need separate records with shared capability IDs. > - This pull request establishes that contract and gives maintenance ownership of both maps. > - The benefit is repeatable access review without changing product behavior or permissions. ## Linked Issues or Issue Description Refs: #15983, which added the portable verification skills and updated the feature map. This is its contract-only follow-up. Related: #15288 added the original feature map. **Issue type** Missing documentation and maintenance contract. **Where is the issue?** The product map, its missing agent access companions, and the verification skills. **What's wrong?** There is no shared convention for API presence, agent entry points, permission boundaries, access gaps, or evidence. An API declaration does not prove that a permitted native or remote agent can use it. **Suggested fix** Keep the product and agent access maps as siblings under `maps/`. Reuse capability IDs. Record representative source inventory separately from operation review and live proof. Keep both maps aligned through the maintenance skill. ## What Changed - Move the existing product map to `maps/feature-map/`. Preserve its recipes and Poteto format. Update relative links and add companion links. - Add `maps/agent-access-map/` with 35 area files and 171 matching capability IDs. Seed representative API operations, agent paths, policies and known gaps from the reviewed source inventory. Mark entries as `baseline`, `source-only`, and live verification `not-run`. - Define the shared contract in `maps/README.md`. Track API presence, agent availability, policy, evidence and review depth separately. Require operation enumeration before marking a broad capability operation-reviewed. - Extend `maintain-verification-skill` to reconcile both maps and inspect actual route policy and production tool admission. Update creation, verification, notices and contributor references to use the shared contract. - Add `scripts/check-maps.mjs` and ten regression tests. Check matching areas/IDs, indexes, references, contract fields, API method/path examples, justified exceptions and evidence-state consistency. - Keep the coverage inventory unchanged. Preserve skill invocation policies and both MIT license blocks. No product API, permission, UI, runtime helper, migration, dependency or workflow changes. ## Verification - Passed: `node scripts/check-maps.mjs` reports 35 paired areas and 171 matching capability IDs. All local map references resolve. - Passed: `node --test scripts/check-maps.test.mjs` runs ten tests. Fixtures cover missing companions, renamed/orphan/duplicate IDs, broken references, missing operation fields, empty exceptions, missing API examples, empty policy fields, and verified evidence without passed verification. - Passed: metadata validation for all three verification skills. Skill and contributor links resolve. Invocation policies and both license blocks are unchanged. - Compared relocated recipes with current master. Contents differ only in relative links and companion links. `coverage.json` is byte-for-byte unchanged. Master's updated read-only interaction test reference is retained. - Passed: `git diff --check`. The branch starts from current master and contains only this contract change. - Repository-wide typecheck, tests and build were not run locally for this documentation and Node structural-check scope. Live agent access was not exercised. Those gaps are explicit in the baseline entries; the structural check does not certify behavior. CI and automated review are pending. ## Risks - The companions are representative source baselines. They are not complete operation inventories or passed agent journeys. - The checker cannot establish actual permissions, native tool admission, operation completeness or truthful live evidence. Maintenance must inspect source and verify with the actual permitted agent context. - External links to the old product-map directory may need updates. Repository references have been updated. - Existing access gaps and board/user restrictions remain recorded. This contract does not change authority or implement missing paths. ## Model Used OpenAI GPT-6 through Codex assisted with this change. The session does not expose the exact serving model ID, configured context-window size, or reasoning setting. Capabilities used: source analysis, file edits, shell commands, and structural regression tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../create-verification-skill/NOTICE.md | 3 +- .../skills/create-verification-skill/SKILL.md | 14 +- .../maintain-verification-skill/NOTICE.md | 3 +- .../maintain-verification-skill/SKILL.md | 116 ++++++++-- .agents/skills/verify-paperclip/SKILL.md | 10 +- AGENTS.md | 8 +- doc/DEVELOPING.md | 8 +- maps/README.md | 135 +++++++++++ maps/agent-access-map/README.md | 69 ++++++ maps/agent-access-map/access.md | 136 +++++++++++ maps/agent-access-map/activity.md | 129 +++++++++++ maps/agent-access-map/agent-chat.md | 113 ++++++++++ maps/agent-access-map/agents.md | 140 ++++++++++++ maps/agent-access-map/app-permissions.md | 132 +++++++++++ maps/agent-access-map/budgets-costs.md | 108 +++++++++ maps/agent-access-map/cases.md | 108 +++++++++ maps/agent-access-map/chat-channels.md | 132 +++++++++++ maps/agent-access-map/cli-operations.md | 134 +++++++++++ maps/agent-access-map/companies.md | 115 ++++++++++ maps/agent-access-map/connection-setup.md | 207 +++++++++++++++++ maps/agent-access-map/developer-labs.md | 99 ++++++++ maps/agent-access-map/documents-artifacts.md | 139 ++++++++++++ .../execution-environments.md | 132 +++++++++++ maps/agent-access-map/gateways-profiles.md | 131 +++++++++++ maps/agent-access-map/goals.md | 87 +++++++ maps/agent-access-map/inbox-search.md | 112 +++++++++ maps/agent-access-map/instance-operations.md | 130 +++++++++++ .../navigation-preferences.md | 138 ++++++++++++ maps/agent-access-map/onboarding.md | 136 +++++++++++ maps/agent-access-map/pipelines.md | 109 +++++++++ maps/agent-access-map/plugins.md | 107 +++++++++ maps/agent-access-map/projects.md | 113 ++++++++++ .../questions-and-approvals.md | 212 ++++++++++++++++++ maps/agent-access-map/recovery.md | 207 +++++++++++++++++ maps/agent-access-map/routines.md | 135 +++++++++++ maps/agent-access-map/runs-adapters.md | 111 +++++++++ maps/agent-access-map/secrets.md | 138 ++++++++++++ maps/agent-access-map/skills.md | 141 ++++++++++++ maps/agent-access-map/status-cards.md | 110 +++++++++ maps/agent-access-map/steering.md | 180 +++++++++++++++ maps/agent-access-map/task-coordination.md | 114 ++++++++++ maps/agent-access-map/tasks.md | 136 +++++++++++ maps/agent-access-map/teams.md | 108 +++++++++ maps/agent-access-map/workspaces.md | 144 ++++++++++++ {feature-map => maps/feature-map}/README.md | 32 ++- {feature-map => maps/feature-map}/access.md | 10 +- {feature-map => maps/feature-map}/activity.md | 8 +- .../feature-map}/agent-chat.md | 10 +- {feature-map => maps/feature-map}/agents.md | 10 +- .../feature-map}/app-permissions.md | 8 +- .../feature-map}/budgets-costs.md | 8 +- {feature-map => maps/feature-map}/cases.md | 8 +- .../feature-map}/chat-channels.md | 8 +- .../feature-map}/cli-operations.md | 10 +- .../feature-map}/companies.md | 8 +- .../feature-map}/connection-setup.md | 24 +- .../feature-map}/coverage.json | 0 .../feature-map}/developer-labs.md | 8 +- .../feature-map}/documents-artifacts.md | 10 +- .../feature-map}/execution-environments.md | 8 +- .../feature-map}/gateways-profiles.md | 8 +- {feature-map => maps/feature-map}/goals.md | 8 +- .../feature-map}/inbox-search.md | 12 +- .../feature-map}/instance-operations.md | 8 +- .../feature-map}/navigation-preferences.md | 8 +- .../feature-map}/onboarding.md | 10 +- .../feature-map}/pipelines.md | 8 +- {feature-map => maps/feature-map}/plugins.md | 8 +- {feature-map => maps/feature-map}/projects.md | 8 +- .../feature-map}/questions-and-approvals.md | 32 +-- {feature-map => maps/feature-map}/recovery.md | 26 ++- {feature-map => maps/feature-map}/routines.md | 8 +- .../feature-map}/runs-adapters.md | 8 +- {feature-map => maps/feature-map}/secrets.md | 8 +- {feature-map => maps/feature-map}/skills.md | 10 +- .../feature-map}/status-cards.md | 8 +- {feature-map => maps/feature-map}/steering.md | 20 +- .../feature-map}/task-coordination.md | 8 +- {feature-map => maps/feature-map}/tasks.md | 10 +- {feature-map => maps/feature-map}/teams.md | 8 +- .../feature-map}/workspaces.md | 8 +- scripts/check-maps.mjs | 131 +++++++++++ scripts/check-maps.test.mjs | 89 ++++++++ 83 files changed, 5425 insertions(+), 186 deletions(-) create mode 100644 maps/README.md create mode 100644 maps/agent-access-map/README.md create mode 100644 maps/agent-access-map/access.md create mode 100644 maps/agent-access-map/activity.md create mode 100644 maps/agent-access-map/agent-chat.md create mode 100644 maps/agent-access-map/agents.md create mode 100644 maps/agent-access-map/app-permissions.md create mode 100644 maps/agent-access-map/budgets-costs.md create mode 100644 maps/agent-access-map/cases.md create mode 100644 maps/agent-access-map/chat-channels.md create mode 100644 maps/agent-access-map/cli-operations.md create mode 100644 maps/agent-access-map/companies.md create mode 100644 maps/agent-access-map/connection-setup.md create mode 100644 maps/agent-access-map/developer-labs.md create mode 100644 maps/agent-access-map/documents-artifacts.md create mode 100644 maps/agent-access-map/execution-environments.md create mode 100644 maps/agent-access-map/gateways-profiles.md create mode 100644 maps/agent-access-map/goals.md create mode 100644 maps/agent-access-map/inbox-search.md create mode 100644 maps/agent-access-map/instance-operations.md create mode 100644 maps/agent-access-map/navigation-preferences.md create mode 100644 maps/agent-access-map/onboarding.md create mode 100644 maps/agent-access-map/pipelines.md create mode 100644 maps/agent-access-map/plugins.md create mode 100644 maps/agent-access-map/projects.md create mode 100644 maps/agent-access-map/questions-and-approvals.md create mode 100644 maps/agent-access-map/recovery.md create mode 100644 maps/agent-access-map/routines.md create mode 100644 maps/agent-access-map/runs-adapters.md create mode 100644 maps/agent-access-map/secrets.md create mode 100644 maps/agent-access-map/skills.md create mode 100644 maps/agent-access-map/status-cards.md create mode 100644 maps/agent-access-map/steering.md create mode 100644 maps/agent-access-map/task-coordination.md create mode 100644 maps/agent-access-map/tasks.md create mode 100644 maps/agent-access-map/teams.md create mode 100644 maps/agent-access-map/workspaces.md rename {feature-map => maps/feature-map}/README.md (87%) rename {feature-map => maps/feature-map}/access.md (78%) rename {feature-map => maps/feature-map}/activity.md (75%) rename {feature-map => maps/feature-map}/agent-chat.md (77%) rename {feature-map => maps/feature-map}/agents.md (76%) rename {feature-map => maps/feature-map}/app-permissions.md (76%) rename {feature-map => maps/feature-map}/budgets-costs.md (79%) rename {feature-map => maps/feature-map}/cases.md (78%) rename {feature-map => maps/feature-map}/chat-channels.md (79%) rename {feature-map => maps/feature-map}/cli-operations.md (77%) rename {feature-map => maps/feature-map}/companies.md (76%) rename {feature-map => maps/feature-map}/connection-setup.md (88%) rename {feature-map => maps/feature-map}/coverage.json (100%) rename {feature-map => maps/feature-map}/developer-labs.md (76%) rename {feature-map => maps/feature-map}/documents-artifacts.md (71%) rename {feature-map => maps/feature-map}/execution-environments.md (76%) rename {feature-map => maps/feature-map}/gateways-profiles.md (74%) rename {feature-map => maps/feature-map}/goals.md (73%) rename {feature-map => maps/feature-map}/inbox-search.md (68%) rename {feature-map => maps/feature-map}/instance-operations.md (78%) rename {feature-map => maps/feature-map}/navigation-preferences.md (77%) rename {feature-map => maps/feature-map}/onboarding.md (79%) rename {feature-map => maps/feature-map}/pipelines.md (77%) rename {feature-map => maps/feature-map}/plugins.md (78%) rename {feature-map => maps/feature-map}/projects.md (80%) rename {feature-map => maps/feature-map}/questions-and-approvals.md (84%) rename {feature-map => maps/feature-map}/recovery.md (87%) rename {feature-map => maps/feature-map}/routines.md (73%) rename {feature-map => maps/feature-map}/runs-adapters.md (76%) rename {feature-map => maps/feature-map}/secrets.md (75%) rename {feature-map => maps/feature-map}/skills.md (76%) rename {feature-map => maps/feature-map}/status-cards.md (74%) rename {feature-map => maps/feature-map}/steering.md (87%) rename {feature-map => maps/feature-map}/task-coordination.md (75%) rename {feature-map => maps/feature-map}/tasks.md (76%) rename {feature-map => maps/feature-map}/teams.md (80%) rename {feature-map => maps/feature-map}/workspaces.md (77%) create mode 100644 scripts/check-maps.mjs create mode 100644 scripts/check-maps.test.mjs diff --git a/.agents/skills/create-verification-skill/NOTICE.md b/.agents/skills/create-verification-skill/NOTICE.md index d887f754207..68ab4a95a19 100644 --- a/.agents/skills/create-verification-skill/NOTICE.md +++ b/.agents/skills/create-verification-skill/NOTICE.md @@ -7,7 +7,8 @@ The fork imported pstack 0.15.15 by Lauren Tan (poteto) from [cursor/plugins](https://github.com/cursor/plugins/tree/ccb5507cec1546dc88135c1139c811e6c59115ba/pstack), upstream revision `ccb5507cec1546dc88135c1139c811e6c59115ba`. -Paperclip adapts the workflow to the existing repo-root `feature-map/`. +Paperclip adapts the workflow to the sibling `maps/feature-map/` and +`maps/agent-access-map/` directories and their shared access contract. It preserves the invocation policy in `agents/openai.yaml`, removes the Cursor-specific `disable-model-invocation` frontmatter field, and omits the separate runtime skill dependency because Paperclip supplies execution. diff --git a/.agents/skills/create-verification-skill/SKILL.md b/.agents/skills/create-verification-skill/SKILL.md index de10a42c2d4..782513a549b 100644 --- a/.agents/skills/create-verification-skill/SKILL.md +++ b/.agents/skills/create-verification-skill/SKILL.md @@ -32,7 +32,19 @@ Write `.agents/skills/verify-/SKILL.md` with YAML frontmatter (`name: verif ## 3. Seed the feature map -For Paperclip, use the existing repo-root `feature-map/` directory and its `README.md`; have the generated verification skill reference that map instead of creating a second map inside the skill directory. Reuse existing feature files and use Poteto's format below for new or updated entries. For other projects, create `.agents/skills/verify-/features/README.md` plus one file per user-facing feature you can identify (aim for the top 3-5 to start, from routes, commands, menus, or docs). Follow the shape in [`references/feature-map-example/`](references/feature-map-example/), with a README index and one file per feature. Each file answers, from the user's point of view: what the feature is, how to reach it, how to drive it with the harness, and what observable end state proves it works. The four H2s are `Sub-features`, `How to get to it (user POV)`, `Driving it with `, and `Gotchas`. The map is the repo's maintained verification source; a proof that drives one convenient entry point is incomplete when the map lists others. +For Paperclip, read the [map contract](../../../maps/README.md) and use the sibling +`maps/feature-map/` and `maps/agent-access-map/` directories. The generated +verification skill consumes both maps and reports drift; maintenance owns their +updates. Reuse existing feature files and preserve Poteto's product format. Every +new feature file needs a same-named agent access companion with matching stable +sub-feature IDs. Record APIs, actual agent entry points, policy, evidence and gaps +using the contract. Mark representative source inventory as `baseline` / +`source-only`; enumerate operations before claiming full access coverage. Reuse +Paperclip's APIs, admitted tools and existing verification infrastructure. Run +`node scripts/check-maps.mjs` after map changes. Do not create maps inside a skill +directory or generate a second runtime. + +For other projects, create `.agents/skills/verify-/features/README.md` plus one file per user-facing feature you can identify (aim for the top 3-5 to start, from routes, commands, menus, or docs). Follow the shape in [`references/feature-map-example/`](references/feature-map-example/), with a README index and one file per feature. Each file answers, from the user's point of view: what the feature is, how to reach it, how to drive it with the harness, and what observable end state proves it works. The four H2s are `Sub-features`, `How to get to it (user POV)`, `Driving it with `, and `Gotchas`. The map is the repo's maintained verification source; a proof that drives one convenient entry point is incomplete when the map lists others. ## 4. Prove the generated skill before handing it over diff --git a/.agents/skills/maintain-verification-skill/NOTICE.md b/.agents/skills/maintain-verification-skill/NOTICE.md index 0c7b0648ebc..120888c9db5 100644 --- a/.agents/skills/maintain-verification-skill/NOTICE.md +++ b/.agents/skills/maintain-verification-skill/NOTICE.md @@ -7,7 +7,8 @@ The fork imported pstack 0.15.15 by Lauren Tan (poteto) from [cursor/plugins](https://github.com/cursor/plugins/tree/ccb5507cec1546dc88135c1139c811e6c59115ba/pstack), upstream revision `ccb5507cec1546dc88135c1139c811e6c59115ba`. -Paperclip adapts the workflow to the existing repo-root `feature-map/`. +Paperclip adapts the workflow to the sibling `maps/feature-map/` and +`maps/agent-access-map/` directories and their shared access contract. It preserves the invocation policy in `agents/openai.yaml`, removes the Cursor-specific `disable-model-invocation` frontmatter field, and omits the separate runtime skill dependency because Paperclip supplies execution. diff --git a/.agents/skills/maintain-verification-skill/SKILL.md b/.agents/skills/maintain-verification-skill/SKILL.md index d95e6e8842e..8d261ef3fb4 100644 --- a/.agents/skills/maintain-verification-skill/SKILL.md +++ b/.agents/skills/maintain-verification-skill/SKILL.md @@ -1,40 +1,124 @@ --- name: maintain-verification-skill -description: "Periodic pass that keeps a project's verification skill and feature map honest: parallel source readers per feature, one live session driving every feature, at most one PR of proven corrections. Use for /maintain-verification-skill or \"audit the verify skill\"." +description: "Keep a project's verification skill and product/agent access maps aligned with current source and real evidence. Use for /maintain-verification-skill, auditing the verify skill, or reconciling feature and agent access coverage." --- -# Maintain a verification skill +# Maintain a verification skill and its maps -A feature map rots the moment the app changes. This skill is the upkeep loop for a skill generated by `/create-verification-skill` (or any project-local verification skill with a feature map). The unit of rigor is the feature, not every sentence: cover every feature file from source and exercise every feature live, without terminalising every bullet. +Keep user journeys and agent access aligned with the app. For Paperclip, read the +[map contract](../../../maps/README.md), the +[feature index](../../../maps/feature-map/README.md), and the +[agent access index](../../../maps/agent-access-map/README.md). These are sibling +maps under `maps/`, with the same area filenames and capability IDs. Preserve +[Poteto's product format](../create-verification-skill/references/feature-map-example/). +Agent companions follow the shared contract; they do not duplicate user recipes. + +An API endpoint, an OpenAPI actor label, a declared scenario tool and an admitted +production agent path are different evidence. Verify which permitted agent +surface can actually express an operation. Track source review, operation review +and executed proof separately. A source-only baseline is useful, but cannot +certify complete coverage or live behavior. ## Outcomes -Pick one, and say which: +Pick one and report source scope, live scope and remaining gaps separately: -- **clean** — every feature got source and live coverage; nothing worth shipping. No branch, no PR. -- **changed** — one PR ships proven doc, harness, or map corrections. -- **blocked** — coverage could not finish or a proven fix could not ship safely. Say exactly what blocked it. +- **clean** — the requested scope got source and live coverage, with no corrections. +- **changed** — proven skill/map/check corrections are ready to review; name any + access gaps or unexecuted coverage that remains. +- **blocked** — requested coverage or a proven correction could not finish; name + the unmet prerequisite and evidence captured so far. + +A targeted audit covers its named areas; do not present it as a full pass. A full +pass reads every area from source and exercises every reachable feature, plus +relevant permitted-agent and denial paths. Report unreachable prerequisites and +not-run operations rather than silently reducing the scope. ## Edit scope -Only edit the verification skill's own directory (its SKILL.md and any harness scripts it owns) and its feature-map directory. For Paperclip, the map is the existing repo-root `feature-map/`; maintain it in place instead of creating or moving it into a skill directory. Use [Poteto's feature-map format](../create-verification-skill/references/feature-map-example/) for new or updated entries. Never edit product code during a run: a behavior the map describes that the app no longer does is either doc drift (fix the map) or a product regression (report it, don't paper over it in docs). +For Paperclip, edit the target verification skill, these maintenance instructions +when needed, `maps/`, and the map-specific structural check +`scripts/check-maps.mjs` / `scripts/check-maps.test.mjs`. Fix references to those +maps in contributor docs or skill instructions when their locations/IDs change. +Keep the maps in `maps/{feature-map,agent-access-map}/`, outside skill directories. + +For other projects, maintain their existing verification skill and map locations; +the Paperclip directory convention does not authorize relocating another repo. +Never edit product APIs, permission enforcement, tool bindings or UI behavior +in a maintenance run. Record product/access gaps for implementation separately; +a permission denial is not authorization to widen access. ## Pass -0. **Locate the target.** Find the verification skill to maintain: the project-local skill whose body has launch/drive sections and a feature map (usually `.agents/skills/verify-*/`). Several candidates → ask which one; none → stop and point at `/create-verification-skill` instead of inventing a target. +0. **Locate the target and scope.** Find the project-local verification skill + (usually `.agents/skills/verify-*/`). Several candidates: ask which; none: + point to `/create-verification-skill`. Honor a requested narrower audit. + Record the source commit and the last review for affected map entries. -1. **Index hygiene.** Read the feature map README and glob its sibling files. Fix missing, extra, duplicate, or dead entries. Lightweight; no generated inventory. +1. **Check both indexes.** Pair same-named feature and agent files, then match + stable sub-feature IDs. Fix orphan/missing entries and broken references. + For Paperclip run `node scripts/check-maps.mjs`. This checks structure, + not API authorization, real tool bindings or live behavior. -2. **Source wave.** One read-only subagent per feature file, launched concurrently. Each explains "how does this user-facing feature work?" from source, flags likely doc drift with citations, and returns one concise live-verification recipe. Children never drive the app and never edit files. Return shape: feature summary / source entry points / likely drift or none / one recipe. +2. **Source wave.** Use read-only subagents per area, with each reader owning + the feature file and its access companion; schedule within available slots. + Readers never edit or drive the app. Each returns the user capability/source + entry points, likely drift with citations, meaningful operations, their APIs, + actor/grant/resource/run/mode boundaries, real agent entry points by surface, + missing access/discovery/policy decisions, and a concise verification recipe. + Follow recent changes to routes, contracts, authorization, native tool + admission, CLI and MCP as well as UI. Inspect actual route/service policy; + compare it with OpenAPI and production bindings, not only scenario catalogs. -3. **Reconcile.** Every feature file has a returned summary. Merge overlapping recipes into as few app states as practical. Spot-check cited drift; don't re-prove clean claims. Sweep recent churn for user-facing surfaces missing from the map — require a concrete source path before calling one missing. +3. **Reconcile both maps.** Spot-check cited drift and merge recipes into as + few app states as practical. Every affected business operation needs an API + and a permitted agent path, or an explicit gap. A dedicated tool is optional + when existing API/CLI/MCP fallback suffices. Distinguish native/remote access + from legacy REST and personal assistant OAuth. Record field/scope limits, + unavailable clients, protocol requirements and native fallback restrictions. + Keep justified local/developer/operator exceptions, but split shared data + operations from presentation. A board-only route records a restriction and + any existing request/result handoff; an unresolved delegation question stays + visible. Split broad baselines into per-operation records before marking + them operation-reviewed. Add newly discovered capabilities only with concrete + source; add their companion at the same time. -4. **Live pass.** Required even when source looks clean. The coordinator owns all driving; follow the verification skill's own launch model — one long-lived instance driven serially for servers and UIs, or a fresh isolated session per drive for short-lived CLIs (the skill's Launch section decides, not this one). Exercise every feature at least once, and hold three invariants the whole pass, whatever the failure: (1) never drive an instance you haven't health-checked since it last did something surprising — doctor before first drive, doctor on each fresh session where sessions are the unit, doctor again after any failed drive, and where doctor can't see the failure (a wedged UI state on a healthy process), reset to a known state or relaunch rather than hoping; (2) evidence captured so far survives every cleanup, checked at its named location, not assumed; (3) nothing a drive started outlives that drive's usefulness — failed-iteration residue is cleaned whether the session is stuck, exited, or shared (for a shared instance, clean the residue, not the instance). A doctor failure caused by skill drift is drift: fix it under edit scope and retry once — restart whatever the fix invalidated, nothing more — before calling the pass `blocked`. A feature that can't be reached is `verified-unreachable` only with the concrete prerequisite (auth, entitlement, OS, external state) and the route attempted; if the map omits that prerequisite, that's drift. Any harness fix from triage gets re-driven live before it ships. Final teardown happens after the last drive of the run — including those re-proofs — so nothing outlives the run (evidence stays, per the skill). +4. **Live pass.** Required for a verification maintenance pass even if source + looks clean. The coordinator owns serial driving, using the target skill's + existing launch/doctor/evidence/cleanup workflow and Paperclip's existing + API/runner/MCP fixtures. Exercise the real user path and the relevant actual + agent path separately. For access checks use the permitted agent identity, + run, mode and target; assert results and relevant denials. A board request or + board test-as-agent does not replace agent-credential proof. Use disposable + fixtures for mutation/denial tests and record simulated providers honestly. + Never assume an optional client is admitted to a native run. -5. **Triage.** Wrong or missing user-POV description → doc drift, fix it. Working behavior the harness can't drive → harness gap, fix it; a harness fix follows the same helpers rule as generation (scripts executable, invocation documented in the skill body). App behavior that's actually broken → product gap; record it for the user, keep it out of this PR. + Keep three invariants: health-check before driving and after surprises; + verify captured evidence survives cleanup; clean up everything the drive + owned once no longer needed. Reset a wedged UI even if process health passes. + Follow the target skill's session model: one long-lived owned server/UI + instance, or isolated short-lived CLI sessions. After a skill-induced failure, + fix within edit scope and retry once, restarting only what was invalidated. + Record missing auth/provider/environment prerequisites and attempted paths. + Re-drive changed harness helpers before shipping them. Final teardown follows + the last proof attempt and preserves evidence. -6. **Ship or stop.** For changed: one PR of proven corrections, re-read every changed file first. For clean or blocked: no PR, report the outcome and the coverage honestly. +5. **Triage and record.** User description/recipe drift updates the product map; + endpoint/path/permission/discovery drift updates its companion. Missing or + broken product behavior stays a product gap, with evidence. Never rewrite a + recipe or mark an access exception to bless a failure. Map evidence states + remain source-only/not-run unless an actual proof supports the named operation + and surface. Keep run artifacts/results in the evidence location or issue; + `maps/feature-map/coverage.json` remains documentation scope. -Keep concise run notes (features covered, unreachable prerequisites, confirmed drift, outcome) in a scratch location; don't commit them. +6. **Review corrections.** Re-read changed files, check paired IDs/references and + run the smallest relevant validation. Report outcome and source/live coverage + honestly. If shipping is requested or already authorized, update the existing + task PR where appropriate, or create at most one PR of proven corrections. + A clean pass needs no branch or PR. Do not implement access-policy changes + merely to make the maintenance report green. + +Keep concise run notes and proof artifacts in the target skill's named evidence +location; do not commit ephemeral run logs as map content. Origin and license: [NOTICE.md](NOTICE.md). diff --git a/.agents/skills/verify-paperclip/SKILL.md b/.agents/skills/verify-paperclip/SKILL.md index 2728797cff8..b16467140a0 100644 --- a/.agents/skills/verify-paperclip/SKILL.md +++ b/.agents/skills/verify-paperclip/SKILL.md @@ -5,9 +5,13 @@ description: Launch an isolated Paperclip instance, drive its real web UI or CLI # Verify Paperclip -Use the repo-root [feature map](../../../feature-map/README.md) to select -verification recipes. Read the relevant feature file before driving it. Report -any recipe drift with evidence; map updates belong to +Read the [map contract](../../../maps/README.md), then use the +[feature map](../../../maps/feature-map/README.md) to select user verification +recipes. For API/agent access, read the same-named +[agent access companion](../../../maps/agent-access-map/README.md) and use the +actual permitted agent identity, target and work mode. Follow existing API/runner/MCP +fixtures for access proof; a board request does not prove agent access. Report +recipe or access drift with evidence; map updates belong to [maintain-verification-skill](../maintain-verification-skill/SKILL.md). Read [development instructions](../../../doc/DEVELOPING.md) for current commands. diff --git a/AGENTS.md b/AGENTS.md index 53f25d6d514..55ee4734b84 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,10 +152,16 @@ Notes: ## 7. Verification Before Hand-off -[feature-map/README.md](feature-map/README.md) is an optional reference for user +[maps/feature-map/README.md](maps/feature-map/README.md) is an optional reference for user entry points, verification recipes, and coverage gaps. The map records coverage scope, not proof that a live journey passed. +The sibling [agent access map](maps/agent-access-map/README.md) records APIs, +agent entry points and permission boundaries using the same capability IDs. +Follow [the map contract](maps/README.md): update affected entries when mapped +behavior or access changes, and run `node scripts/check-maps.mjs` after map edits. +Its structural checks do not prove live behavior or authorization. + Default local/agent test path: ```sh diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index feef1880754..233c2e5cf66 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -495,11 +495,17 @@ npx paperclipai allowed-hostname dotta-macbook-pro ## Test Commands -The [feature map](../feature-map/README.md) is an optional reference for user +The [feature map](../maps/feature-map/README.md) is an optional reference for user entry points, targeted tests, manual verification recipes, and coverage gaps. Its page inventory is a source snapshot. The documented journeys have separate verification steps and do not run automatically from the map. +The sibling [agent access map](../maps/agent-access-map/README.md) describes API +and agent reachability for the same capabilities. The [map contract](../maps/README.md) +defines synchronization, permissions and evidence. After map edits, run +`node scripts/check-maps.mjs` for structural/reference checks; it does not start an +instance or qualify access as an agent. + Use the cheap local default unless you are specifically working on browser flows: ```sh diff --git a/maps/README.md b/maps/README.md new file mode 100644 index 00000000000..5946460d2d4 --- /dev/null +++ b/maps/README.md @@ -0,0 +1,135 @@ +# Paperclip maps + +The maps describe the same capabilities from two perspectives: + +- [Feature map](feature-map/README.md): what a person can do, where to reach it, + and how to verify the user journey. Preserve its Poteto format. +- [Agent access map](agent-access-map/README.md): the APIs and agent entry points + for those capabilities, their permission boundaries, and access verification. + +These are sibling directories under `maps/`. Each feature file has a same-named +agent access companion. Reuse the product map's sub-feature IDs, so `tasks/create` +identifies the same capability in both maps. Product behavior remains governed by +[the implementation spec](../doc/SPEC-implementation.md); a map cannot grant +authority or redefine a permission denial as success. + +## Access contract + +Every business operation needs both an API and a usable agent entry point for an +actor authorized to perform it. A dedicated tool is optional when REST, the CLI, +an assigned MCP client, or native `search_api` / `call_api` already provides the +operation. Record which agent surfaces actually have that path; an installed CLI +or optional MCP package cannot be assumed available to every native/remote run. + +Track these independently: + +| Dimension | Values and meaning | +| --- | --- | +| API | `present`, `missing`, `not-applicable`, or `unreviewed`. Use exact methods/paths; distinguish protocols from REST. | +| Agent access | `available`, `partial`, `missing`, `restricted`, `operator-only`, `not-applicable`, or `unreviewed`. Name the supported surface and actual entry point. | +| Policy | Actor, action/grant, company/resource/ownership scope, responsible-user intersection, run requirements, work modes, and gates relevant to the operation. | +| Evidence | `source-only`, `verified`, `failed`, `blocked`, or `not-run`. Source presence and successful board calls are not agent verification. | +| Review depth | `baseline` records representative operations and known gaps; `operation-reviewed` enumerates the capability's meaningful operations and their individual access contracts. | + +Broad capabilities such as task properties must be split into operations before +they count as operation-reviewed: renaming, metadata edits, assignment, +dependencies, and lifecycle changes can use different tools and permission rules. +An available read path cannot certify a write path. List/get/create/update/delete, +execution/control, credential handling, and resolution are separate where their +authority or transport differs. Do not duplicate request schemas from OpenAPI. + +### Exceptions and restrictions + +Presentation-only state, static developer fixtures, and pre-start/local operator +work may use `not-applicable` for a domain API, with a concrete reason and the +existing local path. When a capability combines shared data with presentation, +split the operations: browser recents do not exempt resource lookup from API +coverage. Promoting local state to a shared feature requires revisiting the entry. + +A board-only endpoint is `restricted` for agent identity, not an automatic +exception or a missing API. Cite the enforced policy and record whether the +agent can request the authorized human's action and observe its result. An +unresolved delegation question remains in **Gaps**. Company access, a job title, +a personal assistant's OAuth grant, or an accepted interaction does not grant +board identity. Keep budget, approval, secret and execution authority intact. + +Check actual route/service authorization and production tool admission. OpenAPI +actor labels and scenario/evaluation tool declarations are supporting metadata; +record disagreements rather than using them as proof of access. Native API +fallback restrictions, tool field/scope limits, and rollout settings are part of +the agent path, even when a legacy agent can call the REST endpoint directly. + +## Companion file format + +Each `agent-access-map/.md` has an H1, a relative link to its product file, +review date and source commit, followed by one H2 backticked ID per product +sub-feature. Each capability contains these fields: + +- **Review:** `baseline` or `operation-reviewed`. +- **API:** presence state and exact operation IDs, or a reasoned exception. +- **Agent access:** availability state and concrete entry points by surface. +- **Policy:** relevant permission and runtime boundaries, with source references. +- **Evidence:** evidence state, reviewed commit and sources or executed proof. +- **Verification:** allowed/denied assertions and observable result; record what + has actually run separately from the proposed recipe. +- **Gaps:** missing APIs/paths, discovery drift, unreviewed operations or policy + decisions; use `none` only for the enumerated scope that was reviewed. + +For an operation-reviewed capability, use H3 backticked operation IDs and repeat +API, Agent access, Policy, Evidence, Verification and Gaps for each operation. +Use small operation names such as `create`, `update`, `read`, or `run-now`. +The enclosing capability has `Review: operation-reviewed` only after its +meaningful operations have been enumerated. The following excerpt shows one +operation; include the others before using that review label: + +```markdown +## `definition` + +Review: `operation-reviewed`. + +### `create` + +API: `present` — POST /api/companies/{companyId}/routines. +Agent access: `partial` — agent REST and CLI routine create; default native +mutation binding missing, and call_api blocks routine mutations. +Policy: same company; assigneeAgentId must be the calling agent; cite the +routine authorization and native API policy sources. +Evidence: `source-only` — reviewed commit and source links; no live proof. +Verification: `not-run` — create an owned routine; reject assignment to another +agent; inspect saved configuration, attribution and retry behavior. +Gaps: default native create path; verify using the real permitted agent context. +``` + +## Keeping the maps synchronized + +When a change adds/removes a capability or changes its behavior, API, permission, +tool binding, CLI support, or work mode, update the affected companion and product +recipe as appropriate in the same change. Preserve IDs across renames where the +capability is unchanged; update both indexes and cross-links together. An API-only +change may update the companion without rewriting unchanged user instructions. + +[maintain-verification-skill](../.agents/skills/maintain-verification-skill/SKILL.md) +owns reconciliation of both maps. It follows changed source and authorization, +checks real agent reachability, and records product gaps without implementing +them during a maintenance pass. The creation skill uses this structure. +[verify-paperclip](../.agents/skills/verify-paperclip/SKILL.md) consumes the relevant +recipes and reports evidence/drift; map editing belongs to maintenance. + +Run the structural check after map changes: + +```sh +node scripts/check-maps.mjs +``` + +It checks paired areas/IDs, indexes, required fields, and local references. It +does not certify endpoint authorization, production bindings, operation +completeness, or live behavior. Source audits must check those against the +existing API and tool contracts; avoid a second schema catalog or runtime. + +For live access proof, use Paperclip's existing API/runner/MCP fixtures or an owned +isolated instance. Use the actual agent identity, target and work mode. Assert the +allowed result and relevant denial (another company/private resource, wrong +owner, missing grant, invalid run, restricted mode or resolver). Mutations also +need persisted state/attribution and retry behavior. Capture evidence using the +[existing artifact workflow](../doc/AGENT-ARTIFACTS.md); keep executed run results +separate from product-map `coverage.json`, which records documentation scope. diff --git a/maps/agent-access-map/README.md b/maps/agent-access-map/README.md new file mode 100644 index 00000000000..9b8a94ab47e --- /dev/null +++ b/maps/agent-access-map/README.md @@ -0,0 +1,69 @@ +# Paperclip agent access map + +This map accompanies the [feature map](../feature-map/README.md). It records APIs, +agent entry points, permission boundaries and verification for the same capability IDs. +The [shared contract](../README.md) defines the format and maintenance rules. + +Initial inventory: **35 areas and 171 sub-features**, source-reviewed on 2026-10-12 +at `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. All entries are `baseline` and `source-only`; none +claims complete operation enumeration or freshly verified agent access. Existing +API paths, native gaps, actor restrictions and local/operator exceptions are +recorded separately. These statuses do not represent a live pass rate. + +## Areas + +| Area | Product map | Capabilities | +| --- | --- | --- | +| [Login, invitations, and access](access.md) | [access](../feature-map/access.md) | 5 | +| [Dashboards, activity, and audit trails](activity.md) | [activity](../feature-map/activity.md) | 5 | +| [Agent conversations and project handoff](agent-chat.md) | [agent-chat](../feature-map/agent-chat.md) | 4 | +| [Hiring, agent configuration, and organization](agents.md) | [agents](../feature-map/agents.md) | 5 | +| [App access, action permissions, and testing](app-permissions.md) | [app-permissions](../feature-map/app-permissions.md) | 5 | +| [Costs, budgets, and spending controls](budgets-costs.md) | [budgets-costs](../feature-map/budgets-costs.md) | 4 | +| [Cases and structured work records](cases.md) | [cases](../feature-map/cases.md) | 4 | +| [External chat and email conversations](chat-channels.md) | [chat-channels](../feature-map/chat-channels.md) | 5 | +| [CLI, API clients, and local worktrees](cli-operations.md) | [cli-operations](../feature-map/cli-operations.md) | 5 | +| [Companies and portability](companies.md) | [companies](../feature-map/companies.md) | 4 | +| [Connection setup](connection-setup.md) | [connection-setup](../feature-map/connection-setup.md) | 8 | +| [Developer previews and diagnostic labs](developer-labs.md) | [developer-labs](../feature-map/developer-labs.md) | 4 | +| [Documents, attachments, and work products](documents-artifacts.md) | [documents-artifacts](../feature-map/documents-artifacts.md) | 5 | +| [Local, SSH, and sandbox environments](execution-environments.md) | [execution-environments](../feature-map/execution-environments.md) | 5 | +| [Tool gateways and access profiles](gateways-profiles.md) | [gateways-profiles](../feature-map/gateways-profiles.md) | 5 | +| [Goals and work alignment](goals.md) | [goals](../feature-map/goals.md) | 3 | +| [Inbox, decisions, and search](inbox-search.md) | [inbox-search](../feature-map/inbox-search.md) | 4 | +| [Installation, configuration, updates, and backups](instance-operations.md) | [instance-operations](../feature-map/instance-operations.md) | 5 | +| [Navigation, profile, and announcements](navigation-preferences.md) | [navigation-preferences](../feature-map/navigation-preferences.md) | 5 | +| [Onboarding and first work](onboarding.md) | [onboarding](../feature-map/onboarding.md) | 5 | +| [Pipelines, review queues, and learnings](pipelines.md) | [pipelines](../feature-map/pipelines.md) | 4 | +| [Plugins and contributed product surfaces](plugins.md) | [plugins](../feature-map/plugins.md) | 4 | +| [Projects and repositories](projects.md) | [projects](../feature-map/projects.md) | 4 | +| [Questions and approvals](questions-and-approvals.md) | [questions-and-approvals](../feature-map/questions-and-approvals.md) | 8 | +| [Recovery](recovery.md) | [recovery](../feature-map/recovery.md) | 8 | +| [Routines, schedules, and triggers](routines.md) | [routines](../feature-map/routines.md) | 5 | +| [Runs, harnesses, and model accounts](runs-adapters.md) | [runs-adapters](../feature-map/runs-adapters.md) | 4 | +| [Secrets, personal credentials, and proposals](secrets.md) | [secrets](../feature-map/secrets.md) | 5 | +| [Skill discovery, authoring, and policy](skills.md) | [skills](../feature-map/skills.md) | 5 | +| [Status cards and refreshed summaries](status-cards.md) | [status-cards](../feature-map/status-cards.md) | 4 | +| [Steering and queued messages](steering.md) | [steering](../feature-map/steering.md) | 7 | +| [Delegation, dependencies, and signoff](task-coordination.md) | [task-coordination](../feature-map/task-coordination.md) | 4 | +| [Task creation and lifecycle](tasks.md) | [tasks](../feature-map/tasks.md) | 5 | +| [Team packages and catalog installation](teams.md) | [teams](../feature-map/teams.md) | 4 | +| [Execution workspaces, services, and files](workspaces.md) | [workspaces](../feature-map/workspaces.md) | 5 | + +## Using an entry + +Read its Policy and Gaps before selecting an agent path. Native fallback, legacy +REST/CLI, stdio MCP and a personal assistant connection can have different +authority and availability. Use a real admitted production binding; a scenario +declaration is not an installed tool. A `restricted` entry records current +enforcement and leaves any unresolved delegation decision visible. + +For API catalog disagreements, follow the route/service authorization and record +discovery drift. For `not-applicable`, retain the concrete exception and local +path. Split shared business operations from client-only presentation. + +Use `maintain-verification-skill` to reconcile changed source and both maps. +Use `verify-paperclip` and existing API/runner/MCP fixtures to collect the +permitted-agent and denial evidence; report blocked/not-run coverage honestly. +Run `node scripts/check-maps.mjs` after editing. The structural check does not +certify permissions, API catalog coverage, tool admission or live behavior. diff --git a/maps/agent-access-map/access.md b/maps/agent-access-map/access.md new file mode 100644 index 00000000000..4382be3416c --- /dev/null +++ b/maps/agent-access-map/access.md @@ -0,0 +1,136 @@ +# Login, invitations, and access — agent access + +Product map: [Login, invitations, and access](../feature-map/access.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `login` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/auth/get-session`. +- `POST /api/bootstrap/claim`. + +Agent access: `restricted` — Agent authenticates with its own issued bearer; board login +uses human/client auth. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: An agent identity is not a board login or board claim; explicit delegation is a +product-policy decision. + +## `invitations` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/invites`. +- `POST /api/invites/{token}/accept`. + +Agent access: `partial` — REST with invite permission; CLI access; native call_api +blocks invite credentials. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Invite creation has an agent permission branch. Human joining and agent onboarding +are different operations. + +## `membership` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/members`. +- `PATCH /api/companies/{companyId}/members/{memberId}/permissions`. + +Agent access: `partial` — REST with the specific company permission; native fallback for +unrestricted operations. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Company membership APIs include agent grants; instance-admin user operations stay +board-only. + +## `resources` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}`. +- `GET /api/projects/{id}`. + +Agent access: `available` — REST or scoped task/project tools. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Company/resource authorization is implemented; a negative test must cover private +resources and another company. + +## `cli-auth` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/cli-auth/challenges`. +- `POST /api/cli-auth/revoke-current`. + +Agent access: `restricted` — Board CLI authorization; agents use issued agent +credentials. + +Policy: Company access plus action-specific grants; instance administration requires a +board administrator. + +Evidence: `source-only` — [implementation](../../server/src/routes/access.ts#L3018) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: CLI board login/revocation cannot be counted as agent self-authentication. diff --git a/maps/agent-access-map/activity.md b/maps/agent-access-map/activity.md new file mode 100644 index 00000000000..b5d2041289c --- /dev/null +++ b/maps/agent-access-map/activity.md @@ -0,0 +1,129 @@ +# Dashboards, activity, and audit trails — agent access + +Product map: [Dashboards, activity, and audit trails](../feature-map/activity.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `overview` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/dashboard`. + +Agent access: `available` — REST / native call_api / CLI dashboard. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Dashboard data is accessible; live-work rendering and event streaming are separate +surfaces. + +## `activity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/activity`. + +Agent access: `available` — REST / native call_api / CLI activity. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Audit export and raw activity creation have extra restrictions; do not equate them +with activity reading. + +## `runs` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/heartbeat-runs`. +- `GET /api/heartbeat-runs/{runId}/events`. + +Agent access: `available` — REST / native call_api / CLI run. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Telemetry/run visibility is checked independently from seeing the source task. + +## `routine-audit` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/routines/{id}/runs`. + +Agent access: `available` — REST / native call_api / CLI routine runs. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Routine execution history is distinct from configuration revisions and scheduling +changes. + +## `timeline` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/timeline`. + +Agent access: `available` — REST / native call_api. + +Policy: Same company plus visibility/audit/cost/run permissions where the route requires +them. + +Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: API exists; no dedicated timeline tool is necessary for this requirement. diff --git a/maps/agent-access-map/agent-chat.md b/maps/agent-access-map/agent-chat.md new file mode 100644 index 00000000000..e38bd25bee6 --- /dev/null +++ b/maps/agent-access-map/agent-chat.md @@ -0,0 +1,113 @@ +# Agent conversations and project handoff — agent access + +Product map: [Agent conversations and project handoff](../feature-map/agent-chat.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `discovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/chats`. +- `GET /api/companies/{companyId}/chats/{agentRef}`. + +Agent access: `restricted` — Board-user REST; the responding agent gets its assigned +conversation context. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agents cannot enumerate or create another person's standing chats. Detail route is +missing from the generated API catalog. + +## `history` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/comments`. +- `POST /api/issues/{id}/comments`. + +Agent access: `partial` — Assigned conversation task tools / REST for eligible task +history and replies. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent responses are supported; creating the person's chat and impersonating their +messages are different actions. + +## `handoff` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/projects`. +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native list_projects/create_project/create_task; project MCP +tools; REST. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent handoff is implemented; execution readiness, assignment permission, and +repository access still apply. + +## `conference-room` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/board/chat/stream`. + +Agent access: `partial` — Existing SSE client in local_trusted mode with the +experimental gate. + +Policy: Conversation discovery/creation requires a board user and the Agent Chat gate; +task access follows task visibility. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L18077) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Not callable through generic REST tooling: protocol and deployment constraints +apply. This route is not guarded by assertBoard, so its intended agent policy needs +clarification. diff --git a/maps/agent-access-map/agents.md b/maps/agent-access-map/agents.md new file mode 100644 index 00000000000..46c3ee462a2 --- /dev/null +++ b/maps/agent-access-map/agents.md @@ -0,0 +1,140 @@ +# Hiring, agent configuration, and organization — agent access + +Product map: [Hiring, agent configuration, and organization](../feature-map/agents.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `hire` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/agent-hires`. +- `POST /api/companies/{companyId}/agents`. + +Agent access: `partial` — Native hire_agent; REST / CLI agent; runtime inheritance for +native hires. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Identity/persona hiring works. Operator account login and sensitive +adapter/environment configuration are separate guarded actions. + +## `instructions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/{id}/instructions-bundle/file`. +- `PUT /api/agents/{id}/instructions-bundle/file`. + +Agent access: `available` — Native read/update/history/restore instruction tools; REST / +CLI. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Canonical entry file, revision guard, company boundary, and protected-change +policy apply. + +## `organization` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/org`. +- `PATCH /api/agents/{id}`. + +Agent access: `available` — list_agents/get_agent; native call_api for permitted +configuration fields; REST / CLI. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Reporting-line changes need configuration authorization; a title alone gives no +authority. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/{id}/pause`. +- `POST /api/agents/{id}/resume`. +- `POST /api/agents/{id}/terminate`. + +Agent access: `partial` — REST resume under change grant; pause/terminate require board; +native call_api blocks all three. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Resume is agent-eligible but lacks a default native binding. Pause and termination +are actor-boundary decisions. + +## `built-in` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/built-in-agents/{key}/provision`. + +Agent access: `partial` — REST / native fallback for eligible provisioning; built-in +routine controls require board. + +Policy: Agent creation/configuration uses action grants, resource policies and change +consent; some controls require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L1395) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Provisioning checks agents:create. Enabling/running its built-in routine is not +the same permission path. diff --git a/maps/agent-access-map/app-permissions.md b/maps/agent-access-map/app-permissions.md new file mode 100644 index 00000000000..e6d7f106a87 --- /dev/null +++ b/maps/agent-access-map/app-permissions.md @@ -0,0 +1,132 @@ +# App access, action permissions, and testing — agent access + +Product map: [App access, action permissions, and +testing](../feature-map/app-permissions.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `agent-access` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/tool-connections/{connectionId}/installs`. + +Agent access: `restricted` — Board management API; agents use their resulting assigned +app tools. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Granting access is distinct from using access. No equivalent agent management path +exists in these routes. + +## `action-policy` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-profiles/{profileId}/entries`. + +Agent access: `restricted` — Board management API; agent execution follows +off/ask/allowed. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: An agent cannot authorize itself by editing the policy. Decide whether any +explicitly delegated administration should be supported. + +## `catalog-change` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-profiles/{profileId}/new-tools/review`. + +Agent access: `restricted` — Board catalog-review API; agents can discover authorized +tools. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Catalog refresh/review and quarantined-action enablement are management +operations. + +## `test-action` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-connections/{connectionId}/test-calls`. + +Agent access: `restricted` — Board test-as-agent API; agent executes its own assigned +tools normally. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: A board test performed as an agent is not proof the agent can manage or invoke the +testing endpoint. + +## `account-maintenance` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-connections/{connectionId}/reconnect`. +- `GET /api/tool-connections/{connectionId}/activity`. + +Agent access: `restricted` — Board account management; native +connections_search/connection_request can request human setup. + +Policy: Connection/profile/policy management routes require a board user; tool use +separately evaluates agent grants and action policies. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-access.ts#L517) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agent requests and continued tool use exist; reconnect/revoke/audit management are +human paths. diff --git a/maps/agent-access-map/budgets-costs.md b/maps/agent-access-map/budgets-costs.md new file mode 100644 index 00000000000..e716ebc9e5b --- /dev/null +++ b/maps/agent-access-map/budgets-costs.md @@ -0,0 +1,108 @@ +# Costs, budgets, and spending controls — agent access + +Product map: [Costs, budgets, and spending controls](../feature-map/budgets-costs.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `cost-reporting` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/costs/summary`. +- `GET /api/companies/{companyId}/costs/by-agent`. + +Agent access: `available` — REST / native call_api / CLI cost. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Cost visibility applies. Provider accounting imports and quota-window inspection +have additional board gates. + +## `budgets` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/budgets/overview`. +- `POST /api/companies/{companyId}/budgets/policies`. + +Agent access: `restricted` — Agent-readable overview where allowed; board-only +configuration. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Read API exists; no agent-write path for budget policies, including agents with +ordinary company access. + +## `incidents` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/budgets/overview`. + +Agent access: `available` — REST / native call_api for allowed visibility. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Inspection is supported; hard-stop or pause relief does not follow from seeing the +incident. + +## `resolution` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/budget-incidents/{incidentId}/resolve`. + +Agent access: `restricted` — Board resolution API; agent requests the authorized +person's action. + +Policy: Reading costs uses company/resource cost policy. Budget policies and incident +resolution require a board actor. + +Evidence: `source-only` — [implementation](../../server/src/routes/costs.ts#L346) at the +reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: A governance-policy decision is needed before delegating budget increases or +incident resolution. diff --git a/maps/agent-access-map/cases.md b/maps/agent-access-map/cases.md new file mode 100644 index 00000000000..b0dc7215b95 --- /dev/null +++ b/maps/agent-access-map/cases.md @@ -0,0 +1,108 @@ +# Cases and structured work records — agent access + +Product map: [Cases and structured work records](../feature-map/cases.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `browse` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/cases`. + +Agent access: `available` — REST / native call_api; agent cases reference. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: No dedicated production list_cases binding is required because the REST fallback +exists. + +## `structure` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/cases`. +- `PATCH /api/cases/{id}`. + +Agent access: `available` — REST / native call_api; parentCaseId and JSON fields. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Structured case data is supported; grouping/rendering is presentation, not a new +endpoint requirement. + +## `history` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/cases/{id}/events`. +- `GET /api/cases/{id}/documents/{key}/revisions`. + +Agent access: `available` — REST / native call_api. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Source-backed access only; case visibility and document locks still apply. + +## `task-links` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/cases/{id}/links`. +- `GET /api/issues/{issueId}/cases`. + +Agent access: `available` — REST / native call_api. + +Policy: enableCases gate; company/case/task boundaries, active run where required, +source trust, and checked-out mutation ownership. + +Evidence: `source-only` — [implementation](../../server/src/routes/cases.ts#L592) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Case/task association exists; source trust and issue visibility must be tested +with agent credentials. diff --git a/maps/agent-access-map/chat-channels.md b/maps/agent-access-map/chat-channels.md new file mode 100644 index 00000000000..4aeeccdcd8a --- /dev/null +++ b/maps/agent-access-map/chat-channels.md @@ -0,0 +1,132 @@ +# External chat and email conversations — agent access + +Product map: [External chat and email conversations](../feature-map/chat-channels.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `providers` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/chat-endpoints`. + +Agent access: `restricted` — Board setup APIs; native connection request can ask for +setup. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Provider installation and account enrollment are not available to an ordinary +agent actor. + +## `identity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/chat-identity-links/confirm`. + +Agent access: `restricted` — Human identity-link flow. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No impersonation path; native call_api additionally blocks identity-link +operations. + +## `conversation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications`. + +Agent access: `partial` — Assigned-agent reply pipeline; native completion; provider +tools where assigned. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Normal agent replies work through server delivery. Explicit endpoint publication +APIs are board management paths. + +## `attachments` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/attachments`. +- `POST /api/companies/{companyId}/issues/{issueId}/attachments`. + +Agent access: `partial` — Native register_deliverable / attachment tools when admitted; +agent REST; server-owned delivery. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Uploading/registering is supported. Provider delivery status and board replay are +separate operations. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/chat-endpoints/{endpointId}/activity`. +- `PATCH /api/chat-endpoints/{endpointId}`. + +Agent access: `restricted` — Board lifecycle APIs; agent requests repair when blocked. + +Policy: Endpoint management/identity linking requires a board user; delivery is +server-owned and agent tools use scoped assignments. + +Evidence: `source-only` — [implementation](../../server/src/routes/chat-channels.ts#L75) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Activity/repair/replay/disable use board endpoint access, not the agent's ability +to reply. diff --git a/maps/agent-access-map/cli-operations.md b/maps/agent-access-map/cli-operations.md new file mode 100644 index 00000000000..3513fe1593d --- /dev/null +++ b/maps/agent-access-map/cli-operations.md @@ -0,0 +1,134 @@ +# CLI, API clients, and local worktrees — agent access + +Product map: [CLI, API clients, and local worktrees](../feature-map/cli-operations.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `context` + +Review: `baseline`. + +API: `not-applicable` — Local CLI configuration has no matching domain REST resource; +explicitly classify local operator configuration. + +Agent access: `operator-only` — CLI context/profile options and issued agent +environment. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Local CLI configuration has no matching domain REST resource; explicitly classify +local operator configuration. + +## `domain-commands` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/me`. +- `GET /api/companies/{companyId}/issues`. + +Agent access: `available` — CLI client commands with agent bearer; REST and native +tools. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Each command reuses API authorization; a command listed in help is not necessarily +allowed to this agent. + +## `outputs` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/issues/{id}/documents/{key}`. +- `POST /api/issues/{id}/work-products`. + +Agent access: `available` — CLI issue/asset/skill commands; REST; native +document/deliverable tools. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Binary transfers, document versions and work-product registration need distinct +assertions. + +## `run-controls` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/heartbeat-runs/{runId}`. +- `POST /api/agents/{id}/heartbeat/invoke`. + +Agent access: `partial` — CLI run/agent commands; read API; board-only +invocation/control routes. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent credentials can read eligible runs. Operator invocation/cancellation must +not be counted as ordinary agent access. + +## `worktree-instances` + +Review: `baseline`. + +API: `not-applicable` — Local checkout and pre-start provisioning use local tools, not +company REST authorization. No new runtime is needed. + +Agent access: `operator-only` — CLI worktree/test-drive/dev service commands on an +authorized owned checkout. + +Policy: CLI preserves the selected bearer identity and run header; installing a CLI does +not grant board rights. + +Evidence: `source-only` — [implementation](../../cli/src/commands/client/common.ts#L49) +at the reviewed commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Local checkout and pre-start provisioning use local tools, not company REST +authorization. No new runtime is needed. diff --git a/maps/agent-access-map/companies.md b/maps/agent-access-map/companies.md new file mode 100644 index 00000000000..0366a4cbf4e --- /dev/null +++ b/maps/agent-access-map/companies.md @@ -0,0 +1,115 @@ +# Companies and portability — agent access + +Product map: [Companies and portability](../feature-map/companies.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `selection` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}`. +- `GET /api/companies`. +- `POST /api/companies`. + +Agent access: `partial` — Agent reads its bound company; CLI context chooses it; board +lists/creates companies. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: A worker cannot enumerate/create companies via its company agent key. Browser +selection is local state. + +## `identity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/companies/{companyId}`. +- `PATCH /api/companies/{companyId}/branding`. + +Agent access: `available` — Same-company CEO REST / native call_api / CLI company. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: The CEO restriction is a real policy, not a missing API; ordinary employees need +denial evidence. + +## `archive` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/archive`. +- `PATCH /api/companies/{companyId}`. + +Agent access: `partial` — Board archive endpoint; CEO company PATCH can express status +changes. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Convenience archive and general company PATCH have different authorization. Review +equivalence rather than declaring all archival human-only. + +## `portability` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/exports`. +- `POST /api/companies/{companyId}/imports/preview`. +- `POST /api/companies/{companyId}/imports/apply`. + +Agent access: `partial` — CEO REST / CLI company; native export restricted, eligible +same-company import uses fallback. + +Policy: Same-company CEO or board for identity/import operations; list/create/archive +convenience endpoint is board-gated. + +Evidence: `source-only` — [implementation](../../server/src/routes/companies.ts#L1245) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Secure exports lack a default native binding; new-company/ZIP import flows have +different board and transport constraints. diff --git a/maps/agent-access-map/connection-setup.md b/maps/agent-access-map/connection-setup.md new file mode 100644 index 00000000000..99e430698db --- /dev/null +++ b/maps/agent-access-map/connection-setup.md @@ -0,0 +1,207 @@ +# Connection setup — agent access + +Product map: [Connection setup](../feature-map/connection-setup.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `catalog` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /runtime-tools/connections/search`. + +Agent access: `available` — Native connections_search / run-bound runtime-tools MCP. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Runtime tool broker searches available services without granting connection +administration. + +## `identity-and-access` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/tools/connections`. + +Agent access: `restricted` — Board setup API plus agent connection_request. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Selecting account ownership/eligible agents is management; an agent can request +the intended connection. + +## `credentials` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/me/connections/{connectionId}/start-authorization`. + +Agent access: `partial` — Scoped start-authorization/request flow; human provider login. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: An initiation endpoint exists; it is not authorization to complete the person's +credential flow. + +## `handoff` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/connection-intents/{interactionId}/complete`. + +Agent access: `restricted` — Addressed-user completion; server wakes the agent with +updated access. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: OAuth/callback and addressed-user checks are deliberate separate boundaries. + +## `draft-recovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/connection-intents/{interactionId}/phase`. +- `POST /api/tool-connections/{connectionId}/reconnect`. + +Agent access: `restricted` — Human setup/resume APIs; agent request state and +continuation. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Board setup recovery exists. Browser-only navigation state is not automatically an +agent API gap. + +## `task-request` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /runtime-tools/connections/request`. + +Agent access: `available` — Native connection_request / run-bound runtime-tools MCP. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: This is the existing agent bridge for human-required setup; do not invent a second +approval system. + +## `channel-setup` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/chat-endpoints`. + +Agent access: `restricted` — Board channel management; agent requests required setup. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Provider endpoint, represented agent and linked sender are operator choices today. + +## `failure` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-connections/{connectionId}/health-check`. + +Agent access: `partial` — Agent observes connection tool errors; board repair APIs. + +Policy: Setup and credentials belong to the addressed authorized human; active-run tools +can search/request access. + +Evidence: `source-only` — +[implementation](../../server/src/routes/connection-intents.ts#L130) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Error reporting and human repair exist; no generic agent permission to reconnect +accounts. diff --git a/maps/agent-access-map/developer-labs.md b/maps/agent-access-map/developer-labs.md new file mode 100644 index 00000000000..c8034e1d574 --- /dev/null +++ b/maps/agent-access-map/developer-labs.md @@ -0,0 +1,99 @@ +# Developer previews and diagnostic labs — agent access + +Product map: [Developer previews and diagnostic labs](../feature-map/developer-labs.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `design` + +Review: `baseline`. + +API: `not-applicable` — No domain API required for static component examples; label this +as an explicit exception. + +Agent access: `not-applicable` — Browser/Storybook / existing UI tooling. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: No domain API required for static component examples; label this as an explicit +exception. + +## `interaction-labs` + +Review: `baseline`. + +API: `not-applicable` — A synthetic card's API parity belongs to the real feature, not +to a new lab API. + +Agent access: `not-applicable` — Browser fixture plus the corresponding real interaction +API in its owning area. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: A synthetic card's API parity belongs to the real feature, not to a new lab API. + +## `performance` + +Review: `baseline`. + +API: `not-applicable` — There is no domain REST action for scrolling a static fixture; +capture browser evidence separately. + +Agent access: `not-applicable` — Browser performance fixture and measurement tooling. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: There is no domain REST action for scrolling a static fixture; capture browser +evidence separately. + +## `route-gates` + +Review: `baseline`. + +API: `not-applicable` — Route registration and feature gates are not interchangeable; no +universal developer-only gate exists. + +Agent access: `not-applicable` — Build/route inspection; relevant instance +feature-setting API for actual gates. + +Policy: Synthetic developer fixtures; no persisted company feature or agent authority +represented by the fixture. + +Evidence: `source-only` — [implementation](../../ui/src/App.tsx) at the reviewed commit +above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: Route registration and feature gates are not interchangeable; no universal +developer-only gate exists. diff --git a/maps/agent-access-map/documents-artifacts.md b/maps/agent-access-map/documents-artifacts.md new file mode 100644 index 00000000000..afa3297a1dc --- /dev/null +++ b/maps/agent-access-map/documents-artifacts.md @@ -0,0 +1,139 @@ +# Documents, attachments, and work products — agent access + +Product map: [Documents, attachments, and work +products](../feature-map/documents-artifacts.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `documents` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/documents`. +- `PUT /api/issues/{id}/documents/{key}`. +- `POST /api/issues/{id}/documents/{key}/revisions/{revisionId}/restore`. + +Agent access: `available` — Native list/read/write documents; other-task bridge; REST / +CLI / MCP. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Supported. Native helpers focus on the active task; generic API or explicit +other-task tools cover additional scope. + +## `annotations` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/documents/{key}/annotations`. +- `PATCH /api/issues/{id}/documents/{key}/annotations/{threadId}`. + +Agent access: `available` — REST / native call_api. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Annotation operations exist without a special annotation tool; check revision +anchors and resolver authority. + +## `attachments` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues/{issueId}/attachments`. +- `GET /api/assets/{assetId}/content`. + +Agent access: `available` — Native register_deliverable and API multipart/binary +support; REST / CLI / MCP transfers. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native API uploads are bounded; attachment reading has target/provider and +permission conditions. + +## `work-products` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/work-products`. +- `PATCH /api/work-products/{id}`. + +Agent access: `available` — Native register_deliverable for file outputs; REST / native +call_api for metadata. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: A registered reference does not prove uploaded bytes, external delivery, or +accepted review. + +## `library` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/artifacts`. + +Agent access: `available` — REST / native call_api. + +Policy: Task/resource visibility, mutation/checkout ownership, revision/lock guards and +permitted deliverable context. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L10299) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Library search/filter uses the artifact API; no new tool needed merely to mirror +the UI. diff --git a/maps/agent-access-map/execution-environments.md b/maps/agent-access-map/execution-environments.md new file mode 100644 index 00000000000..74860af970f --- /dev/null +++ b/maps/agent-access-map/execution-environments.md @@ -0,0 +1,132 @@ +# Local, SSH, and sandbox environments — agent access + +Product map: [Local, SSH, and sandbox +environments](../feature-map/execution-environments.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/environments`. +- `POST /api/companies/{companyId}/environments`. + +Agent access: `restricted` — Board read/admin management REST / CLI environment. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: API exists; an agent cannot use instance environment management as an ordinary +company actor. + +## `capabilities` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/environments/capabilities`. + +Agent access: `partial` — Board environment capabilities endpoint; native run gets +server-selected runtime context. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Endpoint discovery and already-bound run capabilities differ; decide whether a +safe agent-readable capability listing is desired. + +## `probe` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/environments/{id}/probe`. +- `POST /api/companies/{companyId}/environments/probe-config`. + +Agent access: `restricted` — Board/admin probes / CLI environment. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Reachability probes are not granted by an agent's right to execute assigned work. + +## `assignment` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/agents/{id}`. +- `PATCH /api/projects/{id}`. + +Agent access: `partial` — Permitted agent/project REST / native fallback, subject to +protected configuration policy. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Choosing among permitted targets differs from creating/configuring host targets; +test authorized and denied selection. + +## `images` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/environments/{environmentId}/custom-image-setup-sessions`. + +Agent access: `restricted` — Board/admin image APIs and existing terminal protocol. + +Policy: Environment management is instance administrator/board; permitted assignment +uses agent/project policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/environments.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No default agent custom-image administration/terminal path; this is a policy +decision before adding a tool. diff --git a/maps/agent-access-map/gateways-profiles.md b/maps/agent-access-map/gateways-profiles.md new file mode 100644 index 00000000000..de326b65acf --- /dev/null +++ b/maps/agent-access-map/gateways-profiles.md @@ -0,0 +1,131 @@ +# Tool gateways and access profiles — agent access + +Product map: [Tool gateways and access profiles](../feature-map/gateways-profiles.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `gateway` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/tools/gateways`. + +Agent access: `restricted` — Board tools:admin REST; CLI connections/configuration where +supported. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No agent named-gateway management path; assigned tool use exists separately. + +## `profiles` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/tools/profiles`. +- `PATCH /api/tool-profiles/{profileId}`. + +Agent access: `restricted` — Board management REST; agent receives effective tool +assignment. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agent access to tools does not permit profile editing. + +## `client-connect` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/tool-gateway/gateways/{gatewayId}/mcp`. +- `POST /api/tool-gateway/sessions`. + +Agent access: `partial` — Existing scoped MCP client/session flow; assigned native app +tools. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent tool consumption exists. Copying/managing the endpoint and its client +credentials remain management operations. + +## `tokens` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-gateway/gateways/{gatewayId}/tokens`. +- `POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke`. + +Agent access: `restricted` — Board tools:admin token management. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Native call_api blocks credential management; a grant to use an app does not +create gateway-token authority. + +## `activity` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/tool-gateway/audit`. + +Agent access: `restricted` — Board tools:view_audit; agent receives its own tool +receipts/errors. + +Policy: Named gateway/profile administration and credentials are board tools:admin; +agent sessions/tool use use separate scoped authority. + +Evidence: `source-only` — [implementation](../../server/src/routes/tool-gateway.ts#L353) +at the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Audit management API exists but broad audit visibility is not an agent use-path +guarantee. diff --git a/maps/agent-access-map/goals.md b/maps/agent-access-map/goals.md new file mode 100644 index 00000000000..2bf3f977bff --- /dev/null +++ b/maps/agent-access-map/goals.md @@ -0,0 +1,87 @@ +# Goals and work alignment — agent access + +Product map: [Goals and work alignment](../feature-map/goals.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `hierarchy` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/goals`. +- `POST /api/companies/{companyId}/goals`. +- `PATCH /api/goals/{id}`. + +Agent access: `available` — REST / native call_api / CLI goal / MCP read helpers. + +Policy: Same-company access; related project/task assignments have their own resource +permissions. + +Evidence: `source-only` — [implementation](../../server/src/routes/goals.ts#L14) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Real REST path exists. Scenario list_goals declarations are not evidence of a +production native binding. + +## `alignment` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/projects/{id}`. +- `PATCH /api/issues/{id}`. + +Agent access: `available` — REST / native call_api for permitted non-lifecycle fields. + +Policy: Same-company access; related project/task assignments have their own resource +permissions. + +Evidence: `source-only` — [implementation](../../server/src/routes/goals.ts#L14) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Goal association is data access, separate from completing or reassigning the task. + +## `navigation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/goals/{id}`. +- `GET /api/projects/{id}`. +- `GET /api/issues/{id}`. + +Agent access: `available` — REST and task/project/goal read tools; links returned as +context. + +Policy: Same-company access; related project/task assignments have their own resource +permissions. + +Evidence: `source-only` — [implementation](../../server/src/routes/goals.ts#L14) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Navigation renders records already exposed by APIs; no separate click-navigation +API is required. diff --git a/maps/agent-access-map/inbox-search.md b/maps/agent-access-map/inbox-search.md new file mode 100644 index 00000000000..2c079231dd9 --- /dev/null +++ b/maps/agent-access-map/inbox-search.md @@ -0,0 +1,112 @@ +# Inbox, decisions, and search — agent access + +Product map: [Inbox, decisions, and search](../feature-map/inbox-search.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `views` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/me/inbox-lite`. +- `GET /api/agents/me/inbox/mine`. +- `GET /api/companies/{companyId}/issues`. + +Agent access: `partial` — REST / native call_api; MCP inbox/task search. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent inbox and Mine support exist; board attention/unread/dismissal aggregation +is a separate user-only surface. + +## `triage` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/inbox-archive`. +- `DELETE /api/issues/{id}/inbox-archive`. + +Agent access: `partial` — REST / native call_api; existing garden-inbox workflow. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Archive has an agent-aware responsible-user policy. Personal read/unread/dismissal +does not have equivalent agent semantics everywhere. + +## `decisions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/decision-queues`. +- `GET /api/companies/{companyId}/decision-queues/{key}/items`. + +Agent access: `partial` — REST / native call_api under decision queue authorization. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Queue browsing/managing may be allowed; governed approval decisions still have +separate board/resolver boundaries. + +## `search` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/search`. +- `GET /api/companies/{companyId}/issues`. + +Agent access: `available` — Native search_tasks for tasks; REST / native call_api for +broader search. + +Policy: Own agent context; responsible-user inbox policy and explicit opt-in/grants for +cross-user archive; decision policy separately. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L3952) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: General company search and dedicated task search are different result sets; use +the appropriate one. diff --git a/maps/agent-access-map/instance-operations.md b/maps/agent-access-map/instance-operations.md new file mode 100644 index 00000000000..d51cccf323f --- /dev/null +++ b/maps/agent-access-map/instance-operations.md @@ -0,0 +1,130 @@ +# Installation, configuration, updates, and backups — agent access + +Product map: [Installation, configuration, updates, and +backups](../feature-map/instance-operations.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `install-update` + +Review: `baseline`. + +API: `not-applicable` — No uniform company REST updater API. Treat pre-start +installation as an operator exception or design an explicit delegated operator API. + +Agent access: `operator-only` — Existing CLI install/update/rollback commands. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: No uniform company REST updater API. Treat pre-start installation as an operator +exception or design an explicit delegated operator API. + +## `server-service` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/health`. + +Agent access: `operator-only` — Existing CLI service/dev controls; health REST; owned +workspace services have separate agent APIs. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Instance start/stop and task workspace start/stop are different capabilities. +Local service control is not agent company authority. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/instance/settings`. +- `PATCH /api/instance/settings`. + +Agent access: `restricted` — Board/admin instance settings; CLI configure/env; local +configuration files. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Settings APIs cover parts of configuration; pre-start database/binding/storage +configuration is local operator tooling. + +## `diagnostics` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/health`. + +Agent access: `partial` — REST health plus local CLI doctor. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Health is an API; full local doctor/repair inspects host configuration and is not +equivalent to that response. + +## `backup` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/instance/database-backups`. + +Agent access: `restricted` — Board/admin backup API; local CLI db:backup; restore into +an authorized separate target. + +Policy: Local machine operator and instance-administrator authority; distinct from +company-scoped agent keys. + +Evidence: `source-only` — [implementation](../../cli/src/index.ts#L91) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: API creation exists. Restore/lifecycle are operator workflows rather than ordinary +company agent capabilities. diff --git a/maps/agent-access-map/navigation-preferences.md b/maps/agent-access-map/navigation-preferences.md new file mode 100644 index 00000000000..e5693a33b17 --- /dev/null +++ b/maps/agent-access-map/navigation-preferences.md @@ -0,0 +1,138 @@ +# Navigation, profile, and announcements — agent access + +Product map: [Navigation, profile, and +announcements](../feature-map/navigation-preferences.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `navigation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/projects`. +- `GET /api/companies/{companyId}/resource-memberships/me`. + +Agent access: `partial` — Resource APIs plus user-specific stars; browser-local recents. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Underlying resources have APIs; recents are client-local, and personal starring +needs its own identity policy. + +## `preferences` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/sidebar-preferences/me`. +- `PUT /api/sidebar-preferences/me`. + +Agent access: `restricted` — Board-user preference API; browser section collapse. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Catalog metadata says board_or_agent, but requireBoardUserId rejects agents. +Collapse is in-memory; global collapse is disabled. + +## `profile` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/users/{userSlug}/profile`. +- `PATCH /api/auth/profile`. + +Agent access: `partial` — Agent can read eligible company profile; changing personal +identity requires board user. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Reading another visible user's profile and editing a person's identity are +distinct permission paths. + +## `announcements` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/announcements/current`. +- `POST /api/announcements/{id}/dismiss`. + +Agent access: `restricted` — Board-user announcement API. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: User-specific instance-wide dismissal has no agent delegation path today. + +## `fallbacks` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}`. +- `GET /api/projects/{id}`. + +Agent access: `partial` — API not-found/access semantics; browser router redirects. + +Policy: Persisted user preferences require board user context; resource reads still +apply company visibility. + +Evidence: `source-only` — +[implementation](../../server/src/routes/sidebar-preferences.ts#L8) at the reviewed +commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: Record lookup exists. Route redirects/fallback presentation do not need a separate +domain API. diff --git a/maps/agent-access-map/onboarding.md b/maps/agent-access-map/onboarding.md new file mode 100644 index 00000000000..f4eb025f50c --- /dev/null +++ b/maps/agent-access-map/onboarding.md @@ -0,0 +1,136 @@ +# Onboarding and first work — agent access + +Product map: [Onboarding and first work](../feature-map/onboarding.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `instance` + +Review: `baseline`. + +API: `not-applicable` — Bootstrapping an instance cannot depend solely on an API served +by that not-yet-running instance. + +Agent access: `operator-only` — Existing CLI onboard/run/doctor / test-drive. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise the listed local workflow in an authorized, owned +operator environment. Verify its isolation and result; do not count local machine +authority as company-agent API access. + +Gaps: Bootstrapping an instance cannot depend solely on an API served by that +not-yet-running instance. + +## `company` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies`. +- `POST /api/companies/{companyId}/agents`. + +Agent access: `partial` — Board company creation; authorized in-company agent hiring +API. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: API building blocks exist; an ordinary agent cannot create its parent company with +a company key. + +## `adapter` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/adapters/{type}/models`. +- `POST /api/companies/{companyId}/adapters/{type}/test-environment`. + +Agent access: `partial` — Permitted agent/model API; operator +provider-account/environment setup. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Account authorization, environment administration and model selection need +separate checks. + +## `first-task` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task / agent REST / CLI issue. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Task creation is agent-accessible where permitted; scheduling/readiness does not +follow from successful creation. + +## `resume` + +Review: `baseline`. + +API: `not-applicable` — No server onboarding-draft API for this browser-local state; +classify explicitly or intentionally make it a cross-client product feature. + +Agent access: `not-applicable` — Browser localStorage wizard draft; persisted resources +use their domain APIs. + +Policy: Instance setup is operator work; company creation is board; agent creation/task +creation retain their individual permissions. + +Evidence: `source-only` — +[implementation](../../ui/src/components/OnboardingWizard.tsx#L279) at the reviewed +commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: No server onboarding-draft API for this browser-local state; classify explicitly +or intentionally make it a cross-client product feature. diff --git a/maps/agent-access-map/pipelines.md b/maps/agent-access-map/pipelines.md new file mode 100644 index 00000000000..b33ca8ce805 --- /dev/null +++ b/maps/agent-access-map/pipelines.md @@ -0,0 +1,109 @@ +# Pipelines, review queues, and learnings — agent access + +Product map: [Pipelines, review queues, and learnings](../feature-map/pipelines.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `stages` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/pipelines/{pipelineId}/stages`. +- `PATCH /api/pipelines/{pipelineId}/stages/{stageId}`. + +Agent access: `available` — REST / native call_api / CLI pipelines where supported. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Configuration paths exist. Generic fallback blocks case automation execution +paths; do not conflate configuration and dispatch. + +## `items` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/pipelines/{pipelineId}/cases`. +- `POST /api/pipelines/{pipelineId}/cases`. + +Agent access: `available` — REST / native call_api / CLI pipelines. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agents need run ID on mutations; task-linked changes also check issue +mutation/checkout ownership. + +## `review` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/review-cases`. +- `POST /api/cases/{caseId}/review`. + +Agent access: `available` — REST / native call_api / CLI pipeline case review. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Review policy and actor ownership still apply; existence of the endpoint is not +blanket review authority. + +## `learnings` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/case-events`. + +Agent access: `available` — REST / native call_api / CLI case events. + +Policy: Company access, active agent run for mutations, case/issue source trust and +ownership, and review audience. + +Evidence: `source-only` — [implementation](../../server/src/routes/pipelines.ts#L265) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Learning view is a filtered case-event projection; no separate learning API is +necessary. diff --git a/maps/agent-access-map/plugins.md b/maps/agent-access-map/plugins.md new file mode 100644 index 00000000000..2f81f2062e9 --- /dev/null +++ b/maps/agent-access-map/plugins.md @@ -0,0 +1,107 @@ +# Plugins and contributed product surfaces — agent access + +Product map: [Plugins and contributed product surfaces](../feature-map/plugins.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `installation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/plugins/install`. + +Agent access: `restricted` — Instance-admin REST / CLI plugin. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No ordinary agent install path; installing executable plugin code is an explicit +authority decision. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/plugins/{pluginId}/config`. +- `POST /api/plugins/{pluginId}/config`. + +Agent access: `restricted` — Board read/admin write; CLI plugin. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Company-scoped configuration value does not turn the instance management route +into an agent route. + +## `contributions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/plugins/tools`. +- `POST /api/plugins/tools/execute`. + +Agent access: `partial` — Agent REST plus assigned native/gateway plugin tools and +scoped plugin APIs. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Actual plugin tool use exists; a contributed UI route alone does not prove that +plugin's API/tool coverage. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/plugins/{pluginId}/enable`. +- `POST /api/plugins/{pluginId}/disable`. + +Agent access: `restricted` — Instance-admin REST / CLI plugin; dev watcher for reload. + +Policy: Instance plugin management requires board/admin; declared tools and scoped APIs +apply their own company/agent policy. + +Evidence: `source-only` — [implementation](../../server/src/routes/plugins.ts#L965) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Disable/remove and development reload are management workflows with no default +company agent access. diff --git a/maps/agent-access-map/projects.md b/maps/agent-access-map/projects.md new file mode 100644 index 00000000000..bdec3cf2aa3 --- /dev/null +++ b/maps/agent-access-map/projects.md @@ -0,0 +1,113 @@ +# Projects and repositories — agent access + +Product map: [Projects and repositories](../feature-map/projects.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `lifecycle` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/projects`. +- `PATCH /api/projects/{id}`. +- `DELETE /api/projects/{id}`. + +Agent access: `available` — Native create_project/list_projects; REST / CLI / MCP. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Actual data operations exist; project discovery and private-resource permissions +still apply. + +## `task-context` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/issues`. +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task/search_tasks; REST / CLI / MCP. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Use explicit project context; task creation, assignment, and execution remain +separate checks. + +## `repositories` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/project-repositories`. +- `PUT /api/projects/{id}/repositories`. + +Agent access: `partial` — Native list_project_repositories/create_project for initial +selection; board-only replacement API. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Authorized initial selection works. Replacement of repositories on an existing +project hard-requires board and needs a delegation decision. + +## `defaults` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/projects/{id}`. +- `GET /api/projects/{id}/workspaces`. + +Agent access: `partial` — REST / native call_api for permitted fields; CLI +project/workspace. + +Policy: Company/project visibility, protected configuration/runtime policies; some +repository/privacy management requires board. + +Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Description/goal/target selection exist; host workspace command mutation is denied +to agents and budget policy is board-managed. diff --git a/maps/agent-access-map/questions-and-approvals.md b/maps/agent-access-map/questions-and-approvals.md new file mode 100644 index 00000000000..7ed7d2de128 --- /dev/null +++ b/maps/agent-access-map/questions-and-approvals.md @@ -0,0 +1,212 @@ +# Questions and approvals — agent access + +Product map: [Questions and approvals](../feature-map/questions-and-approvals.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `questions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/interactions`. +- `POST /api/issues/{id}/interactions/{interactionId}/respond`. + +Agent access: `partial` — Native request_human_input; REST / MCP forms; authorized +responder endpoint. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agent creates questions. It cannot fabricate a human answer; answering follows +resolver policy. + +## `dismiss-and-reopen` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. + +Agent access: `partial` — Persisted question API plus browser card state. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: Presentation dismissal/reopening does not resolve the question and does not +require a new mutation API. + +## `confirmations` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/interactions`. +- `POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment`. + +Agent access: `partial` — Native request_human_input and call_api resolve-from-comment; +authorized REST resolver. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Conversational confirmation is supported under resolver policy; generic +acceptance/response controls remain restricted. + +## `plan-review` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/issues/{id}/documents/{key}`. +- `POST /api/issues/{id}/interactions`. + +Agent access: `partial` — Native write_document/request_human_input; explicit review +recipient. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Plan revision and confirmation work. Accepting as a human is not a generic agent +capability. + +## `audience` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. + +Agent access: `available` — API resolver checks; assigned review context/resolve_review +for a native review run. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Audience is a permission boundary to test, not an endpoint that creates new +authority. + +## `governance` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/approvals`. +- `POST /api/approvals/{id}/approve`. +- `POST /api/approvals/{id}/reject`. + +Agent access: `restricted` — Agent REST/MCP can request/read/comment/resubmit; formal +approve/reject require board. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Scenario decide_approval does not establish a production agent approval path. +Preserve explicit approver authority. + +## `tool-review` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/tool-gateway/action-requests/{id}/approve`. +- `POST /api/tool-gateway/action-requests/{id}/decline`. + +Agent access: `restricted` — Agent invokes assigned ask-policy tool; board approval +executes stored request. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: The request/continuation path exists. Agent cannot grant itself once/always-allow +scope via generic API. + +## `continuation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. +- `GET /api/heartbeat-runs/{runId}`. + +Agent access: `available` — Server delivery and resumed assigned agent context. + +Policy: Current task/run and named audience/resolver policy; governed approval decisions +require board independently of an ordinary interaction. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16638) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Continuation is server-owned; prove exactly one wake/no duplicate action after +resolution with existing integration fixtures. diff --git a/maps/agent-access-map/recovery.md b/maps/agent-access-map/recovery.md new file mode 100644 index 00000000000..a6a12a3f1c0 --- /dev/null +++ b/maps/agent-access-map/recovery.md @@ -0,0 +1,207 @@ +# Recovery — agent access + +Product map: [Recovery](../feature-map/recovery.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `diagnosis` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/diagnostics/blockers`. +- `GET /api/issues/{id}/recovery-actions`. + +Agent access: `available` — REST / native call_api; CLI issue diagnostics. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Diagnostics expose recorded cause and boundaries; source-backed reading does not +prove successful recovery. + +## `bounded-continuation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/recovery-actions/resolve`. + +Agent access: `partial` — Eligible agent REST / CLI; native generic mutation restricted. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Underlying route has agent checks; no corresponding default native recovery +mutation tool is advertised. + +## `source-action` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/recovery-actions/resolve`. + +Agent access: `partial` — Eligible agent REST / CLI; source-bound recovery state. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Need operation-level eligibility and a real native binding for permitted recovery +actions; do not route around lifecycle authority. + +## `exhaustion` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/recovery-actions`. +- `GET /api/heartbeat-runs/{runId}`. + +Agent access: `partial` — REST / native call_api reads; operator escalation flow. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Reading exhaustion is supported. Continuing past the recorded gate requires the +specified owner/action. + +## `workspace` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/recovery-actions/retry-workspace-export`. +- `POST /api/execution-workspaces/{id}/reconcile-branch`. + +Agent access: `restricted` — Board/runtime management APIs; agent reads diagnosis and +performs permitted workspace work. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Export retry/reconciliation APIs exist but require board. Native generic +execution-control mutations are also blocked. + +## `unknown-outcome` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve`. + +Agent access: `restricted` — Board provider-outcome resolution; agent may inspect +assigned provider tools. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Uncertain-side-effect resolution is operator/source-specific; do not replay to +obtain apparent parity. + +## `user-continuation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/comments`. + +Agent access: `partial` — User comment API and server-owned wake reconsideration; native +agent consumes bound wake. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Receiving the continuation is supported; impersonating a saved user message or +retry control is not. + +## `reconnect` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/events/ws`. +- `GET /api/issues/{id}`. + +Agent access: `partial` — Existing event protocol; REST refresh of persisted task state. + +Policy: Task/run visibility, run ownership and source/action policy; workspace export +retry and some operator controls require board. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L9478) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Streaming reconnect needs its protocol client. Polling/read APIs remain available; +replay is not proof of a fresh successful action. diff --git a/maps/agent-access-map/routines.md b/maps/agent-access-map/routines.md new file mode 100644 index 00000000000..bb3ec03599a --- /dev/null +++ b/maps/agent-access-map/routines.md @@ -0,0 +1,135 @@ +# Routines, schedules, and triggers — agent access + +Product map: [Routines, schedules, and triggers](../feature-map/routines.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `definition` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/routines`. +- `PATCH /api/routines/{id}`. + +Agent access: `partial` — Agent REST / CLI routine create/update; optional installed MCP +generic request. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api rejects routine mutation; manage_routine is scenario-only/unbound. +Agent REST is implemented. + +## `triggers` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/routines/{id}/triggers`. +- `PATCH /api/routine-triggers/{id}`. + +Agent access: `partial` — Owned-routine agent REST / CLI routine trigger commands. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: No production default native trigger-management binding. Webhook creation/rotation +also returns credential material. + +## `run-now` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/routines/{id}/run`. + +Agent access: `partial` — Owned-routine agent REST / CLI routine run. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: A scheduled/waiting native agent cannot use call_api for this eligible operation; +a real control binding is missing. + +## `history` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/routines/{id}/runs`. +- `GET /api/routines/{id}/revisions`. + +Agent access: `available` — REST / native call_api / CLI routine. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Runs are readable in company scope; configuration revision access checks routine +ownership for agents. + +## `management` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/routines/{id}`. + +Agent access: `partial` — Owned-routine agent REST / CLI; normal native fallback can +manage separate folder metadata where permitted. + +Policy: Agents may read company routines but only create/manage routines assigned to +themselves; trigger secrets need secure handling. + +Evidence: `source-only` — [implementation](../../server/src/routes/routines.ts#L99) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Pause/archive routine mutation lacks a production native binding. Plugin-managed +routine rules remain separate. diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md new file mode 100644 index 00000000000..bd2f509c545 --- /dev/null +++ b/maps/agent-access-map/runs-adapters.md @@ -0,0 +1,111 @@ +# Runs, harnesses, and model accounts — agent access + +Product map: [Runs, harnesses, and model accounts](../feature-map/runs-adapters.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `availability` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/adapters`. +- `GET /api/companies/{companyId}/adapters/{type}/models`. + +Agent access: `partial` — Agent company-model API and bound runtime context; board +adapter/account inventories. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Raw instance adapter listing requires board, while company model discovery exists +for eligible agents. + +## `validation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/adapters/{type}/test-environment`. + +Agent access: `partial` — Permitted agent-creation validation path / REST / CLI; +operator account login. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Environment test path checks creation/configuration policy; missing provider auth +must use its owner setup flow. + +## `runs` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/heartbeat-runs/{runId}`. +- `GET /api/heartbeat-runs/{runId}/log`. + +Agent access: `available` — REST / native call_api / CLI run. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Provider trace reveals/downloads require instance-admin and cannot be counted as +ordinary run output access. + +## `session` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/{id}/runtime-state/reset-session`. +- `GET /api/agents/{id}/task-sessions`. + +Agent access: `restricted` — Board session controls; native server-owned session +continuation. + +Policy: Run/cost visibility and agent configuration policy; adapter installation/account +login/session control require board/admin. + +Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Execution context reset is not a permitted generic native API mutation or an +ordinary agent management path. diff --git a/maps/agent-access-map/secrets.md b/maps/agent-access-map/secrets.md new file mode 100644 index 00000000000..083c56da74c --- /dev/null +++ b/maps/agent-access-map/secrets.md @@ -0,0 +1,138 @@ +# Secrets, personal credentials, and proposals — agent access + +Product map: [Secrets, personal credentials, and proposals](../feature-map/secrets.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `company-secrets` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/secrets/catalog`. +- `POST /api/companies/{companyId}/secrets`. + +Agent access: `partial` — Agent metadata catalog; board secret administration; agent +secret proposal API. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Metadata and requesting a change exist; generic native API blocks secret +management/value transport. + +## `user-secrets` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/me/user-secrets`. +- `POST /api/companies/{companyId}/me/user-secrets`. + +Agent access: `restricted` — Current board user's personal-secret API; agent receives +only authorized run binding. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Agent cannot edit a person's private credential value through their ordinary agent +key. + +## `proposals` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/agents/me/secret-proposals`. +- `GET /api/agents/me/secret-proposals`. + +Agent access: `partial` — Active-run agent REST with redaction; board +approval/rejection. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api blocks secret-proposals and no dedicated production proposal tool +is in its default authority set. + +## `vault-import` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/secrets/remote-import/preview`. +- `POST /api/companies/{companyId}/secrets/remote-import`. + +Agent access: `restricted` — Board authorized vault import API / CLI provider +operations. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Vault import is not implied by having a secret binding or the ability to propose a +secret. + +## `runtime-delivery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/agents/me/secrets`. +- `POST /api/agents/me/secrets/{key}/value`. + +Agent access: `partial` — Authorized environment/broker delivery; eligible legacy agent +REST access. + +Policy: Board secret administration, active-run/self/report proposal policy, and scoped +secret broker bindings; personal secret values belong to their user. + +Evidence: `source-only` — [implementation](../../server/src/routes/secrets.ts#L194) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native metadata reads exist; value call is blocked and scenario read_secret_value +is unbound. Do not add raw secret-returning fallback tools. diff --git a/maps/agent-access-map/skills.md b/maps/agent-access-map/skills.md new file mode 100644 index 00000000000..b4ce67184c8 --- /dev/null +++ b/maps/agent-access-map/skills.md @@ -0,0 +1,141 @@ +# Skill discovery, authoring, and policy — agent access + +Product map: [Skill discovery, authoring, and policy](../feature-map/skills.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `discovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/skills`. +- `POST /api/companies/{companyId}/skills/import`. + +Agent access: `available` — REST / native call_api / CLI skills and sources. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Installation/import is governed by company skill policy, not inferred from an +agent's role name. + +## `authoring` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/skills`. +- `PATCH /api/companies/{companyId}/skills/{skillId}/files`. + +Agent access: `available` — Native create_skill/update_skill for single-file operations; +REST for other files/metadata. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Writable/fork policy and expected version apply. Native helpers intentionally +support a narrower shape than full API. + +## `versions` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/skills/{skillId}/versions`. + +Agent access: `available` — REST / native call_api / CLI skills. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Revision metadata has an API; pinned execution skill versions and installed latest +are distinct. + +## `policy` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PUT /api/companies/{companyId}/skill-policy`. +- `POST /api/agents/{id}/skills/sync`. + +Agent access: `partial` — REST / native call_api under users:manage_permissions or +target configuration policy. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Policy administration has an explicit agent grant branch; assigning agent skills +also checks the target's config authority. + +## `testing` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/skills/{skillId}/test-runs`. + +Agent access: `partial` — Permitted agent REST; native read fallback for history; no +dedicated CLI test command found. + +Policy: Company skill policy by action/resource plus platform invariants; test execution +additionally requires task-assignment authority. + +Evidence: `source-only` — +[implementation](../../server/src/routes/company-skills.ts#L208) at the reviewed commit +above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api rejects test-run creation/control while the route can authorize +agents. No default native orchestration binding. diff --git a/maps/agent-access-map/status-cards.md b/maps/agent-access-map/status-cards.md new file mode 100644 index 00000000000..ef2dbf42b66 --- /dev/null +++ b/maps/agent-access-map/status-cards.md @@ -0,0 +1,110 @@ +# Status cards and refreshed summaries — agent access + +Product map: [Status cards and refreshed summaries](../feature-map/status-cards.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `authoring` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/status-cards`. +- `PATCH /api/status-cards/{id}`. + +Agent access: `available` — REST / native call_api. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Real agent create/update permission branch exists; not inherently a board-only +feature. + +## `generation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/status-cards/{id}/recompile`. +- `POST /api/status-cards/{id}/refresh`. + +Agent access: `available` — REST / native call_api for authorized own card. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Execution result must be checked. Agent-authored prompt length and tasks:assign +limits apply. + +## `inspection` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/status-cards/{id}`. +- `GET /api/status-cards/{id}/summary-revisions`. +- `GET /api/status-cards/{id}/dry-run`. + +Agent access: `available` — REST / native call_api. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Read/history/debug APIs exist; a rendered compiling state is not evidence of +successful generation. + +## `archive` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/status-cards/{id}`. + +Agent access: `available` — REST / native call_api under own-card management policy. + +Policy: Feature gate, tasks:assign decision; agents manage only cards they authored; +compiler/summarizer has extra bound-task checks. + +Evidence: `source-only` — [implementation](../../server/src/routes/status-cards.ts#L32) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Archive/restore is represented by card state, separate from delete; check exact +allowed status transition. diff --git a/maps/agent-access-map/steering.md b/maps/agent-access-map/steering.md new file mode 100644 index 00000000000..0b872acd931 --- /dev/null +++ b/maps/agent-access-map/steering.md @@ -0,0 +1,180 @@ +# Steering and queued messages — agent access + +Product map: [Steering and queued messages](../feature-map/steering.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `queue` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/queued-comments`. + +Agent access: `partial` — REST / native call_api for visible queue; server-owned queue +admission. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Agents can read eligible queue state; sending/steering the person's queued +messages requires separate authority. + +## `edit-order-discard` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}/queued-comments/{commentId}`. +- `PUT /api/issues/{id}/queued-comments/order`. +- `DELETE /api/issues/{id}/queued-comments/{commentId}`. + +Agent access: `restricted` — Board-user queue mutation API. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: No agent user-message edit/reorder/discard path; explicit delegated-user policy +needed before granting one. + +## `steer` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/queued-comments/{commentId}/steer`. + +Agent access: `restricted` — Board-user execution control API; agent receives eligible +new run/wake. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Generic native API blocks this execution control; no authority to impersonate the +steering user. + +## `interrupt` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/queued-comments/interrupt`. +- `POST /api/heartbeat-runs/{runId}/cancel`. + +Agent access: `restricted` — Board-user interruption/cancel API. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Explicit operator action is distinct from an agent yielding/blocking its own turn. + +## `stop-and-pause` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/tree-holds`. +- `POST /api/agents/{id}/pause`. + +Agent access: `restricted` — Board control APIs; agent paperclip_finish/paperclip_block +for its own turn. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Task-tree/agent pause and confirmed stop need policy-preserving authority; no +generic agent management path. + +## `drafts` + +Review: `baseline`. + +API: `not-applicable` — No shared server draft resource for this presentation state; +cross-client drafts would be a deliberate new capability. + +Agent access: `not-applicable` — Browser-local task composer draft / submission state. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — use the product recipe for the local/developer surface. Verify +listed shared-resource reads separately; local presentation alone is not agent API +evidence. + +Gaps: No shared server draft resource for this presentation state; cross-client drafts +would be a deliberate new capability. + +## `approval-queue` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/interactions`. +- `GET /api/issues/{id}/queued-comments`. + +Agent access: `partial` — Agent can inspect authorized persisted interactions; server +continuation/queue delivery. + +Policy: Queue inspection follows task visibility; queued user-message mutation and run +cancellation require a board actor/user. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts#L16166) at +the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Resolving as a human and controlling queued wakes is separate from seeing the +approval response. diff --git a/maps/agent-access-map/task-coordination.md b/maps/agent-access-map/task-coordination.md new file mode 100644 index 00000000000..c32d5849d2c --- /dev/null +++ b/maps/agent-access-map/task-coordination.md @@ -0,0 +1,114 @@ +# Delegation, dependencies, and signoff — agent access + +Product map: [Delegation, dependencies, and +signoff](../feature-map/task-coordination.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `delegation` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task/reassign_task; REST / CLI / MCP. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Parent/child and provenance paths exist; same-company assignment does not remove +protected task/resource policy. + +## `dependencies` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. + +Agent access: `available` — Native set_dependencies for current task; agent REST under +mutation/checkout policy. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Generic native PATCH rejects blockedByIssueIds so use its real dedicated tool. + +## `signoff` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. +- `POST /api/issues/{id}/interactions`. + +Agent access: `partial` — Native paperclip_finish/paperclip_block and assigned +resolve_review; legacy agent REST disposition. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Review policy determines final completion; formal board approval is a different +boundary. + +## `tree-control` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/tree-control/preview`. +- `POST /api/issues/{id}/tree-holds`. +- `POST /api/issues/{id}/tree-holds/{holdId}/release`. + +Agent access: `restricted` — Board-only task-tree APIs. + +Policy: Task visibility/mutation, task-assignment and ownership policy; review audience +independent; tree controls board-only. + +Evidence: `source-only` — +[implementation](../../server/src/routes/issue-tree-control.ts#L95) at the reviewed +commit above. + +Verification: `not-run` — assert the documented agent denial with an owned test fixture; +verify any listed agent request/result path separately. A delegated operation needs its +own policy and access review. + +Gaps: Even reads/state/preview require board. There is no agent tree-control path just +from company/task permissions. diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md new file mode 100644 index 00000000000..d0ccdbc978c --- /dev/null +++ b/maps/agent-access-map/tasks.md @@ -0,0 +1,136 @@ +# Task creation and lifecycle — agent access + +Product map: [Task creation and lifecycle](../feature-map/tasks.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `create` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/issues`. + +Agent access: `available` — Native create_task; REST / CLI / MCP. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Creation is implemented; agent/user assignment and attachments may need +narrower/different APIs. + +## `organize` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/companies/{companyId}/issues`. + +Agent access: `available` — Native search_tasks; REST / CLI; UI grouping/navigation +state separately. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Search/filter API exists; sorting/grouping/presentation defaults are not +necessarily shared server preferences. + +## `properties` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. +- `PUT /api/issues/{id}/title`. + +Agent access: `partial` — Native set_task_title/reassign_task/set_dependencies; fallback +for permitted non-lifecycle fields; REST. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native fallback blocks status/assignment/dependency policy fields. Dedicated tools +differ in scope and supported fields. + +## `discussion` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/issues/{id}/comments`. +- `POST /api/companies/{companyId}/issues/{issueId}/attachments`. + +Agent access: `available` — Native report_progress/comment_on_task/register_deliverable; +REST / CLI / MCP. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Other-task comments and active-task progress have separate scopes; explicit +reopen/interrupt is not a generic comment capability. + +## `completion` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/issues/{id}`. + +Agent access: `available` — Native paperclip_finish/paperclip_block through runner +protocol; legacy agent REST disposition. + +Policy: Company/task visibility, assignment/change policy, active-run checkout ownership +and lifecycle rules. + +Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Server governs whether a reported result becomes done/review/blocked. A generic +lifecycle PATCH is intentionally rejected for native runs. diff --git a/maps/agent-access-map/teams.md b/maps/agent-access-map/teams.md new file mode 100644 index 00000000000..600e463eca6 --- /dev/null +++ b/maps/agent-access-map/teams.md @@ -0,0 +1,108 @@ +# Team packages and catalog installation — agent access + +Product map: [Team packages and catalog installation](../feature-map/teams.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `discovery` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/teams/catalog`. +- `GET /api/teams/catalog/{catalogId}`. + +Agent access: `available` — REST / native call_api / CLI teams. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Team browsing exists even though there is no dedicated teams product screen. + +## `preview` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/preview`. + +Agent access: `available` — REST / native call_api / CLI teams preview. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Preview does not install a team; its audit write should not be mistaken for agent +execution proof. + +## `install` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/install`. + +Agent access: `available` — Authorized agent REST / native call_api / CLI teams install. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Actual agent permission branch exists. Resulting agents/accounts/workspaces may +still need operator setup. + +## `configuration` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/preview`. +- `POST /api/companies/{companyId}/teams/catalog/{catalogId}/install`. + +Agent access: `partial` — Team package variables/options via REST/CLI; resulting +resources use their own APIs. + +Policy: Authenticated catalog browsing; same-company preview; agent install requires +agents:create grant or canCreateAgents. + +Evidence: `source-only` — [implementation](../../server/src/routes/teams-catalog.ts#L36) +at the reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: There is no independent generic team-edit resource; distinguish package options +from installed agent/project configuration. diff --git a/maps/agent-access-map/workspaces.md b/maps/agent-access-map/workspaces.md new file mode 100644 index 00000000000..5804821580c --- /dev/null +++ b/maps/agent-access-map/workspaces.md @@ -0,0 +1,144 @@ +# Execution workspaces, services, and files — agent access + +Product map: [Execution workspaces, services, and files](../feature-map/workspaces.md). + +Review date: 2026-10-12. Source commit: `f981fa3cc10cb8663a87dec60345fbff46d54d0a`. + +This is a source-reviewed baseline of representative operations and known gaps. +Each capability needs operation-level review before its entire scope can be +qualified. No live agent execution is claimed. Follow the [map contract](../README.md) +for format, permission checks, evidence and maintenance. + +## `binding` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{id}/heartbeat-context`. +- `GET /api/execution-workspaces/{id}`. + +Agent access: `available` — Native get_task_context / REST / CLI / MCP workspace read. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Run/task context identifies its actual realized workspace; record existence is not +a running service. + +## `provisioning` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/execution-workspaces/{id}`. +- `GET /api/execution-workspaces/{id}/workspace-operations`. + +Agent access: `partial` — REST / native call_api reads; task/project assignment triggers +server provisioning. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Observing readiness exists. Creating host commands or arbitrary provision targets +is a separate protected operation. + +## `services` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `POST /api/execution-workspaces/{id}/runtime-services/{action}`. + +Agent access: `partial` — Authorized agent REST / CLI workspace runtime-service / +existing stdio MCP controls. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Native call_api blocks runtime-service mutation; control_workspace_service is not +in the production authority set. Optional MCP availability must be recorded. + +## `files-git` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `GET /api/issues/{issueId}/file-resources/content`. +- `GET /api/issues/{issueId}/file-resources/list`. + +Agent access: `partial` — Authorized workspace filesystem tools; Dot workspace bridge +when enabled; board file-resource HTTP UI. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Files panel HTTP requires board. Agents can use their admitted workspace file +tools; remote/Dot scope and Git command availability differ. + +## `closure` + +Review: `baseline`. + +API: `present` — representative operation IDs: + +- `PATCH /api/execution-workspaces/{id}`. +- `GET /api/execution-workspaces/{id}/close-readiness`. +- `POST /api/execution-workspaces/{id}/reconcile-branch`. + +Agent access: `partial` — REST / native fallback for permitted update; CLI workspace; +reconcile-branch requires board. + +Policy: Company/project/workspace visibility and runtime authority; active run, trust +preset and task ownership where required. + +Evidence: `source-only` — +[implementation](../../server/src/routes/workspace-runtime-service-authz.ts#L76) at the +reviewed commit above. + +Verification: `not-run` — exercise a listed permitted operation with actual agent +credentials/run context; inspect its result and applicable persistence/attribution. +Assert the relevant denied actor, owner, company or mode from Policy. Review the +remaining operations before qualifying the whole capability. + +Gaps: Closure/readiness exists; not every workspace can be reopened, and branch +reconciliation has a board boundary. diff --git a/feature-map/README.md b/maps/feature-map/README.md similarity index 87% rename from feature-map/README.md rename to maps/feature-map/README.md index 623b4617465..0ac2ab9a5b9 100644 --- a/feature-map/README.md +++ b/maps/feature-map/README.md @@ -3,7 +3,11 @@ Start here when reproducing a user-facing bug, verifying a change, or deciding which surfaces a fix must cover. Each recipe describes what a person can do, where they can do it, the expected result, and the evidence needed to verify it. -Product behavior is still governed by [the implementation spec](../doc/SPEC-implementation.md). +Product behavior is still governed by [the implementation spec](../../doc/SPEC-implementation.md). + +The sibling [agent access map](../agent-access-map/README.md) records APIs, +agent entry points and permission boundaries for the same capability IDs. +Follow the [shared map contract](../README.md) when maintaining either map. ## Features @@ -94,7 +98,7 @@ fixture integration. Record executed results separately from the map. ## Before driving a journey -1. Use this checkout's [isolated test drive](../doc/DEVELOPING.md#one-command-isolated-manual-test-drive) +1. Use this checkout's [isolated test drive](../../doc/DEVELOPING.md#one-command-isolated-manual-test-drive) for manual product checks. It creates a temporary instance and prints its URL and data directory. Do not guess that a server on port 3100 belongs to you. 2. Record the commit, URL, company, login role, relevant experimental settings, @@ -114,11 +118,11 @@ fixture integration. Record executed results separately from the map. evidence; creating state through the API does not prove the creation UI. For agent-driven acceptance work, start with the -[Paperclip verification skill](../.agents/skills/verify-paperclip/SKILL.md) for an +[Paperclip verification skill](../../.agents/skills/verify-paperclip/SKILL.md) for an isolated launch, ownership/readiness checks, browser driving, evidence, and cleanup. It uses this directory as its feature map. The existing -[dev-workspace run/verify skill](../.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md) -and [evaluation skill](../.agents/skills/paperclip-evals/SKILL.md) describe runtime +[dev-workspace run/verify skill](../../.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md) +and [evaluation skill](../../.agents/skills/paperclip-evals/SKILL.md) describe runtime ownership and evidence handling. Reuse them; this map introduces no environment launcher, credentials, scheduled job, or second test framework. @@ -134,7 +138,7 @@ A fix is verified across its affected surfaces only when each has evidence or an explicit reason it does not apply. Shared code alone does not prove parity. Keep product regressions visible; do not change the recipe to bless a failure. For Paperclip-assigned work, attach evidence through the -[artifact workflow](../doc/AGENT-ARTIFACTS.md). +[artifact workflow](../../doc/AGENT-ARTIFACTS.md). ## Inventory and remaining depth @@ -148,8 +152,8 @@ The page inventory is a source snapshot dated 2026-10-05. The map also includes entry points hosted inside other modules (pipeline Review Queue and Learnings, task documents, onboarding), CLI-only operations, and operator work. The team recipe explicitly distinguishes the current CLI/API path from catalog -UI components without a current top-level route. Refer to [route registration](../ui/src/App.tsx) -and the [CLI registry](../cli/src/index.ts) when changing reachability. +UI components without a current top-level route. Refer to [route registration](../../ui/src/App.tsx) +and the [CLI registry](../../cli/src/index.ts) when changing reachability. Remaining depth includes complete provider/auth/attachment matrices, every harness/model/environment capability combination, third-party plugin features, @@ -159,13 +163,17 @@ can include capabilities that do not add a page file. ## Maintaining the reference -The map is documentation only. It adds no CI checks, automatic journey execution, -or required inventory updates for future pull requests. +The map documents user journeys. It does not execute them automatically; +`coverage.json` records documentation scope rather than live test health. When maintaining a recipe, compare its entry points and expected results with the current source. Check that linked tests still exercise the stated behavior and that local references still exist. Refresh the inventory snapshot when it -helps explain the current product. Maintenance is optional and review-based. +helps explain the current product. Keep the same-named agent access companion +and stable sub-feature IDs aligned when behavior or access changes. Source-only +access inventory remains distinct from live verification. Run +`node scripts/check-maps.mjs` after map edits for structural/reference checks; +follow the shared contract for the source and behavioral checks it cannot perform. Each recipe starts with an H1 and a user-visible description, then exactly: @@ -176,7 +184,7 @@ Each recipe starts with an H1 and a user-visible description, then exactly: 4. **Gotchas** — misleading look-alikes, feature gates, and invalid evidence. New or updated entries follow -[Poteto's feature-map examples](../.agents/skills/create-verification-skill/references/feature-map-example/). +[Poteto's feature-map examples](../../.agents/skills/create-verification-skill/references/feature-map-example). Existing entries with a `Driving it` heading can adopt the named harness when their recipes are updated. The original format was inspired by [Omnigent's feature map](https://github.com/omnigent-ai/omnigent/tree/91acfbbb59f6fc210ff95a9e9428aadd62e06582/feature-map). diff --git a/feature-map/access.md b/maps/feature-map/access.md similarity index 78% rename from feature-map/access.md rename to maps/feature-map/access.md index 5d61c99ad2e..7bfed7126ee 100644 --- a/feature-map/access.md +++ b/maps/feature-map/access.md @@ -2,7 +2,9 @@ People and agents enter the correct instance and company with the permissions granted to their identity. Login, membership, resource access, and CLI authorization have separate controls. -Implementation: [auth page](../ui/src/pages/Auth.tsx), [company members](../ui/src/pages/CompanyAccess.tsx), [invitation](../ui/src/pages/InviteLanding.tsx). +Implementation: [auth page](../../ui/src/pages/Auth.tsx), [company members](../../ui/src/pages/CompanyAccess.tsx), [invitation](../../ui/src/pages/InviteLanding.tsx). + +Agent access: [companion](../agent-access-map/access.md). ## Sub-features @@ -35,19 +37,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). For ### `login` -Automated: [auth UI](../ui/src/pages/Auth.test.tsx) and [session routes](../server/src/__tests__/auth-session-route.test.ts) cover their respective layers. +Automated: [auth UI](../../ui/src/pages/Auth.test.tsx) and [session routes](../../server/src/__tests__/auth-session-route.test.ts) cover their respective layers. Manual: Sign in, reload a company deep link, sign out, and revisit it. Confirm the browser requests authentication rather than showing cached company data. Test an invalid claim separately on disposable state. ### `invite-members` -Automated: [member UI](../ui/src/pages/CompanyAccess.test.tsx) and [invite replay](../server/src/__tests__/invite-accept-replay.test.ts) provide component and route evidence. +Automated: [member UI](../../ui/src/pages/CompanyAccess.test.tsx) and [invite replay](../../server/src/__tests__/invite-accept-replay.test.ts) provide component and route evidence. Manual: Accept an invite as the intended user, inspect the resulting company and permissions, then reuse and expire disposable invites. Change a member permission, reload as that member, and verify a denied operation on another company remains denied. ### `cli-authorization` -Automated: [board auth client](../cli/src/__tests__/board-auth.test.ts) and [token commands](../cli/src/__tests__/token.test.ts) cover the client contracts. +Automated: [board auth client](../../cli/src/__tests__/board-auth.test.ts) and [token commands](../../cli/src/__tests__/token.test.ts) cover the client contracts. Manual: Authorize the intended CLI context, make a read-only company request, then revoke its token and retry. Verify failure is explicit and does not silently fall back to another identity. diff --git a/feature-map/activity.md b/maps/feature-map/activity.md similarity index 75% rename from feature-map/activity.md rename to maps/feature-map/activity.md index fea542d988a..0a80901ddfa 100644 --- a/feature-map/activity.md +++ b/maps/feature-map/activity.md @@ -2,7 +2,9 @@ People inspect company health, recent activity, run history, and routine activity, then follow evidence back to the task or agent responsible. -Implementation: [dashboard](../ui/src/pages/Dashboard.tsx), [live dashboard](../ui/src/pages/DashboardLive.tsx), [audit hub](../ui/src/pages/audit/AuditHub.tsx). +Implementation: [dashboard](../../ui/src/pages/Dashboard.tsx), [live dashboard](../../ui/src/pages/DashboardLive.tsx), [audit hub](../../ui/src/pages/audit/AuditHub.tsx). + +Agent access: [companion](../agent-access-map/activity.md). ## Sub-features @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep ### `dashboard` -Automated: [dashboard service](../server/src/__tests__/dashboard-service.test.ts) covers server aggregation; [dashboard helpers](../ui/src/pages/Dashboard.test.ts) is narrower UI helper coverage. +Automated: [dashboard service](../../server/src/__tests__/dashboard-service.test.ts) covers server aggregation; [dashboard helpers](../../ui/src/pages/Dashboard.test.ts) is narrower UI helper coverage. Manual: Compare the dashboard with the known fixture tasks and runs. Start and finish a small task, refresh/reload, and verify the summary changes and links reach the right records. Exercise empty data and a failed fetch. ### `activity-audit` -Automated: [activity service](../server/src/__tests__/activity-service.test.ts), [audit runs](../ui/src/pages/audit/AuditRuns.test.tsx), and [routine audit](../ui/src/pages/audit/RoutineAuditActivity.test.tsx) cover these separate data/host layers. +Automated: [activity service](../../server/src/__tests__/activity-service.test.ts), [audit runs](../../ui/src/pages/audit/AuditRuns.test.tsx), and [routine audit](../../ui/src/pages/audit/RoutineAuditActivity.test.tsx) cover these separate data/host layers. Manual: Find a known mutation by actor/resource and a known run by status. Change filters, load more, and follow the source links. Reload the chosen view and verify no company leakage or duplicate events. Inspect a routine run from its audit entry. diff --git a/feature-map/agent-chat.md b/maps/feature-map/agent-chat.md similarity index 77% rename from feature-map/agent-chat.md rename to maps/feature-map/agent-chat.md index e53725def70..7152588c2fc 100644 --- a/feature-map/agent-chat.md +++ b/maps/feature-map/agent-chat.md @@ -2,7 +2,9 @@ A person can hold a persistent conversation with an agent, return to its history, and hand useful work into a project or task. The gated conference-room chat is a separate conversation surface. -Implementation: [agent chat](../ui/src/pages/AgentChat.tsx), [chat landing](../ui/src/pages/AgentChats.tsx), [conference room](../ui/src/pages/BoardChat.tsx). +Implementation: [agent chat](../../ui/src/pages/AgentChat.tsx), [chat landing](../../ui/src/pages/AgentChats.tsx), [conference room](../../ui/src/pages/BoardChat.tsx). + +Agent access: [companion](../agent-access-map/agent-chat.md). ## Sub-features @@ -31,7 +33,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `agent-conversation` -Automated: [chat landing](../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior. +Automated: [chat landing](../../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior. Manual: Start a conversation, send a recognizable request, and inspect its reply. Navigate away and reopen the same history. Use `/new` for a fresh session within @@ -40,13 +42,13 @@ inaccessible saved conversation; verify no cross-user or cross-company history a ### `project-handoff` -Automated: [chat project tools](../server/src/__tests__/chat-project-tools.test.ts) checks tool contracts; live model choice and UI handoff require a manual run. +Automated: [chat project tools](../../server/src/__tests__/chat-project-tools.test.ts) checks tool contracts; live model choice and UI handoff require a manual run. Manual: Ask the configured agent to create bounded project work, inspect the proposed/created target, and open the resulting task. Verify the request context and intended project survived and the task produces a useful output. ### `conference-room` -Automated: [board chat gate](../server/src/__tests__/board-chat-route-feature-flag.test.ts) proves route gating, not a live conversation. +Automated: [board chat gate](../../server/src/__tests__/board-chat-route-feature-flag.test.ts) proves route gating, not a live conversation. Preconditions for this entry point: `local_trusted` mode and an installed, authenticated `claude` CLI, in addition to the Conference Room flag. These are diff --git a/feature-map/agents.md b/maps/feature-map/agents.md similarity index 76% rename from feature-map/agents.md rename to maps/feature-map/agents.md index 021505b41c0..673d7c45982 100644 --- a/feature-map/agents.md +++ b/maps/feature-map/agents.md @@ -2,7 +2,9 @@ Operators hire agents, configure their identity and instructions, assign reporting relationships, and manage their lifecycle. Built-in agents have their own setup and lifecycle constraints. -Implementation: [hiring](../ui/src/pages/NewAgent.tsx), [configuration](../ui/src/components/AgentConfigForm.tsx), [organization](../ui/src/pages/OrgChart.tsx). +Implementation: [hiring](../../ui/src/pages/NewAgent.tsx), [configuration](../../ui/src/components/AgentConfigForm.tsx), [organization](../../ui/src/pages/OrgChart.tsx). + +Agent access: [companion](../agent-access-map/agents.md). ## Sub-features @@ -33,7 +35,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `hire-agent` -Automated: [new agent page](../ui/src/pages/NewAgent.test.tsx) and [configuration form](../ui/src/components/AgentConfigForm.render.test.tsx) cover mocked UI behavior. +Automated: [new agent page](../../ui/src/pages/NewAgent.test.tsx) and [configuration form](../../ui/src/components/AgentConfigForm.render.test.tsx) cover mocked UI behavior. Manual: Hire an agent with a distinct name and role. Complete any required approval, reload the roster, and open its configuration. Confirm manager, adapter, and model persisted; run a small task to prove execution readiness. @@ -44,13 +46,13 @@ from the hiring picker; API fixture creation does not prove this UI journey. ### `agent-configuration` -Automated: [instruction service](../server/src/__tests__/agent-instructions-service.test.ts) and [instruction revisions](../server/src/__tests__/agent-instruction-revisions.test.ts) cover file/revision contracts. +Automated: [instruction service](../../server/src/__tests__/agent-instructions-service.test.ts) and [instruction revisions](../../server/src/__tests__/agent-instruction-revisions.test.ts) cover file/revision contracts. Manual: Save a harmless instruction edit, reload, and verify its revision and effect in the next eligible run. Pause and resume the disposable agent; confirm pause prevents new work and does not erase previous runs. ### `organization` -Automated: [org chart](../ui/src/pages/OrgChart.test.tsx) is component evidence. Built-in setup also has [configuration modal tests](../ui/src/components/ConfigureBuiltInAgentModal.test.tsx). +Automated: [org chart](../../ui/src/pages/OrgChart.test.tsx) is component evidence. Built-in setup also has [configuration modal tests](../../ui/src/components/ConfigureBuiltInAgentModal.test.tsx). Manual: Inspect a manager and report, change the relationship through the offered controls, and reload both views. Exercise a rejected invalid relationship. Configure a built-in agent and verify it retains its required identity and restrictions. diff --git a/feature-map/app-permissions.md b/maps/feature-map/app-permissions.md similarity index 76% rename from feature-map/app-permissions.md rename to maps/feature-map/app-permissions.md index fd8a825c51f..7726294d328 100644 --- a/feature-map/app-permissions.md +++ b/maps/feature-map/app-permissions.md @@ -2,7 +2,9 @@ After connecting an app, an operator decides which agents may use it and whether each action is off, requires approval, or is allowed. Activity and action tests help verify the resulting access. -Implementation: [app detail](../ui/src/pages/apps/AppDetail.tsx), [permissions](../ui/src/pages/apps/app-detail/PermissionsPanel.tsx), [activity](../ui/src/pages/apps/app-detail/ActivityPanel.tsx). +Implementation: [app detail](../../ui/src/pages/apps/AppDetail.tsx), [permissions](../../ui/src/pages/apps/app-detail/PermissionsPanel.tsx), [activity](../../ui/src/pages/apps/app-detail/ActivityPanel.tsx). + +Agent access: [companion](../agent-access-map/app-permissions.md). ## Sub-features @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep ### `permissions` -Automated: [app detail](../ui/src/pages/apps/AppDetail.test.tsx) covers agent/action controls, quarantined actions, and navigation using mocked requests. +Automated: [app detail](../../ui/src/pages/apps/AppDetail.test.tsx) covers agent/action controls, quarantined actions, and navigation using mocked requests. Manual: Grant one agent access, leave another excluded, and exercise off, ask, and allowed states for a harmless action. Reload between changes. Verify actual denied calls stay denied and ask-first creates a decision rather than executing early. ### `test-and-maintain` -Automated: [app detail tests](../ui/src/pages/apps/AppDetail.test.tsx) cover test-dialog/UI contracts. Real provider execution and revocation require the selected provider’s live check. +Automated: [app detail tests](../../ui/src/pages/apps/AppDetail.test.tsx) cover test-dialog/UI contracts. Real provider execution and revocation require the selected provider’s live check. Manual: Run a read-only action with explicit inputs and inspect returned data and activity. Refresh discovery and review new actions. Revoke or disconnect only the disposable credential, then verify subsequent use fails with an actionable reconnect state. diff --git a/feature-map/budgets-costs.md b/maps/feature-map/budgets-costs.md similarity index 79% rename from feature-map/budgets-costs.md rename to maps/feature-map/budgets-costs.md index cd0bb3b2b99..2c7fed9149e 100644 --- a/feature-map/budgets-costs.md +++ b/maps/feature-map/budgets-costs.md @@ -2,7 +2,9 @@ Operators inspect recorded spend and configure budget controls for the company, projects, and agents. Hard stops pause or prevent eligible work until the governing incident is resolved. -Implementation: [costs and budget host](../ui/src/pages/Costs.tsx), [audit hub](../ui/src/pages/audit/AuditHub.tsx). +Implementation: [costs and budget host](../../ui/src/pages/Costs.tsx), [audit hub](../../ui/src/pages/audit/AuditHub.tsx). + +Agent access: [companion](../agent-access-map/budgets-costs.md). ## Sub-features @@ -27,7 +29,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `cost-overview` -Automated: [costs service](../server/src/__tests__/costs-service.test.ts) covers cost contracts; [embedded costs UI](../ui/src/pages/Costs.test.tsx) has narrow Audit-host coverage. +Automated: [costs service](../../server/src/__tests__/costs-service.test.ts) covers cost contracts; [embedded costs UI](../../ui/src/pages/Costs.test.tsx) has narrow Audit-host coverage. Manual: Inspect a known fixture run and reconcile recorded spend with the chosen period and resource. Switch the report period/company and inspect the **By @@ -36,7 +38,7 @@ or unavailable cost data without treating missing data as zero spend. ### `budget-enforcement` -Automated: [budget service](../server/src/__tests__/budgets-service.test.ts) exercises soft incidents, hard stops, scope, and valid budget raises. +Automated: [budget service](../../server/src/__tests__/budgets-service.test.ts) exercises soft incidents, hard stops, scope, and valid budget raises. Manual: Create a small fixture budget, cross its threshold with synthetic recorded spend, and verify the warning or hard-stop state. Attempt new work and confirm it is gated. Raise the correct budget through an authorized action and verify eligible continuation without clearing unrelated manual pauses. diff --git a/feature-map/cases.md b/maps/feature-map/cases.md similarity index 78% rename from feature-map/cases.md rename to maps/feature-map/cases.md index f1c12ccb7a0..a36ad4b9c13 100644 --- a/feature-map/cases.md +++ b/maps/feature-map/cases.md @@ -2,7 +2,9 @@ The cases experiment exposes structured records with types, fields, parent/child relationships, activity, revisions, and related task work. -Implementation: [case list](../ui/src/pages/Cases.tsx), [case detail](../ui/src/pages/CaseDetail.tsx). +Implementation: [case list](../../ui/src/pages/Cases.tsx), [case detail](../../ui/src/pages/CaseDetail.tsx). + +Agent access: [companion](../agent-access-map/cases.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `case-list` -Automated: [case list](../ui/src/pages/Cases.test.tsx) covers filters, persisted view state, and keyboard/tree behavior with mocked data. +Automated: [case list](../../ui/src/pages/Cases.test.tsx) covers filters, persisted view state, and keyboard/tree behavior with mocked data. Manual: Search for a child, verify its parent context remains understandable, change filters/grouping/columns, and reload. Open the record by keyboard and direct link. Include empty results and terminal records. ### `case-record` -Automated: [case routes](../server/src/__tests__/cases-routes.test.ts) covers API behavior; [case fields](../ui/src/components/CaseFieldsPanel.test.tsx) covers field UI separately. +Automated: [case routes](../../server/src/__tests__/cases-routes.test.ts) covers API behavior; [case fields](../../ui/src/components/CaseFieldsPanel.test.tsx) covers field UI separately. Manual: Change status or labels, reload, inspect activity, and follow child/task links. Edit a case document and inspect its document revisions separately. diff --git a/feature-map/chat-channels.md b/maps/feature-map/chat-channels.md similarity index 79% rename from feature-map/chat-channels.md rename to maps/feature-map/chat-channels.md index 071e53c1867..4a6a55efc2f 100644 --- a/feature-map/chat-channels.md +++ b/maps/feature-map/chat-channels.md @@ -2,7 +2,9 @@ People can talk to an assigned agent through configured external channels, bind their identity, exchange files, and inspect delivery state. Each provider has a different identity, threading, and attachment contract. -Implementation: [endpoint setup](../ui/src/pages/apps/chat/ChatEndpointSetup.tsx), [endpoint detail](../ui/src/pages/apps/chat/ChatEndpointDetail.tsx), [email setup](../ui/src/pages/apps/chat/EmailEndpointSetup.tsx). +Implementation: [endpoint setup](../../ui/src/pages/apps/chat/ChatEndpointSetup.tsx), [endpoint detail](../../ui/src/pages/apps/chat/ChatEndpointDetail.tsx), [email setup](../../ui/src/pages/apps/chat/EmailEndpointSetup.tsx). + +Agent access: [companion](../agent-access-map/chat-channels.md). ## Sub-features @@ -32,13 +34,13 @@ Setup is covered in [connection setup](./connection-setup.md). ### `external-thread` -Automated: [channel integration](../server/src/__tests__/chat-channels.integration.test.ts) uses provider fixtures; it does not prove a live Slack, Discord, Teams, Telegram, GitHub, email, or Photon delivery. +Automated: [channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) uses provider fixtures; it does not prove a live Slack, Discord, Teams, Telegram, GitHub, email, or Photon delivery. Manual: For each supported provider being changed, send a unique message, verify the intended agent/company receives it, and inspect the actual external reply in the original thread. Repeat with an unlinked/unauthorized sender, duplicate inbound delivery, and one supported attachment. Record provider-specific results separately. ### `endpoint-management` -Automated: [channel integration](../server/src/__tests__/chat-channels.integration.test.ts) covers shared endpoint/delivery behavior; live credential repair and provider ownership remain manual checks. +Automated: [channel integration](../../server/src/__tests__/chat-channels.integration.test.ts) covers shared endpoint/delivery behavior; live credential repair and provider ownership remain manual checks. Manual: Inspect the bound agent and identity, recent activity, and delivery failure details. Repair a disposable credential, retry a safe message when offered, and verify exactly one external reply. Disable the endpoint and confirm new traffic is not silently processed. diff --git a/feature-map/cli-operations.md b/maps/feature-map/cli-operations.md similarity index 77% rename from feature-map/cli-operations.md rename to maps/feature-map/cli-operations.md index b9d77696af9..e3ddaeb6e38 100644 --- a/feature-map/cli-operations.md +++ b/maps/feature-map/cli-operations.md @@ -2,7 +2,9 @@ Operators and agents can work through explicit CLI/API context, manage domain resources, inspect runs, and create isolated local worktree instances for development. -Implementation: [command registry](../cli/src/index.ts), [client context](../cli/src/commands/client/context.ts), [worktree commands](../cli/src/commands/worktree.ts). +Implementation: [command registry](../../cli/src/index.ts), [client context](../../cli/src/commands/client/context.ts), [worktree commands](../../cli/src/commands/worktree.ts). + +Agent access: [companion](../agent-access-map/cli-operations.md). ## Sub-features @@ -37,19 +39,19 @@ even when a profile stores a company. A persona label is not authentication proo ### `client-context` -Automated: [context](../cli/src/__tests__/context.test.ts), [HTTP client](../cli/src/__tests__/http.test.ts), and [operations parity](../cli/src/__tests__/operations-parity.test.ts) cover client contracts, not every live endpoint. +Automated: [context](../../cli/src/__tests__/context.test.ts), [HTTP client](../../cli/src/__tests__/http.test.ts), and [operations parity](../../cli/src/__tests__/operations-parity.test.ts) cover client contracts, not every live endpoint. Manual: Inspect the selected context and identity, list the disposable company’s resources, create a harmless task through the supported command, and verify it in the browser. Exercise an invalid token and wrong-company resource. Inspect JSON output and nonzero errors rather than only printed success text. ### `subresources-and-runs` -Automated: [issue subresources](../cli/src/__tests__/issue-subresources.test.ts) and [asset/skill parity](../cli/src/__tests__/admin-asset-skill-parity.test.ts) cover command/API contracts. +Automated: [issue subresources](../../cli/src/__tests__/issue-subresources.test.ts) and [asset/skill parity](../../cli/src/__tests__/admin-asset-skill-parity.test.ts) cover command/API contracts. Manual: Write a small task document or attach a fixture using the supported command, open it in the UI, and compare contents. Invoke eligible test work, inspect the resulting run ID/status/output, and verify denial when the actor lacks permission. ### `worktree-instance` -Automated: [worktree commands](../cli/src/__tests__/worktree.test.ts) and [test drive](../cli/src/__tests__/test-drive.test.ts) cover isolation/setup contracts. +Automated: [worktree commands](../../cli/src/__tests__/worktree.test.ts) and [test drive](../../cli/src/__tests__/test-drive.test.ts) cover isolation/setup contracts. Manual: Initialize an owned disposable worktree instance, inspect its printed paths and ports, verify health and login, and confirm any seed data belongs to the intended source. Stop and clean up only that instance; inspect preserved work before deleting any checkout. diff --git a/feature-map/companies.md b/maps/feature-map/companies.md similarity index 76% rename from feature-map/companies.md rename to maps/feature-map/companies.md index 585a0588a1a..0740c974556 100644 --- a/feature-map/companies.md +++ b/maps/feature-map/companies.md @@ -2,7 +2,9 @@ An operator can switch between companies, change their identity and settings, archive them, and preview an import or export. Each company remains a separate scope for work and agents. -Implementation: [companies](../ui/src/pages/Companies.tsx), [settings](../ui/src/pages/CompanySettings.tsx), [import](../ui/src/pages/CompanyImport.tsx), [export](../ui/src/pages/CompanyExport.tsx). +Implementation: [companies](../../ui/src/pages/Companies.tsx), [settings](../../ui/src/pages/CompanySettings.tsx), [import](../../ui/src/pages/CompanyImport.tsx), [export](../../ui/src/pages/CompanyExport.tsx). + +Agent access: [companion](../agent-access-map/companies.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `company-switcher` -Automated: [company list](../ui/src/pages/Companies.test.tsx) and [archive UI](../ui/src/pages/CompanySettingsArchive.test.tsx) cover UI behavior with mocks. +Automated: [company list](../../ui/src/pages/Companies.test.tsx) and [archive UI](../../ui/src/pages/CompanySettingsArchive.test.tsx) cover UI behavior with mocks. Manual: Create or select a company, rename it, and reload. Switch to the second company and back; verify tasks, agents, and settings follow the selection. Archive the disposable company and verify its visibility and offered restoration path. ### `package-transfer` -Automated: [portability routes](../server/src/__tests__/company-portability-routes.test.ts) and [CLI import/export](../cli/src/__tests__/company-import-export-e2e.test.ts) cover package and command behavior, not every external Git host. +Automated: [portability routes](../../server/src/__tests__/company-portability-routes.test.ts) and [CLI import/export](../../cli/src/__tests__/company-import-export-e2e.test.ts) cover package and command behavior, not every external Git host. Manual: Export a small company, inspect the package preview, and import into a disposable target. Verify counts, references, conflict choices, and agent configuration. Run one imported task after supplying its required local credentials. Confirm source company data is unchanged. diff --git a/feature-map/connection-setup.md b/maps/feature-map/connection-setup.md similarity index 88% rename from feature-map/connection-setup.md rename to maps/feature-map/connection-setup.md index de51d3c6b7d..808f7d952ce 100644 --- a/feature-map/connection-setup.md +++ b/maps/feature-map/connection-setup.md @@ -5,6 +5,8 @@ setup from either Apps or a task that needs access. A saved draft, a completed provider login, a usable connection, and a resumed task are separate checkpoints. Chat channels additionally require an endpoint and linked sender identity. +Agent access: [companion](../agent-access-map/connection-setup.md). + ## Sub-features - `catalog`: find a provider, inspect its available methods, and open setup; @@ -63,16 +65,16 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Choose a disposable provider account/resource and record its auth method, deployment mode, account ownership, and access selection. For live setup, use the provider's documented prerequisites in the -[connector playbook](../doc/connections/CONNECTOR-PLAYBOOK.md). Automated fixtures +[connector playbook](../../doc/connections/CONNECTOR-PLAYBOOK.md). Automated fixtures do not establish OAuth registration, public callbacks, or production provider access. ### `apps-catalog` -Automated: [Browse](../ui/src/pages/apps/Browse.test.tsx), -[unconnected app](../ui/src/pages/apps/AppNotConnected.test.tsx), and -[AppsConnect](../ui/src/pages/apps/AppsConnect.test.tsx) cover catalog entry, +Automated: [Browse](../../ui/src/pages/apps/Browse.test.tsx), +[unconnected app](../../ui/src/pages/apps/AppNotConnected.test.tsx), and +[AppsConnect](../../ui/src/pages/apps/AppsConnect.test.tsx) cover catalog entry, access selection, provider-specific method choices, and setup failures with -mocked APIs. [Connection intents](../tests/e2e/connection-intents.spec.ts) +mocked APIs. [Connection intents](../../tests/e2e/connection-intents.spec.ts) drives store and task setup against one fake provider through a useful continuation: ```sh @@ -99,7 +101,7 @@ Test no-auth and authenticated endpoints separately; never infer one from the ot ### `task-request` -Automated: [interaction card](../ui/src/components/IssueThreadInteractionCard.test.tsx) +Automated: [interaction card](../../ui/src/components/IssueThreadInteractionCard.test.tsx) checks addressed-user controls. AppsConnect tests cover page/dialog parity, task-bound OAuth, popup handoff, and verifying a saved connection before closing. The connection-intents browser suite above exercises fixture-backed continuation. @@ -114,7 +116,7 @@ task does not grant the right to resolve the request. Automated: AppsConnect tests cover exact-draft resume, declined OAuth, reconnect lookup failure/retry, response-lost creation, archived connection revival, and -saved account reuse. [App detail](../ui/src/pages/apps/AppDetail.test.tsx) covers +saved account reuse. [App detail](../../ui/src/pages/apps/AppDetail.test.tsx) covers the connection host. These tests do not complete a real provider login. Manual: leave setup through its footer, reload Apps, choose Finish setup, and @@ -124,10 +126,10 @@ action afterwards. Count connections before/after to catch accidental duplicates ### `chat-channel` -Automated: [setup routing](../ui/src/pages/apps/chat/ChatEndpointSetup.routing.test.tsx), -[saved setup state](../ui/src/pages/apps/chat/ChatEndpointSetup.state.test.ts), -[email setup](../ui/src/pages/apps/chat/EmailEndpointSetup.test.tsx), and -[identity confirmation](../ui/src/pages/apps/chat/ChatIdentityConfirm.test.tsx) +Automated: [setup routing](../../ui/src/pages/apps/chat/ChatEndpointSetup.routing.test.tsx), +[saved setup state](../../ui/src/pages/apps/chat/ChatEndpointSetup.state.test.ts), +[email setup](../../ui/src/pages/apps/chat/EmailEndpointSetup.test.tsx), and +[identity confirmation](../../ui/src/pages/apps/chat/ChatIdentityConfirm.test.tsx) cover shared UI logic with fixtures. Provider-specific live setup is not implied. Manual: complete setup for one named provider, reload endpoint settings, link diff --git a/feature-map/coverage.json b/maps/feature-map/coverage.json similarity index 100% rename from feature-map/coverage.json rename to maps/feature-map/coverage.json diff --git a/feature-map/developer-labs.md b/maps/feature-map/developer-labs.md similarity index 76% rename from feature-map/developer-labs.md rename to maps/feature-map/developer-labs.md index 41c24ed1dbb..f2cdeb607d1 100644 --- a/feature-map/developer-labs.md +++ b/maps/feature-map/developer-labs.md @@ -2,7 +2,9 @@ Contributors can inspect design examples, interaction labs, and performance fixtures. These are development surfaces and do not count as production feature verification. -Implementation: [route registration](../ui/src/App.tsx), [design guide](../ui/src/pages/DesignGuide.tsx), [long-thread fixture](../ui/src/pages/IssueChatLongThreadPerf.tsx). +Implementation: [route registration](../../ui/src/App.tsx), [design guide](../../ui/src/pages/DesignGuide.tsx), [long-thread fixture](../../ui/src/pages/IssueChatLongThreadPerf.tsx). + +Agent access: [companion](../agent-access-map/developer-labs.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `preview-lab` -Automated: [onboarding variant](../ui/src/components/OnboardingWizardVariant.test.tsx) is an example component contract. There is no claim of full automated acceptance coverage for every lab page. +Automated: [onboarding variant](../../ui/src/components/OnboardingWizardVariant.test.tsx) is an example component contract. There is no claim of full automated acceptance coverage for every lab page. Manual: Open the specific fixture state, inspect controls and layout, then repeat the affected interaction in the actual product host. Report fixture results separately from persisted task/company results. ### `performance-fixture` -Automated: [task thread component](../ui/src/components/IssueChatThread.test.tsx) does not establish a performance budget. Timing/scroll measurements need a dedicated browser run. +Automated: [task thread component](../../ui/src/components/IssueChatThread.test.tsx) does not establish a performance budget. Timing/scroll measurements need a dedicated browser run. Manual: Record browser/build/data size, exercise scrolling and rendering in the long-thread fixture, and retain timing or trace evidence. Its composer is diff --git a/feature-map/documents-artifacts.md b/maps/feature-map/documents-artifacts.md similarity index 71% rename from feature-map/documents-artifacts.md rename to maps/feature-map/documents-artifacts.md index e445c9cddb1..c3ae6f1c3cd 100644 --- a/feature-map/documents-artifacts.md +++ b/maps/feature-map/documents-artifacts.md @@ -2,7 +2,9 @@ People read and revise task documents, annotate specific content, open attached files, and inspect the outputs an agent hands back. The artifact library provides another route to the same work. -Implementation: [task documents](../ui/src/components/IssueDocumentsSection.tsx), [artifact library](../ui/src/pages/Artifacts.tsx), [document panel](../ui/src/components/task-side-panel/TaskDocumentPanel.tsx). +Implementation: [task documents](../../ui/src/components/IssueDocumentsSection.tsx), [artifact library](../../ui/src/pages/Artifacts.tsx), [document panel](../../ui/src/components/task-side-panel/TaskDocumentPanel.tsx). + +Agent access: [companion](../agent-access-map/documents-artifacts.md). ## Sub-features @@ -35,13 +37,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `task-document` -Automated: [documents service](../server/src/__tests__/documents-service.test.ts), [annotations](../server/src/__tests__/document-annotations-service.test.ts), and [revision restore](../server/src/__tests__/issue-document-restore-routes.test.ts) cover server contracts. +Automated: [documents service](../../server/src/__tests__/documents-service.test.ts), [annotations](../../server/src/__tests__/document-annotations-service.test.ts), and [revision restore](../../server/src/__tests__/issue-document-restore-routes.test.ts) cover server contracts. Manual: Edit a paragraph, save, reload, and compare revisions. Add an annotation to a specific selection and inspect it after another edit. Resolve it through the supported action. Restore a disposable revision and confirm the new current document and history agree. ### `task-output` -Automated: [attachment routes](../server/src/__tests__/issue-attachment-routes.test.ts) and [work products](../server/src/__tests__/work-products.test.ts) cover data/access behavior; [artifact arrival browser suite](../tests/e2e/artifact-tab-arrival.spec.ts) targets arrival. [Artifact uploads](../tests/e2e/artifact-uploads.spec.ts) drives file selection/drop, filename collisions, retry, and preservation of paused/unassigned tasks. +Automated: [attachment routes](../../server/src/__tests__/issue-attachment-routes.test.ts) and [work products](../../server/src/__tests__/work-products.test.ts) cover data/access behavior; [artifact arrival browser suite](../../tests/e2e/artifact-tab-arrival.spec.ts) targets arrival. [Artifact uploads](../../tests/e2e/artifact-uploads.spec.ts) drives file selection/drop, filename collisions, retry, and preservation of paused/unassigned tasks. Manual: Upload a small Markdown file and CSV through Artifacts. Markdown becomes a versioned task document; other files become attachments. Read the document, @@ -53,7 +55,7 @@ Test an inaccessible user/company and explicit missing workspace-only resources. ### `artifact-library` -Automated: [artifact page](../ui/src/pages/Artifacts.test.tsx) covers grouping, search requests, and pagination using mocks. +Automated: [artifact page](../../ui/src/pages/Artifacts.test.tsx) covers grouping, search requests, and pagination using mocks. Manual: Find the task’s output through search and grouping, open its stack, page through results, and reload a direct stack link. Confirm the same file/content appears as from the task. diff --git a/feature-map/execution-environments.md b/maps/feature-map/execution-environments.md similarity index 76% rename from feature-map/execution-environments.md rename to maps/feature-map/execution-environments.md index df145e17d3e..bb1fa10d78f 100644 --- a/feature-map/execution-environments.md +++ b/maps/feature-map/execution-environments.md @@ -2,7 +2,9 @@ Operators configure where agents execute, probe readiness, select an environment for work, and inspect remote provisioning or custom-image state when the installed provider supports it. -Implementation: [environment settings](../ui/src/pages/CompanyEnvironments.tsx), [environment runtime](../server/src/services/environment-runtime.ts). +Implementation: [environment settings](../../ui/src/pages/CompanyEnvironments.tsx), [environment runtime](../../server/src/services/environment-runtime.ts). + +Agent access: [companion](../agent-access-map/execution-environments.md). ## Sub-features @@ -32,7 +34,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `environment-settings` -Automated: [environment UI](../ui/src/pages/CompanyEnvironments.test.tsx), [driver configuration UI](../ui/src/pages/CompanySettings.test.tsx), and [environment service](../server/src/__tests__/environment-service.test.ts) cover configuration behavior. +Automated: [environment UI](../../ui/src/pages/CompanyEnvironments.test.tsx), [driver configuration UI](../../ui/src/pages/CompanySettings.test.tsx), and [environment service](../../server/src/__tests__/environment-service.test.ts) cover configuration behavior. Manual: Save a disposable target, reload it, and run its available probe. Verify both success and missing-credential/reachability errors identify the target. Confirm unavailable providers are not offered as working run targets. @@ -42,7 +44,7 @@ execution. Remote-driver probes and a real bounded run need their own evidence. ### `remote-execution` -Automated: [execution target](../server/src/__tests__/environment-execution-target.test.ts) and [custom images](../server/src/__tests__/environment-custom-images-service.test.ts) cover contracts; [SSH live test](../server/src/__tests__/environment-live-ssh.test.ts) requires its own external setup. +Automated: [execution target](../../server/src/__tests__/environment-execution-target.test.ts) and [custom images](../../server/src/__tests__/environment-custom-images-service.test.ts) cover contracts; [SSH live test](../../server/src/__tests__/environment-live-ssh.test.ts) requires its own external setup. Manual: Run a bounded command in the intended target, inspect its output and workspace, and verify execution did not silently fall back to the server host. For custom-image changes, build/probe the disposable image before assigning it. Inspect cleanup and shutdown after the run. diff --git a/feature-map/gateways-profiles.md b/maps/feature-map/gateways-profiles.md similarity index 74% rename from feature-map/gateways-profiles.md rename to maps/feature-map/gateways-profiles.md index e00b5a03ac3..de5f4b58e9d 100644 --- a/feature-map/gateways-profiles.md +++ b/maps/feature-map/gateways-profiles.md @@ -2,7 +2,9 @@ Administrators expose selected tools through gateways and reusable access profiles, connect clients, manage tokens, and inspect gateway activity. -Implementation: [gateways](../ui/src/pages/apps/gateways/GatewaysList.tsx), [gateway detail](../ui/src/pages/apps/gateways/GatewayDetail.tsx), [profiles](../ui/src/pages/tools/profiles/ProfileWizard.tsx). +Implementation: [gateways](../../ui/src/pages/apps/gateways/GatewaysList.tsx), [gateway detail](../../ui/src/pages/apps/gateways/GatewayDetail.tsx), [profiles](../../ui/src/pages/tools/profiles/ProfileWizard.tsx). + +Agent access: [companion](../agent-access-map/gateways-profiles.md). ## Sub-features @@ -33,13 +35,13 @@ endpoint authorization independently of whether the admin UI is visible. ### `gateway-client` -Automated: [gateway service](../server/src/__tests__/tool-gateway-service.test.ts), [connect client dialog](../ui/src/pages/apps/gateways/ConnectClientDialog.test.tsx), and [token panel](../ui/src/pages/apps/gateways/panels/TokensPanel.test.tsx) cover service/UI contracts. +Automated: [gateway service](../../server/src/__tests__/tool-gateway-service.test.ts), [connect client dialog](../../ui/src/pages/apps/gateways/ConnectClientDialog.test.tsx), and [token panel](../../ui/src/pages/apps/gateways/panels/TokensPanel.test.tsx) cover service/UI contracts. Manual: Create a disposable gateway with one harmless tool, connect a client using its provided configuration, discover/call that tool, and inspect activity. Revoke the client token and confirm the same call fails. Test a tool outside the allowed set. ### `access-profile` -Automated: [profile wizard](../ui/src/pages/tools/profiles/ProfileWizard.test.tsx) and [profile detail](../ui/src/pages/tools/profiles/ProfileDetail.test.tsx) cover component behavior with mocks. +Automated: [profile wizard](../../ui/src/pages/tools/profiles/ProfileWizard.test.tsx) and [profile detail](../../ui/src/pages/tools/profiles/ProfileDetail.test.tsx) cover component behavior with mocks. Manual: Create a bounded profile, choose its offered tools and options, save or resume its draft, and reload the detail. Apply/connect it through the offered flow and inspect the actual client-visible tool set. Verify cancellation and invalid selections retain an understandable draft. diff --git a/feature-map/goals.md b/maps/feature-map/goals.md similarity index 73% rename from feature-map/goals.md rename to maps/feature-map/goals.md index 1e83cf608e3..44322e7fbe3 100644 --- a/feature-map/goals.md +++ b/maps/feature-map/goals.md @@ -2,7 +2,9 @@ People define company and lower-level goals and connect projects and tasks to the outcome they serve. -Implementation: [goals](../ui/src/pages/Goals.tsx), [goal detail](../ui/src/pages/GoalDetail.tsx). +Implementation: [goals](../../ui/src/pages/Goals.tsx), [goal detail](../../ui/src/pages/GoalDetail.tsx). + +Agent access: [companion](../agent-access-map/goals.md). ## Sub-features @@ -26,7 +28,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Prep ### `goal-detail` -Automated: [project-goal validation](../server/src/__tests__/project-goal-validation.test.ts) covers relationship validation; [goal detail control](../ui/src/pages/GoalDetail.test.tsx) only tests the properties toggle, not the whole creation journey. +Automated: [project-goal validation](../../server/src/__tests__/project-goal-validation.test.ts) covers relationship validation; [goal detail control](../../ui/src/pages/GoalDetail.test.tsx) only tests the properties toggle, not the whole creation journey. Manual: Choose **Add Goal/New Goal**, enter **Goal title**, select the level, and create a parent. Open it and choose **Sub Goal** to create a child with the @@ -37,7 +39,7 @@ and verify a cross-company or invalid relationship is rejected. ### `task-goal-context` -Automated: [task goal routes](../server/src/__tests__/issues-goal-context-routes.test.ts) and [goal fallback](../server/src/__tests__/issue-goal-fallback.test.ts) cover the server rules. +Automated: [task goal routes](../../server/src/__tests__/issues-goal-context-routes.test.ts) and [goal fallback](../../server/src/__tests__/issue-goal-fallback.test.ts) cover the server rules. Manual: Compare a task with an explicit goal to one relying on project context. Change the allowed association, reload, and verify the displayed/injected context follows the documented server rule rather than retaining an unrelated goal. diff --git a/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md similarity index 68% rename from feature-map/inbox-search.md rename to maps/feature-map/inbox-search.md index cdcaab7f44c..ac3570b5c38 100644 --- a/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -2,7 +2,9 @@ People find work that needs their attention, distinguish unread activity from required decisions, archive handled items, and search across the company. -Implementation: [inbox](../ui/src/pages/Inbox.tsx), [decisions](../ui/src/pages/WhatNeedsMe.tsx), [search](../ui/src/pages/Search.tsx). +Implementation: [inbox](../../ui/src/pages/Inbox.tsx), [decisions](../../ui/src/pages/WhatNeedsMe.tsx), [search](../../ui/src/pages/Search.tsx). + +Agent access: [companion](../agent-access-map/inbox-search.md). ## Sub-features @@ -31,26 +33,26 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Seed ### `inbox` -Automated: [archive routes](../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. Reading, archiving, and navigating that page remain manual checks. +Automated: [archive routes](../../server/src/__tests__/inbox-archive-routes.test.ts) cover server mutations. [Task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, and presentation constants; they do not render the combined Inbox/Tasks page. Reading, archiving, and navigating that page remain manual checks. Manual: Read an unread task, return to Unread, archive a handled item, and reload. Verify personal triage state changes while task status stays correct. Switch users and companies to check isolation. ### `decisions` -Automated: [decision queues](../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual. +Automated: [decision queues](../../server/src/__tests__/decision-queues-routes.test.ts) covers route behavior. End-to-end queue-to-source navigation is manual. Manual: Open a pending decision, follow its source, resolve it through the supported control, and reload the queue. Confirm it retires only after the decision persisted and unrelated pending items remain. ### `search` -Automated: [search page](../ui/src/pages/Search.test.tsx) and [company search service](../server/src/__tests__/company-search-service.test.ts) cover UI and server search separately. +Automated: [search page](../../ui/src/pages/Search.test.tsx) and [company search service](../../server/src/__tests__/company-search-service.test.ts) cover UI and server search separately. Manual: Search a unique task title and content phrase, open the result, and compare the actual record. Test filters, no results, and a second company. Verify stale results do not expose inaccessible data. ## Gotchas - Combined Inbox/Tasks requires both `enableCombinedInboxTasks` and - `enableStreamlinedUi`. [Standalone Inbox tests](../ui/src/pages/Inbox.test.tsx) + `enableStreamlinedUi`. [Standalone Inbox tests](../../ui/src/pages/Inbox.test.tsx) render archive/read controls and toolbars; this is not live multi-user evidence. - Personal archival is not task cancellation or completion. diff --git a/feature-map/instance-operations.md b/maps/feature-map/instance-operations.md similarity index 78% rename from feature-map/instance-operations.md rename to maps/feature-map/instance-operations.md index bb1c572621b..0e02f78a36d 100644 --- a/feature-map/instance-operations.md +++ b/maps/feature-map/instance-operations.md @@ -2,7 +2,9 @@ An operator can install and run Paperclip, diagnose the instance, configure supported deployment settings, manage its service, and produce a recoverable database backup. -Implementation: [CLI entry point](../cli/src/index.ts), [instance settings](../ui/src/pages/InstanceExperimentalSettings.tsx), [backup command](../cli/src/commands/db-backup.ts). +Implementation: [CLI entry point](../../cli/src/index.ts), [instance settings](../../ui/src/pages/InstanceExperimentalSettings.tsx), [backup command](../../cli/src/commands/db-backup.ts). + +Agent access: [companion](../agent-access-map/instance-operations.md). ## Sub-features @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `operator-cli` -Automated: [doctor](../cli/src/__tests__/doctor.test.ts), [update command](../cli/src/__tests__/update-command.test.ts), and [service manager](../cli/src/__tests__/service-manager.test.ts) cover command contracts. +Automated: [doctor](../../cli/src/__tests__/doctor.test.ts), [update command](../../cli/src/__tests__/update-command.test.ts), and [service manager](../../cli/src/__tests__/service-manager.test.ts) cover command contracts. Manual: Inspect the selected installation/channel and configuration, start the isolated instance, confirm health and browser access, then stop that instance. For update changes, use the dry-run/check path first and verify installed version and rollback state on an owned disposable installation. ### `settings-backup` -Automated: [experimental settings](../ui/src/pages/InstanceExperimentalSettings.test.tsx) and [database backup routes](../server/src/__tests__/instance-database-backups-routes.test.ts) cover their UI/API contracts. +Automated: [experimental settings](../../ui/src/pages/InstanceExperimentalSettings.test.tsx) and [database backup routes](../../server/src/__tests__/instance-database-backups-routes.test.ts) cover their UI/API contracts. Manual: Change a harmless experimental setting, reload, and confirm the affected route honors it. Back up a disposable instance, inspect the reported file and metadata, and restore using the documented procedure into a second isolated target. Verify a known record after restoration. diff --git a/feature-map/navigation-preferences.md b/maps/feature-map/navigation-preferences.md similarity index 77% rename from feature-map/navigation-preferences.md rename to maps/feature-map/navigation-preferences.md index 7d957567dbc..bb2490483c4 100644 --- a/feature-map/navigation-preferences.md +++ b/maps/feature-map/navigation-preferences.md @@ -2,7 +2,9 @@ People organize their sidebar, move between company resources, maintain their profile, and dismiss product announcements with the correct persistence and identity scope. -Implementation: [sidebar](../ui/src/components/Sidebar.tsx), [profile](../ui/src/pages/ProfileSettings.tsx), [announcements](../ui/src/components/AnnouncementWell.tsx). +Implementation: [sidebar](../../ui/src/components/Sidebar.tsx), [profile](../../ui/src/pages/ProfileSettings.tsx), [announcements](../../ui/src/components/AnnouncementWell.tsx). + +Agent access: [companion](../agent-access-map/navigation-preferences.md). ## Sub-features @@ -28,7 +30,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `sidebar-deep-links` -Automated: [sidebar](../ui/src/components/Sidebar.test.tsx) and [sidebar preference routes](../server/src/__tests__/sidebar-preferences-routes.test.ts) cover UI/server behavior. +Automated: [sidebar](../../ui/src/components/Sidebar.test.tsx) and [sidebar preference routes](../../server/src/__tests__/sidebar-preferences-routes.test.ts) cover UI/server behavior. Manual: Star and reorder supported resources, collapse a section, navigate and reload. Switch company/user and inspect the intended preference scope. Open an unavailable resource and a legacy route; verify clear fallback or correct redirect without leaking another company’s state. @@ -39,7 +41,7 @@ ordering are saved server-side. Local-trusted mode does not prove two-user isola ### `profile-announcements` -Automated: [profile page](../ui/src/pages/ProfileSettings.test.tsx), [announcement hook](../ui/src/hooks/useAnnouncement.test.tsx), and [announcement routes](../server/src/__tests__/announcements-routes.test.ts) cover separate UI/persistence layers. +Automated: [profile page](../../ui/src/pages/ProfileSettings.test.tsx), [announcement hook](../../ui/src/hooks/useAnnouncement.test.tsx), and [announcement routes](../../server/src/__tests__/announcements-routes.test.ts) cover separate UI/persistence layers. Manual: Change a disposable user’s display details, reload, and inspect an attributed profile link. Dismiss a fixture announcement, switch companies, and verify it remains dismissed for that user; check another user still has their own dismissal state. diff --git a/feature-map/onboarding.md b/maps/feature-map/onboarding.md similarity index 79% rename from feature-map/onboarding.md rename to maps/feature-map/onboarding.md index a49cbb0f01b..ecfaeefaaef 100644 --- a/feature-map/onboarding.md +++ b/maps/feature-map/onboarding.md @@ -2,7 +2,9 @@ An operator can start an instance, create a company, configure its first agent, and reach a real first task. Instance setup and company onboarding are separate steps. -Implementation: [web wizard](../ui/src/components/OnboardingWizard.tsx), [CLI setup](../cli/src/commands/onboard.ts). +Implementation: [web wizard](../../ui/src/components/OnboardingWizard.tsx), [CLI setup](../../cli/src/commands/onboard.ts). + +Agent access: [companion](../agent-access-map/onboarding.md). ## Sub-features @@ -31,10 +33,10 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `web-wizard` -Automated: [wizard tests](../ui/src/components/OnboardingWizard.test.tsx) cover mocked wizard behavior; [onboarding browser suite](../tests/e2e/onboarding.spec.ts) supplies its own test environment. Live model execution is a separate check. +Automated: [wizard tests](../../ui/src/components/OnboardingWizard.test.tsx) cover mocked wizard behavior; [onboarding browser suite](../../tests/e2e/onboarding.spec.ts) supplies its own test environment. Live model execution is a separate check. - **Create company and resume.** Start a fresh empty instance and run doctor using - the [verification skill](../.agents/skills/verify-paperclip/SKILL.md). Run + the [verification skill](../../.agents/skills/verify-paperclip/SKILL.md). Run `node .agents/skills/verify-paperclip/scripts/onboarding-smoke.mjs --evidence-dir "$VERIFY_EVIDENCE_DIR"`. The helper opens `/onboarding` in a fresh self-hosted instance, enters a unique name in `e.g. Northwind Labs`, and chooses `Continue`. The agent-name field must @@ -50,7 +52,7 @@ or explicit authentication gate. Reload both company and task. ### `cli-setup` -Automated: [CLI onboarding](../cli/src/__tests__/onboard.test.ts) and [isolated test drive](../cli/src/__tests__/test-drive.test.ts) cover their setup contracts; they do not establish that every model account is usable. +Automated: [CLI onboarding](../../cli/src/__tests__/onboard.test.ts) and [isolated test drive](../../cli/src/__tests__/test-drive.test.ts) cover their setup contracts; they do not establish that every model account is usable. Manual: Run setup in the disposable directory, open the URL it prints, and confirm the instance identity and company. Restart using that same configuration and verify saved state. Exercise an unavailable adapter and confirm an actionable error. diff --git a/feature-map/pipelines.md b/maps/feature-map/pipelines.md similarity index 77% rename from feature-map/pipelines.md rename to maps/feature-map/pipelines.md index efd59e34048..a8f7780796a 100644 --- a/feature-map/pipelines.md +++ b/maps/feature-map/pipelines.md @@ -2,7 +2,9 @@ With the pipeline experiment enabled, people move structured work through configured stages, inspect stage automation, review outputs, and retain learning records. -Implementation: [pipeline board and item hosts](../ui/src/pages/Pipelines.tsx), [pipeline configuration](../ui/src/pages/PipelineSettings.tsx). +Implementation: [pipeline board and item hosts](../../ui/src/pages/Pipelines.tsx), [pipeline configuration](../../ui/src/pages/PipelineSettings.tsx). + +Agent access: [companion](../agent-access-map/pipelines.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `pipeline-board` -Automated: [pipeline UI helpers](../ui/src/pages/Pipelines.test.tsx) cover grouping and presentation helpers; [pipeline service](../server/src/__tests__/pipelines-service.test.ts) covers service behavior. +Automated: [pipeline UI helpers](../../ui/src/pages/Pipelines.test.tsx) cover grouping and presentation helpers; [pipeline service](../../server/src/__tests__/pipelines-service.test.ts) covers service behavior. Manual: Configure stages and a harmless automation, add an item, and inspect its stage. Follow its task/work references and observe the resulting transition. Reload grouping and settings; verify rejected or failed transitions leave the item in an intelligible state. ### `item-review-learning` -Automated: [pipeline tutorial browser suite](../tests/e2e/pipelines-tutorial-flow.spec.ts) targets a fixture journey; it does not qualify arbitrary stage automation or learning quality. +Automated: [pipeline tutorial browser suite](../../tests/e2e/pipelines-tutorial-flow.spec.ts) targets a fixture journey; it does not qualify arbitrary stage automation or learning quality. Manual: Open a pending review, inspect its source output and revision, accept or reject, and verify the persisted stage outcome. Follow a learning record to its source context and confirm it belongs to the intended pipeline/company. diff --git a/feature-map/plugins.md b/maps/feature-map/plugins.md similarity index 78% rename from feature-map/plugins.md rename to maps/feature-map/plugins.md index 4c6788cdd7b..839f2a5e26e 100644 --- a/feature-map/plugins.md +++ b/maps/feature-map/plugins.md @@ -2,7 +2,9 @@ Operators install and configure plugins, inspect their status and errors, and use their contributed pages, tools, and managed resources within the plugin’s granted capabilities. -Implementation: [plugin manager](../ui/src/pages/PluginManager.tsx), [plugin settings](../ui/src/pages/PluginSettings.tsx), [plugin page](../ui/src/pages/PluginPage.tsx). +Implementation: [plugin manager](../../ui/src/pages/PluginManager.tsx), [plugin settings](../../ui/src/pages/PluginSettings.tsx), [plugin page](../../ui/src/pages/PluginPage.tsx). + +Agent access: [companion](../agent-access-map/plugins.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `plugin-administration` -Automated: [install authorization](../server/src/__tests__/plugin-install-route-security.test.ts) and [plugin settings](../ui/src/pages/PluginSettings.test.tsx) cover route/UI contracts. +Automated: [install authorization](../../server/src/__tests__/plugin-install-route-security.test.ts) and [plugin settings](../../ui/src/pages/PluginSettings.test.tsx) cover route/UI contracts. Manual: Install the fixture, supply its configuration, activate it, and inspect status. Save an invalid configuration and verify a useful error. Disable/remove the disposable plugin and check its tools and managed resources no longer behave as active. ### `plugin-contribution` -Automated: [static plugin UI](../server/src/__tests__/plugin-ui-static.test.ts) and [company settings contribution](../ui/src/pages/CompanySettingsPluginPage.test.tsx) cover serving/host behavior. +Automated: [static plugin UI](../../server/src/__tests__/plugin-ui-static.test.ts) and [company settings contribution](../../ui/src/pages/CompanySettingsPluginPage.test.tsx) cover serving/host behavior. Manual: Open the contributed page by navigation and cold deep link, perform a harmless plugin action, and inspect its real outcome. Switch company and test an actor without the capability. Reload with the plugin unavailable and verify the host reports that state. diff --git a/feature-map/projects.md b/maps/feature-map/projects.md similarity index 80% rename from feature-map/projects.md rename to maps/feature-map/projects.md index ff2cb3c2539..0ed3470073f 100644 --- a/feature-map/projects.md +++ b/maps/feature-map/projects.md @@ -2,7 +2,9 @@ People group tasks into projects, attach goals and repositories, and configure the context and defaults used by project work. -Implementation: [project list](../ui/src/pages/Projects.tsx), [project detail](../ui/src/pages/ProjectDetail.tsx). +Implementation: [project list](../../ui/src/pages/Projects.tsx), [project detail](../../ui/src/pages/ProjectDetail.tsx). + +Agent access: [companion](../agent-access-map/projects.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `project-list` -Automated: [projects UI](../ui/src/pages/Projects.test.tsx) and [project detail](../ui/src/pages/ProjectDetail.test.tsx) are component tests. +Automated: [projects UI](../../ui/src/pages/Projects.test.tsx) and [project detail](../../ui/src/pages/ProjectDetail.test.tsx) are component tests. Manual: Create a project, add a task from its view, and reload. Verify the task belongs to that project, list counts are plausible, and links preserve company scope. Archive only the disposable project and inspect list/task behavior. ### `repository-settings` -Automated: [repository persistence](../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../tests/e2e/project-repositories.spec.ts) cover configured repository workflows. +Automated: [repository persistence](../../server/src/__tests__/project-repositories-persistence.test.ts) and [repository browser suite](../../tests/e2e/project-repositories.spec.ts) cover configured repository workflows. Manual: Attach a small repository, save, and create a project task. Inspect the actual checkout/revision used by its run. Test an invalid repository and missing credentials without treating a saved URL as a working workspace. diff --git a/feature-map/questions-and-approvals.md b/maps/feature-map/questions-and-approvals.md similarity index 84% rename from feature-map/questions-and-approvals.md rename to maps/feature-map/questions-and-approvals.md index 32b611d3211..4ecf303f2aa 100644 --- a/feature-map/questions-and-approvals.md +++ b/maps/feature-map/questions-and-approvals.md @@ -5,6 +5,8 @@ history. A question, plan confirmation, governance approval, and app-tool review can look similar but have different authority and continuation rules. Each decision must stay attached to its original request and survive reload. +Agent access: [companion](../agent-access-map/questions-and-approvals.md). + ## Sub-features - `questions`: single-choice, multiple-choice, custom, and free-text answers; @@ -78,11 +80,11 @@ a component fixture alone cannot prove live response delivery. ### `task-thread` -Automated: [interaction cards](../ui/src/components/IssueThreadInteractionCard.test.tsx), -[paged task cards](../ui/src/components/task-chat/TaskChatInteractionCard.test.tsx), -and [task thread](../ui/src/components/TaskChatThread.test.tsx) cover form, -receipt, and dismissal behavior. [Resolution routes](../server/src/__tests__/issue-thread-interaction-routes.test.ts) -and [response delivery](../server/src/__tests__/question-response-delivery.test.ts) +Automated: [interaction cards](../../ui/src/components/IssueThreadInteractionCard.test.tsx), +[paged task cards](../../ui/src/components/task-chat/TaskChatInteractionCard.test.tsx), +and [task thread](../../ui/src/components/TaskChatThread.test.tsx) cover form, +receipt, and dismissal behavior. [Resolution routes](../../server/src/__tests__/issue-thread-interaction-routes.test.ts) +and [response delivery](../../server/src/__tests__/question-response-delivery.test.ts) cover authority and durable continuation. Run a targeted set with: ```sh @@ -99,7 +101,7 @@ before trying the old approval, then review the current revision. ### `agent-chat` -Automated: the [task thread suite](../ui/src/components/TaskChatThread.test.tsx) +Automated: the [task thread suite](../../ui/src/components/TaskChatThread.test.tsx) parameterizes unanswered-question history for conversation mode on and off. This proves component parity, not a model's plan handoff. @@ -109,7 +111,7 @@ history remains and the conversation does not close as a completed task. ### `attention` -Automated: [attention resolver](../ui/src/components/AttentionInteractionResolver.test.tsx) +Automated: [attention resolver](../../ui/src/components/AttentionInteractionResolver.test.tsx) covers preparing-request refresh without accepting it. The resolution-route tests above cover the API, not every Decisions or Inbox navigation path. @@ -121,8 +123,8 @@ manual coverage gap. ### `board-approval` -Automated: [approval service](../server/src/__tests__/approvals-service.test.ts) -and [idempotency routes](../server/src/__tests__/approval-routes-idempotency.test.ts) +Automated: [approval service](../../server/src/__tests__/approvals-service.test.ts) +and [idempotency routes](../../server/src/__tests__/approval-routes-idempotency.test.ts) cover persistence and duplicate decisions. They do not drive the approval pages. Manual: create disposable formal approval requests; approve one from the list, @@ -132,7 +134,7 @@ the recorded decision and its governed effect, not just a success toast. ### `app-review` -Automated: [connection reviews](../tests/e2e/connection-reviews.spec.ts) drives +Automated: [connection reviews](../../tests/e2e/connection-reviews.spec.ts) drives task/queue synchronization, approve, decline, remembered permission, provider failure, and restart with a scripted process agent and local MCP provider: @@ -144,11 +146,11 @@ Manual: dismiss and reopen a review, decide it from each entry point using fresh requests, and inspect the continuation's useful result. Always allow must show its scope; a future action with changed arguments should obey the saved rule. Provider failure must remain an approved decision with a failed execution. -Real provider and model checks follow [Task reviews](../doc/connections/TASK-REVIEWS.md#verification-workflows). +Real provider and model checks follow [Task reviews](../../doc/connections/TASK-REVIEWS.md#verification-workflows). ### `skill-test` -Automated: [read-only interaction contract](../server/src/__tests__/issue-thread-interaction-routes.test.ts) +Automated: [read-only interaction contract](../../server/src/__tests__/issue-thread-interaction-routes.test.ts) checks that fetching interactions does not mutate them. Shared card suites cover rendering. Neither proves the complete Skill Studio host or its permission matrix. @@ -159,9 +161,9 @@ referenced unit tests. ### `external-channel` -Automated: [chat question forms](../server/src/services/chat-question-forms.test.ts), -[Discord forms](../server/src/services/chat-discord-question-forms.test.ts), -and [interaction publications](../server/src/__tests__/chat-interaction-publications.test.ts) +Automated: [chat question forms](../../server/src/services/chat-question-forms.test.ts), +[Discord forms](../../server/src/services/chat-discord-question-forms.test.ts), +and [interaction publications](../../server/src/__tests__/chat-interaction-publications.test.ts) cover serialization and delivery logic with fixtures. Manual: use a disposable provider conversation, answer as the linked sender, diff --git a/feature-map/recovery.md b/maps/feature-map/recovery.md similarity index 87% rename from feature-map/recovery.md rename to maps/feature-map/recovery.md index 3e8a8564678..0236b1e4dec 100644 --- a/feature-map/recovery.md +++ b/maps/feature-map/recovery.md @@ -5,6 +5,8 @@ continuation is available. Recovery retains task context and ownership, honors pause and budget gates, and distinguishes confirmed failure from an uncertain external outcome. A retry acknowledgement alone is not proof that work resumed. +Agent access: [companion](../agent-access-map/recovery.md). + ## Sub-features - `diagnosis`: a task/run exposes the recorded cause, evidence, owner, attempt @@ -68,12 +70,12 @@ to get a failure screen. Use the existing deterministic recovery suite first. ### `source-task` -Automated: [recovery actions](../server/src/__tests__/issue-recovery-actions.test.ts) +Automated: [recovery actions](../../server/src/__tests__/issue-recovery-actions.test.ts) covers source scoping, bounded attempts, operator stops, quota monitoring, ownership, stale action retirement, and continuation deduplication. -[Recovery cards](../ui/src/components/IssueRecoveryActionCard.test.tsx) cover +[Recovery cards](../../ui/src/components/IssueRecoveryActionCard.test.tsx) cover diagnosis, retry progress, exhaustion, and action availability. The fixture-backed -[execution-recovery journeys](../tests/e2e/execution-recovery/recovery.spec.ts) +[execution-recovery journeys](../../tests/e2e/execution-recovery/recovery.spec.ts) start their own test-drive instances and exercise safe, uncertain, restart, manager-lineage, and legacy-unknown paths: @@ -90,7 +92,7 @@ history remains. A fixture pass does not qualify every live harness/provider. ### `run-detail` -Automated: [run workspace recovery](../ui/src/components/RunWorkspaceRecoverySurface.test.tsx) +Automated: [run workspace recovery](../../ui/src/components/RunWorkspaceRecoverySurface.test.tsx) tests this host; it is component coverage, not an end-to-end repair. Manual: prepare a failed run with error code `workspace_validation_failed`, a @@ -105,10 +107,10 @@ gap. ### `operator-retry` -Automated: [disposition notices](../ui/src/components/DispositionRecoveryNotice.test.tsx) +Automated: [disposition notices](../../ui/src/components/DispositionRecoveryNotice.test.tsx) tests pending/error/gated states and waiting for the real retry acknowledgement. The recovery-card suite covers workspace divergence, reissue, reconcile, and -confirmation. [Workspace export](../ui/src/components/WorkspaceExportRecovery.test.tsx) +confirmation. [Workspace export](../../ui/src/components/WorkspaceExportRecovery.test.tsx) covers its separate repair surface. Manual: reproduce an exhausted disposable action. Click once, verify pending @@ -120,10 +122,10 @@ action paths. A changed card label is insufficient proof of workspace integrity. ### `post-stop-message` -Automated: [legacy continuation](../server/src/services/recovery/legacy-continuation.test.ts), -[queue routes](../server/src/__tests__/issue-queued-comments-routes.test.ts), and -[native restart recovery](../server/src/services/native-runtime/native-restart-recovery.test.ts) -cover their respective recovery paths. [Legacy failure continuation](../tests/e2e/legacy-failure-continuation.spec.ts) +Automated: [legacy continuation](../../server/src/services/recovery/legacy-continuation.test.ts), +[queue routes](../../server/src/__tests__/issue-queued-comments-routes.test.ts), and +[native restart recovery](../../server/src/services/native-runtime/native-restart-recovery.test.ts) +cover their respective recovery paths. [Legacy failure continuation](../../tests/e2e/legacy-failure-continuation.spec.ts) adds a browser journey; it is not native runtime proof. Manual: stop a disposable run, submit a distinguishable follow-up, and observe @@ -134,7 +136,7 @@ outcomes, inspect the provider before authorizing another operation. ### `browser-reconnect` -Automated: [live-update recovery](../ui/src/context/LiveUpdatesProvider.recovery.test.tsx) +Automated: [live-update recovery](../../ui/src/context/LiveUpdatesProvider.recovery.test.tsx) covers client transport recovery with mocks. It does not prove runner restart. Manual: go offline during a disposable run, let the server reach a new state, @@ -155,5 +157,5 @@ only. Separately inspect the underlying execution if it is still stuck. silently trigger recovery that fights the user's instruction. - Source inspection and mocked component tests do not qualify real server restart continuity. Use the appropriate runtime suite for that claim. -- See [execution semantics](../doc/execution-semantics.md) for the governing +- See [execution semantics](../../doc/execution-semantics.md) for the governing recovery contract and [steering](./steering.md) for saved follow-ups. diff --git a/feature-map/routines.md b/maps/feature-map/routines.md similarity index 73% rename from feature-map/routines.md rename to maps/feature-map/routines.md index 99e437da265..621fd21bca6 100644 --- a/feature-map/routines.md +++ b/maps/feature-map/routines.md @@ -2,7 +2,9 @@ Operators define repeatable work, configure its inputs and triggers, run it on demand or automatically, and inspect the execution history and resulting tasks. -Implementation: [routine list](../ui/src/pages/Routines.tsx), [routine detail](../ui/src/pages/RoutineDetail.tsx), [trigger editor](../ui/src/components/routine-triggers/TriggerWizard.tsx). +Implementation: [routine list](../../ui/src/pages/Routines.tsx), [routine detail](../../ui/src/pages/RoutineDetail.tsx), [trigger editor](../../ui/src/components/routine-triggers/TriggerWizard.tsx). + +Agent access: [companion](../agent-access-map/routines.md). ## Sub-features @@ -29,7 +31,7 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `routine-editor` -Automated: [routine detail helper](../ui/src/pages/RoutineDetail.test.tsx) tests project-selector options, not the rendered editor. [Routine service](../server/src/__tests__/routines-service.test.ts) covers server contracts. Saving and running through the editor remain manual checks. +Automated: [routine detail helper](../../ui/src/pages/RoutineDetail.test.tsx) tests project-selector options, not the rendered editor. [Routine service](../../server/src/__tests__/routines-service.test.ts) covers server contracts. Saving and running through the editor remain manual checks. Manual: Save an agent, request, project, and a required variable. Reload and inspect the saved definition. Run now with a distinguishable input, follow the resulting task, and verify its output and history entry. Check a missing required input. @@ -40,7 +42,7 @@ complete the generated task or establish a useful task output. ### `trigger-history` -Automated: [trigger wizard](../ui/src/components/routine-triggers/TriggerWizard.test.tsx) is component coverage; [routine integration](../server/src/__tests__/routines-e2e.test.ts) is server integration rather than a provider-wide browser proof. +Automated: [trigger wizard](../../ui/src/components/routine-triggers/TriggerWizard.test.tsx) is component coverage; [routine integration](../../server/src/__tests__/routines-e2e.test.ts) is server integration rather than a provider-wide browser proof. Manual: Set a near-term disposable schedule or authorized test webhook, verify one eligible execution and its source trigger, then disable it and confirm further invocations stop. Inspect a failed execution without losing its history. Remove the temporary trigger after testing. diff --git a/feature-map/runs-adapters.md b/maps/feature-map/runs-adapters.md similarity index 76% rename from feature-map/runs-adapters.md rename to maps/feature-map/runs-adapters.md index 93651b8fd89..bec86d34f2c 100644 --- a/feature-map/runs-adapters.md +++ b/maps/feature-map/runs-adapters.md @@ -2,7 +2,9 @@ Operators choose an agent harness and model account, inspect execution history and transcripts, and distinguish a working configuration from a successful task result. -Implementation: [adapter manager](../ui/src/pages/AdapterManager.tsx), [run host](../ui/src/pages/AgentDetail.tsx), [configuration](../ui/src/components/AgentConfigForm.tsx). +Implementation: [adapter manager](../../ui/src/pages/AdapterManager.tsx), [run host](../../ui/src/pages/AgentDetail.tsx), [configuration](../../ui/src/components/AgentConfigForm.tsx). + +Agent access: [companion](../agent-access-map/runs-adapters.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Reco ### `adapter-setup` -Automated: [adapter validation routes](../server/src/__tests__/agent-adapter-validation-routes.test.ts) and [model refresh](../server/src/__tests__/adapter-model-refresh-routes.test.ts) cover server behavior. +Automated: [adapter validation routes](../../server/src/__tests__/agent-adapter-validation-routes.test.ts) and [model refresh](../../server/src/__tests__/adapter-model-refresh-routes.test.ts) cover server behavior. Manual: Select the intended adapter and account, refresh model choices, and run its environment test. Save, reload, then execute a small task. Repeat with a deliberately unavailable account and verify the failure remains visible. ### `run-history` -Automated: [live run routes](../server/src/__tests__/agent-live-run-routes.test.ts) and [run event sequencing](../server/src/__tests__/heartbeat-run-event-sequencing.test.ts) cover run data contracts. +Automated: [live run routes](../../server/src/__tests__/agent-live-run-routes.test.ts) and [run event sequencing](../../server/src/__tests__/heartbeat-run-event-sequencing.test.ts) cover run data contracts. Manual: Start a disposable task, open its running transcript, and follow it to a terminal outcome. Reload and compare task/run IDs, status, output, and cost. Verify a failed run remains distinguishable from a completed run with useful output. diff --git a/feature-map/secrets.md b/maps/feature-map/secrets.md similarity index 75% rename from feature-map/secrets.md rename to maps/feature-map/secrets.md index d9d6d42ff26..34082ab4ba9 100644 --- a/feature-map/secrets.md +++ b/maps/feature-map/secrets.md @@ -2,7 +2,9 @@ Operators manage company secret references, users supply their own credentials where required, and reviewers decide proposed secret changes without exposing secret values in ordinary UI evidence. -Implementation: [secrets page](../ui/src/pages/Secrets.tsx), [personal secrets](../ui/src/pages/secrets/MyUserSecretsTab.tsx), [proposals](../ui/src/pages/secrets/ProposalsTab.tsx). +Implementation: [secrets page](../../ui/src/pages/Secrets.tsx), [personal secrets](../../ui/src/pages/secrets/MyUserSecretsTab.tsx), [proposals](../../ui/src/pages/secrets/ProposalsTab.tsx). + +Agent access: [companion](../agent-access-map/secrets.md). ## Sub-features @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `secret-management` -Automated: [secrets service](../server/src/__tests__/secrets-service.test.ts) and [user ownership](../server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts) cover storage/access contracts. +Automated: [secrets service](../../server/src/__tests__/secrets-service.test.ts) and [user ownership](../../server/src/__tests__/secrets-service-user-secret-owner-scoped.test.ts) cover storage/access contracts. Manual: Create a dummy secret reference, bind it through a supported field, and verify its use in a harmless run without logging the value. Update it and reload. Switch users and confirm personal values remain owner-scoped and missing credentials produce an actionable state. ### `proposal-import` -Automated: [proposal routes](../server/src/__tests__/secret-proposals-routes.test.ts) and [vault import dialog](../ui/src/pages/secrets/ImportFromVaultDialog.test.tsx) cover route/UI layers; a real vault integration needs live validation. +Automated: [proposal routes](../../server/src/__tests__/secret-proposals-routes.test.ts) and [vault import dialog](../../ui/src/pages/secrets/ImportFromVaultDialog.test.tsx) cover route/UI layers; a real vault integration needs live validation. Manual: Review a dummy proposal, reject one and accept another, then inspect the resulting reference state and history. Preview a vault import, select only disposable entries, and verify imported references and denied-access errors after reload. diff --git a/feature-map/skills.md b/maps/feature-map/skills.md similarity index 76% rename from feature-map/skills.md rename to maps/feature-map/skills.md index ed289935fef..4085f690090 100644 --- a/feature-map/skills.md +++ b/maps/feature-map/skills.md @@ -2,7 +2,9 @@ Operators discover or import skills, author and test them in Skill Studio, manage source versions, and decide which agents may use them. -Implementation: [skill library](../ui/src/pages/CompanySkills.tsx), [studio](../ui/src/pages/SkillStudio.tsx), [sources](../ui/src/pages/SkillSources.tsx), [agent skills](../ui/src/pages/agent-skills/AgentSkillsTab.tsx). +Implementation: [skill library](../../ui/src/pages/CompanySkills.tsx), [studio](../../ui/src/pages/SkillStudio.tsx), [sources](../../ui/src/pages/SkillSources.tsx), [agent skills](../../ui/src/pages/agent-skills/AgentSkillsTab.tsx). + +Agent access: [companion](../agent-access-map/skills.md). ## Sub-features @@ -35,19 +37,19 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `library-sources` -Automated: [library UI](../ui/src/pages/CompanySkills.test.tsx) and [source service](../server/src/__tests__/skill-sources.test.ts) cover their respective layers. +Automated: [library UI](../../ui/src/pages/CompanySkills.test.tsx) and [source service](../../server/src/__tests__/skill-sources.test.ts) cover their respective layers. Manual: Install a fixture, locate it in Installed, inspect its source and files, and reload. Import a source update and compare versions before accepting it. Check a source requiring unavailable repository access and verify the failure is explicit. ### `studio` -Automated: [Studio](../ui/src/pages/SkillStudio.test.tsx) covers UI behavior; model-driven skill testing remains a separate manual result. +Automated: [Studio](../../ui/src/pages/SkillStudio.test.tsx) covers UI behavior; model-driven skill testing remains a separate manual result. Manual: Create or fork a disposable skill, edit instructions, save, and reload. Run a bounded test, respond to any question, and inspect the output. Verify the saved version matches what was tested and failed saves retain the draft. ### `agent-enablement` -Automated: [policy service](../server/src/__tests__/company-skill-policy-service.test.ts) and [agent skills state](../ui/src/pages/agent-skills/AgentSkillsTab.test.ts) cover policy/state contracts. +Automated: [policy service](../../server/src/__tests__/company-skill-policy-service.test.ts) and [agent skills state](../../ui/src/pages/agent-skills/AgentSkillsTab.test.ts) cover policy/state contracts. Manual: Enable the skill for one test agent and leave another without access. Run a small task on each, inspect the available skill context, then change the release/policy and verify the next eligible run uses the intended version. diff --git a/feature-map/status-cards.md b/maps/feature-map/status-cards.md similarity index 74% rename from feature-map/status-cards.md rename to maps/feature-map/status-cards.md index ec0b2b240c4..c91082da6b6 100644 --- a/feature-map/status-cards.md +++ b/maps/feature-map/status-cards.md @@ -2,7 +2,9 @@ The status-card experiment turns a saved prompt/query into an inspectable summary of watched work, with refresh history, configuration, and archive/restore controls. -Implementation: [status page](../ui/src/pages/StatusCards/index.tsx), [card detail](../ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx). +Implementation: [status page](../../ui/src/pages/StatusCards/index.tsx), [card detail](../../ui/src/pages/StatusCards/StatusCardDetailDrawer.tsx). + +Agent access: [companion](../agent-access-map/status-cards.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `status-board` -Automated: [status-card service/routes](../server/src/__tests__/status-cards.test.ts) covers gated lifecycle and generation contracts; [tile UI](../ui/src/pages/StatusCards/StatusCardTile.test.tsx) covers rendering. +Automated: [status-card service/routes](../../server/src/__tests__/status-cards.test.ts) covers gated lifecycle and generation contracts; [tile UI](../../ui/src/pages/StatusCards/StatusCardTile.test.tsx) covers rendering. Manual: Create a bounded card, inspect its generation task, and wait for a saved summary. Change a watched task, refresh, and verify both the summary and update history reflect the source change. Exercise a failed generation and confirm it is not presented as fresh. ### `card-settings` -Automated: [settings form](../ui/src/pages/StatusCards/StatusCardSettingsForm.test.tsx) and [update engine](../server/src/__tests__/status-card-update-engine.test.ts) cover separate UI/engine layers. +Automated: [settings form](../../ui/src/pages/StatusCards/StatusCardSettingsForm.test.tsx) and [update engine](../../server/src/__tests__/status-card-update-engine.test.ts) cover separate UI/engine layers. Manual: Change a disposable card’s prompt or summarizer, run the offered rebuild/refresh, and inspect the resulting query and summary. Archive and restore it, reload its direct link, and check the actual new generation outcome. diff --git a/feature-map/steering.md b/maps/feature-map/steering.md similarity index 87% rename from feature-map/steering.md rename to maps/feature-map/steering.md index 02d1cfa91bc..8366f9614d9 100644 --- a/feature-map/steering.md +++ b/maps/feature-map/steering.md @@ -5,6 +5,8 @@ not been consumed, and choose when to steer or interrupt. Stopping a run and pausing a task are separate controls. The UI must preserve the message, explain why it is waiting, and only claim delivery after an authoritative acknowledgement. +Agent access: [companion](../agent-access-map/steering.md). + ## Sub-features - `queue`: a busy task saves follow-ups and shows their order and wait reason. @@ -57,11 +59,11 @@ unsupported steering without treating an absent capability as a product failure. ### `task-composer` -Automated: [queued-message UI](../ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx) +Automated: [queued-message UI](../../ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx) covers order, failure restoration, unavailable steering, stale revisions, and -discard acknowledgements. [Queue routes](../server/src/__tests__/issue-queued-comments-routes.test.ts) +discard acknowledgements. [Queue routes](../../server/src/__tests__/issue-queued-comments-routes.test.ts) cover durable ordering, authorization, steering acknowledgements, interrupt -recovery, and concurrent dispatch. [Composer](../ui/src/components/task-chat/TaskChatComposer.test.tsx) +recovery, and concurrent dispatch. [Composer](../../ui/src/components/task-chat/TaskChatComposer.test.tsx) covers drafts and uncertain saves. ```sh @@ -69,9 +71,9 @@ pnpm exec vitest run ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx ``` Automated stop coverage is separate: -[composer stop](../tests/e2e/composer-stop.spec.ts) with -[its configuration](../tests/e2e/playwright-composer-stop.config.ts) and -[ACP stop/continuation](../tests/e2e/acp-stop-continuation.spec.ts). Inspect their +[composer stop](../../tests/e2e/composer-stop.spec.ts) with +[its configuration](../../tests/e2e/playwright-composer-stop.config.ts) and +[ACP stop/continuation](../../tests/e2e/acp-stop-continuation.spec.ts). Inspect their fixture and opt-in prerequisites; skipped native cases are not native proof. Manual: queue three distinguishable messages. Edit one, reorder them, reload, @@ -86,7 +88,7 @@ when available; the named component tests do not prove every host integration. ### `agent-chat` -Automated: [agent chat sessions](../tests/e2e/agent-chat-sessions.spec.ts) covers +Automated: [agent chat sessions](../../tests/e2e/agent-chat-sessions.spec.ts) covers persistent conversation/session behavior with deterministic harness fixtures; shared composer tests cover the controls. These do not qualify live steering for every harness. @@ -100,7 +102,7 @@ when the change touches delivery. ### `request-response` -Automated: [response delivery](../server/src/__tests__/question-response-delivery.test.ts) +Automated: [response delivery](../../server/src/__tests__/question-response-delivery.test.ts) proves queueing/coalescing and retry-safe receipts. The queue-route suite tests separate comment and approval queues, explicit steering, and fresh-session restrictions on plan approvals. @@ -113,7 +115,7 @@ approval outcome through an ordinary queued-message editor. ### `external-channel` -Automated: [chat interaction arbitration](../server/src/services/chat-interaction-arbitration.test.ts) +Automated: [chat interaction arbitration](../../server/src/services/chat-interaction-arbitration.test.ts) covers backend arbitration with simulated requests. It is not a provider UI test. Manual: send two distinguishable messages through one disposable linked diff --git a/feature-map/task-coordination.md b/maps/feature-map/task-coordination.md similarity index 75% rename from feature-map/task-coordination.md rename to maps/feature-map/task-coordination.md index 609c86dc9f8..aff32dc01d1 100644 --- a/feature-map/task-coordination.md +++ b/maps/feature-map/task-coordination.md @@ -2,7 +2,9 @@ A larger task can coordinate child work, dependencies, reviewers, and execution controls while preserving a clear owner and a traceable completion decision. -Implementation: [task thread](../ui/src/pages/IssueDetail.tsx), [execution policy](../server/src/services/issues.ts). +Implementation: [task thread](../../ui/src/pages/IssueDetail.tsx), [execution policy](../../server/src/services/issues.ts). + +Agent access: [companion](../agent-access-map/task-coordination.md). ## Sub-features @@ -31,13 +33,13 @@ creates a hold; it does not change task statuses to a paused status. ### `task-relationships` -Automated: [dependency wakeups](../server/src/__tests__/issue-dependency-wakeups-routes.test.ts) checks server behavior; visual relationship navigation remains a manual check. +Automated: [dependency wakeups](../../server/src/__tests__/issue-dependency-wakeups-routes.test.ts) checks server behavior; visual relationship navigation remains a manual check. Manual: Block one task on another, finish the prerequisite, and observe the dependent task’s eligible continuation. Follow parent/child links back to the source request. Verify no duplicate run or false parent completion occurs. ### `review-and-controls` -Automated: [execution policy](../server/src/__tests__/issue-execution-policy.test.ts), [tree control](../server/src/__tests__/issue-tree-control-service.test.ts), and [signoff browser suite](../tests/e2e/signoff-policy.spec.ts) target these layers. A linked suite is not a claim that its current run is green. +Automated: [execution policy](../../server/src/__tests__/issue-execution-policy.test.ts), [tree control](../../server/src/__tests__/issue-tree-control-service.test.ts), and [signoff browser suite](../../tests/e2e/signoff-policy.spec.ts) target these layers. A linked suite is not a claim that its current run is green. Manual: Complete implementation under a review-required policy and confirm it waits for the required review outcome. Exercise rejection and later approval. Pause the parent while child work is pending, then verify resumption respects budget and ownership gates. diff --git a/feature-map/tasks.md b/maps/feature-map/tasks.md similarity index 76% rename from feature-map/tasks.md rename to maps/feature-map/tasks.md index b32039dbdd6..19eb631af3c 100644 --- a/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -2,7 +2,9 @@ People create, assign, organize, discuss, and finish tasks. A task retains its owner, project, priority, history, and outputs as it moves through the work lifecycle. -Implementation: [new task](../ui/src/components/NewIssueDialog.tsx), [task list](../ui/src/pages/Issues.tsx), [task detail](../ui/src/pages/IssueDetail.tsx). +Implementation: [new task](../../ui/src/components/NewIssueDialog.tsx), [task list](../../ui/src/pages/Issues.tsx), [task detail](../../ui/src/pages/IssueDetail.tsx). + +Agent access: [companion](../agent-access-map/tasks.md). ## Sub-features @@ -35,7 +37,7 @@ whether a real or simulated harness handles execution. ### `new-task` -Automated: [creation dialog](../ui/src/components/NewIssueDialog.test.tsx) is component coverage with mocked requests. +Automated: [creation dialog](../../ui/src/components/NewIssueDialog.test.tsx) is component coverage with mocked requests. Manual: Create a uniquely named task with an agent and project. Open its resulting link and reload. Confirm text, attachment, assignment, and project persisted, and verify execution started only when the task and agent are eligible. @@ -45,13 +47,13 @@ Use a human/unassigned task to verify persistence without invoking a model. ### `task-list` -Automated: [task-list helpers](../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, deduplication, and presentation constants. The suite does not render the page or exercise filtering, grouping, opening a task, and returning; those interactions still need a running-browser check. +Automated: [task-list helpers](../../ui/src/pages/Issues.test.tsx) cover search URLs, pagination, deduplication, and presentation constants. The suite does not render the page or exercise filtering, grouping, opening a task, and returning; those interactions still need a running-browser check. Manual: Filter by status and assignee, change grouping/sort, open a task, and return. Verify the correct records and selected view survive navigation and reload. Include an empty result and a failed request. ### `task-detail` -Automated: [task detail](../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../server/src/__tests__/issues-service.test.ts) covers server contracts. +Automated: [task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts. Manual: Add a comment and edit authorized properties. Reload and verify history, actor, and values. Complete a disposable task and inspect its deliverable; reopen through an available action and confirm the next execution follows current policy. diff --git a/feature-map/teams.md b/maps/feature-map/teams.md similarity index 80% rename from feature-map/teams.md rename to maps/feature-map/teams.md index f5822ab4645..c3ecdecbd4a 100644 --- a/feature-map/teams.md +++ b/maps/feature-map/teams.md @@ -2,7 +2,9 @@ Operators can preview and install a team package with its agents and related resources. Catalog/package installation is available through the CLI/API; the repository also contains catalog UI components that are not a current top-level route. -Implementation: [CLI teams](../cli/src/commands/client/teams.ts), [catalog components](../ui/src/pages/TeamCatalog.tsx). +Implementation: [CLI teams](../../cli/src/commands/client/teams.ts), [catalog components](../../ui/src/pages/TeamCatalog.tsx). + +Agent access: [companion](../agent-access-map/teams.md). ## Sub-features @@ -27,13 +29,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Use ### `catalog-cli` -Automated: [team commands](../cli/src/__tests__/teams.test.ts) and [catalog service](../server/src/__tests__/teams-catalog-service.test.ts) cover command and service contracts. +Automated: [team commands](../../cli/src/__tests__/teams.test.ts) and [catalog service](../../server/src/__tests__/teams-catalog-service.test.ts) cover command and service contracts. Manual: List an entry, preview its proposed agents and resources, install it into the disposable company, and inspect each created resource. Run a small task after configuring accounts. Retry preview/install as supported and inspect duplicate/conflict behavior. ### `catalog-components` -Automated: [catalog component](../ui/src/pages/TeamCatalog.test.tsx) and [install hook](../ui/src/pages/useInstallTeamCatalogEntry.test.tsx) cover preview and install UI logic with mocks. +Automated: [catalog component](../../ui/src/pages/TeamCatalog.test.tsx) and [install hook](../../ui/src/pages/useInstallTeamCatalogEntry.test.tsx) cover preview and install UI logic with mocks. Manual: When changing or mounting these components, exercise preview, manager/source options, cancellation, failed install, and success. Follow created agents in the actual company. Record whether this was a component harness or a real product entry point. diff --git a/feature-map/workspaces.md b/maps/feature-map/workspaces.md similarity index 77% rename from feature-map/workspaces.md rename to maps/feature-map/workspaces.md index acf92476222..786f5f4d8ef 100644 --- a/feature-map/workspaces.md +++ b/maps/feature-map/workspaces.md @@ -2,7 +2,9 @@ A project or task can use a managed workspace with inspectable files, Git state, services, and runtime controls. Operators can follow its lifecycle through provisioning, use, and closure. -Implementation: [workspace list](../ui/src/pages/Workspaces.tsx), [execution workspace](../ui/src/pages/ExecutionWorkspaceDetail.tsx), [project workspace](../ui/src/pages/ProjectWorkspaceDetail.tsx). +Implementation: [workspace list](../../ui/src/pages/Workspaces.tsx), [execution workspace](../../ui/src/pages/ExecutionWorkspaceDetail.tsx), [project workspace](../../ui/src/pages/ProjectWorkspaceDetail.tsx). + +Agent access: [companion](../agent-access-map/workspaces.md). ## Sub-features @@ -28,13 +30,13 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab ### `task-workspace` -Automated: [workspace card](../ui/src/components/IssueWorkspaceCard.test.tsx) and [task binding](../server/src/__tests__/issue-runtime-workspace-binding.test.ts) cover host/binding contracts. +Automated: [workspace card](../../ui/src/components/IssueWorkspaceCard.test.tsx) and [task binding](../../server/src/__tests__/issue-runtime-workspace-binding.test.ts) cover host/binding contracts. Manual: Run a task that writes a small file, follow the bound workspace, and inspect the file and revision. Reload and verify links still refer to the same workspace, not another checkout or a stale run. ### `workspace-management` -Automated: [workspace detail](../ui/src/pages/ExecutionWorkspaceDetail.test.tsx) and [runtime service](../server/src/__tests__/workspace-runtime.test.ts) cover UI and runtime contracts separately. +Automated: [workspace detail](../../ui/src/pages/ExecutionWorkspaceDetail.test.tsx) and [runtime service](../../server/src/__tests__/workspace-runtime.test.ts) cover UI and runtime contracts separately. Manual: Provision a disposable workspace, wait for readiness, start an available service, open its endpoint, and inspect a real response and logs. Stop it and verify it actually stops. Exercise close/reopen only after inspecting pending changes and active runs. diff --git a/scripts/check-maps.mjs b/scripts/check-maps.mjs new file mode 100644 index 00000000000..9aef27c5d32 --- /dev/null +++ b/scripts/check-maps.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptPath = fileURLToPath(import.meta.url); +const defaultRoot = path.resolve(path.dirname(scriptPath), ".."); +const fields = ["API", "Agent access", "Policy", "Evidence", "Verification", "Gaps"]; +const values = { + Review: ["baseline", "operation-reviewed"], + API: ["present", "missing", "not-applicable", "unreviewed"], + "Agent access": ["available", "partial", "missing", "restricted", "operator-only", "not-applicable", "unreviewed"], + Evidence: ["source-only", "verified", "failed", "blocked", "not-run"], + Verification: ["passed", "failed", "blocked", "not-run"], +}; + +function withoutExamples(text) { + return text.replace(/^(`{3,}|~{3,})[^\n]*\n[\s\S]*?^\1\s*$/gm, ""); +} + +function sections(text, level) { + const pattern = new RegExp("^" + "#".repeat(level) + " `([^`]+)`[^\\n]*$", "gm"); + const matches = [...text.matchAll(pattern)]; + return matches.map((match, index) => ({ + id: match[1], + body: text.slice(match.index + match[0].length, matches[index + 1]?.index ?? text.length), + })); +} + +export function checkMaps(repoRoot = defaultRoot) { + const errors = []; + const featureRoot = path.join(repoRoot, "maps/feature-map"); + const agentRoot = path.join(repoRoot, "maps/agent-access-map"); + const fail = (file, message) => errors.push(`${path.relative(repoRoot, file)}: ${message}`); + function read(file) { + if (!existsSync(file)) { fail(file, "missing file"); return ""; } + return withoutExamples(readFileSync(file, "utf8")); + } + function files(dir) { + if (!existsSync(dir)) { fail(dir, "missing map directory"); return []; } + return readdirSync(dir).filter((name) => name.endsWith(".md") && name !== "README.md").sort(); + } + function unique(ids, file, label) { + const seen = new Set(); + for (const id of ids) { + if (seen.has(id)) fail(file, `duplicate ${label}: ${id}`); + seen.add(id); + } + } + function links(file, text) { + for (const match of text.matchAll(/\]\(([^)\n]+)\)/g)) { + const target = match[1].replace(/^<|>$/g, ""); + if (/^(?:[a-z][a-z\d+.-]*:|#)/i.test(target)) continue; + if (target.startsWith("/")) { fail(file, `use a relative map reference: ${target}`); continue; } + let local; + try { local = decodeURIComponent(target.split(/[?#]/, 1)[0]); } + catch { fail(file, `invalid local reference: ${target}`); continue; } + if (!existsSync(path.resolve(path.dirname(file), local))) fail(file, `broken local reference: ${target}`); + } + } + function field(file, body, name) { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = body.match(new RegExp(`^${escaped}:[ \\t]*(\\S[^\\n]*)$`, "m")); + if (!match) { fail(file, `missing ${name} field`); return null; } + if (!values[name]) return null; + const state = match[1].match(/^`([^`]+)`/); + if (!state || !values[name].includes(state[1])) { fail(file, `invalid ${name} state`); return null; } + if (["not-applicable", "restricted", "operator-only"].includes(state[1]) && !match[1].slice(state[0].length).replace(/[\s—–:.-]/g, "")) { + fail(file, `${name} ${state[1]} needs a reason or path`); + } + return state[1]; + } + function accessRecord(file, body) { + const states = Object.fromEntries(fields.map((name) => [name, field(file, body, name)])); + if (states.Evidence === "verified" && states.Verification !== "passed") { + fail(file, "verified evidence requires a passed verification record"); + } + const api = body.split(/^API:/m)[1]?.split(/^(?:Agent access|Policy|Evidence|Verification|Gaps):/m)[0] ?? ""; + if (states.API === "present" && !/\b(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS) \/[^\s`]+/.test(api)) { + fail(file, "present API needs an exact method/path example"); + } + } + + const productFiles = files(featureRoot); + const agentFiles = files(agentRoot); + const indexes = [read(path.join(featureRoot, "README.md")), read(path.join(agentRoot, "README.md"))]; + let capabilities = 0; + for (const name of new Set([...productFiles, ...agentFiles])) { + const productFile = path.join(featureRoot, name); + const agentFile = path.join(agentRoot, name); + const product = read(productFile); + const agent = read(agentFile); + const subfeatures = product.split(/^## Sub-features\s*$/m)[1]?.split(/^## /m)[0] ?? ""; + const ids = [...subfeatures.matchAll(/^- `([^`]+)`:/gm)].map((match) => match[1]); + if (!ids.length) fail(productFile, "missing stable sub-feature IDs"); + unique(ids, productFile, "capability"); + const entries = sections(agent, 2); + unique(entries.map((entry) => entry.id), agentFile, "capability"); + for (const id of ids) if (!entries.some((entry) => entry.id === id)) fail(agentFile, `missing capability: ${id}`); + for (const entry of entries) { + if (!ids.includes(entry.id)) fail(agentFile, `orphan capability: ${entry.id}`); + const review = field(agentFile, entry.body, "Review"); + if (review === "operation-reviewed") { + const operations = sections(entry.body, 3); + if (!operations.length) fail(agentFile, `operation-reviewed capability ${entry.id} has no operations`); + unique(operations.map((operation) => operation.id), agentFile, "operation"); + for (const operation of operations) accessRecord(agentFile, operation.body); + } else accessRecord(agentFile, entry.body); + } + if (!agent.includes(`](../feature-map/${name})`)) fail(agentFile, "missing matching product link"); + if (!product.includes(`](../agent-access-map/${name})`)) fail(productFile, "missing matching companion link"); + for (const [index, text] of indexes.entries()) { + if (!new RegExp(`\\]\\((?:\\./)?${name.replaceAll(".", "\\.")}\\)`).test(text)) { + fail(path.join(index === 0 ? featureRoot : agentRoot, "README.md"), `missing area: ${name}`); + } + } + if (!/^Review date: \d{4}-\d{2}-\d{2}\. Source commit: `[a-f\d]{40}`\./m.test(agent)) fail(agentFile, "missing review date/source commit"); + links(productFile, product); + links(agentFile, agent); + capabilities += ids.length; + } + for (const file of [path.join(repoRoot, "maps/README.md"), path.join(featureRoot, "README.md"), path.join(agentRoot, "README.md")]) links(file, read(file)); + return { errors, areas: productFiles.length, capabilities }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + const result = checkMaps(); + for (const error of result.errors) console.error(error); + if (result.errors.length) process.exitCode = 1; + else console.log(`Maps aligned: ${result.areas} areas, ${result.capabilities} capability IDs; references and contract fields valid. Structural checks only.`); +} diff --git a/scripts/check-maps.test.mjs b/scripts/check-maps.test.mjs new file mode 100644 index 00000000000..aae6e43aff8 --- /dev/null +++ b/scripts/check-maps.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { checkMaps } from "./check-maps.mjs"; + +const record = `API: \`present\` — POST /api/example. +Agent access: \`partial\` — agent REST; native binding missing. +Policy: same company and calling owner. +Evidence: \`source-only\` — implementation inspected. +Verification: \`not-run\` — assert permitted owner and denied other company. +Gaps: native mutation path. +`; + +function fixture(t) { + const root = mkdtempSync(path.join(os.tmpdir(), "paperclip-map-contract-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const dir of ["maps/feature-map", "maps/agent-access-map"]) mkdirSync(path.join(root, dir), { recursive: true }); + const product = path.join(root, "maps/feature-map/example.md"); + const agent = path.join(root, "maps/agent-access-map/example.md"); + writeFileSync(path.join(root, "maps/README.md"), "# Contract\n"); + for (const dir of ["feature-map", "agent-access-map"]) writeFileSync(path.join(root, "maps", dir, "README.md"), "[Example](example.md)\n"); + writeFileSync(product, "# Example\n\n[Agent access](../agent-access-map/example.md)\n\n## Sub-features\n\n- `create`: create a record.\n\n## How to get to it (user POV)\n"); + writeFileSync(agent, `# Agent access\n\nProduct map: [Example](../feature-map/example.md).\n\nReview date: 2026-10-12. Source commit: \`${"a".repeat(40)}\`.\n\n## \`create\`\n\nReview: \`baseline\`.\n\n${record}`); + return { root, product, agent }; +} + +test("paired source-only records pass without being promoted to live proof", (t) => { + const { root } = fixture(t); + assert.deepEqual(checkMaps(root), { errors: [], areas: 1, capabilities: 1 }); +}); + +test("a product area cannot lose its companion", (t) => { + const { root, agent } = fixture(t); + rmSync(agent); + assert.ok(checkMaps(root).errors.some((error) => error.includes("missing file"))); +}); + +test("renaming a capability on one side reports missing and orphan IDs", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace("## `create`", "## `update`")); + const result = checkMaps(root); + assert.ok(result.errors.some((error) => error.includes("missing capability: create"))); + assert.ok(result.errors.some((error) => error.includes("orphan capability: update"))); +}); + +test("duplicate IDs cannot disguise the missing capability", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8") + `\n## \`create\`\n\nReview: \`baseline\`.\n\n${record}`); + assert.ok(checkMaps(root).errors.some((error) => error.includes("duplicate capability: create"))); +}); + +test("a moved or deleted source reference is reported", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8") + "\n[Source](../../server/deleted.ts#L12)\n"); + assert.ok(checkMaps(root).errors.some((error) => error.includes("broken local reference"))); +}); + +test("every reviewed operation needs its own access fields", (t) => { + const { root, agent } = fixture(t); + const text = readFileSync(agent, "utf8").replace("Review: `baseline`.", "Review: `operation-reviewed`.\n\n### `create`"); + writeFileSync(agent, text + `\n### \`update\`\n\n${record.replace(/^API:.*\n/m, "")}`); + assert.ok(checkMaps(root).errors.some((error) => error.includes("missing API field"))); +}); + +test("verified evidence cannot coexist with verification not run", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace("Evidence: `source-only`", "Evidence: `verified`")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("verified evidence requires a passed verification record"))); +}); + +test("an exception needs its reason rather than an empty exemption label", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace(/^API:.*$/m, "API: `not-applicable`.")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("not-applicable needs a reason"))); +}); + +test("API presence needs a concrete method and path", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace("POST /api/example", "see the backend")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("present API needs an exact method/path"))); +}); + +test("an empty policy cannot borrow the next field's content", (t) => { + const { root, agent } = fixture(t); + writeFileSync(agent, readFileSync(agent, "utf8").replace(/^Policy:.*$/m, "Policy:\n")); + assert.ok(checkMaps(root).errors.some((error) => error.includes("missing Policy field"))); +}); From f0e12c851971a21f7aadf0835b016bfaf2067314 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:34:02 -0500 Subject: [PATCH 4/4] Add managed Slack setup with an independent own-app path (#15679) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack connects a person to an agent through an installed app and a verified identity. > - Independent installations must remain usable without Paperclip Cloud or manager credentials. > - Approved Cloud deployments can use Slack manager grants to create and install each agent's app. > - Both paths should share credential storage, identity checks, recovery, and the existing runtime. > - This pull request adds gated managed setup and keeps independent app setup under Advanced. > - It also makes ongoing connection settings and personal test messages easier to use. ## Linked Issues or Issue Description **Subsystem affected** Slack setup, connection settings, tenant credential storage, and the Cloud connector handoff. **Problem or motivation** Approved deployments need an installation flow without pasted configuration tokens. Open-source users must retain their independent Slack app flow. A connection manager also needs a simple way to check a bot from Settings. **Proposed solution** Use the managed path when the instance is configured to support it. Choose agent and Connect are the two visible stops. Continue starts authorization or installation. Use your own app remains under Advanced before creation. Save personal manager grants by company, Paperclip user, Slack user, workspace, and manager app. Reuse only verified identities for test messages. Unknown identities must complete a private Slack connection challenge before linking and receiving the test DM. **Alternatives considered** A shared bot would change each agent's Slack identity. Requiring Slack CLI or a hosted service for self-hosted users would remove independent installation. **Roadmap alignment** This maintainer-requested change improves Connected Apps and experimental Agent Chat. It adds provisioning and management controls to the existing Slack runtime. **Additional context** The companion Cloud broker profile is a separate deployment prerequisite. Managed setup remains gated. Existing-app adoption is deferred. ## What Changed - Extract Slack registration, provider requests, completion, customer-owned setup, managed setup, and personal manager grants into focused services. Preserve the compatibility facade and independent removal cleanup. - Add company-scoped manager grants and a registration reference through an additive, idempotent migration after current master. Keep runtime credentials separate from app-registration secrets. - Add authenticated manager authorization, sealed handoff, serialized refresh and revocation, app creation, managed installation, and installer-bound OAuth fallback. - Preserve creation intent, block automatic uncertain recreation, save credentials before downstream checks, and validate app, workspace, bot, scopes, and installer identity. - Add the two-stop production wizard and ordered stories. Keep the own-app, manual, existing-app, and legacy-draft paths. - Simplify Settings. Open Slack configuration targets the saved app. The test-message action has its own row. Remove the old manual-avatar section, channel-message section, and broken permissions link. - Add paginated account discovery and a private connection challenge for unknown accounts. Expired invitations stop waiting and offer a new private invitation. Save send receipts before provider dispatch and preserve confirmed delivery when activity logging fails. - Reinstall the saved managed app on explicit reconnect, replace revoked credentials, and resume completion. Persist reconnect intent and duplicate-request receipts without creating another app. - Update provider browser tests, recovery tests, secret redaction, setup documentation, and the agent prompt. Preserve current master's durable verification scheduling. ## Verification - Full workspace typecheck and repository build passed on `56d389d47a`. The subsequent reconnect retry fix in `288364eada` also passed UI typecheck and UI build. - 114 focused Slack registration cases and 13 production managed-wizard cases passed, including fresh-token reconnect, retry after installation failure, and duplicate reconnect requests. - All nine affected Slack browser cases passed against the production UI and a disposable fixture server. They cover managed setup, automatic setup, own-app selection, refresh, recovery, mobile behavior, and connection settings. - Eight focused real-database test-message cases passed. They cover a private ownership challenge, saved grant reuse, account pagination, revoked and conflicting links, workspace mismatch, disabled DMs, revoked membership, audit failure, ambiguous dispatch, and secret canaries. - UI token gates and the production Storybook build passed. - Greptile reviewed exact head `288364eada5d9c114fad991ff634612639d06672` at 5/5 with no actionable findings or unresolved review threads. - The full repository test suite and current-head CI are running. Their final results will replace this line before merge. - Earlier live feature previews created two bots in the authorized Paperclip Slack workspace and reused one personal manager grant. Local managed Continue created and installed a bot without a configuration token or a separate Add to Slack action. Avatars, welcome DMs, signed callbacks, and direct replies were observed. A Settings test DM also arrived in the signed-in user's Slack account. - Earlier staging thread qualification required manual publication recovery for one bot. Current-head live qualification remains required before enabling the production default. Fixture results do not establish Slack approval behavior in other workspaces. ## Risks - Personal manager tokens can create apps. They stay in scoped vault entries. The manager client secret stays at the broker. Company, actor/session, origin, and app identity checks apply throughout setup. - The additive migration must also work on qualification instances that applied earlier branch migrations. Table, column, index, and constraint creation is idempotent. - Creation and message timeouts can leave uncertain outcomes. Saved request IDs prevent automatic duplicate dispatch. - Unknown Slack identities must receive and complete their private challenge while signed in. Account selection alone does not grant Paperclip permissions. Existing revoked or conflicting links are not overwritten. - A development-only manager transport uses an explicitly configured private credential file for local qualification. Production ignores this transport; no manager credentials are bundled with open source. - Managed rollout requires Slack approval, the companion broker deployment, configured secrets, and final live qualification. Removing a connection does not delete its Slack app. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution, and browser tools. The session does not expose the exact serving model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DATABASE.md | 12 + doc/DEVELOPING.md | 9 + doc/connections/slack-setup.md | 162 ++++ .../src/migrations/0335_absurd_bulldozer.sql | 39 + ...{0330_snapshot.json => 0335_snapshot.json} | 821 +++++++++++++++++- packages/db/src/migrations/meta/_journal.json | 7 + .../src/schema/chat_slack_manager_grants.ts | 26 + .../db/src/schema/chat_slack_registrations.ts | 3 + packages/db/src/schema/index.ts | 2 + packages/shared/src/slack-app-manifest.ts | 45 +- packages/shared/src/types/chat-channels.ts | 6 +- .../shared/src/validators/chat-channels.ts | 5 +- .../chat-channels.integration.test.ts | 428 ++++++++- server/src/__tests__/http-log-policy.test.ts | 2 +- server/src/__tests__/redact-sensitive.test.ts | 5 + .../slack-development-manager.test.ts | 73 ++ server/src/middleware/http-log-policy.ts | 2 +- server/src/middleware/redact-sensitive.ts | 7 + server/src/routes/chat-channels.ts | 50 +- server/src/routes/openapi.ts | 40 +- server/src/services/chat-channels.ts | 94 +- .../chat-slack-registration-cleanup.ts | 5 +- .../src/services/chat-slack-registration.ts | 534 +----------- .../connectors/slack/setup/completion.ts | 164 ++++ .../connectors/slack/setup/customer-owned.ts | 139 +++ .../slack/setup/development-manager.ts | 73 ++ .../connectors/slack/setup/managed.ts | 81 ++ .../connectors/slack/setup/manager-grants.ts | 269 ++++++ .../connectors/slack/setup/provider-client.ts | 58 ++ .../connectors/slack/setup/registration.ts | 136 +++ .../connectors/slack/setup/service.ts | 151 ++++ .../services/connectors/slack/test-message.ts | 134 +++ .../paperclip-cloud-connector.test.ts | 17 +- .../src/services/paperclip-cloud-connector.ts | 19 +- tests/e2e/chat-adapters-ui-providers.spec.ts | 128 ++- tests/e2e/chat-adapters-ui.shared.ts | 20 +- ui/src/api/chatEndpoints.ts | 11 +- .../apps/chat/ChatEndpoint.clipboard.test.tsx | 22 +- ui/src/pages/apps/chat/ChatEndpointDetail.tsx | 52 +- ui/src/pages/apps/chat/ChatEndpointSetup.tsx | 107 ++- .../pages/apps/chat/SlackAutomaticSetup.tsx | 184 +--- ui/src/pages/apps/chat/SlackSetupPrompt.tsx | 8 +- .../apps/chat/SlackTestMessageButton.tsx | 88 ++ ui/src/pages/apps/chat/SlackToolSettings.tsx | 12 +- .../pages/apps/chat/chat-ui-contract.test.ts | 3 +- .../chat/slack/ManagedSlackSetup.test.tsx | 88 ++ .../apps/chat/slack/ManagedSlackSetup.tsx | 92 ++ .../apps/chat/slack/OwnSlackAppSetup.tsx | 185 ++++ ui/src/pages/apps/chat/slack/SlackSetup.tsx | 11 + .../pages/apps/chat/slack/setup-state.test.ts | 30 + ui/src/pages/apps/chat/slack/setup-state.ts | 30 + ui/storybook/fixtures/SlackSetupFixture.tsx | 33 +- .../chat-management.stories.tsx | 54 +- .../stories/slack-managed-setup.stories.tsx | 104 +++ 54 files changed, 3966 insertions(+), 914 deletions(-) create mode 100644 doc/connections/slack-setup.md create mode 100644 packages/db/src/migrations/0335_absurd_bulldozer.sql rename packages/db/src/migrations/meta/{0330_snapshot.json => 0335_snapshot.json} (98%) create mode 100644 packages/db/src/schema/chat_slack_manager_grants.ts create mode 100644 server/src/__tests__/slack-development-manager.test.ts create mode 100644 server/src/services/connectors/slack/setup/completion.ts create mode 100644 server/src/services/connectors/slack/setup/customer-owned.ts create mode 100644 server/src/services/connectors/slack/setup/development-manager.ts create mode 100644 server/src/services/connectors/slack/setup/managed.ts create mode 100644 server/src/services/connectors/slack/setup/manager-grants.ts create mode 100644 server/src/services/connectors/slack/setup/provider-client.ts create mode 100644 server/src/services/connectors/slack/setup/registration.ts create mode 100644 server/src/services/connectors/slack/setup/service.ts create mode 100644 server/src/services/connectors/slack/test-message.ts create mode 100644 ui/src/pages/apps/chat/SlackTestMessageButton.tsx create mode 100644 ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx create mode 100644 ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx create mode 100644 ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx create mode 100644 ui/src/pages/apps/chat/slack/SlackSetup.tsx create mode 100644 ui/src/pages/apps/chat/slack/setup-state.test.ts create mode 100644 ui/src/pages/apps/chat/slack/setup-state.ts create mode 100644 ui/storybook/stories/slack-managed-setup.stories.tsx diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2177689e1b9..5862fe40a55 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -701,6 +701,18 @@ this non-secret binding for this namespace; Slack bot installation does not use PKCE. Removal and manual recovery invalidate outstanding attempts under the same credential-mutation lease used by configuration. +### Slack manager workspace grants + +`chat_slack_manager_grants` stores a personal workspace authorization, scoped by +company, Paperclip user, Slack user, workspace and manager app. Access/refresh +credentials are references to user-scoped vault entries, never plaintext columns. +Revision fencing protects reauthorization from concurrent revocation. Per-method +rate-limit deadlines retain Slack's Retry-After across setup retries. + +`chat_slack_registrations.manager_grant_id` is optional, with a composite company +foreign key. Existing registrations remain customer owned with a null reference. +Deleting one endpoint removes its registration credentials and attempts, but does +not revoke grants used by other bots. See [Slack setup](connections/slack-setup.md). ## Transaction-aware delivery work signals diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 233c2e5cf66..575b1430fa8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2239,3 +2239,12 @@ Chromium. The test starts its own loopback Vite server and mocks API responses; it needs no running Paperclip instance or provider credentials. The same spec lives in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it on pull requests. + +### Slack provisioning paths + +Self-hosted development continues to use independent Slack app setup. Managed +Slack installation is a disabled-by-default Cloud feature that also requires the +companion broker profile and Slack approval. Do not add manager client credentials +to a local `.env` or an open-source deployment. See +[Slack setup and Cloud qualification](connections/slack-setup.md) for the gates, +recovery behavior, and production-component stories. diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md new file mode 100644 index 00000000000..78abb8ef67f --- /dev/null +++ b/doc/connections/slack-setup.md @@ -0,0 +1,162 @@ +# Slack app ownership and setup + +Slack has one chat runtime and two provisioning paths. A saved connection's +`slackSetupMethod` selects provisioning; it does not change tools, scopes, +conversation routing, people permissions, or channel access. + +| Method | App creation and installation | Default | +| --- | --- | --- | +| `managed` | A personal Slack manager grant creates a child app; managed installation supplies its bot token. Slack consent/admin approval remains a fallback. | New drafts on qualified Cloud deployments only | +| `automatic` | A temporary app configuration access token creates a customer-owned app; the user approves its OAuth installation. | New self-hosted drafts and Cloud without managed availability | +| `manual` | User creates an app from the canonical manifest and enters credentials. | Legacy drafts with no saved method | +| `existing` | User supplies credentials for an existing app. | Explicit selection | + +Existing drafts and active bots retain their method. Enabling managed setup never +adopts existing apps. Choose agent offers **Use your own app** under **Advanced**, below Slash command, +when the instance supports managed setup and creation has not started. The own-app flow retains **Create manually** and **Use an existing +app** under Advanced. After creation dispatch, a managed draft cannot switch paths. + +Managed setup has two visible stops: Choose agent and Connect. **Continue** starts +manager authorization or installation in the saved workspace automatically. Multiple +workspaces still require a selection. Recovery actions remain explicit; opening +a failed or uncertain registration never retries creation automatically. +Creation, installation, message waiting, and success use the same production +components and server evidence as the own-app flow. A click or OAuth return does +not constitute successful configuration. + +## Cloud deployment prerequisites + +Managed setup is disabled by default. Qualification requires all of: + +- The instance chat-connector rollout gate and connection-management permission. +- Verified Cloud runtime identity, connector broker signing/sealing configuration, + and valid independently configured public board and webhook HTTPS origins. +- `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true` on the approved tenant deployment. +- The companion Cloud broker deployment with the explicitly enabled `slack.manager` + profile, approved Slack manager app, token rotation, and its client secret in + the broker's secret store. No manager client secret belongs in the tenant. + +The broker must advertise its manager app identity as well as the enabled profile. +Previously saved grants from a different manager app are not reused. A broker +without this capability leaves managed setup unavailable. Do not enable the tenant +gate until real Slack qualification has passed; fixture coverage does not prove +Marketplace eligibility or a workspace's approval policy. + +Self-hosted instances do not need a Paperclip account, broker enrollment, manager +credentials, or hosted-service calls. They continue using the own-app flow. + +## Personal workspace authorization + +Manager grants belong to a company **and one Paperclip user**, and bind a verified +Slack user, workspace, and manager app. Another company member must authorize their +own account, even for the same workspace. Access and rotating refresh tokens are +user-scoped vault entries. Refreshes serialize across connections, persist the new +refresh token, and require fresh authorization after an ambiguous exchange. + +Revoking a grant requests provider revocation, removes its local credentials, invalidates pending manager +authorization, and prevents future provisioning with it. The local activity records whether provider revocation succeeded. Provider errors never restore local access. It does not uninstall +apps or disable already-installed bots. Removing a bot deletes endpoint-specific +secrets and pending setup attempts; a reusable personal manager grant survives. +Provider-side app removal remains explicit through the saved Slack management URL. + +## Recovery + +- A dispatched creation with an unknown result is **uncertain**. Never automatically + create again. The user must inspect Slack and explicitly confirm that no app was + created before a new request identity is accepted. +- Denied or pending installation retains the app. Resume with the same grant or + complete the child app's state-bound OAuth approval; the installer must match the + grant's Slack identity before personal linking. +- Returned bot credentials are vaulted before inventory or runtime configuration. + Retry connecting reuses those credentials after network or inventory failures. + Rejected or under-scoped bot credentials instead reinstall the saved app; they + never trigger another app creation. +- Manifest updates finish before managed installation. Changes requiring updated + permissions therefore pass through installation again before activation. +- Expired or revoked manager grants require reconnection. Losing managed + availability pauses setup; it never changes the saved ownership method. +- Slack rate limits are grouped by manager/workspace/method. Provider `Retry-After` + is persisted across matching grants in the company, survives restarts, and blocks + early retries. Other tenant instances must observe Slack's limits independently; + no background loop retries app creation. + +All OAuth attempts bind company, actor/session, endpoint, registration revision, +and configured origins, expire after ten minutes, and are claimed once before +exchange. HTTP diagnostics redact manager tokens, refresh tokens, codes, and sealed +handoffs. Local activity records contain only IDs and safe outcome codes. This adds +no first-party telemetry. + +## Code and verification + +Server provisioning lives in `server/src/services/connectors/slack/setup/`; +`chat-slack-registration.ts` remains the compatibility facade. Shared completion +owns credential binding, personal linking, welcome messages, and verification. +Removal cleanup stays independently callable. Slack wizard components and stage +selection live in `ui/src/pages/apps/chat/slack/`. + +Production Storybook journeys: **Connections / Slack / Managed setup** and +**Connections / Slack / Automatic setup** (the persisted own-app automatic path). +Server integration cases cover personal grants, duplicate requests, uncertain +creation, approval fallback, refresh, revocation, removal, saved-credential recovery, +rate limits, and secret canaries. Browser cases are in +`tests/e2e/chat-adapters-ui-providers.spec.ts`; companion broker tests cover manager +user OAuth and the authenticated tenant handoff. + +Before enabling a deployment, create two test agents in the authorized workspace. +The second must reuse the personal grant without another configuration token or +manager authorization. Verify avatars, identity linking, signed inbound events, +and same-thread replies. Record real approval behavior separately from fixtures. + +### Managed setup during broker outages + +Cloud deployments configured for managed Slack keep the managed default when the broker profile is temporarily unavailable. New drafts remain on **Connect** and offer **Retry**. **Use your own app** remains available under **Advanced** on Choose agent before creation. Discovery loading or failure never selects the configuration-token page. Self-hosted instances without managed configuration still use their independent own-app flow; saved draft methods do not change automatically. Provisioning continues to require current broker availability. + +### Local managed-bot qualification + +Maintainers with an approved Slack manager app can exercise the real managed +journey from an authenticated local test drive exposed through HTTPS (including +TryCloudflare). This development transport talks directly to Slack and reuses +the production manager-grant vault, state claims, provisioning, completion, and +runtime. It does not exercise the Cloud broker's sealed handoff; qualify that +separately on staging afterward. + +Set `NODE_ENV=development`, `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true`, and +`PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG` to an absolute path outside the +checkout containing a mode-0600 JSON file with `appId`, `clientId`, and +`clientSecret`. Use only an authorized qualification manager app. No credentials +are shipped with Paperclip. Production ignores this development configuration, +and Cloud runtime identity continues to select the Cloud broker. + +Configure the local instance for authenticated public access, with its public +URL and chat webhook URL pointing to the HTTPS tunnel. Add the exact public URL +plus `/api/chat-slack/managed/oauth/callback` to the manager app's OAuth redirect +URLs, preserving any existing redirects. Start a **new** Slack setup: saved +own-app drafts retain their selected method. Authorize the manager app normally; +do not copy user grants from another instance. The second bot should reuse the +same local user's vaulted grant. A changed tunnel requires updating public +origins and the manager redirect before starting fresh authorization. + +Remove the temporary redirect when retiring the test drive and securely remove +its development credential file. Removing a bot does not revoke a manager grant +used by other bots. + +### Test messages from Settings + +**Send me a test message** sends a DM to the signed-in connection manager, +using their linked identity in this workspace. If this bot is not linked yet, +Paperclip can reuse that user's verified manager grant or another bot's linked +identity in the same company and workspace. Otherwise, the user selects their +Slack account and receives a private connection invitation in Slack. They must +open that invitation, sign in, and confirm before Paperclip links the account and +sends the test DM. The account picker loads further workspace pages on request. Existing revoked or conflicting identity links are not overwritten. +Direct messages must be enabled, and the user must be an active company operator. + +The action records a durable request before sending. Repeating the same request +returns its saved result; an uncertain delivery is never automatically resent. +**Open Slack configuration** opens the saved app's OAuth & Permissions page. +Legacy connections without an app ID open Slack's app list instead. + +An explicit managed reconnect reinstalls the saved app and verifies the replacement +bot credentials. Its request ID is saved before dispatch, so a duplicate completed +request does not reinstall again. Credential-configuration recovery still resumes +the saved token after a downstream failure. No reconnect creates another Slack app. diff --git a/packages/db/src/migrations/0335_absurd_bulldozer.sql b/packages/db/src/migrations/0335_absurd_bulldozer.sql new file mode 100644 index 00000000000..56224dbe057 --- /dev/null +++ b/packages/db/src/migrations/0335_absurd_bulldozer.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS "chat_slack_manager_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "slack_user_id" text NOT NULL, + "workspace_id" text NOT NULL, + "workspace_name" text NOT NULL, + "manager_app_id" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "access_secret_id" uuid, + "refresh_secret_id" uuid, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chat_slack_manager_grants_status_check" CHECK ("chat_slack_manager_grants"."status" in ('active', 'revoked', 'reauthorize')) +); + +--> statement-breakpoint + +ALTER TABLE "chat_slack_registrations" ADD COLUMN IF NOT EXISTS "manager_grant_id" uuid; +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_manager_grants" ADD CONSTRAINT "chat_slack_manager_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_company_id_idx" ON "chat_slack_manager_grants" USING btree ("company_id","id"); +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_owner_idx" ON "chat_slack_manager_grants" USING btree ("company_id","user_id","manager_app_id","workspace_id"); +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_registrations" ADD CONSTRAINT "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk" FOREIGN KEY ("company_id","manager_grant_id") REFERENCES "public"."chat_slack_manager_grants"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "revision" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint + +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "rate_limits" jsonb DEFAULT '{}'::jsonb NOT NULL; diff --git a/packages/db/src/migrations/meta/0330_snapshot.json b/packages/db/src/migrations/meta/0335_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0330_snapshot.json rename to packages/db/src/migrations/meta/0335_snapshot.json index 460bb97a1cf..b348334bbd3 100644 --- a/packages/db/src/migrations/meta/0330_snapshot.json +++ b/packages/db/src/migrations/meta/0335_snapshot.json @@ -1,6 +1,6 @@ { - "id": "0a93bc99-bf0c-4ea2-ab27-6043b6746cd5", - "prevId": "76861d12-d9a9-4f5f-bcbc-0fb06c0addbc", + "id": "18c156f2-8344-4282-98b7-f702f0035c81", + "prevId": "39d82f2c-8fd5-4606-84df-f51880dbd6a7", "version": "7", "dialect": "postgresql", "tables": { @@ -416,6 +416,12 @@ "primaryKey": false, "notNull": false }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "agent_id": { "name": "agent_id", "type": "uuid", @@ -462,6 +468,27 @@ } }, "indexes": { + "budget_reservations_fast_response_idx": { + "name": "budget_reservations_fast_response_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fast_response_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "budget_reservations_decision_idx": { "name": "budget_reservations_decision_idx", "columns": [ @@ -580,7 +607,7 @@ "checkConstraints": { "budget_reservations_source_check": { "name": "budget_reservations_source_check", - "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int = 1" + "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int + (\"budget_reservations\".\"fast_response_request_id\" is not null)::int = 1" }, "budget_reservations_state_check": { "name": "budget_reservations_state_check", @@ -3737,6 +3764,28 @@ "method": "btree", "with": {} }, + "agent_wakeup_requests_question_response_history_idx": { + "name": "agent_wakeup_requests_question_response_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'question-response:%'", + "concurrently": true, + "method": "btree", + "with": {} + }, "agent_wakeup_requests_connection_intent_delivery_idempotency_uq": { "name": "agent_wakeup_requests_connection_intent_delivery_idempotency_uq", "columns": [ @@ -12384,6 +12433,189 @@ }, "isRLSEnabled": false }, + "public.chat_slack_manager_grants": { + "name": "chat_slack_manager_grants", + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_app_id": { + "name": "manager_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "access_secret_id": { + "name": "access_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_secret_id": { + "name": "refresh_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limits": { + "name": "rate_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_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()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_slack_manager_grants_company_id_idx": { + "name": "chat_slack_manager_grants_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_slack_manager_grants_owner_idx": { + "name": "chat_slack_manager_grants_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "manager_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_slack_manager_grants_company_id_companies_id_fk": { + "name": "chat_slack_manager_grants_company_id_companies_id_fk", + "tableFrom": "chat_slack_manager_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_slack_manager_grants_status_check": { + "name": "chat_slack_manager_grants_status_check", + "value": "\"chat_slack_manager_grants\".\"status\" in ('active', 'revoked', 'reauthorize')" + } + }, + "isRLSEnabled": false + }, "public.chat_slack_registrations": { "name": "chat_slack_registrations", "schema": "", @@ -12400,6 +12632,12 @@ "primaryKey": false, "notNull": true }, + "manager_grant_id": { + "name": "manager_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "request_id": { "name": "request_id", "type": "uuid", @@ -12490,6 +12728,21 @@ }, "indexes": {}, "foreignKeys": { + "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk": { + "name": "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk", + "tableFrom": "chat_slack_registrations", + "tableTo": "chat_slack_manager_grants", + "columnsFrom": [ + "company_id", + "manager_grant_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_slack_registrations", @@ -16725,6 +16978,19 @@ "primaryKey": false, "notNull": false }, + "package_mode": { + "name": "package_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skills'" + }, + "repository_package": { + "name": "repository_package", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "excluded_folders": { "name": "excluded_folders", "type": "jsonb", @@ -17052,6 +17318,87 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_repository_snapshots": { + "name": "company_skill_repository_snapshots", + "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 + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_repository_snapshots_content_idx": { + "name": "company_skill_repository_snapshots_content_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_repository_snapshots_company_id_companies_id_fk": { + "name": "company_skill_repository_snapshots_company_id_companies_id_fk", + "tableFrom": "company_skill_repository_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_stars": { "name": "company_skill_stars", "schema": "", @@ -17906,6 +18253,18 @@ "primaryKey": false, "notNull": true }, + "repository_snapshot_id": { + "name": "repository_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_skill_path": { + "name": "repository_skill_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, "revision_number": { "name": "revision_number", "type": "integer", @@ -18062,6 +18421,19 @@ "onDelete": "cascade", "onUpdate": "no action" }, + "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk": { + "name": "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk", + "tableFrom": "company_skill_versions", + "tableTo": "company_skill_repository_snapshots", + "columnsFrom": [ + "repository_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, "company_skill_versions_author_agent_id_agents_id_fk": { "name": "company_skill_versions_author_agent_id_agents_id_fk", "tableFrom": "company_skill_versions", @@ -19576,6 +19948,34 @@ "method": "btree", "with": {} }, + "cost_events_data_quality_idx": { + "name": "cost_events_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cost_events\".\"billing_type\" = 'unknown' or (\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included')", + "concurrently": false, + "method": "btree", + "with": {} + }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", "columns": [ @@ -19826,7 +20226,7 @@ "checkConstraints": { "cost_events_usage_kind_check": { "name": "cost_events_usage_kind_check", - "value": "\"cost_events\".\"usage_kind\" = 'decision' or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" + "value": "\"cost_events\".\"usage_kind\" in ('decision', 'fast_response') or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" }, "cost_events_nonnegative_amounts": { "name": "cost_events_nonnegative_amounts", @@ -27491,6 +27891,378 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_fast_responses": { + "name": "company_fast_responses", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_sponsored": { + "name": "allow_sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "company_fast_responses_company_id_companies_id_fk": { + "name": "company_fast_responses_company_id_companies_id_fk", + "tableFrom": "company_fast_responses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "company_fast_responses_configured": { + "name": "company_fast_responses_configured", + "value": "(\"company_fast_responses\".\"connection_id\" is null) = (\"company_fast_responses\".\"grant_id\" is null) and (not \"company_fast_responses\".\"enabled\" or (\"company_fast_responses\".\"connection_id\" is not null and \"company_fast_responses\".\"model\" is not null))" + } + }, + "isRLSEnabled": false + }, + "public.fast_response_requests": { + "name": "fast_response_requests", + "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 + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_comment_id": { + "name": "source_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sponsored": { + "name": "sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "publication_status": { + "name": "publication_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment_id": { + "name": "comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "fast_response_requests_source_idx": { + "name": "fast_response_requests_source_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_history_idx": { + "name": "fast_response_requests_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_work_idx": { + "name": "fast_response_requests_work_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"fast_response_requests\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_response_requests_company_id_companies_id_fk": { + "name": "fast_response_requests_company_id_companies_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_response_requests_cost_event_id_cost_events_id_fk": { + "name": "fast_response_requests_cost_event_id_cost_events_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_response_requests_status_check": { + "name": "fast_response_requests_status_check", + "value": "\"fast_response_requests\".\"status\" in ('pending', 'running', 'succeeded', 'failed', 'unknown', 'skipped')" + } + }, + "isRLSEnabled": false + }, "public.feedback_exports": { "name": "feedback_exports", "schema": "", @@ -29938,6 +30710,34 @@ "method": "btree", "with": {} }, + "heartbeat_runs_cost_data_quality_idx": { + "name": "heartbeat_runs_cost_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"finished_at\", \"created_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true and \"heartbeat_runs\".\"status\" in ('succeeded','failed','timed_out','cancelled','interrupted')", + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", "columns": [ @@ -32392,6 +33192,19 @@ "primaryKey": false, "notNull": false }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'comment'" + }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "body": { "name": "body", "type": "text", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4b1ef9079c1..22c832f0017 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2332,6 +2332,13 @@ "when": 1791807836288, "tag": "0334_rare_slipstream", "breakpoints": true + }, + { + "idx": 335, + "version": "7", + "when": 1791832982875, + "tag": "0335_absurd_bulldozer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_slack_manager_grants.ts b/packages/db/src/schema/chat_slack_manager_grants.ts new file mode 100644 index 00000000000..fb4d5ef8861 --- /dev/null +++ b/packages/db/src/schema/chat_slack_manager_grants.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { check, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +/** Reusable personal management authorization; never a bot runtime credential. */ +export const chatSlackManagerGrants = pgTable("chat_slack_manager_grants", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + slackUserId: text("slack_user_id").notNull(), + workspaceId: text("workspace_id").notNull(), + workspaceName: text("workspace_name").notNull(), + managerAppId: text("manager_app_id").notNull(), + status: text("status").$type<"active" | "revoked" | "reauthorize">().notNull().default("active"), + accessSecretId: uuid("access_secret_id"), + refreshSecretId: uuid("refresh_secret_id"), + revision: integer("revision").notNull().default(0), + rateLimits: jsonb("rate_limits").$type>().notNull().default({}), + expiresAt: timestamp("expires_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, t => [ + uniqueIndex("chat_slack_manager_grants_company_id_idx").on(t.companyId, t.id), + uniqueIndex("chat_slack_manager_grants_owner_idx").on(t.companyId, t.userId, t.managerAppId, t.workspaceId), + check("chat_slack_manager_grants_status_check", sql`${t.status} in ('active', 'revoked', 'reauthorize')`), +]); diff --git a/packages/db/src/schema/chat_slack_registrations.ts b/packages/db/src/schema/chat_slack_registrations.ts index 0edad4a8917..5d17b660bd0 100644 --- a/packages/db/src/schema/chat_slack_registrations.ts +++ b/packages/db/src/schema/chat_slack_registrations.ts @@ -1,12 +1,14 @@ import { sql } from "drizzle-orm"; import { check, foreignKey, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import type { SlackRegistrationStatus } from "@paperclipai/shared"; +import { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; import { chatEndpoints } from "./chat_channels.js"; /** App provisioning is distinct from installation and runtime credentials. No plaintext secrets. */ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { endpointId: uuid("endpoint_id").primaryKey(), companyId: uuid("company_id").notNull(), + managerGrantId: uuid("manager_grant_id"), requestId: uuid("request_id").notNull(), status: text("status").$type().notNull(), manifest: jsonb("manifest").$type>().notNull(), @@ -22,6 +24,7 @@ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, t => [ + foreignKey({ columns: [t.companyId, t.managerGrantId], foreignColumns: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.id] }), foreignKey({ columns: [t.companyId, t.endpointId], foreignColumns: [chatEndpoints.companyId, chatEndpoints.id] }).onDelete("cascade"), check("chat_slack_registration_status_check", sql`${t.status} in ('creating', 'uncertain', 'failed', 'install', 'credentials_saved', 'configured', 'removed')`), ]); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 4c4bad38337..630a0869b55 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -243,6 +243,8 @@ export { chatVoiceReports } from "./chat_voice_reports.js"; export { chatVoicePhoneLines, chatVoiceInboundCalls } from "./chat_voice_inbound.js"; export * from "./decision_models.js"; export { userCompanyPreferences } from "./user_company_preferences.js"; + +export { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js"; export * from "./fast_responses.js"; diff --git a/packages/shared/src/slack-app-manifest.ts b/packages/shared/src/slack-app-manifest.ts index d8ec7d59ee0..e6d3f251424 100644 --- a/packages/shared/src/slack-app-manifest.ts +++ b/packages/shared/src/slack-app-manifest.ts @@ -22,7 +22,7 @@ export function buildSlackAppManifest(input: { redirectUri?: string; }) { return { - display_information: { name: input.app.appName }, + display_information: { name: input.app.appName, description: `Talk to ${input.agentName} and manage work in Paperclip.`.slice(0, 140) }, features: { app_home: { home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false }, agent_view: { agent_description: "Work with a Paperclip agent in a task-backed conversation." }, @@ -60,6 +60,7 @@ export const slackRegistrationStateSchema = z.object({ status: slackRegistrationStatusSchema, appId: z.string().nullable().optional(), managementUrl: z.string().url().nullable().optional(), + managerGrantId: z.string().uuid().nullable().optional(), errorCode: z.string().nullable().optional(), }).strict(); export type SlackRegistrationState = z.infer; @@ -79,11 +80,33 @@ export const slackAccountStateSchema = z.object({ }).strict(); export type SlackAccountState = z.infer; export const slackInstallAuthorizationSchema = z.object({ authorizationUrl: z.string().url(), expiresAt: z.string().datetime() }); +export const slackManagerAuthorizationSchema = slackInstallAuthorizationSchema.extend({ handoff: z.object({ kind: z.literal("paperclip_cloud"), session: z.string().min(16).max(512) }).optional() }); +export type SlackManagerAuthorization = z.infer; export type SlackInstallAuthorization = z.infer; export const slackSetupActionSchema = z.object({}).strict(); +export const slackTestMessageSchema = z.object({ + requestId: z.string().uuid(), + slackUserId: z.string().regex(/^[UW][A-Z0-9]+$/).optional(), + accountCursor: z.string().min(1).max(512).optional(), +}).strict(); +export type SlackTestMessageInput = z.infer; +export const slackTestMessageResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("sent"), slackUrl: z.string().url() }).strict(), + z.object({ status: z.literal("connection_required"), accounts: z.array(z.object({ + id: z.string(), name: z.string(), + }).strict()), nextCursor: z.string().nullable() }).strict(), + z.object({ status: z.literal("connection_pending"), principalId: z.string().uuid(), expiresAt: z.string().datetime() }).strict(), +]); +export type SlackTestMessageResult = z.infer; + export function slackRegistrationErrorMessage(code: string): string { const messages: Record = { + slack_managed_unavailable: "Managed Slack setup is unavailable. Try again later or use your own app before creation starts.", + slack_manager_reauthorize: "Reconnect your Slack workspace to continue setting up this agent.", + slack_approval_pending: "Slack is waiting for your workspace administrator to approve this app.", + slack_approval_required: "Your Slack workspace requires approval. Continue in Slack to request it.", + slack_approval_denied: "Your Slack administrator declined this installation. Contact them to continue.", slack_configuration_token_invalid: "Slack rejected the app configuration access token. Generate a new access token and try again.", slack_manifest_invalid: "Slack rejected this app configuration. Check the app details or use manual setup.", slack_manifest_update_pending: "Your Slack app is saved, but its event settings still need to be applied. Enter an app configuration access token to retry configuring the same app.", @@ -96,9 +119,29 @@ export function slackRegistrationErrorMessage(code: string): string { slack_install_identity_mismatch: "Slack returned a different app, workspace, or bot. Install the app created for this connection.", slack_install_scopes_missing: "Slack did not grant all required bot permissions. Install the app again and approve its requested permissions.", slack_bot_already_connected: "This Slack bot is already connected to Paperclip. Resume its existing connection or use a different app.", + slack_install_token_invalid: "Slack rejected the saved bot credentials. Continue in Slack to authorize this app again.", slack_configuration_incomplete: "The app is installed, but Paperclip could not finish connecting it. Retry connecting the saved installation.", slack_install_account_missing: "Slack did not identify your account. Authorize the same app again to connect your Slack account.", slack_install_account_conflict: "This Slack account already has an existing link. Manage account links in connection settings, or authorize with your own Slack account.", }; return messages[code] ?? "Slack setup could not be completed. Resume setup and try again."; } + +/** Persisted legacy values keep their original meaning. Missing means manual. */ +export type SlackSetupMethod = "managed" | "automatic" | "manual" | "existing"; +export function isProvisionedSlackSetup(method: string | undefined): boolean { + return method === "automatic" || method === "managed"; +} +export const SLACK_MANAGER_SCOPES = ["app_configurations:read", "app_configurations:write", "managed_apps:install"] as const; +export const slackSetupOptionsSchema = z.object({ + managedAvailable: z.boolean(), + defaultMethod: z.enum(["managed", "automatic"]), + workspaces: z.array(z.object({ grantId: z.string().uuid(), workspaceId: z.string(), workspaceName: z.string(), userId: z.string() })), +}); +export type SlackSetupOptions = z.infer; +export const slackManagedProvisionSchema = z.object({ + requestId: z.string().uuid(), grantId: z.string().uuid(), confirmedNoAppCreated: z.boolean().optional(), reconnect: z.boolean().optional(), +}).strict(); +export type SlackManagedProvisionInput = z.infer; +export const slackManagedResultSchema = z.object({ authorization: slackInstallAuthorizationSchema.optional() }); +export type SlackManagedResult = z.infer; diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index af49abfb8da..5ebcda06a2b 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -190,7 +190,8 @@ export interface SlackAppConfiguration { } export interface ChatEndpointSetupState { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: import("../slack-app-manifest.js").SlackRegistrationState; /** Provider-confirmed app icon outcome; configuration tokens are never retained. */ slackAvatar?: import("../slack-app-manifest.js").SlackAvatarState; @@ -508,10 +509,11 @@ export interface CreateChatEndpointInput { applicationId?: string; name?: string; slackApp?: SlackAppConfiguration; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; } export interface UpdateChatEndpointInput { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; slackApp?: SlackAppConfiguration; communicationInstructions?: string; allowDirectMessages?: boolean; diff --git a/packages/shared/src/validators/chat-channels.ts b/packages/shared/src/validators/chat-channels.ts index f2553588ef3..53b263462f0 100644 --- a/packages/shared/src/validators/chat-channels.ts +++ b/packages/shared/src/validators/chat-channels.ts @@ -80,9 +80,10 @@ export const createChatEndpointSchema = z applicationId: z.string().uuid().optional(), name: z.string().trim().min(1).max(160).optional(), slackApp: slackAppConfigurationSchema.optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), }) .strict() - .refine(value => !value.slackApp || value.provider === "slack", { + .refine(value => (!value.slackApp && !value.slackSetupMethod) || value.provider === "slack", { message: "Slack app details only apply to Slack connections", path: ["slackApp"], }); @@ -90,7 +91,7 @@ export const updateChatEndpointSchema = z .object({ communicationInstructions: multilineTextSchema.pipe(z.string().trim().max(4000)).optional(), slackApp: slackAppConfigurationSchema.optional(), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), allowDirectMessages: z.boolean().optional(), requireAtMention: z.boolean().optional(), allowGroupChats: z.boolean().optional(), diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 8b8b0f29641..6893a548784 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,3 +1,7 @@ +import * as activityLogModule from "../services/activity-log.js"; +import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; +import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js"; +import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js"; import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; import { registerChatDeliveryWork } from "../services/chat-delivery-work.js"; @@ -7,14 +11,11 @@ import { aiConnectionService } from "../services/ai-connections.js"; import { fastResponseService } from "../services/fast-responses.js"; import { fastResponseReceipt } from "../services/fast-response-provider.js"; import { fastResponseRequests } from "@paperclipai/db"; -import { chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; -import { buildSlackAppManifest } from "@paperclipai/shared"; import { toolAccessService } from "../services/tool-access.js"; import { toolActionRequests, toolInvocations } from "@paperclipai/db"; import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js"; import { secretService } from "../services/secrets.js"; import { githubChatWizardService } from "../services/chat-github-wizard.js"; -import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js"; import { githubChatManagementService } from "../services/chat-github-management.js"; import { githubChatReviewService } from "../services/chat-github-reviews.js"; @@ -6262,7 +6263,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const token = `xoxb-bot-token-canary-${id}`; const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; const state = { - failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, + managedAccessToken: token, rejectOldBotToken: false, + userListCursor: null as string | null, userListNextCursor: "", + managedBotAppId: appId, grantedScopes: scopes, managedError: null as string | null, failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, failManifestUpdate: false, timeoutManifestUpdate: false, beforeManifestUpdate: null as null | (() => Promise), failIcon: false, timeoutIcon: false, failUser: false, failWelcome: false, timeoutWelcome: false, @@ -6270,20 +6273,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { user: { id: installerId, team_id: "TAUTO", name: "installer", real_name: "Installing Person", is_bot: false, deleted: false }, beforeIcon: null as null | (() => Promise), beforeAuth: null as null | (() => Promise), + beforeExchange: null as null | (() => Promise), oauth: { ok: true, app_id: appId, token_type: "bot", access_token: token, team: { id: "TAUTO" }, bot_user_id: botId, authed_user: { id: installerId }, scope: scopes.join(",") }, }; const fallback = fakeSlackFetch(botId); const provider = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BMANAGED", app_id: state.managedBotAppId, user_id: botId, deleted: false } }); + if (url.endsWith("apps.managedInstall")) return Response.json(state.managedError ? { ok: false, error: state.managedError } : { ok: true, app_id: appId, team_id: "TAUTO", api_access_tokens: { bot_access_token: state.managedAccessToken } }); if (url.endsWith("apps.manifest.validate")) return Response.json(state.failValidate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true }); if (url.endsWith("apps.manifest.create")) { if (state.failCreate) throw new Error(`Timeout ${configToken} ${clientSecret}`); - return Response.json({ ok: true, app_id: appId, credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); + return Response.json({ ok: true, app_id: appId, team_id: "TAUTO", credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); } if (url.endsWith("apps.manifest.update")) { await state.beforeManifestUpdate?.(); if (state.timeoutManifestUpdate) throw new Error(`Manifest timeout ${configToken} ${clientSecret}`); - return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId }); + return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId, permissions_updated: true }); } if (url.endsWith("apps.icon.set")) { await state.beforeIcon?.(); @@ -6291,13 +6297,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { return Response.json(state.failIcon ? { ok: false, error: "missing_scope", detail: `${configToken} ${signingSecret}` } : { ok: true }); } if (url.endsWith("oauth.v2.access")) { + await state.beforeExchange?.(); if (state.failExchange) throw new Error(`Timeout ${clientSecret} ${token}`); return Response.json(state.oauth); } if (url.endsWith("auth.test")) { await state.beforeAuth?.(); + if (state.rejectOldBotToken && new Headers(init?.headers).get("authorization") !== `Bearer ${state.managedAccessToken}`) return Response.json({ ok: false, error: "token_revoked" }); if (state.failAuth) throw new Error(`Temporary failure ${token}`); - return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, bot_id: "BMANAGED", user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": state.grantedScopes.join(",") } }); + } + if (url.endsWith("users.list")) { + state.userListCursor = new URLSearchParams(String(init?.body)).get("cursor"); + return Response.json({ ok: true, members: [state.user], response_metadata: { next_cursor: state.userListNextCursor } }); } if (url.endsWith("users.info")) { await state.beforeUser?.(); @@ -6323,6 +6335,408 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const install = async () => new URL((await service.slackRegistration.install(endpoint.id, actor)).authorizationUrl).searchParams.get("state")!; return { ...company, endpoint, service, sdk, app, state, provider, create, install, createInput, token, appId, botId, installerId, avatarBytes, renderAvatar }; } + async function managedFixture() { + const broker = { + getCapabilities: vi.fn(async () => ["slack.manager"]), + getSlackManagerAppId: vi.fn(async () => "AMANAGER"), + startAuthorization: vi.fn(async (_input: { returnState: string }) => ({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=broker", expiresAt: new Date(Date.now() + 600_000).toISOString() })), + claim: vi.fn(), refresh: vi.fn(), revoke: vi.fn(async () => undefined), + }; + const f = await fixture({ slackManaged: { enabled: true, broker: broker as unknown as PaperclipCloudConnector } }); + const credentials: SealedConnectorCredentials = { v: 1, provider: "slack", profile: "slack.manager", instanceId: "test", environment: "staging", subject: actor.userId, companyId: f.companyId, + accessToken: "xoxe.xoxp-manager-access-canary", refreshToken: "xoxe-manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: new Date(Date.now() + 43_200_000).toISOString(), refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], slackManager: { appId: "AMANAGER", workspaceId: "TAUTO", workspaceName: "Test workspace", userId: f.installerId } }; + broker.claim.mockResolvedValue(credentials); + broker.refresh.mockResolvedValue({ ...credentials, accessToken: "xoxe.rotated-canary", refreshToken: "xoxe.rotated-refresh-canary" }); + await f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId); + const grants = f.service.slackRegistration.managerGrants; + const authorize = async () => { await grants.authorize(f.endpoint.id, actor); return broker.startAuthorization.mock.calls.at(-1)![0].returnState; }; + const authorizeState = await authorize(); + await grants.complete(authorizeState, "claim-canary", null, actor); + const choices = await grants.choices(f.companyId, actor); + expect(choices.workspaces).toHaveLength(1); + const grantId = choices.workspaces[0]!.grantId; + const input = { requestId: randomUUID(), grantId }; + const provision = () => f.service.slackRegistration.managed.provision(f.endpoint.id, actor, input); + return { ...f, broker, credentials, grants, authorize, authorizeState, grantId, input, provision }; + } + describe("Slack test messages", () => { + const posts = (f: Awaited>) => f.provider.mock.calls.filter(([url]) => String(url).endsWith("chat.postMessage")); + it("sends to the current linked user and reuses a durable result on duplicate requests", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(200); + expect(result.body).toEqual({ status: "sent", slackUrl: "https://app.slack.com/client/TAUTO/DWELCOME" }); + await f.service.sendSlackTestMessage(f.endpoint.id, actor, input); + expect(posts(f)).toHaveLength(1); + expect(new URLSearchParams(String(posts(f)[0]![1]!.body)).get("channel")).toBe(f.installerId); + }); + it("links the actor's own manager identity without requiring a connect command", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).toMatchObject({ status: "sent" }); + expect(await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id))).toEqual([expect.objectContaining({ paperclipUserId: actor.userId, status: "linked" })]); + }); + it("requires a private Slack challenge for an unknown account before linking and sending the test", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual({ status: "connection_required", accounts: [{ id: f.installerId, name: "Installing Person" }], nextCursor: null }); + expect(posts(f)).toHaveLength(0); + const pending = await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, slackUserId: f.installerId }); + expect(pending.status).toBe("connection_pending"); + const links = await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(links).toEqual([expect.objectContaining({ status: "pending", paperclipUserId: null })]); + const fields = new URLSearchParams(String(posts(f)[0]![1]!.body)); + expect(fields.get("text")).not.toContain("This is your test message"); + const blocks = JSON.parse(fields.get("blocks")!); + const invitationUrl = /<([^|]+)\|/.exec(blocks[1].text.text)![1]!; + const token = new URL(invitationUrl).searchParams.get("token")!; + expect(JSON.stringify(pending)).not.toContain(token); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual(pending); + expect(posts(f)).toHaveLength(1); + await f.service.confirmIdentityLink(token, actor.userId); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(new URLSearchParams(String(posts(f).at(-1)![1]!.body)).get("text")).toContain("This is your test message"); + }); + it("loads another workspace page instead of truncating account discovery", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); f.state.userListNextCursor = "next-page"; + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "connection_required", nextCursor: "next-page" }); + f.state.userListNextCursor = ""; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, accountCursor: "next-page" })).toMatchObject({ status: "connection_required", nextCursor: null }); + expect(f.state.userListCursor).toBe("next-page"); + expect(posts(f)).toHaveLength(0); + }); + it("does not downgrade confirmed delivery when activity logging fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const original = activityLogModule.logActivity; + const audit = vi.spyOn(activityLogModule, "logActivity").mockImplementation(async (database, entry) => { + if (entry.action === "chat_slack.test_message_sent") throw new Error("Synthetic activity failure"); + return original(database, entry); + }); + onTestFinished(() => audit.mockRestore()); + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(posts(f)).toHaveLength(1); + }); + it("does not restore revoked access or take another user's identity", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("existing link"); + await db.update(chatIdentityLinks).set({ status: "linked", paperclipUserId: "another-user" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID(), slackUserId: f.installerId })).rejects.toThrow("existing link"); + expect(posts(f)).toHaveLength(0); + }); + it("rejects wrong-workspace users, disabled DMs, and membership revocation", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + f.state.user.team_id = "TOTHER"; + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("personal Slack account"); + f.state.user.team_id = "TAUTO"; + await db.update(chatEndpoints).set({ allowDirectMessages: false }).where(eq(chatEndpoints.id, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("Enable direct messages"); + await db.update(chatEndpoints).set({ allowDirectMessages: true }).where(eq(chatEndpoints.id, f.endpoint.id)); + await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, actor.userId))); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("active Paperclip account"); + expect(posts(f)).toHaveLength(0); + }); + it("redacts provider failures and never retries an ambiguous send", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); f.state.timeoutWelcome = true; + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(422); + expect(JSON.stringify(result.body)).not.toContain("canary"); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).rejects.toThrow("may already have been sent"); + expect(posts(f)).toHaveLength(1); + const actions = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "slack_test_message"))); + expect(JSON.stringify(actions)).not.toContain("canary"); + expect(actions[0]!.status).toBe("uncertain"); + }); + }); + describe("managed Slack", () => { + it("keeps open-source setup independent of the broker", async () => { + const f = await fixture(); + expect(await f.service.slackRegistration.managerGrants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "automatic", workspaces: [] }); + await f.create(); + expect((await f.service.get(f.endpoint.id)).setup.slackSetupMethod).toBe("automatic"); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId)).rejects.toThrow(); + }); + it("keeps configured Cloud drafts managed when the broker profile disappears", async () => { + const f = await managedFixture(); + f.broker.getSlackManagerAppId.mockResolvedValue(null); + expect(f.grants.defaultMethod()).toBe("managed"); + expect(await f.grants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "managed", workspaces: [] }); + const next = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId }, actor.userId); + expect(next.setup.slackSetupMethod).toBe("managed"); + await expect(f.provision()).rejects.toThrow(); + }); + it("creates and installs once, uploads the avatar and links only the manager grant owner", async () => { + const f = await managedFixture(); + await Promise.all([f.provision(), f.provision()]); + const saved = await f.service.get(f.endpoint.id); + expect(saved.setup).toMatchObject({ slackSetupMethod: "managed", slackRegistration: { status: "configured", appId: f.appId }, slackAvatar: { status: "uploaded" }, slackAccount: { status: "linked", paperclipUserId: actor.userId, externalUserId: f.installerId } }); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + const activities = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId)); + for (const secret of [f.credentials.accessToken, f.credentials.refreshToken!, f.token, clientSecret, signingSecret, "claim-canary"]) expect(JSON.stringify({ saved, activities, choices: await f.grants.choices(f.companyId, actor) })).not.toContain(secret); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "automatic" }, actor.userId)).rejects.toThrow(); + }); + it("reuses the same personal workspace grant for another draft without another authorization", async () => { + const f = await managedFixture(); + const draft = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed" }, actor.userId); + expect((await f.grants.choices(f.companyId, actor)).workspaces[0]!.grantId).toBe(f.grantId); + const otherActor = { ...actor, userId: "another-user", bypassPermissionCheck: true }; + expect((await f.grants.choices(f.companyId, otherActor)).workspaces).toEqual([]); + await expect(f.service.slackRegistration.managed.provision(draft.id, otherActor, { ...f.input, requestId: randomUUID() })).rejects.toThrow(); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + }); + it("installs a second bot using the same grant without new manager consent", async () => { + const f = await managedFixture(); await f.provision(); + const secondBot = `${f.botId}SECOND`; + const secondApp = `${f.appId}SECOND`; + const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; + const provider: typeof fetch = async (input, init) => { + const url = String(input); + if (url.endsWith("apps.manifest.create")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", credentials: { client_id: "222.333", client_secret: clientSecret, signing_secret: signingSecret } }); + if (url.endsWith("apps.managedInstall")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", api_access_tokens: { bot_access_token: "xoxb-second-managed" } }); + if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TAUTO", user_id: secondBot, bot_id: "BSECOND", team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BSECOND", app_id: secondApp, user_id: secondBot, deleted: false } }); + return f.provider(input, init); + }; + const { service } = createService(new FakeChatSdkRuntime(), provider, { renderSlackAvatar: f.renderAvatar, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }); + const second = await service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed", slackApp: appDetails }, actor.userId); + await service.slackRegistration.managed.provision(second.id, actor, { grantId: f.grantId, requestId: randomUUID() }); + expect((await service.get(second.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: secondApp }); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("rejects missing tool scopes before activating a managed bot", async () => { + const f = await managedFixture(); f.state.grantedScopes = []; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_scopes_missing" }); + }); + it.each(["revoked", "attention", "active"] as const)("reinstalls the configured app on an explicit %s reconnect", async status => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedAccessToken = `xoxb-reconnected-${f.botId}`; f.state.rejectOldBotToken = true; + const reconnectInput = { ...f.input, requestId: randomUUID(), reconnect: true }; + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + expect((await f.service.get(f.endpoint.id)).status).not.toBe("revoked"); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: f.appId }); + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + }); + it("preserves configured-app reconnect intent when managed installation fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status: "revoked" }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedError = "temporary_error"; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", appId: f.appId }); + f.state.managedError = null; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + }); + it("reinstalls the saved app after missing bot scopes without creating another app", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(2); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("retains reinstall intent after a provider failure instead of retrying rejected credentials", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.managedError = "internal_error"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", errorCode: "slack_provider_failure" }); + f.state.managedError = null; f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(3); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("clears a manager reconnect error after renewing the bound grant", async () => { + const f = await managedFixture(); f.state.managedError = "invalid_auth"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manager_reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBeNull(); + f.state.managedError = null; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("reconnects a rejected manager token without losing pending manifest configuration", async () => { + const f = await managedFixture(); f.state.failManifestUpdate = true; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manifest_update_pending"); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + f.state.failManifestUpdate = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect((await f.service.get(f.endpoint.id)).setup.slackAvatar?.status).toBe("uploaded"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.icon.set"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("does not restore a revoked grant when an in-flight refresh returns", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockImplementation(async () => { + await f.grants.revoke(f.grantId, f.companyId, actor); + return f.credentials; + }); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("revoked"); + }); + it("claims concurrent manager callbacks once", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const results = await Promise.allSettled([1, 2].map(() => f.grants.complete(state, "claim", null, actor))); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(f.broker.claim).toHaveBeenCalledTimes(2); + }); + it("atomically consumes manager state and rejects wrong actors and expired attempts", async () => { + const f = await managedFixture(); + await expect(f.grants.complete(f.authorizeState, "replay", null, actor)).rejects.toThrow(); + const state = await f.authorize(); + await expect(f.grants.complete(state, "claim", null, { ...actor, userId: "another-user", bypassPermissionCheck: true })).rejects.toThrow(); + await db.update(toolOauthStates).set({ expiresAt: new Date(0) }).where(eq(toolOauthStates.state, state)); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("preserves declined manager authorization as a safe recoverable outcome", async () => { + const f = await managedFixture(); + await f.grants.complete(await f.authorize(), null, "access_denied", actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBe("slack_install_declined"); + }); + it("resumes saved installation credentials after inventory failure without creating or reinstalling", async () => { + const f = await managedFixture(); f.state.failInventory = true; + await f.provision(); + expect((await f.service.slackRegistration.registration(f.endpoint.id))?.status).toBe("credentials_saved"); + f.state.failInventory = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("never recreates an uncertain app", async () => { + const f = await managedFixture(); f.state.failCreate = true; + await f.provision(); await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("uncertain"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("uses bound OAuth for approval fallback and rejects a different installer", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.oauth.authed_user.id = "UOTHER"; + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + expect((await f.service.get(f.endpoint.id)).providerAccountId).toBeNull(); + }); + it("does not activate a bot when the manager grant is revoked during child OAuth", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.beforeExchange = () => f.grants.revoke(f.grantId, f.companyId, actor); + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + }); + it("uses the held transaction for grant queries without acquiring another pool connection", async () => { + const f = await managedFixture(); + const transactionContext = new AsyncLocalStorage(); + const originalTransaction = db.transaction.bind(db); + const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((work, ...args) => + originalTransaction(tx => transactionContext.run(true, () => work(tx)), ...args)); + const querySpies = (["select", "insert", "update", "delete", "execute"] as const).map(method => { + const original = db[method].bind(db) as (...args: unknown[]) => unknown; + return vi.spyOn(db, method).mockImplementation(((...args: unknown[]) => { + if (transactionContext.getStore()) throw new Error("Acquired outer pool connection from held transaction"); + return original(...args); + }) as never); + }); + try { + await f.grants.token(f.grantId, f.companyId, actor); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + await f.grants.token(f.grantId, f.companyId, actor); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await f.grants.request(f.grantId, f.companyId, actor, "apps.manifest.validate", { manifest: "{}" }); + await f.grants.complete(await f.authorize(), "renewed-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBeUndefined(); + await f.grants.revoke(f.grantId, f.companyId, actor); + expect(f.broker.revoke).toHaveBeenCalledTimes(2); + } finally { + transactionSpy.mockRestore(); + for (const spy of querySpies) spy.mockRestore(); + } + }); + it("serializes refreshes across endpoints and never replays an ambiguous refresh", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + const tokens = await Promise.all([1, 2].map(() => f.grants.token(f.grantId, f.companyId, actor))); + expect(tokens.every(result => result.token === "xoxe.rotated-canary")).toBe(true); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockRejectedValue(new Error("provider echoed manager-canary")); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect(f.broker.refresh).toHaveBeenCalledTimes(2); + }); + it("rejects a bot token belonging to a different Slack app before activation", async () => { + const f = await managedFixture(); f.state.managedBotAppId = "ADIFFERENT"; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_identity_mismatch" }); + }); + it("rejects manager identity changes and both callback-origin and ingress-origin drift", async () => { + const f = await managedFixture(); const state = await f.authorize(); + for (const change of [{ publicBaseUrl: "https://changed.example" }, { webhookPublicBaseUrl: "https://changed.example" }]) { + const changed = createService(new FakeChatSdkRuntime(), f.provider, { ...change, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }).service; + await expect(changed.slackRegistration.managerGrants.complete(state, "claim", null, actor)).rejects.toThrow(); + } + f.broker.getSlackManagerAppId.mockResolvedValue("ANEWMANAGER"); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + it("preserves provider rate-limit backoff across subsequent attempts", async () => { + const f = await managedFixture(); f.state.managedError = "ratelimited"; + await f.provision(); + f.state.managedError = null; await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_setup_rate_limited"); + }); + it("routes the broker claim_id callback back to the saved wizard without exposing the handoff", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const response = await request(f.app).get(`/api/chat-slack/managed/oauth/callback?state=${state}&claim_id=sealed-claim-canary`).set("accept", "application/json").expect(303); + expect(f.broker.claim.mock.calls.at(-1)![0].claimId).toBe("sealed-claim-canary"); + expect(response.headers.location).toContain(f.endpoint.id); + expect(response.text).not.toContain("sealed-claim-canary"); + expect(response.headers["cache-control"]).toBe("no-store"); + }); + it("does not accept another company's grant or keep authorizations after removal", async () => { + const f = await managedFixture(); const other = await seedCompany(); + await expect(f.grants.token(f.grantId, other.companyId, { ...actor, bypassPermissionCheck: true })).rejects.toThrow(); + const state = await f.authorize(); + await f.service.configure(f.endpoint.id, { action: "remove" }, actor.userId); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("active"); + }); + it("revocation invalidates pending authorization and prevents future provisioning", async () => { + const f = await managedFixture(); const state = await f.authorize(); + await f.grants.revoke(f.grantId, f.companyId, actor); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + }); function setupEvent(f: Awaited>, patch: Record = {}, url = f.endpoint.setup.webhookUrl!, secret = signingSecret, timestamp?: string) { return signedSlackWebhookRequest({ url, contentType: "application/json", signingSecret: secret, ...(timestamp ? { timestamp } : {}), body: JSON.stringify({ type: "event_callback", api_app_id: f.appId, team_id: "TAUTO", event_id: "EvSETUP", event: { diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index f6a92125283..0c57d5159b0 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -65,7 +65,7 @@ describe("isPrivateWebhookHttpRequest", () => { describe("isSecretSensitiveHttpRequest", () => { it("protects Slack registration, installation, recovery, and all callback methods", () => { for (const suffix of ["registration", "install", "resume"]) expect(isSecretSensitiveHttpRequest("POST", `/api/chat-endpoints/id/slack/${suffix}`)).toBe(true); - for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) + for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "/api/chat-slack/managed/oauth/callback?claim=canary", "/api/chat-slack/managed/oauth/callback?code=canary", "/api/chat-endpoints/endpoint/slack/managed/authorize", "/api/chat-endpoints/endpoint/slack/managed/provision", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) expect(isSecretSensitiveHttpRequest(method, path)).toBe(true); }); it("identifies credential-bearing chat setup mutations", () => { diff --git a/server/src/__tests__/redact-sensitive.test.ts b/server/src/__tests__/redact-sensitive.test.ts index b0467a7fb96..151c9212a5d 100644 --- a/server/src/__tests__/redact-sensitive.test.ts +++ b/server/src/__tests__/redact-sensitive.test.ts @@ -6,6 +6,11 @@ import { stripSecretBearingUrlParts, } from "../middleware/redact-sensitive.js"; +it("redacts Slack manager grants and sealed OAuth handoffs", () => { + const result = redactSensitive({ managerToken: "manager-canary", refreshToken: "refresh-canary", sealed: { ct: "sealed-canary" }, handoff: { token: "handoff-canary" }, claim_id: "claim-canary" }); + expect(JSON.stringify(result)).not.toContain("canary"); +}); + describe("redactSensitive", () => { it("redacts a plaintext password field on a sign-in body", () => { const body = { diff --git a/server/src/__tests__/slack-development-manager.test.ts b/server/src/__tests__/slack-development-manager.test.ts new file mode 100644 index 00000000000..314e087843c --- /dev/null +++ b/server/src/__tests__/slack-development-manager.test.ts @@ -0,0 +1,73 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import { developmentSlackManager } from "../services/connectors/slack/setup/development-manager.js"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(path => rmSync(path, { recursive: true, force: true }))); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "slack-development-")); roots.push(root); + const path = join(root, "manager.json"); + writeFileSync(path, JSON.stringify({ appId: "A123", clientId: "123.456", clientSecret: "client-secret-canary" }), { mode: 0o600 }); + return { path, env: { NODE_ENV: "development", PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "true", PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG: path } }; +} +const origin = () => "https://local-test.trycloudflare.com"; +const identity = { subject: "actor", companyId: "company", profile: "slack.manager" as const }; +const user = { id: "U123", token_type: "user", access_token: "access-canary", refresh_token: "refresh-canary", expires_in: 43200, scope: SLACK_MANAGER_SCOPES.join(",") }; +const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); + +describe("local managed Slack qualification", () => { + it("requires explicit development opt-in and refuses production", () => { + const { env } = fixture(); + expect(developmentSlackManager(origin, {})).toBeNull(); + expect(developmentSlackManager(origin, { ...env, NODE_ENV: "production" })).toBeNull(); + expect(developmentSlackManager(origin, { ...env, PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "false" })).toBeNull(); + }); + it("rejects readable secrets and invalid HTTPS origins", async () => { + const { env, path } = fixture(); chmodSync(path, 0o644); + expect(() => developmentSlackManager(origin, env)).toThrow(); + chmodSync(path, 0o600); + await expect(developmentSlackManager(() => "http://local.test", env)!.getSlackManagerAppId()).rejects.toThrow(); + }); + it("builds a scoped authorization URL without exposing the client secret", async () => { + const { env } = fixture(); + const client = developmentSlackManager(origin, env)!; + const result = await client.startAuthorization({ ...identity, returnUri: `${origin()}/api/chat-slack/managed/oauth/callback`, returnState: "bound-state" }); + const url = new URL(result.authorizationUrl); + expect(url.origin).toBe("https://slack.com"); + expect(url.searchParams.get("state")).toBe("bound-state"); + expect(url.searchParams.get("user_scope")).toBe(SLACK_MANAGER_SCOPES.join(",")); + expect(JSON.stringify(result)).not.toContain("client-secret-canary"); + await expect(client.startAuthorization({ ...identity, returnUri: "https://other.test/callback", returnState: "bound-state" })).rejects.toThrow(); + }); + it("exchanges the code against the exact callback and preserves actor/workspace identity", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValue(response({ ok: true, app_id: "A123", team: { id: "T123", name: "Paperclip" }, authed_user: user })); + const result = await developmentSlackManager(origin, env, fetcher)!.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }); + expect(result.slackManager).toEqual({ appId: "A123", workspaceId: "T123", workspaceName: "Paperclip", userId: "U123" }); + expect(result.subject).toBe("actor"); expect(result.companyId).toBe("company"); + const [url, request] = fetcher.mock.calls[0]; + expect(url).toBe("https://slack.com/api/oauth.v2.access"); + expect(request.redirect).toBe("error"); + expect(request.body.get("redirect_uri")).toBe(`${origin()}/api/chat-slack/managed/oauth/callback`); + expect(request.body.get("code")).toBe("code-canary"); + }); + it.each(["wrong-app", "missing-scope", "provider-echo"])("rejects %s without echoing secrets", async reason => { + const { env } = fixture(); + const value = reason === "provider-echo" ? { ok: false, error: "client-secret-canary code-canary" } : { ok: true, app_id: reason === "wrong-app" ? "AOTHER" : "A123", team: { id: "T123", name: "Paperclip" }, authed_user: { ...user, scope: reason === "missing-scope" ? "" : user.scope } }; + const client = developmentSlackManager(origin, env, vi.fn().mockResolvedValue(response(value)))!; + const error = await client.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }).catch(error => error); + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toMatch(/canary/); + }); + it("rotates refresh credentials and verifies revocation", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValueOnce(response({ ok: true, ...user })).mockResolvedValueOnce(response({ ok: false, error: "invalid_auth" })); + const client = developmentSlackManager(origin, env, fetcher)!; + expect((await client.refresh({ ...identity, refreshToken: "old-refresh" })).refreshToken).toBe("refresh-canary"); + expect(fetcher.mock.calls[0][1].body.get("grant_type")).toBe("refresh_token"); + await expect(client.revoke({ ...identity, token: "access-canary" })).rejects.toThrow(); + }); +}); diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index 5c5731659d2..222f97d476c 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -93,7 +93,7 @@ export function isSecretSensitiveHttpRequest( // Avatar uploads and dedicated Dot MCP calls may contain private image bytes. if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!method || !url) return false; - if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; + if (/^\/api\/chat-slack\/(?:managed\/)?oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false; const pathname = normalizePath(url); diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts index 0b4ba2d39d4..3d6b874f6d7 100644 --- a/server/src/middleware/redact-sensitive.ts +++ b/server/src/middleware/redact-sensitive.ts @@ -16,6 +16,13 @@ const SENSITIVE_KEYS = new Set([ // material under `credentials`. Redact the whole subtree instead of trying // to keep an ever-changing allowlist of provider-specific field names in // sync with every connector. + "sealed", + "handoff", + "claim", + "claimid", + "claim_id", + "managertoken", + "manager_token", "configurationtoken", "configuration_token", "credential", diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 6395e5ecd89..6dbde753c35 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -1,4 +1,4 @@ -import { slackRegistrationSchema, slackSetupActionSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared"; import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js"; import { Router, @@ -164,6 +164,10 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { async (req, res) => { const companyId = req.params.companyId as string; await assertConnectionManager(req, companyId); + if (req.body.provider === "slack" && !req.body.slackSetupMethod) { + req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod(); + } + if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable"); res .status(201) .json(await service.create(companyId, req.body, actorUserId(req))); @@ -182,6 +186,50 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { return { userId: actor.actorId, sessionId: actor.sessionId, bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true }; }; + router.get("/companies/:companyId/chat-slack/setup-options", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/authorize", validate(slackSetupActionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/provision", validate(slackManagedProvisionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body)); + }); + router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req)); + res.sendStatus(204); + }); + router.get("/chat-slack/managed/oauth/callback", async (req, res) => { + res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); + const actor = slackActor(req); + const state = typeof req.query.state === "string" ? req.query.state : ""; + const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor); + if (expiredId) { res.redirect(303, await service.slackRegistration.returnPath(expiredId)); return; } + const pending = await service.slackRegistration.managerGrants.pending(state, actor); + await assertConnectionManager(req, pending.current.endpoint.companyId); + if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) { + res.type("html").send(oauthCallbackInterstitialHtml()); return; + } + const id = await service.slackRegistration.managerGrants.complete(state, + typeof req.query.claim_id === "string" ? req.query.claim_id : null, + typeof req.query.error === "string" ? req.query.error : null, actor, + typeof req.query.code === "string" ? req.query.code : null); + res.redirect(303, await service.slackRegistration.returnPath(id)); + }); + router.post("/chat-endpoints/:endpointId/slack/test-message", validate(slackTestMessageSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body)); + }); router.post("/chat-endpoints/:endpointId/slack/registration", validate(slackRegistrationSchema), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index f39cd53431a..9b113c62805 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1,5 +1,5 @@ import { updateFastResponseSchema } from "@paperclipai/shared"; -import { slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; import { experimentalApiMetadata } from "./experimental-api-metadata.js"; import { experimentalApiPaths, @@ -803,8 +803,9 @@ const chatAdapterCapabilitiesResponseSchema = z const chatEndpointSetupResponseSchema = z .object({ step: z.enum(["choose_agent", "provider_setup", "test", "complete"]), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), slackRegistration: slackRegistrationStateSchema.optional(), + slackManagerError: z.string().optional(), slackAvatar: slackAvatarStateSchema.optional(), slackAccount: slackAccountStateSchema.optional(), slackOAuthCallbackUri: z.string().nullable().optional(), @@ -2563,6 +2564,41 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"], + summary: "Discover Slack setup methods and the current user's authorized workspaces", + description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.", + request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden }, +}); +for (const action of ["authorize", "provision"] as const) registry.registerPath({ + method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"], + summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app", + description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) }, + responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable }, +}); +registry.registerPath({ + method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"], + summary: "Revoke the current user's workspace grant for future managed provisioning", + description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.", + request: { params: z.object({ companyId: z.string().uuid(), grantId: z.string().uuid() }) }, responses: { 204: { description: "Personal manager grant revoked" }, 401: r.unauthorized, 403: r.forbidden }, +}); +registry.registerPath({ + method: "get", path: "/api/chat-slack/managed/oauth/callback", tags: ["chat-channels"], + summary: "Redeem a sealed manager authorization and return to Slack setup", + description: "Authenticated, same-origin continuation with company, actor, session, endpoint revision and configured-origin checks. The broker handoff is single use; credentials are never returned to the browser. Explicit local development qualification accepts a Slack code instead of a broker claim; production ignores code.", + request: { query: z.object({ state: z.string(), claim_id: z.string().optional(), code: z.string().optional(), error: z.string().optional() }) }, + responses: { 200: { description: "Same-origin continuation page" }, 303: { description: "Return to saved setup" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict }, +}); + +registry.registerPath({ + method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"], + summary: "Connect the current user's Slack identity and send a test DM", + description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(slackTestMessageSchema) }, + responses: { 200: r.ok(slackTestMessageResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable, 429: { description: "Wait before sending another test message" } }, +}); + for (const action of ["registration", "install", "resume"] as const) { registry.registerPath({ method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"], diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 31338f7acc1..e1077280799 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,6 @@ +import { slackTestMessageService } from "./connectors/slack/test-message.js"; +import type { SlackSetupOptions } from "./connectors/slack/setup/registration.js"; +import { isProvisionedSlackSetup } from "@paperclipai/shared"; import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; @@ -1297,6 +1300,7 @@ export interface ChatChannelServiceOptions { claimId: string; }) => Promise; renderSlackAvatar?: (request: AgentAvatarRequest) => Promise; + slackManaged?: import("./connectors/slack/setup/registration.js").SlackSetupOptions["managed"]; storage?: StorageService; } @@ -1682,6 +1686,7 @@ function providerSetupState( slackApp: endpoint.setup.slackApp, slackSetupMethod: endpoint.setup.slackSetupMethod, slackAvatar: endpoint.setup.slackAvatar, + slackManagerError: endpoint.setup.slackManagerError, slackAccount: endpoint.setup.slackAccount, // Slack registers the slash command in the provider configuration. // Keep that identity immutable when the assigned agent is renamed; @@ -4414,7 +4419,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { actorUserId?: string | null, ) { if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail"); - if (input.slackApp && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + if ((input.slackApp || input.slackSetupMethod) && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); const agent = await db .select({ id: agents.id, name: agents.name, status: agents.status }) .from(agents) @@ -4526,7 +4532,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { step: "provider_setup", ...(input.provider === "slack" ? { slackApp: input.slackApp ?? defaultSlackAppConfiguration(agent.name), - command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: "automatic" } + command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: input.slackSetupMethod ?? slackRegistration.managerGrants.defaultMethod() } : {}), }, }); @@ -4572,13 +4578,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } values.communicationInstructions = input.communicationInstructions; } + if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); if (input.slackSetupMethod && (existing.endpoint.provider !== "slack" || existing.endpoint.botExternalId || existing.endpoint.status !== "draft")) { throw conflict("Setup method can only change before connecting the app"); } if (input.slackApp || input.slackSetupMethod) { const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); - if (registration && registration.status !== "failed") { - if (input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); + if (registration && (registration.managerGrantId || registration.status !== "failed")) { + if (registration.managerGrantId || input.slackSetupMethod === "managed" || input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); // Manual recovery fences pending authorization under the same credential lease. await slackRegistration.cleanup(endpointId, credentialLease); } @@ -6886,7 +6893,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await syncSpekoVoiceTools(tx, endpoint, actorUserId, true, options.allowLocalVoiceBoard === true); } const observedSlackUrl = (current.setup as InternalSetupState).slackCallbackSurfaces?.events?.url; - const preserveSlackVerification = current.setup.slackSetupMethod === "automatic" + const preserveSlackVerification = isProvisionedSlackSetup(current.setup.slackSetupMethod) && (current.setup as InternalSetupState).slackVerificationSigningFingerprint === createHash("sha256").update(credentials.signingSecret ?? "").digest("hex") && Boolean(observedSlackUrl && slackCallbackMatchesPublicUrl(observedSlackUrl, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`)); await notifyChatEndpointWork(tx); @@ -18637,7 +18644,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId))); if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 }); let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret; - if (!secret && current.setup.slackSetupMethod === "automatic") { + if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) { const [registration] = await tx.select().from(chatSlackRegistrations) .where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId))); if (registration?.status !== "removed" && registration?.secretIds.signingSecret) { @@ -19252,7 +19259,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // Recheck the signature under the current credential/runtime fence; // an SDK acknowledgement alone is not authentication evidence. let signingFingerprint: string | null = null; - if (!currentSetup.webhookVerifiedAt && currentSetup.slackSetupMethod === "automatic" + if (!currentSetup.webhookVerifiedAt && isProvisionedSlackSetup(currentSetup.slackSetupMethod) && callback.surface === "events" && !callback.isUrlVerification && slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`)) { const payload = JSON.parse(eventBody) as { type?: string; team_id?: string; api_app_id?: string; event_id?: string; event?: { type?: string } }; @@ -26107,8 +26114,32 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return get(endpointId); } + const linkSlackAccount: SlackSetupOptions["linkInstaller"] = async (endpointId, user, actor, lease) => { + const record = await endpointRecord(endpointId); + if (!record || record.endpoint.provider !== "slack" || record.endpoint.providerAccountId !== user.team_id) throw conflict("Slack setup changed"); + const { principal } = await ensurePrincipal(record.endpoint, { + userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), + fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, + }); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); + const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; + if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); + const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); + if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); + if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; + await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, + paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) + .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { + paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), + } }); + await lease.assertOwned(tx); + }); + }; + const slackRegistration = slackChatRegistrationService(db, { - publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, + publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, managed: options.slackManaged, withLock: async (endpointId, work) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26136,29 +26167,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), eq(chatIdentityLinks.status, "linked"), eq(chatIdentityLinks.paperclipUserId, account.paperclipUserId), eq(chatExternalPrincipals.externalId, account.externalUserId), eq(chatExternalPrincipals.providerAccountId, record.endpoint.providerAccountId!))); return Boolean(link); }, - linkInstaller: async (endpointId, user, actor, lease) => { - const record = await endpointRecord(endpointId); - if (!record || record.endpoint.provider !== "slack" || record.endpoint.providerAccountId !== user.team_id) throw conflict("Slack setup changed"); - const { principal } = await ensurePrincipal(record.endpoint, { - userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), - fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, - }); - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); - const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; - if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); - const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); - if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); - if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; - await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, - paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) - .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { - paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), - } }); - await lease.assertOwned(tx); - }); - }, + linkInstaller: linkSlackAccount, configure: async (endpointId, credentials, actor, lease) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26168,6 +26177,28 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, }); + const sendSlackTestMessage = slackTestMessageService(db, { + fetch: fetchImpl, + withLock: async (endpointId, work) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return withCredentialMutationLease(record.endpoint, work); + }, + runtimeBotToken: async endpointId => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return (await resolveCredentials(record.endpoint)).botToken; + }, + linkInstaller: linkSlackAccount, + ensureAccount: async (endpointId, user) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const { principal } = await ensurePrincipal(record.endpoint, { userId: String(user.id), userName: String(user.name ?? user.id), fullName: String(user.real_name ?? user.id), isBot: false, isMe: false }); + return principal.id; + }, + createLinkIntent, + }); + const githubRegistration = githubChatRegistrationService(db, { publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl, storeApp: async (endpointId, userId, app) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }, "manifest"); }, @@ -26345,6 +26376,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { voice, authorizeFastResponse, slackRegistration, + sendSlackTestMessage, saveGitHubSetupProgress: async (endpointId: string, stage: NonNullable["stage"]) => { const record = await endpointRecord(endpointId); if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); diff --git a/server/src/services/chat-slack-registration-cleanup.ts b/server/src/services/chat-slack-registration-cleanup.ts index 22a2b758598..97292a773a5 100644 --- a/server/src/services/chat-slack-registration-cleanup.ts +++ b/server/src/services/chat-slack-registration-cleanup.ts @@ -6,8 +6,11 @@ import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-le // Keep generic connection removal independent of the registration service's // authorization graph; access routes already load tool-access through the barrel. export async function removeSlackRegistration(db: Db, endpointId: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); const vault = secretService(db); const [row] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); + const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + if (endpoint) await db.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, endpoint.connectionId), like(toolOauthStates.state, "slack-manager.%"))); if (!row) return; await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ status: "removed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); @@ -16,4 +19,4 @@ export async function removeSlackRegistration(db: Db, endpointId: string, lease: for (const id of Object.values(row.secretIds)) { await lease.assertOwned(); await vault.remove(id); } await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ secretIds: {} }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } +} diff --git a/server/src/services/chat-slack-registration.ts b/server/src/services/chat-slack-registration.ts index 905c7ab0346..ea187cfff2f 100644 --- a/server/src/services/chat-slack-registration.ts +++ b/server/src/services/chat-slack-registration.ts @@ -1,531 +1,3 @@ -import { notifyChatVerificationWork } from "./chat-work-notifications.js"; -import { createAgentAvatarPool } from "./agent-avatar-pool.js"; -import type { AgentAvatarRequest } from "./agent-avatars.js"; -import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js"; -import { createHash, randomBytes } from "node:crypto"; -import { and, eq, gt, inArray, like, sql } from "drizzle-orm"; -import { agents, chatEndpoints, chatSlackRegistrations, companies, toolConnections, toolOauthStates, type Db } from "@paperclipai/db"; -import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, slackRegistrationErrorMessage, type SlackRegistrationInput, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState } from "@paperclipai/shared"; -import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; -import { accessService } from "./access.js"; -import { logActivity } from "./activity-log.js"; -import { logger } from "../middleware/logger.js"; -import { instanceSettingsService } from "./instance-settings.js"; -import { secretService } from "./secrets.js"; -import { writeConnectionCredential } from "./connection-credentials.js"; -import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-lease.js"; - -export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } -type Registration = typeof chatSlackRegistrations.$inferSelect; -const prefix = "slack-install."; -const timeoutMs = 20_000; -const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); -const knownProviderErrors: Record = { - invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", - token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", - invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", - ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", - missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", -}; - -export function slackRegistrationProjection(row: Registration): SlackRegistrationState { - return { - status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, - appId: row.appId, - managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", - errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, - }; -} - -export function slackChatRegistrationService(db: Db, options: { - publicOrigin: () => string | null; - webhookOrigin: () => string | null; - fetch?: typeof fetch; - renderAvatar?: (request: AgentAvatarRequest) => Promise; - withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; - runtimeSigningSecret: (endpointId: string) => Promise; - runtimeBotToken: (endpointId: string) => Promise; - canWelcome: (endpointId: string, account: SlackAccountState) => Promise; - linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; - configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; -}) { - const fetchImpl = options.fetch ?? fetch; - const vault = secretService(db); - let avatarPool: ReturnType | undefined; - function origin(value: string | null) { - if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); - const url = new URL(value); - if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) - throw badRequest("Slack setup requires a configured public HTTPS origin"); - return url.origin; - } - const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; - async function registration(endpointId: string) { - return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; - } - async function endpoint(endpointId: string, actor: SlackSetupActor) { - if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); - const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) - .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) - .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) - .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); - if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); - if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) - throw forbidden("Missing permission: tools:manage_connections"); - return row; - } - function assertOrigins(row: Registration, publicId: string) { - if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); - const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; - if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) - throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); - } - async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { - await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, - action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, - details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); - } - async function api(method: string, fields: Record | FormData, bearer?: string) { - const response = await fetchImpl(`https://slack.com/api/${method}`, { - method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), - headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, - body: fields instanceof FormData ? fields : new URLSearchParams(fields), - }); - const result = object(await response.json()); - if (!response.ok || result.ok !== true) { - const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); - throw providerFailure(code); - } - return result; - } - async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, - patch: Partial = {}, account?: SlackAccountState) { - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const ids = { ...current.secretIds }; - for (const [key, value] of Object.entries(values)) { - const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", - configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, - ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); - ids[key] = result.secret.id; - } - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); - if (account) { - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); - // Reauthorization never changes an established personal account binding. - if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - } - await lease.assertOwned(tx); - }); - } - async function readSecret(row: Registration, key: string) { - if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); - return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { - accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, - }); - } - async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - } - async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived" || saved.setup.slackSetupMethod !== "automatic") throw conflict("Slack setup changed. Resume the saved connection."); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) - .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); - await lease.assertOwned(tx); - }); - } - async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const previous = await registration(endpointId); - if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); - if (previous?.appId) { - if (previous.errorCode === "slack_manifest_update_pending") { - await configureManifest(previous, actor, input.credentials.configurationToken, lease); - } - return; - } - if (previous?.requestId === input.requestId) return; - if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { - await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); - return; - } - if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Automatic creation is only available for a new Slack connection"); - const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); - const redirect = callbackUri(); - const manifest = buildSlackAppManifest({ app, agentName: current.agentName, - webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); - // Slack cannot authenticate a challenge until its creation response gives us - // the signing secret. Add event subscriptions only after that secret is durable. - const { event_subscriptions: _events, ...creationSettings } = manifest.settings; - const creationManifest = { ...manifest, settings: creationSettings }; - try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } - catch (error) { - if (error instanceof Error && "status" in error) throw error; - throw providerFailure("slack_provider_failure"); - } - // No provider creation has happened until this intent is durable. - await endpoint(endpointId, actor); - await lease.assertOwned(); - const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, - manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, - createdByUserId: actor.userId, errorCode: null, updatedAt: new Date() }; - const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) - .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); - await audit(row, actor, "creation_started"); - let createdAppId: string; - try { - const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); - const credentials = object(result.credentials); - if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) - || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) - || typeof credentials.client_secret !== "string" || !credentials.client_secret - || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) - throw new Error("Incomplete Slack creation response"); - await endpoint(endpointId, actor); - await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, - { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); - createdAppId = result.app_id; - } catch (error) { - // Only documented rejection responses prove that creation did not happen. - const code = object(object(error).details).code; - const rejected = typeof code === "string" && Object.values(knownProviderErrors).includes(code); - await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); - await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); - return; - } - // The app and credentials are durable before optional icon provisioning. - // A retry/restart always reuses this app, even if icon upload is interrupted. - const created = { ...row, appId: createdAppId }; - if (!await configureManifest(created, actor, input.credentials.configurationToken, lease)) return; - await saveAvatar(created, { status: "pending" }, actor, lease); - let avatar: SlackAvatarState; - try { - await endpoint(endpointId, actor); - await lease.assertOwned(); - // Cloud ingress requires a tenant session. Send the preset PNG bytes so - // Slack does not need to fetch an authenticated Paperclip URL. - const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render; - const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; - delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. - const png = await render({ appearance, - size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); - await endpoint(endpointId, actor); - await lease.assertOwned(); - const upload = new FormData(); - upload.set("app_id", createdAppId); - upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); - await api("apps.icon.set", upload, input.credentials.configurationToken); - avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; - } catch { - // Provider echoes (including tokens) never become saved state or activity. - avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; - } - await saveAvatar(created, avatar, actor, lease); - try { await audit(created, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack avatar activity could not be recorded"); } - // An audit outage cannot change the saved creation outcome. - try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } - }); - } - async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Resume the saved Slack connection before configuring its app"); - assertOrigins(row, current.endpoint.publicId); - await lease.assertOwned(); - try { - await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); - } catch { - // The pending marker was saved with the app secrets before dispatch. Retrying - // the same manifest is safe even when Slack's response was lost. - await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); - return false; - } - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - await audit(row, actor, "manifest_updated"); - return true; - } - async function install(endpointId: string, actor: SlackSetupActor) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); - if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); - if (current.endpoint.setup.slackSetupMethod !== "automatic" || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); - assertOrigins(row, current.endpoint.publicId); - const state = `${prefix}${randomBytes(32).toString("base64url")}`; - const expiresAt = new Date(Date.now() + 10 * 60_000); - const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); - await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, - codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, - endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), - createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, - requestedScopes: scopes, expiresAt }); - await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - const url = new URL("https://slack.com/oauth/v2/authorize"); - url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, - ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); - await audit(row, actor, "installation_started"); - return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; - }); - } - async function expiredReturn(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; - const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); - if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - return options.withLock(current.endpoint.id, async lease => { - const latest = await endpoint(current.endpoint.id, actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId - || latest.endpoint.setup.slackSetupMethod !== "automatic" || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; - assertOrigins(row, latest.endpoint.publicId); - await lease.assertOwned(); - await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); - await setFailure(row, row.status, "slack_install_expired", lease); - await audit(row, actor, "installation_failed", "slack_install_expired"); - return returnPath(row.endpointId); - }); - } - async function pending(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); - const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); - if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) - throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || current.endpoint.setup.slackSetupMethod !== "automatic" - || current.endpoint.status === "paused" - || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" - || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) - || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri - || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) - throw conflict("Slack setup changed. Resume the saved connection and install again."); - assertOrigins(row, current.endpoint.publicId); - return { attempt, current, row }; - } - async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; - if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); - await notifyChatVerificationWork(tx); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - await lease.assertOwned(tx); - }); - } - async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account) throw providerFailure("slack_install_account_missing"); - if (account.status === "linked") return; - const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); - const user = object((await api("users.info", { user: account.externalUserId }, token)).user); - if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); - await saveAccount(row, { ...account, status: "linked" }, actor, lease); - try { await audit(row, actor, "account_linked"); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } - } - async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account || account.status !== "linked") return; - if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; - const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; - const status = account[field] ?? "pending"; - if (status === "sending") { - await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); - return; // Never replay an ambiguous dispatch after a process restart. - } - if (status !== "pending") return; - await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); - let result: SlackAccountState; - try { - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); - const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, - text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." - : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, - unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); - if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); - result = { ...account, [field]: "sent", dmChannelId: sent.channel }; - } catch (error) { - result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; - } - await saveAccount(row, result, actor, lease); - try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } - } - async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await sendSetupMessage(row, actor, lease, "welcome"); - await sendSetupMessage(row, actor, lease, "verification"); - } - async function notifyVerified(endpointId: string) { - return options.withLock(endpointId, async lease => { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); - const account = saved?.setup.slackAccount; - if (!account || !["verifying", "active"].includes(saved.status)) return; - await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); - }); - } - function pendingVerificationMessage() { - // A pending verification cannot progress until linking, welcome, and an - // authenticated callback finish. Their commits wake this lane. - return and(eq(chatSlackRegistrations.status, "configured"), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, - sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, - sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, - sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`); - } - function pendingVerificationMessages(limit: number) { - return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) - .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) - .where(pendingVerificationMessage()).limit(limit); - } - async function nextVerificationMessageAt(): Promise { - return (await pendingVerificationMessages(1)).length ? Date.now() : null; - } - async function processPendingVerificationMessages(limit = 25) { - for (const row of await pendingVerificationMessages(limit)) { - try { await notifyVerified(row.endpointId); } - catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } - } - } - async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); - if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } - if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); - try { - assertOrigins(row, current.endpoint.publicId); - const botToken = await readSecret(row, "botToken"); - const auth = await api("auth.test", {}, botToken); - if (auth.team_id !== row.workspaceId || auth.user_id !== row.botUserId) throw providerFailure("slack_install_identity_mismatch"); - await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret - ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { - assertOwned: async database => { - await lease.assertOwned(database); - await endpoint(endpointId, actor); - assertOrigins(row, current.endpoint.publicId); - }, - }); - await linkAccount(row, actor, lease); - } catch (error) { - const code = object(object(error).details).code; - const safeCode = code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" - ? "slack_install_identity_mismatch" : code === "chat_provider_permissions_missing" ? "slack_install_scopes_missing" - : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; - await setFailure(row, "credentials_saved", safeCode, lease); - await audit(row, actor, "configuration_failed", safeCode); - return; - } - await lease.assertOwned(); - await db.transaction(async tx => { - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - // Runtime now owns a separate vaulted copy. Keep the registration IDs until - // deletion succeeds so cleanup itself is retryable. - await cleanupStaged(endpointId, lease); - try { await audit(row, actor, "installation_completed"); } - catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } - await welcome(row, actor, lease); - } - async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const ids = { ...row.secretIds }; - for (const key of ["botToken", "signingSecret"]) { - if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } - } - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } - async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { - const before = await pending(state, actor); - return options.withLock(before.row.endpointId, async lease => { - const { row, attempt, current } = await pending(state, actor); - const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); - if (!claimed) throw conflict("This Slack authorization was already used"); - if (error) { - const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; - await setFailure(row, row.status, failureCode, lease); - await audit(row, actor, "installation_failed", failureCode); - return row.endpointId; - } - try { - if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); - const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); - const teamId = object(result.team).id; - const installerId = object(result.authed_user).id; - if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") - || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) - || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - const granted = new Set(String(result.scope ?? "").split(",")); - if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); - if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId - || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - // Reauthorization reuses the runtime signing secret after staging was cleaned. - await saveSecrets(row, { botToken: result.access_token }, actor, lease, - { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, - { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); - } catch (failure) { - const code = object(object(failure).details).code; - const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; - await setFailure(row, row.status, safeCode, lease); - await audit(row, actor, "installation_failed", safeCode); - return row.endpointId; - } - await resumeLocked(row.endpointId, actor, lease); - return row.endpointId; - }); - } - const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); - async function returnPath(endpointId: string) { - const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) - .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); - if (!row) throw notFound("Slack connection not found"); - return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; - } - return { create, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, - close: async () => { await avatarPool?.close(); }, - resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, lease => resumeLocked(endpointId, actor, lease)) }; -} +// Compatibility boundary: both provisioning paths converge on the same Slack runtime. +export { slackChatRegistrationService } from "./connectors/slack/setup/service.js"; +export { slackRegistrationProjection, type SlackSetupActor } from "./connectors/slack/setup/registration.js"; diff --git a/server/src/services/connectors/slack/setup/completion.ts b/server/src/services/connectors/slack/setup/completion.ts new file mode 100644 index 00000000000..afefa935789 --- /dev/null +++ b/server/src/services/connectors/slack/setup/completion.ts @@ -0,0 +1,164 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations } from "@paperclipai/db"; +import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES, type SlackAccountState } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import { secretService } from "../../../secrets.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createSlackCompletion(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, assertOrigins, api, readSecret, audit, setFailure } = store; + const vault = secretService(db); + async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; + if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); + await notifyChatVerificationWork(tx); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + await lease.assertOwned(tx); + }); + } + async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account) throw providerFailure("slack_install_account_missing"); + if (account.status === "linked") return; + const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); + const user = object((await api("users.info", { user: account.externalUserId }, token)).user); + if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); + await endpoint(row.endpointId, actor); + await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); + await saveAccount(row, { ...account, status: "linked" }, actor, lease); + try { await audit(row, actor, "account_linked"); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } + } + async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account || account.status !== "linked") return; + if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; + const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; + const status = account[field] ?? "pending"; + if (status === "sending") { + await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); + return; // Never replay an ambiguous dispatch after a process restart. + } + if (status !== "pending") return; + await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); + let result: SlackAccountState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); + const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, + text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." + : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, + unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); + if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); + result = { ...account, [field]: "sent", dmChannelId: sent.channel }; + } catch (error) { + result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; + } + await saveAccount(row, result, actor, lease); + try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } + } + async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await sendSetupMessage(row, actor, lease, "welcome"); + await sendSetupMessage(row, actor, lease, "verification"); + } + async function notifyVerified(endpointId: string) { + return options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + const account = saved?.setup.slackAccount; + if (!account || !["verifying", "active"].includes(saved.status)) return; + await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); + }); + } + function pendingVerificationMessages(limit: number) { + return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) + .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) + .where(and(eq(chatSlackRegistrations.status, "configured"), inArray(chatEndpoints.status, ["verifying", "active"]), + sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, + sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, + sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, + sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`)).limit(limit); + } + async function nextVerificationMessageAt(): Promise { + return (await pendingVerificationMessages(1)).length ? Date.now() : null; + } + async function processPendingVerificationMessages(limit = 25) { + for (const row of await pendingVerificationMessages(limit)) { + try { await notifyVerified(row.endpointId); } + catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } + } + } + async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); + if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } + if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); + try { + assertOrigins(row, current.endpoint.publicId); + const botToken = await readSecret(row, "botToken"); + const auth = await api("auth.test", {}, botToken, row.managerGrantId ? [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES] : undefined); + if (auth.team_id !== row.workspaceId || row.botUserId && auth.user_id !== row.botUserId || typeof auth.user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(auth.user_id)) throw providerFailure("slack_install_identity_mismatch"); + if (row.managerGrantId) { + if (typeof auth.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(auth.bot_id)) throw providerFailure("slack_install_identity_mismatch"); + const bot = object((await api("bots.info", { bot: auth.bot_id }, botToken)).bot); + if (bot.id !== auth.bot_id || bot.app_id !== row.appId || bot.user_id !== auth.user_id || bot.deleted !== false) throw providerFailure("slack_install_identity_mismatch"); + } + if (!row.botUserId) { + if (!row.managerGrantId) throw providerFailure("slack_install_identity_mismatch"); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ botUserId: auth.user_id }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret + ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { + assertOwned: async database => { + await lease.assertOwned(database); + await endpoint(endpointId, actor); + assertOrigins(row, current.endpoint.publicId); + }, + }); + await linkAccount(row, actor, lease); + } catch (error) { + const code = object(object(error).details).code; + const safeCode = code === "slack_configuration_token_invalid" ? "slack_install_token_invalid" : code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" + ? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing" || code === "slack_setup_permission_denied") ? "slack_install_scopes_missing" + : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; + await setFailure(row, "credentials_saved", safeCode, lease); + await audit(row, actor, "configuration_failed", safeCode); + return; + } + await lease.assertOwned(); + await db.transaction(async tx => { + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + // Runtime now owns a separate vaulted copy. Keep the registration IDs until + // deletion succeeds so cleanup itself is retryable. + await cleanupStaged(endpointId, lease); + try { await audit(row, actor, "installation_completed"); } + catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } + await welcome(row, actor, lease); + } + async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const ids = { ...row.secretIds }; + for (const key of ["botToken", "signingSecret"]) { + if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } + } + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + return { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt }; +} diff --git a/server/src/services/connectors/slack/setup/customer-owned.ts b/server/src/services/connectors/slack/setup/customer-owned.ts new file mode 100644 index 00000000000..6edd552d3fe --- /dev/null +++ b/server/src/services/connectors/slack/setup/customer-owned.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { chatSlackRegistrations } from "@paperclipai/db"; +import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, type SlackRegistrationInput, type SlackAvatarState } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, knownProviderErrors, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createCustomerOwnedSetup(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, callbackUri, origin, api, audit, saveSecrets, setFailure, saveAvatar, assertOrigins, renderAvatar } = store; + async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput, managed?: { grantId: string; workspaceId: string; request: typeof api }) { + return options.withLock(endpointId, async lease => { + const api = managed?.request ?? store.api; + const current = await endpoint(endpointId, actor); + const previous = await registration(endpointId); + const method = managed ? "managed" : "automatic"; + if (current.endpoint.setup.slackSetupMethod !== method) throw conflict("Resume the selected Slack setup method"); + if (previous && previous.managerGrantId !== (managed?.grantId ?? null)) throw conflict("Resume setup with the original Slack workspace"); + if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); + if (previous?.appId) { + if (previous.errorCode === "slack_manifest_update_pending") { + if (!await configureManifest(previous, actor, input.credentials.configurationToken, lease, method, api)) return; + if (current.endpoint.setup.slackAvatar?.status !== "uploaded") await configureAvatar(previous, actor, input.credentials.configurationToken, lease, api); + } + return; + } + if (previous?.requestId === input.requestId) return; + if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { + await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); + return; + } + if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Automatic creation is only available for a new Slack connection"); + const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); + const redirect = callbackUri(); + const manifest = buildSlackAppManifest({ app, agentName: current.agentName, + webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); + // Slack cannot authenticate a challenge until its creation response gives us + // the signing secret. Add event subscriptions only after that secret is durable. + const { event_subscriptions: _events, ...creationSettings } = manifest.settings; + const creationManifest = { ...manifest, settings: creationSettings }; + try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } + catch (error) { + if (error instanceof Error && "status" in error) throw error; + throw providerFailure("slack_provider_failure"); + } + // No provider creation has happened until this intent is durable. + await endpoint(endpointId, actor); + await lease.assertOwned(); + const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, + manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, + createdByUserId: actor.userId, managerGrantId: managed?.grantId ?? null, workspaceId: managed?.workspaceId ?? null, errorCode: null, updatedAt: new Date() }; + const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) + .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); + await audit(row, actor, "creation_started"); + let createdAppId: string; + try { + const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); + const credentials = object(result.credentials); + if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) + || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) + || typeof credentials.client_secret !== "string" || !credentials.client_secret + || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) + throw new Error("Incomplete Slack creation response"); + if (managed && result.team_id !== managed.workspaceId) throw new Error("Slack creation workspace mismatch"); + await endpoint(endpointId, actor); + await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, + { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); + createdAppId = result.app_id; + } catch (error) { + // Only documented rejection responses prove that creation did not happen. + const code = object(object(error).details).code; + const rejected = typeof code === "string" && [...Object.values(knownProviderErrors), "slack_manager_reauthorize"].includes(code); + await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); + await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); + return; + } + // The app and credentials are durable before optional icon provisioning. + // A retry/restart always reuses this app, even if icon upload is interrupted. + const created = { ...row, appId: createdAppId }; + if (!await configureManifest(created, actor, input.credentials.configurationToken, lease, method, api)) return; + await configureAvatar(created, actor, input.credentials.configurationToken, lease, api); + // An audit outage cannot change the saved creation outcome. + try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } + catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } + }); + } + async function configureAvatar(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, api = store.api) { + const current = await endpoint(row.endpointId, actor); + await saveAvatar(row, { status: "pending" }, actor, lease); + let avatar: SlackAvatarState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + // Cloud ingress requires a tenant session. Send the preset PNG bytes so + // Slack does not need to fetch an authenticated Paperclip URL. + const render = renderAvatar; + const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; + delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. + const png = await render({ appearance, + size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + const upload = new FormData(); + upload.set("app_id", row.appId!); + upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); + await api("apps.icon.set", upload, configurationToken); + avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; + } catch { + // Provider echoes (including tokens) never become saved state or activity. + avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; + } + await saveAvatar(row, avatar, actor, lease); + try { await audit(row, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId! }, "Slack avatar activity could not be recorded"); } + } + async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, method: "automatic" | "managed" = "automatic", api = store.api) { + const current = await endpoint(row.endpointId, actor); + if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Resume the saved Slack connection before configuring its app"); + assertOrigins(row, current.endpoint.publicId); + await lease.assertOwned(); + try { + await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); + } catch { + // The pending marker was saved with the app secrets before dispatch. Retrying + // the same manifest is safe even when Slack's response was lost. + await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); + return false; + } + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + await audit(row, actor, "manifest_updated"); + return true; + } + return { create, configureManifest }; +} diff --git a/server/src/services/connectors/slack/setup/development-manager.ts b/server/src/services/connectors/slack/setup/development-manager.ts new file mode 100644 index 00000000000..d4e71fd868a --- /dev/null +++ b/server/src/services/connectors/slack/setup/development-manager.ts @@ -0,0 +1,73 @@ +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { createSlackSetupClient, object, providerFailure } from "./provider-client.js"; + +export type SlackManagerClient = Pick & { authorizationMode?: "code" }; + +// Explicit local qualification only. Production continues using the sealed Cloud +// broker; ordinary self-hosted installs neither load credentials nor contact it. +export function developmentSlackManager( + publicOrigin: () => string, + env: NodeJS.ProcessEnv = process.env, + fetchImpl: typeof fetch = fetch, +): SlackManagerClient | null { + if (env.NODE_ENV !== "development" || env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true" + || !env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG) return null; + let config: Record; + try { + const path = env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG; + if (!isAbsolute(path)) throw new Error(); + const stat = statSync(path); + if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error(); + config = object(JSON.parse(readFileSync(path, "utf8"))); + } catch { throw providerFailure("slack_managed_unavailable"); } + const { appId, clientId, clientSecret } = config; + if (typeof appId !== "string" || !/^A[A-Z0-9]+$/.test(appId) + || typeof clientId !== "string" || !/^\d+\.\d+$/.test(clientId) + || typeof clientSecret !== "string" || !clientSecret) throw providerFailure("slack_managed_unavailable"); + const callback = () => { + const url = new URL(publicOrigin()); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw providerFailure("slack_managed_unavailable"); + return `${url.origin}/api/chat-slack/managed/oauth/callback`; + }; + const api = createSlackSetupClient(fetchImpl); + async function credentials(fields: Record, subject: string, companyId: string, initial: boolean): Promise { + let result: Record; + try { result = await api("oauth.v2.access", { client_id: clientId as string, client_secret: clientSecret as string, ...fields }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const user = initial ? object(result.authed_user) : result; + const scopes = typeof user.scope === "string" ? user.scope.split(/[, ]+/) : []; + const team = object(result.team); + if (typeof user.access_token !== "string" || !user.access_token || typeof user.refresh_token !== "string" || !user.refresh_token + || user.token_type !== "user" || typeof user.expires_in !== "number" || !Number.isFinite(user.expires_in) || user.expires_in <= 0 + || SLACK_MANAGER_SCOPES.some(scope => !scopes.includes(scope)) + || (initial && (result.app_id !== appId || typeof team.id !== "string" || !/^T[A-Z0-9]+$/.test(team.id) + || typeof team.name !== "string" || !team.name || typeof user.id !== "string" || !/^[UW][A-Z0-9]+$/.test(user.id)))) throw providerFailure("slack_manager_reauthorize"); + return { + v: 1, accessToken: user.access_token, refreshToken: user.refresh_token, tokenType: "user", + accessTokenExpiresAt: new Date(Date.now() + user.expires_in * 1000).toISOString(), refreshTokenExpiresAt: null, + scopes, subject, companyId, instanceId: "local-slack-qualification", environment: "development", provider: "slack", profile: "slack.manager", + ...(initial ? { slackManager: { appId: appId as string, workspaceId: team.id as string, workspaceName: team.name as string, userId: user.id as string } } : {}), + }; + } + return { + authorizationMode: "code", + async getSlackManagerAppId() { callback(); return appId as string; }, + async startAuthorization(values) { + if (values.returnUri !== callback() || values.profile !== "slack.manager") throw providerFailure("slack_manager_reauthorize"); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: clientId as string, user_scope: SLACK_MANAGER_SCOPES.join(","), redirect_uri: callback(), state: values.returnState }).toString(); + return { authorizationUrl: url.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }; + }, + async claim(values) { return credentials({ code: values.claimId, redirect_uri: callback() }, values.subject, values.companyId, true); }, + async refresh(values) { return credentials({ grant_type: "refresh_token", refresh_token: values.refreshToken }, values.subject, values.companyId, false); }, + async revoke(values) { + try { await api("auth.revoke", {}, values.token); } + catch { throw providerFailure("slack_manager_reauthorize"); } + }, + }; +} diff --git a/server/src/services/connectors/slack/setup/managed.ts b/server/src/services/connectors/slack/setup/managed.ts new file mode 100644 index 00000000000..fc529fc7b9b --- /dev/null +++ b/server/src/services/connectors/slack/setup/managed.ts @@ -0,0 +1,81 @@ +import { and, eq } from "drizzle-orm"; +import { chatActions, chatSlackRegistrations } from "@paperclipai/db"; +import { type SlackManagedProvisionInput, type SlackManagedResult } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import type { createCustomerOwnedSetup } from "./customer-owned.js"; +import type { createSlackManagerGrants } from "./manager-grants.js"; +import type { createSlackCompletion } from "./completion.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; + +/** Managed provisioning ends at the same saved-credential boundary as child OAuth. */ +export function createManagedSlackSetup(store: SlackRegistrationStore, grants: ReturnType, + provisioner: ReturnType, completion: ReturnType, + installFallback: (endpointId: string, actor: SlackSetupActor) => Promise<{ authorizationUrl: string; expiresAt: string }>) { + async function provision(endpointId: string, actor: SlackSetupActor, input: SlackManagedProvisionInput): Promise { + const current = await store.endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const authorized = await grants.token(input.grantId, current.endpoint.companyId, actor); + await provisioner.create(endpointId, actor, { requestId: input.requestId, confirmedNoAppCreated: input.confirmedNoAppCreated, + credentials: { configurationToken: authorized.token } }, { grantId: authorized.grant.id, workspaceId: authorized.grant.workspaceId, request: (method, fields) => grants.request(input.grantId, current.endpoint.companyId, actor, method, fields) }); + let fallback = false; + await store.options.withLock(endpointId, async lease => { + const latest = await store.endpoint(endpointId, actor); + const row = await store.registration(endpointId); + if (!row?.appId || row.status === "removed" || row.errorCode === "slack_manifest_update_pending") return; + if (row.managerGrantId !== authorized.grant.id || latest.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Slack setup changed"); + store.assertOrigins(row, latest.endpoint.publicId); + const reconnectKey = input.reconnect ? `slack_managed_reconnect:${actor.userId}:${input.requestId}` : null; + const [previous] = reconnectKey ? await store.db.select().from(chatActions).where(and(eq(chatActions.companyId, row.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))) : []; + async function completeSaved() { + await completion.resumeLocked(endpointId, actor, lease); + if (reconnectKey && (await store.registration(endpointId))?.status === "configured") { + await lease.assertOwned(); + await store.db.update(chatActions).set({ status: "processed", updatedAt: new Date() }).where(and(eq(chatActions.companyId, row!.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))); + } + } + if (previous && row.status === "configured" && !["revoked", "attention"].includes(latest.endpoint.status)) { + await completeSaved(); return; + } + const needsReinstall = row.status === "configured" && (input.reconnect || ["revoked", "attention"].includes(latest.endpoint.status)) + || row.status === "credentials_saved" && ["slack_install_token_invalid", "slack_install_scopes_missing"].includes(row.errorCode ?? ""); + if (reconnectKey && !previous) { + // Atomically bind an explicit reconnect to its durable installation intent. + // Duplicate requests can return the saved connection without reinstalling. + await store.db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.insert(chatActions).values({ companyId: row.companyId, endpointId, kind: "slack_managed_reconnect", providerActionId: reconnectKey, status: "processing", payload: { grantId: row.managerGrantId, registrationRequestId: row.requestId } }); + if (needsReinstall) await tx.update(chatSlackRegistrations).set({ status: "install", errorCode: row.errorCode ?? "slack_install_failed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + } + if ((row.status === "configured" || row.status === "credentials_saved") && !needsReinstall) { + await completeSaved(); + return; + } + // Keep reinstall intent durable even if this dispatch fails or the process exits. + if (needsReinstall && (!reconnectKey || previous)) await store.setFailure(row, "install", row.errorCode ?? "slack_install_failed", lease); + const { grant } = await grants.token(input.grantId, row.companyId, actor); + try { + await lease.assertOwned(); + const result = await grants.request(grant.id, row.companyId, actor, "apps.managedInstall", { app_id: row.appId, team_id: grant.workspaceId }); + const botToken = object(result.api_access_tokens).bot_access_token; + if (result.app_id !== row.appId || result.team_id !== grant.workspaceId || typeof botToken !== "string" || !botToken.startsWith("xoxb-")) throw providerFailure("slack_install_identity_mismatch"); + if ((await grants.get(grant.id, row.companyId, actor)).status !== "active") throw providerFailure("slack_manager_reauthorize"); + await store.endpoint(endpointId, actor); + // Vault before auth.test/inventory so network failures never lose this installation. + await store.saveSecrets(row, { botToken }, actor, lease, { status: "credentials_saved", workspaceId: grant.workspaceId, errorCode: null }, + { externalUserId: grant.slackUserId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (error) { + const code = object(object(error).details).code; + const safe = typeof code === "string" ? code : "slack_install_failed"; + await store.setFailure(row, needsReinstall ? "install" : row.status, safe, lease); + await store.audit(row, actor, "installation_failed", safe); + fallback = ["slack_approval_required", "slack_approval_pending", "slack_approval_denied"].includes(safe); + return; + } + await completeSaved(); + }); + // OAuth's state/actor binding is ours; never trust a provider-supplied redirect. + return fallback ? { authorization: await installFallback(endpointId, actor) } : {}; + } + return { provision }; +} diff --git a/server/src/services/connectors/slack/setup/manager-grants.ts b/server/src/services/connectors/slack/setup/manager-grants.ts new file mode 100644 index 00000000000..3d902d9928e --- /dev/null +++ b/server/src/services/connectors/slack/setup/manager-grants.ts @@ -0,0 +1,269 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq, gt, like, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackManagerGrants, chatSlackRegistrations, companySecrets, toolOauthStates } from "@paperclipai/db"; +import { SLACK_MANAGER_SCOPES, type SlackSetupOptions as SetupChoices } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { getCloudRuntimeIdentity } from "../../../cloud-runtime-identity.js"; +import { createPaperclipCloudConnector, paperclipCloudConnectorConfigFromEnv, type SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { accessService } from "../../../access.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import { logActivity } from "../../../activity-log.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; +import { developmentSlackManager, type SlackManagerClient } from "./development-manager.js"; + +export const SLACK_MANAGER_STATE_PREFIX = "slack-manager."; +export function createSlackManagerGrants(store: SlackRegistrationStore) { + const { db, options, endpoint, origin, api } = store; + type Database = Parameters[0]; + function broker(): SlackManagerClient | null { + if (options.managed) return options.managed.enabled ? options.managed.broker : null; + if (!getCloudRuntimeIdentity()) { + const development = developmentSlackManager(() => origin(options.publicOrigin())); + if (development) return development; + } + // Open-source instances never contact a hosted service to discover this path. + if (!getCloudRuntimeIdentity() || process.env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true") return null; + const config = paperclipCloudConnectorConfigFromEnv(); + return config ? createPaperclipCloudConnector({ config }) : null; + } + async function access(companyId: string, actor: SlackSetupActor, database: Database = db) { + if (!(await instanceSettingsService(database as typeof db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + if (!actor.bypassPermissionCheck && !await accessService(database as typeof db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections"); + } + // Deployment intent is stable across broker outages and preview rollbacks. + // A missing live profile pauses managed setup; it must not select token entry. + function defaultMethod(): "managed" | "automatic" { return broker() ? "managed" : "automatic"; } + async function available() { + const client = broker(); + if (!client || !(await instanceSettingsService(db).getExperimental()).enableChatConnectors) return false; + try { origin(options.publicOrigin()); origin(options.webhookOrigin()); return Boolean(await client.getSlackManagerAppId()); } + catch { return false; } + } + async function requiredBroker() { + if (!await available()) throw providerFailure("slack_managed_unavailable"); + return broker()!; + } + async function choices(companyId: string, actor: SlackSetupActor): Promise { + await access(companyId, actor); + const managedAvailable = await available(); + const managerAppId = managedAvailable ? await broker()!.getSlackManagerAppId() : null; + const grants = managedAvailable && managerAppId ? await db.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.status, "active"), eq(chatSlackManagerGrants.managerAppId, managerAppId))) : []; + return { managedAvailable, defaultMethod: defaultMethod(), workspaces: grants.map(g => ({ grantId: g.id, workspaceId: g.workspaceId, workspaceName: g.workspaceName, userId: g.slackUserId })) }; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/managed/oauth/callback`; + async function authorize(endpointId: string, actor: SlackSetupActor) { + const client = await requiredBroker(); + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const row = await store.registration(endpointId); + const revisions = await db.select({ id: chatSlackManagerGrants.id, revision: chatSlackManagerGrants.revision }).from(chatSlackManagerGrants) + .where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + const state = `${SLACK_MANAGER_STATE_PREFIX}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + await lease.assertOwned(); + await db.insert(toolOauthStates).values({ state, companyId: current.endpoint.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, revisions, authorizationMode: client.authorizationMode ?? "claim", requestId: row?.requestId ?? null, callbackUri: callbackUri(), webhookOrigin: origin(options.webhookOrigin()), runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, requestedScopes: [...SLACK_MANAGER_SCOPES], expiresAt }); + try { return await client.startAuthorization({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, returnUri: callbackUri(), returnState: state }); } + catch { await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); throw providerFailure("slack_manager_reauthorize"); } + }); + } + async function pending(state: string, actor: SlackSetupActor, allowExpired = false) { + if (!/^slack-manager\.[A-Za-z0-9_-]{43}$/.test(state)) throw forbidden("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), ...(allowExpired ? [] : [gt(toolOauthStates.expiresAt, new Date())]))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) throw forbidden("Slack authorization expired or belongs to another session"); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await store.registration(current.endpoint.id); + if (current.endpoint.setup.slackSetupMethod !== "managed" || current.connection.id !== attempt.connectionId || current.endpoint.companyId !== attempt.companyId + || ["paused", "revoked"].includes(current.endpoint.status) || row?.status === "removed" + || (binding.authorizationMode ?? "claim") !== (broker()?.authorizationMode ?? "claim") + || (row?.requestId ?? null) !== binding.requestId || binding.callbackUri !== callbackUri() || binding.webhookOrigin !== origin(options.webhookOrigin()) + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)) throw conflict("Slack setup changed. Resume authorization."); + return { attempt, current, row, binding }; + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + const { current, attempt } = await pending(state, actor, true); + if (attempt.expiresAt.getTime() > Date.now()) return null; + return options.withLock(current.endpoint.id, async lease => { + const latest = await pending(state, actor, true); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await db.update(chatEndpoints).set({ setup: { ...latest.current.endpoint.setup, slackManagerError: "slack_install_expired" } }).where(eq(chatEndpoints.id, current.endpoint.id)); + return current.endpoint.id; + }); + } + async function complete(state: string, claimId: string | null, error: string | null, actor: SlackSetupActor, authorizationCode: string | null = null) { + const client = await requiredBroker(); + claimId = client.authorizationMode === "code" ? authorizationCode : claimId; + const before = await pending(state, actor); + return options.withLock(before.current.endpoint.id, async lease => { + const { current, row, binding } = await pending(state, actor); + await lease.assertOwned(); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("Slack authorization was already used"); + async function outcome(code: string | null) { + await lease.assertOwned(); + const latest = await endpoint(current.endpoint.id, actor); + await db.update(chatEndpoints).set({ setup: { ...latest.endpoint.setup, slackManagerError: code ?? undefined } }).where(eq(chatEndpoints.id, current.endpoint.id)); + if (code) await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorization_failed", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { code } }); + return current.endpoint.id; + } + if (error || !claimId || claimId.length > 4096) return outcome(error === "access_denied" ? "slack_install_declined" : "slack_manager_reauthorize"); + try { + let grant: SealedConnectorCredentials; + try { grant = await client.claim({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, claimId, redemptionId: randomUUID() }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const identity = grant.slackManager; + if (!identity || identity.appId !== await client.getSlackManagerAppId() || !/^A[A-Z0-9]+$/.test(identity.appId) || !/^T[A-Z0-9]+$/.test(identity.workspaceId) || !/^[UW][A-Z0-9]+$/.test(identity.userId) || typeof identity.workspaceName !== "string" || !identity.workspaceName || identity.workspaceName.length > 255) throw providerFailure("slack_manager_reauthorize"); + if (row?.managerGrantId) { + const existing = await get(row.managerGrantId, current.endpoint.companyId, actor); + if (existing.managerAppId !== identity.appId || existing.workspaceId !== identity.workspaceId || existing.slackUserId !== identity.userId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(current.endpoint.id, actor); + await access(current.endpoint.companyId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager:${current.endpoint.companyId}:${actor.userId}:${identity.appId}:${identity.workspaceId}`}, 0))`); + const [existing] = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.managerAppId, identity.appId), eq(chatSlackManagerGrants.workspaceId, identity.workspaceId))); + if (existing) { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${existing.id}`}, 0))`); + const [latest] = await tx.select().from(chatSlackManagerGrants).where(eq(chatSlackManagerGrants.id, existing.id)); + const revision = Array.isArray(binding.revisions) ? binding.revisions.map(object).find(value => value.id === existing.id)?.revision : undefined; + if (!latest || latest.revision !== revision) throw conflict("Workspace authorization changed. Start again."); + } + const [saved] = await tx.insert(chatSlackManagerGrants).values({ companyId: current.endpoint.companyId, userId: actor.userId, slackUserId: identity.userId, workspaceId: identity.workspaceId, workspaceName: identity.workspaceName, managerAppId: identity.appId }) + .onConflictDoUpdate({ target: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.userId, chatSlackManagerGrants.managerAppId, chatSlackManagerGrants.workspaceId], set: { updatedAt: new Date() } }).returning(); + // A reauthorization must never change which Slack person this grant represents. + if (saved.slackUserId !== identity.userId) throw conflict("Reconnect using the original Slack account"); + await access(current.endpoint.companyId, actor, tx); + await save(tx, saved, grant, actor); + if (row?.managerGrantId === saved.id) { + await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, current.endpoint.id), eq(chatSlackRegistrations.managerGrantId, saved.id), eq(chatSlackRegistrations.errorCode, "slack_manager_reauthorize"))); + } + }); + await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorized", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { workspaceId: identity.workspaceId, managerAppId: identity.appId } }); + return outcome(null); + } catch { return outcome("slack_manager_reauthorize"); } + }); + } + type Grant = typeof chatSlackManagerGrants.$inferSelect; + async function get(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + return readGrant(db, id, companyId, actor); + } + async function readGrant(database: Database, id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor, database); + const [grant] = await database.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + if (!grant) throw forbidden("This Slack workspace authorization belongs to another account"); + return grant; + } + async function save(tx: Parameters[0]>[0], row: Grant, credentials: SealedConnectorCredentials, actor: SlackSetupActor) { + if (credentials.tokenType !== "user" || !credentials.accessToken || !credentials.refreshToken || !credentials.accessTokenExpiresAt || !Number.isFinite(Date.parse(credentials.accessTokenExpiresAt)) || Date.parse(credentials.accessTokenExpiresAt) <= Date.now() || credentials.provider !== "slack" || credentials.profile !== "slack.manager" || credentials.subject !== row.userId || credentials.companyId !== row.companyId || SLACK_MANAGER_SCOPES.some(scope => !credentials.scopes.includes(scope))) throw providerFailure("slack_manager_reauthorize"); + const write = (key: string, value: string, secretId: string | null) => writeConnectionCredential(tx, { companyId: row.companyId, ownerUserId: row.userId, actor: { userId: actor.userId }, connectionName: "Slack workspace management", configPath: `slack_manager.${key}`, definitionKey: `slack_manager.${row.id}.${key}`, label: key, value, ...(secretId ? { existingRef: { secretId, configPath: `slack_manager.${key}` } } : {}) }); + const accessToken = await write("access_token", credentials.accessToken, row.accessSecretId); + const refreshToken = credentials.refreshToken ? await write("refresh_token", credentials.refreshToken, row.refreshSecretId) : null; + await tx.update(chatSlackManagerGrants).set({ accessSecretId: accessToken.secret.id, refreshSecretId: refreshToken?.secret.id ?? row.refreshSecretId, expiresAt: credentials.accessTokenExpiresAt ? new Date(credentials.accessTokenExpiresAt) : null, status: "active", revision: row.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, row.id)); + } + async function secret(row: Grant, id: string | null, database: Database = db) { + if (!id) throw providerFailure("slack_manager_reauthorize"); + const [ref] = await database.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId))); + if (!ref?.userSecretDefinitionId) throw providerFailure("slack_manager_reauthorize"); + // Owner-scoped system consumer; the durable grant is its authority, not a tool binding. + const value = await secretService(database).resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId }); + if (!value) throw providerFailure("slack_manager_reauthorize"); + return value.value; + } + const tokenRequests = new Map>(); + async function token(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + const client = await requiredBroker(); + const managerAppId = await client.getSlackManagerAppId(); + const key = `${companyId}:${actor.userId}:${id}`; + const running = tokenRequests.get(key); + if (running) return running; + const operation = (async () => { + // Commit the single-use refresh claim before dispatch, without holding a + // pool connection while another query or the broker needs it. + const prepared = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const grant = await readGrant(tx, id, companyId, actor); + if (grant.managerAppId !== managerAppId || grant.status !== "active") throw providerFailure("slack_manager_reauthorize"); + if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId, tx) }; + const refreshToken = await secret(grant, grant.refreshSecretId, tx); + const [claimed] = await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: grant.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)).returning(); + return { grant: claimed!, refreshToken }; + }); + if ("token" in prepared) return { grant: prepared.grant, token: prepared.token! }; + try { + const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: prepared.refreshToken }); + await access(companyId, actor); + return await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "reauthorize" || current.revision !== prepared.grant.revision) throw providerFailure("slack_manager_reauthorize"); + await save(tx, current, refreshed, actor); + return { grant: await readGrant(tx, id, companyId, actor), token: refreshed.accessToken }; + }); + } catch { throw providerFailure("slack_manager_reauthorize"); } + })(); + tokenRequests.set(key, operation); + try { return await operation; } finally { if (tokenRequests.get(key) === operation) tokenRequests.delete(key); } + } + async function revoke(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + let providerRevoked = true; + const grant = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + await tx.update(chatSlackManagerGrants).set({ status: "revoked", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, companyId), eq(toolOauthStates.createdByActorId, actor.userId), like(toolOauthStates.state, `${SLACK_MANAGER_STATE_PREFIX}%`))); + const client = broker(); + for (const secretId of [current.accessSecretId, current.refreshSecretId]) if (secretId) { + try { + if (!client) throw providerFailure("slack_managed_unavailable"); + await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId, tx) }); + } catch { providerRevoked = false; } + await secretService(tx).remove(secretId); + } + await tx.update(chatSlackManagerGrants).set({ accessSecretId: null, refreshSecretId: null }).where(eq(chatSlackManagerGrants.id, id)); + return current; + }); + await logActivity(db, { companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_revoked", entityType: "slack_manager_grant", entityId: id, details: { workspaceId: grant.workspaceId, providerRevoked } }); + } + async function request(id: string, companyId: string, actor: SlackSetupActor, method: string, fields: Record | FormData) { + const authorized = await token(id, companyId, actor); + const { grant } = authorized; + // Slack shares a method budget across manager tokens in the same workspace. + // Persist Retry-After across grants and process restarts; never retry a dispatch. + const result = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-api:${companyId}:${grant.managerAppId}:${grant.workspaceId}:${method}`}, 0))`); + // Serialize provider dispatch against refresh/revocation for this personal grant. + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const peers = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.managerAppId, grant.managerAppId), eq(chatSlackManagerGrants.workspaceId, grant.workspaceId))).orderBy(chatSlackManagerGrants.id); + if (peers.some(peer => Date.parse(peer.rateLimits[method] ?? "") > Date.now())) return { error: providerFailure("slack_setup_rate_limited") }; + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "active") return { error: providerFailure("slack_manager_reauthorize") }; + try { return { value: await api(method, fields, await secret(current, current.accessSecretId, tx)) }; } + catch (error) { + const details = object(object(error).details); + if (details.code === "slack_setup_rate_limited") { + const until = new Date(Date.now() + Math.min(Number(details.retryAfterSeconds) || 60, 86_400) * 1000).toISOString(); + for (const peer of peers) await tx.update(chatSlackManagerGrants).set({ rateLimits: sql`${chatSlackManagerGrants.rateLimits} || ${JSON.stringify({ [method]: until })}::jsonb` }).where(eq(chatSlackManagerGrants.id, peer.id)); + } + if (details.code === "slack_configuration_token_invalid") { + await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + return { error: providerFailure("slack_manager_reauthorize") }; + } + return { error }; + } + }); + if ("error" in result) throw result.error; + return result.value!; + } + return { choices, defaultMethod, available, authorize, pending, expiredReturn, complete, get, token, revoke, request }; +} diff --git a/server/src/services/connectors/slack/setup/provider-client.ts b/server/src/services/connectors/slack/setup/provider-client.ts new file mode 100644 index 00000000000..bc051d651cd --- /dev/null +++ b/server/src/services/connectors/slack/setup/provider-client.ts @@ -0,0 +1,58 @@ +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { unprocessable } from "../../../../errors.js"; +export const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); +export const knownProviderErrors: Record = { + app_approval_request_eligible: "slack_approval_required", app_approval_request_pending: "slack_approval_pending", + app_approval_request_denied: "slack_approval_denied", admin_approval_required: "slack_approval_required", + manager_app_not_eligible: "slack_managed_unavailable", feature_not_enabled: "slack_managed_unavailable", + invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", + token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", + invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", + ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", + missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", +}; + +const timeoutMs = 20_000; +export function createSlackSetupClient(fetchImpl: typeof fetch = fetch) { + return async function api(method: string, fields: Record | FormData, bearer?: string, requiredScopes?: readonly string[]) { + const response = await fetchImpl(`https://slack.com/api/${method}`, { + method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), + headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, + body: fields instanceof FormData ? fields : new URLSearchParams(fields), + }); + // Bound the streamed response as well as the request lifetime. + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + if (reader) { + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > 1_048_576) { await reader.cancel(); throw providerFailure("slack_provider_failure"); } + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + } + let result: Record; + try { result = object(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { result = {}; } + if (!response.ok || result.ok !== true) { + const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); + const retry = Number(response.headers.get("retry-after")); + if (response.status === 429 || result.error === "ratelimited") { + throw unprocessable(slackRegistrationErrorMessage("slack_setup_rate_limited"), { + code: "slack_setup_rate_limited", retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? Math.ceil(retry) : 60, + }); + } + throw providerFailure(code); + } + if (requiredScopes) { + const granted = new Set((response.headers.get("x-oauth-scopes") ?? "").split(/[, ]+/)); + if (requiredScopes.some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + } + return result; + } +} diff --git a/server/src/services/connectors/slack/setup/registration.ts b/server/src/services/connectors/slack/setup/registration.ts new file mode 100644 index 00000000000..ca44f0e7f50 --- /dev/null +++ b/server/src/services/connectors/slack/setup/registration.ts @@ -0,0 +1,136 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { createAgentAvatarPool } from "../../../agent-avatar-pool.js"; +import type { AgentAvatarRequest } from "../../../agent-avatars.js"; +import { and, eq } from "drizzle-orm"; +import { agents, chatEndpoints, chatSlackRegistrations, toolConnections, type Db } from "@paperclipai/db"; +import { slackRegistrationErrorMessage, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState, isProvisionedSlackSetup } from "@paperclipai/shared"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../../errors.js"; +import { accessService } from "../../../access.js"; +import { logActivity } from "../../../activity-log.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; + +export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } +export type Registration = typeof chatSlackRegistrations.$inferSelect; + + +export { object, providerFailure, knownProviderErrors } from "./provider-client.js"; +import { object } from "./provider-client.js"; + +export function slackRegistrationProjection(row: Registration): SlackRegistrationState { + return { + status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, + appId: row.appId, + ...(row.managerGrantId ? { managerGrantId: row.managerGrantId } : {}), + managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", + errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, + }; +} + +import { createSlackSetupClient } from "./provider-client.js"; +export type SlackSetupOptions = { + managed?: { enabled: boolean; broker: import("../../../paperclip-cloud-connector.js").PaperclipCloudConnector }; + publicOrigin: () => string | null; + webhookOrigin: () => string | null; + fetch?: typeof fetch; + renderAvatar?: (request: AgentAvatarRequest) => Promise; + withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; + runtimeSigningSecret: (endpointId: string) => Promise; + runtimeBotToken: (endpointId: string) => Promise; + canWelcome: (endpointId: string, account: SlackAccountState) => Promise; + linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; + configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; +}; +export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) { + const fetchImpl = options.fetch ?? fetch; + const vault = secretService(db); + let avatarPool: ReturnType | undefined; + function origin(value: string | null) { + if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) + throw badRequest("Slack setup requires a configured public HTTPS origin"); + return url.origin; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; + async function registration(endpointId: string) { + return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; + } + async function endpoint(endpointId: string, actor: SlackSetupActor) { + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) + .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) + .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) + .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); + if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); + if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) + throw forbidden("Missing permission: tools:manage_connections"); + return row; + } + function assertOrigins(row: Registration, publicId: string) { + if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); + const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; + if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) + throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); + } + async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { + await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, + action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, + details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); + } + const api = createSlackSetupClient(fetchImpl); + async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, + patch: Partial = {}, account?: SlackAccountState) { + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const ids = { ...current.secretIds }; + for (const [key, value] of Object.entries(values)) { + const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", + configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, + ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); + ids[key] = result.secret.id; + } + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); + if (account) { + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); + // Reauthorization never changes an established personal account binding. + if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + } + await lease.assertOwned(tx); + }); + } + async function readSecret(row: Registration, key: string) { + if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); + return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { + accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, + }); + } + async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + } + async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived" || !isProvisionedSlackSetup(saved.setup.slackSetupMethod)) throw conflict("Slack setup changed. Resume the saved connection."); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) + .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); + await lease.assertOwned(tx); + }); + } + const renderAvatar = (request: AgentAvatarRequest) => (options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render)(request); + return { db, options, origin, callbackUri, registration, endpoint, assertOrigins, audit, api, saveSecrets, readSecret, setFailure, saveAvatar, renderAvatar, + close: async () => { await avatarPool?.close(); } }; +} +export type SlackRegistrationStore = ReturnType; diff --git a/server/src/services/connectors/slack/setup/service.ts b/server/src/services/connectors/slack/setup/service.ts new file mode 100644 index 00000000000..f6b61281aea --- /dev/null +++ b/server/src/services/connectors/slack/setup/service.ts @@ -0,0 +1,151 @@ +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +import { createSlackManagerGrants } from "./manager-grants.js"; +import { createManagedSlackSetup } from "./managed.js"; +import { randomBytes } from "node:crypto"; +import { and, eq, gt, like } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations, companies, toolOauthStates, type Db } from "@paperclipai/db"; +import { badRequest, conflict, forbidden, notFound } from "../../../../errors.js"; +import { removeSlackRegistration } from "../../../chat-slack-registration-cleanup.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, createSlackRegistrationStore, type SlackSetupOptions, type SlackSetupActor } from "./registration.js"; +import { createCustomerOwnedSetup } from "./customer-owned.js"; +import { createSlackCompletion } from "./completion.js"; +const prefix = "slack-install."; +export function slackChatRegistrationService(db: Db, options: SlackSetupOptions) { + const store = createSlackRegistrationStore(db, options); + const { endpoint, registration, assertOrigins, callbackUri, audit, api, readSecret, setFailure, saveSecrets } = store; + const provisioner = createCustomerOwnedSetup(store); + const { create } = provisioner; + const completion = createSlackCompletion(store); + const { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt } = completion; + const managerGrants = createSlackManagerGrants(store); + const managed = createManagedSlackSetup(store, managerGrants, provisioner, completion, install); + async function install(endpointId: string, actor: SlackSetupActor) { + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); + if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); + if (!isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + const state = `${prefix}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); + await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, + endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, + requestedScopes: scopes, expiresAt }); + await tx.update(chatSlackRegistrations).set({ errorCode: row.managerGrantId ? row.errorCode : null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, + ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); + await audit(row, actor, "installation_started"); + return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; + }); + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; + const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); + if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + return options.withLock(current.endpoint.id, async lease => { + const latest = await endpoint(current.endpoint.id, actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId + || !isProvisionedSlackSetup(latest.endpoint.setup.slackSetupMethod) || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; + assertOrigins(row, latest.endpoint.publicId); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await setFailure(row, row.status, "slack_install_expired", lease); + await audit(row, actor, "installation_failed", "slack_install_expired"); + return returnPath(row.endpointId); + }); + } + async function pending(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) + throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || !isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) + || current.endpoint.status === "paused" + || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) + || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri + || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) + throw conflict("Slack setup changed. Resume the saved connection and install again."); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + return { attempt, current, row }; + } + async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { + const before = await pending(state, actor); + return options.withLock(before.row.endpointId, async lease => { + const { row, attempt, current } = await pending(state, actor); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("This Slack authorization was already used"); + if (error) { + const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; + await setFailure(row, row.status, failureCode, lease); + await audit(row, actor, "installation_failed", failureCode); + return row.endpointId; + } + try { + if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); + const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); + const teamId = object(result.team).id; + const installerId = object(result.authed_user).id; + if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") + || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) + || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + const granted = new Set(String(result.scope ?? "").split(",")); + if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId + || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); + if (row.managerGrantId) { + const grant = await managerGrants.get(row.managerGrantId, row.companyId, actor); + if (grant.status !== "active" || installerId !== grant.slackUserId || teamId !== grant.workspaceId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(row.endpointId, actor); + // Reauthorization reuses the runtime signing secret after staging was cleaned. + await saveSecrets(row, { botToken: result.access_token }, actor, lease, + { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, + { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (failure) { + const code = object(object(failure).details).code; + const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; + await setFailure(row, row.status, safeCode, lease); + await audit(row, actor, "installation_failed", safeCode); + return row.endpointId; + } + await resumeLocked(row.endpointId, actor, lease); + return row.endpointId; + }); + } + const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); + async function returnPath(endpointId: string) { + const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) + .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); + if (!row) throw notFound("Slack connection not found"); + return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; + } + return { create, managerGrants, managed, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, + close: store.close, + resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (row?.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + return resumeLocked(endpointId, actor, lease); + }) }; +} diff --git a/server/src/services/connectors/slack/test-message.ts b/server/src/services/connectors/slack/test-message.ts new file mode 100644 index 00000000000..47996f0acf5 --- /dev/null +++ b/server/src/services/connectors/slack/test-message.ts @@ -0,0 +1,134 @@ +import { and, eq, gte, inArray, ne } from "drizzle-orm"; +import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db"; +import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared"; +import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; +import { logActivity } from "../../activity-log.js"; +import { instanceSettingsService } from "../../instance-settings.js"; +import { createSlackSetupClient, object } from "./setup/provider-client.js"; +import type { SlackSetupActor, SlackSetupOptions } from "./setup/registration.js"; + +type Options = Pick & { + ensureAccount: (endpointId: string, user: Record) => Promise; + createLinkIntent: (endpointId: string, principalId: string, expiresInSeconds: number) => Promise<{ confirmationUrl: string; expiresAt: string }>; +}; +const human = (user: Record, workspaceId: string) => + typeof user.id === "string" && /^[UW][A-Z0-9]+$/.test(user.id) && user.team_id === workspaceId && + user.is_bot === false && user.is_app_user !== true && user.deleted !== true && user.id !== "USLACKBOT"; + +export function slackTestMessageService(db: Db, options: Options) { + const api = createSlackSetupClient(options.fetch); + return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise => { + const input = slackTestMessageSchema.parse(rawInput); + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + return options.withLock(endpointId, async lease => { + const [record] = await db.select({ endpoint: chatEndpoints, agentName: agents.name }).from(chatEndpoints) + .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) + .innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId), eq(toolConnections.enabled, true), eq(toolConnections.status, "active"))) + .where(eq(chatEndpoints.id, endpointId)); + if (!record || record.endpoint.provider !== "slack") throw notFound("Active Slack connection not found"); + const endpoint = record.endpoint; + if (!["active", "verifying"].includes(endpoint.status) || !endpoint.providerAccountId || !endpoint.allowDirectMessages) throw conflict("Enable direct messages on an active Slack connection first"); + const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, endpoint.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), + )); + if (!membership) throw forbidden("An active Paperclip account is required"); + const actionKey = `slack_test_message:${actor.userId}:${input.requestId}`; + const [previous] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, actionKey))); + if (previous) { + if (previous.status === "processed" && typeof previous.result?.slackUrl === "string") return { status: "sent", slackUrl: previous.result.slackUrl }; + throw conflict("This message may already have been sent. Check your Slack DMs before sending another."); + } + const recent = await db.select({ id: chatActions.id }).from(chatActions).where(and( + eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), inArray(chatActions.kind, ["slack_test_message", "slack_test_message_invitation"]), gte(chatActions.createdAt, new Date(Date.now() - 60_000)), + )).limit(5); + if (recent.length >= 5) throw tooManyRequests("Wait a minute before sending another test message"); + const links = await db.select({ endpointId: chatIdentityLinks.endpointId, slackUserId: chatExternalPrincipals.externalId }).from(chatIdentityLinks) + .innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId))) + .where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.paperclipUserId, actor.userId), eq(chatIdentityLinks.status, "linked"), + eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId), eq(chatExternalPrincipals.isBot, false))); + const current = links.filter(link => link.endpointId === endpointId); + const grants = await db.select({ slackUserId: chatSlackManagerGrants.slackUserId }).from(chatSlackManagerGrants).where(and( + eq(chatSlackManagerGrants.companyId, endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.workspaceId, endpoint.providerAccountId), eq(chatSlackManagerGrants.status, "active"), + )); + const known = [...new Set((current.length ? current : [...links, ...grants]).map(link => link.slackUserId))]; + const [invitation] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, `${actionKey}:invite`))); + const invitedUser = typeof invitation?.payload.slackUserId === "string" ? invitation.payload.slackUserId : undefined; + if (invitedUser && input.slackUserId && input.slackUserId !== invitedUser) throw conflict("This request already invited a different Slack account"); + const userId = input.slackUserId ?? invitedUser ?? (known.length === 1 ? known[0] : undefined); + const token = await options.runtimeBotToken(endpointId); + if (!token) throw conflict("Reconnect this Slack app before sending a message"); + async function call(method: string, fields: Record) { + try { return await api(method, fields, token); } + catch { throw unprocessable("Slack could not complete this request. Check the connection and try again."); } + } + if (!userId) { + const accounts: { id: string; name: string }[] = []; + const result = await call("users.list", { limit: "200", ...(input.accountCursor ? { cursor: input.accountCursor } : {}) }); + for (const value of Array.isArray(result.members) ? result.members : []) { + const user = object(value); + if (human(user, endpoint.providerAccountId)) accounts.push({ id: String(user.id), name: String(user.real_name || user.name || user.id) }); + } + const cursor = object(result.response_metadata).next_cursor; + return { status: "connection_required", accounts, nextCursor: typeof cursor === "string" && cursor ? cursor : null }; + } + const user = object((await call("users.info", { user: userId })).user); + if (user.id !== userId || !human(user, endpoint.providerAccountId)) throw unprocessable("Choose your personal Slack account in this workspace"); + if (!known.includes(userId)) { + const principalId = await options.ensureAccount(endpointId, user); + const principalLinks = await db.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.principalId, principalId))); + if (principalLinks.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) + || principalLinks.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link"); + if (invitation) { + if (invitation.status !== "processed") throw conflict("The invitation may already have been sent. Check Slack before requesting another."); + const expiresAt = String(invitation.result?.expiresAt ?? ""); + if (!expiresAt || new Date(expiresAt).getTime() <= Date.now()) throw conflict("The Slack invitation expired. Request a new test message."); + return { status: "connection_pending", principalId, expiresAt }; + } + // The account holder must follow the private Slack challenge and sign in. + // Selecting a workspace member does not prove identity ownership. + const intent = await options.createLinkIntent(endpointId, principalId, 600); + const [pending] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, principalId, + kind: "slack_test_message_invitation", providerActionId: `${actionKey}:invite`, status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Connect your Slack account to ${record.agentName} in Paperclip to receive your test message.`, + blocks: JSON.stringify([{ type: "section", text: { type: "plain_text", text: `Connect your Slack account to ${record.agentName} in Paperclip.` } }, + { type: "section", text: { type: "mrkdwn", text: `<${intent.confirmationUrl}|Connect my account> to receive your test message.` } }]), + unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm invitation delivery"); + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { expiresAt: intent.expiresAt }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_invitation_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + throw unprocessable("Invitation delivery was not confirmed. Check Slack before requesting another."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test invitation activity could not be recorded"); } + return { status: "connection_pending", principalId, expiresAt: intent.expiresAt }; + } + await options.linkInstaller(endpointId, user, actor, lease); + await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); + await lease.assertOwned(); + // Persist before dispatch: a crash or ambiguous response must never replay the DM. + const [action] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, kind: "slack_test_message", providerActionId: actionKey, + status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + let slackUrl: string; + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Hi, I’m ${record.agentName}. This is your test message. Reply here to talk to me.`, unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm delivery. Check your DMs before trying again."); + slackUrl = `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${response.channel}`; + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { slackUrl }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); } + return { status: "sent", slackUrl }; + }); + }; +} diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 42e8a3cd4bb..a2a0b37abe0 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -7,6 +7,7 @@ import { randomBytes, type KeyObject, } from "node:crypto"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; @@ -406,6 +407,16 @@ describe("Paperclip Cloud connector", () => { })).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); }); + it("opens Slack manager handoffs with their exact profile and personal identity", async () => { + const keys = config(); + const credentials = { v: 1, accessToken: "manager-access-canary", refreshToken: "manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: "2030-01-01T00:00:00.000Z", refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], subject, companyId, instanceId, environment: "staging", provider: "slack", profile: "slack.manager", + slackManager: { appId: "AMANAGER", workspaceId: "TWORKSPACE", workspaceName: "Paperclip", userId: "UOWNER" } }; + const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "slack.manager"); + const connector = createPaperclipCloudConnector({ config: keys.config, request: vi.fn(async () => Response.json({ claimId: "claim", scopes: [...SLACK_MANAGER_SCOPES], sealed })) as typeof fetch }); + await expect(connector.claim({ subject, companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).resolves.toEqual(credentials); + await expect(connector.claim({ subject: "another-user", companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).rejects.toThrow(); + }); it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => { const keys = config(); const credentials = { @@ -612,10 +623,10 @@ function seal( recipientPublicKey: KeyObject, purpose: "initial" | "access" | "events", configValue: PaperclipCloudConnectorConfig, - profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "github.bot" | "github.code", + profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "slack.manager" | "github.bot" | "github.code", ) { - const provider = profile.startsWith("github.") ? "github" : "google"; - const scopes = provider === "github" ? [] : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; + const provider = profile.startsWith("github.") ? "github" : profile === "slack.manager" ? "slack" : "google"; + const scopes = provider === "github" ? [] : profile === "slack.manager" ? SLACK_MANAGER_SCOPES : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; const ephemeral = generateKeyPairSync("x25519"); const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string }; const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string }; diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 7785deeb596..241ff937b57 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -1,4 +1,4 @@ -import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; +import { SLACK_MANAGER_SCOPES, ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { createDecipheriv, createHash, @@ -34,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app"; -export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "github.bot"; -export type PaperclipCloudConnectorProvider = "google" | "github" | "asana"; +export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot"; +export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack"; export type PaperclipCloudConnectorConfig = { baseUrl: string; @@ -60,6 +60,7 @@ export type SealedConnectorCredentials = { provider: PaperclipCloudConnectorProvider; profile: string; appSlug?: string; + slackManager?: { appId: string; workspaceId: string; workspaceName: string; userId: string }; }; export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" }; @@ -95,6 +96,7 @@ type SealedEnvelope = { }; type ConnectorResponse = { + slackManager?: unknown; confirmationUrl?: unknown; authorizationUrl?: unknown; handoff?: unknown; @@ -413,6 +415,13 @@ export function createPaperclipCloudConnector(input: { typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : [] ))]; }, + async getSlackManagerAppId(): Promise { + const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" }); + const manager = response.slackManager; + if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles) || !response.profiles.includes("slack.manager") + || !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null; + return manager.appId; + }, async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) { const profile = values.profile ?? "gmail.draft"; const response = await call("session", { ...values, profile }); @@ -782,6 +791,7 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): provider: PaperclipCloudConnectorProvider; scopes: readonly string[]; } { + if (profile === "slack.manager") return { provider: "slack", scopes: SLACK_MANAGER_SCOPES }; if (profile === "github.bot") return { provider: "github", scopes: [] }; if (isAsanaConnectorProfileId(profile)) { return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; @@ -796,6 +806,7 @@ function isExpectedProviderAuthorizationUrl( profile: PaperclipCloudConnectorProfileId, url: URL, ): boolean { + if (profile === "slack.manager") return url.origin === "https://slack.com" && url.pathname === "/oauth/v2/authorize"; if (isAsanaConnectorProfileId(profile)) { return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize"; } @@ -806,7 +817,7 @@ function isExpectedProviderAuthorizationUrl( } function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId { - return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); + return value === "slack.manager" || isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); } async function sha256Base64Url(value: string): Promise { diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index c64cb950ca8..a7795120a94 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -156,6 +156,114 @@ test.describe.serial("native chat adapter UI", () => { seed = await seedCompanyAndAgent(request); }); + test("Slack: managed workspace setup has two stops, resumes and completes without pasting credentials", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await selectMaya(page); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + await expect(page.getByLabel("App configuration access token", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Signing Secret", { exact: true })).toHaveCount(0); + await page.reload(); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1); + mock.setWebhookVerified(); + await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + }); + test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let refreshFailed = false; + let authorizationRequests = 0; + await page.route("**/chat-slack/setup-options", async route => { + if (!refreshFailed) return route.fallback(); + await route.fulfill({ json: { managedAvailable: true, defaultMethod: "managed", workspaces: [] } }); + }); + await page.route("**/slack/managed/provision", async route => { + refreshFailed = true; + await route.fulfill({ status: 422, json: { error: "Reconnect your Slack workspace.", details: { code: "slack_manager_reauthorize" } } }); + }); + await page.route("**/slack/managed/authorize", async route => { + authorizationRequests++; + await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } }); + }); + await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByRole("alert")).toBeVisible(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await expect.poll(() => authorizationRequests).toBe(1); + await expect(page).toHaveURL("https://slack.test/manager-consent"); + }); + test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + mock.setSlackUncertain(); + const attempts: { requestId: string; confirmedNoAppCreated?: boolean }[] = []; + await page.route("**/slack/managed/provision", async route => { + attempts.push(route.request().postDataJSON()); + await route.fulfill({ json: {} }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox", { name: "I checked Slack and no app was created. Create a new app." }); + const create = page.getByRole("button", { name: "Continue", exact: true }); + await expect(create).toBeDisabled(); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(1); expect(attempts[0]!.confirmedNoAppCreated).toBe(true); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(2); expect(attempts[1]!.requestId).not.toBe(attempts[0]!.requestId); + }); + test("Slack: own-app creation consumes confirmation even when the response is lost", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); + mock.setSlackUncertain(); + let attempts = 0; + await page.route("**/slack/registration", async route => { + attempts++; expect(route.request().postDataJSON().confirmedNoAppCreated).toBe(true); + await route.fulfill({ status: 502, json: { error: "Creation response unavailable" } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox"); + await confirm.check(); + await page.getByLabel("App configuration access token", { exact: true }).fill("fixture-config-token"); + await page.getByRole("button", { name: "Create Slack app", exact: true }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(confirm).not.toBeChecked(); + await expect(page.getByRole("button", { name: "Create Slack app", exact: true })).toBeDisabled(); + expect(attempts).toBe(1); + }); + test("Slack: temporarily unavailable managed setup can retry in place", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let available = false; + await page.route("**/chat-slack/setup-options", async route => { + if (available) return route.fallback(); + await route.fulfill({ json: { managedAvailable: false, defaultMethod: "automatic", workspaces: [] } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible(); + available = true; await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); + test("Slack: own-app selection persists before provisioning", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await page.getByRole("button", { name: "Choose an active agent" }).click(); + await page.getByRole("button", { name: "Select Maya" }).click(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use your own app" }).click(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use managed setup" }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); test("Slack: automatic creation survives refresh, consent, and connection evidence without copying durable secrets", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); @@ -440,6 +548,10 @@ test.describe.serial("native chat adapter UI", () => { ).toBeVisible(); await expectSetupRail(page, true); await selectMaya(page); + if (provider.provider === "slack") { + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Create manually", exact: true }).click(); + } await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId); expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId); await expect( @@ -573,18 +685,12 @@ test.describe.serial("native chat adapter UI", () => { page.getByRole("heading", { name: provider.provider === "slack" ? "Allowed Channels" : "Destinations" }), ).toBeVisible(); if (provider.provider === "slack") { - await page.locator("summary").filter({ hasText: "Message in a channel" }).click(); - await expect(page.getByText("@maya-paperclip you there?", { exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy message" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Allowed Channels" })).toBeVisible(); - await page.locator("summary").filter({ hasText: /^Agent avatar$/ }).click(); - const avatarSection = page.getByRole("region", { name: "Slack avatar" }); - await expect(avatarSection.getByRole("link", { name: "Download avatar" })).toBeVisible(); - await avatarSection.getByText("How to upload in Slack", { exact: true }).click(); - await expect(avatarSection.getByRole("link", { name: "Open Slack app Settings" })).toBeVisible(); - const settingsDownloadEvent = page.waitForEvent("download"); - await avatarSection.getByRole("link", { name: "Download avatar" }).click(); - expect((await settingsDownloadEvent).suggestedFilename()).toBe("maya-paperclip-avatar.png"); + await expect(page.getByRole("button", { name: "Send me a test message" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open Slack configuration" })).toBeVisible(); + await expect(page.getByText("Message in a channel", { exact: true })).toHaveCount(0); + await expect(page.getByText("Agent avatar", { exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "View message" })).toHaveCount(0); } await expect( diff --git a/tests/e2e/chat-adapters-ui.shared.ts b/tests/e2e/chat-adapters-ui.shared.ts index e8904fc98ed..afa8a9686b9 100644 --- a/tests/e2e/chat-adapters-ui.shared.ts +++ b/tests/e2e/chat-adapters-ui.shared.ts @@ -281,7 +281,7 @@ export function endpointFixture(provider: ProviderCase, seed: Seed) { proactiveDirectMessages: false, }, setup: { - slackSetupMethod: undefined as "automatic" | "manual" | "existing" | undefined, + slackSetupMethod: undefined as "managed" | "automatic" | "manual" | "existing" | undefined, slackRegistration: undefined as SlackRegistrationState | undefined, slackApp: undefined as SlackAppConfiguration | undefined, slackOAuthCallbackUri: "https://paperclip.example.test/api/chat-slack/oauth/callback", @@ -353,10 +353,11 @@ export async function installChatControlPlaneMock( resourceCount = 2, photonShared = false, automaticSlack = false, - }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean }, + managedSlack = false, + }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean; managedSlack?: boolean }, ): Promise { const endpoint = endpointFixture(provider, seed); - if (automaticSlack) endpoint.setup.slackSetupMethod = "automatic"; + if (automaticSlack || managedSlack) endpoint.setup.slackSetupMethod = managedSlack ? "managed" : "automatic"; let slackIdentityLinked = false; let githubAppConnected = false; let githubConfiguration = { revision: 0, configuration: { @@ -510,6 +511,7 @@ export async function installChatControlPlaneMock( assignedAgentId: seed.agentId, }); state.createdWithAgentId = String(body.assignedAgentId); + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (provider.provider === "slack" && body.slackApp) { endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; endpoint.setup.command = endpoint.setup.slackApp.command; @@ -536,7 +538,7 @@ export async function installChatControlPlaneMock( if (method === "PATCH") { const body = bodyOf(route); if (body.slackApp) endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; - if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "automatic" | "manual" | "existing"; + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (typeof body.allowDirectMessages === "boolean") state.allowDirectMessages = body.allowDirectMessages; if (typeof body.allowGroupChats === "boolean") @@ -563,6 +565,16 @@ export async function installChatControlPlaneMock( return; } + if (pathname === `/api/companies/${seed.companyId}/chat-slack/setup-options`) { + await fulfill(route, { managedAvailable: managedSlack, defaultMethod: managedSlack ? "managed" : "automatic", workspaces: managedSlack ? [{ grantId: "11111111-1111-4111-8111-111111111111", workspaceId: "TE2E", workspaceName: "Paperclip", userId: "UINSTALLER" }] : [] }); return; + } + if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/managed/provision`) { + const body = bodyOf(route); + expect(body.grantId).toBe("11111111-1111-4111-8111-111111111111"); + expect(body).not.toHaveProperty("credentials"); + state.slackCreations++; state.slackInstallations++; + state.setSlackInstalled(); await fulfill(route, {}); return; + } if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/registration`) { const body = bodyOf(route); expect(body.credentials).toEqual({ configurationToken: "fixture-config-token" }); diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index a640a8e3f98..73aa8080ef4 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -1,3 +1,4 @@ +import type { SlackSetupOptions, SlackManagedProvisionInput, SlackManagedResult } from "@paperclipai/shared"; import { api } from "./client"; import type { SlackAppConfiguration, @@ -140,7 +141,8 @@ export interface ChatEndpoint { messagingEndpoint?: string | null; command?: string | null; slackApp?: SlackAppConfiguration; - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: SlackRegistrationState; slackAvatar?: SlackAvatarState; slackAccount?: SlackAccountState; @@ -202,7 +204,7 @@ export const chatEndpointsApi = { api.get(`/chat-endpoints/${endpointId}`), create: ( companyId: string, - input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration }, + input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration; slackSetupMethod?: "managed" | "automatic" | "manual" | "existing" }, ) => api.post(`/companies/${companyId}/chat-endpoints`, input), update: ( endpointId: string, @@ -216,6 +218,11 @@ export const chatEndpointsApi = { photon?: PhotonChannelConfiguration; }, ) => api.post(`/chat-endpoints/${endpointId}/setup`, input), + sendSlackTestMessage: (endpointId: string, input: import("@paperclipai/shared").SlackTestMessageInput) => + api.post(`/chat-endpoints/${endpointId}/slack/test-message`, input), + slackSetupOptions: (companyId: string) => api.get(`/companies/${companyId}/chat-slack/setup-options`), + authorizeSlackManager: (endpointId: string) => api.post(`/chat-endpoints/${endpointId}/slack/managed/authorize`, {}), + provisionManagedSlack: (endpointId: string, input: SlackManagedProvisionInput) => api.post(`/chat-endpoints/${endpointId}/slack/managed/provision`, input), createSlackApp: (endpointId: string, input: SlackRegistrationInput) => api.post(`/chat-endpoints/${endpointId}/slack/registration`, input), installSlackApp: (endpointId: string) => diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index dedf6127417..adf87af5089 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -503,23 +503,15 @@ describe("chat setup and identity-link clipboard actions", () => { expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("4Send a message"); }); - it("keeps a terminated agent’s saved avatar available in settings", async () => { - mocks.tab = "settings"; - mocks.listAgents.mockResolvedValue([]); - mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", status: "terminated", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "orchid-peach" } }); - await render("slack", true); - expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/orchid-peach/rest.png?size=512&scale=1&background=paperclip-dark"); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); - }); - - it("keeps avatar download available in connector settings", async () => { + it("keeps settings focused on channel access and the test-message action", async () => { mocks.tab = "settings"; await render("slack", true); - const section = container.querySelector('section[aria-label="Slack avatar"]')!; - expect(section.textContent).toContain("Download avatar"); - expect(section.querySelector('a[download]')?.getAttribute("href")).toContain("/cherry-pop/rest.png?size=512&scale=1"); - expect(section.querySelector("details")?.open).toBe(false); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); + expect(container.textContent).toContain("Send me a test message"); + expect(container.textContent).toContain("Open Slack configuration"); + expect(container.textContent).not.toContain("Message in a channel"); + expect(container.textContent).not.toContain("Agent avatar"); + expect(container.textContent).not.toContain("Manage action permissions"); + expect(container.querySelector('a[download]')).toBeNull(); }); it("discovers a newly invited, enabled Slack channel while settings stays open", async () => { diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index d26e6b75416..dd075cf795e 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -1,14 +1,13 @@ +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { resolveAgentAppearance } from "@paperclipai/shared"; +import { SlackTestMessageButton } from "./SlackTestMessageButton"; +import { AgentAvatar } from "@/components/AgentAvatar"; import { Identity } from "@/components/Identity"; import { SlackSetupAdvanced } from "./SlackAppDetails"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { SlackToolsSettings, SlackSearchAccess } from "./SlackToolSettings"; -import { defaultSlackAppName } from "./slack-app-name"; import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions"; -import { SlackAvatarSettings } from "./SlackAvatarStep"; import { agentsApi } from "@/api/agents"; -import { agentAvatarUrl } from "@/lib/agent-avatar-url"; -import { resolveAgentAppearance } from "@paperclipai/shared"; -import { AgentAvatar } from "@/components/AgentAvatar"; import { ChatConversationList } from "./ChatConversationList"; import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement"; import { GitHubBotMention, GitHubAppIdentityActions } from "./GitHubAppIdentity"; @@ -315,8 +314,9 @@ export function ChatEndpointDetail() { endpoint.setup?.step !== "complete" && !(endpoint.provider === "slack" && endpoint.setup?.step === "test") && ["draft", "verifying", "attention", "revoked"].includes(endpoint.status); - const slackUrl = endpoint.provider === "slack" && endpoint.providerAccountId && endpoint.botExternalId - ? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/user/${encodeURIComponent(endpoint.botExternalId)}` + const slackAppId = endpoint.setup?.slackRegistration?.appId; + const slackUrl = endpoint.provider === "slack" + ? slackAppId ? `https://api.slack.com/apps/${encodeURIComponent(slackAppId)}/oauth` : "https://api.slack.com/apps" : null; return ( @@ -347,8 +347,8 @@ export function ChatEndpointDetail() { )} -
- {slackUrl && } +
+ {slackUrl && } {setupIncomplete ? (
+ {endpoint.provider === "slack" && } {endpoint.provider === "github" && } @@ -435,13 +436,6 @@ function Settings({ }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); - const [messageCopied, setMessageCopied] = useState(false); - const avatarAgent = useQuery({ - queryKey: queryKeys.agents.detail(endpoint.assignedAgentId), - queryFn: () => agentsApi.get(endpoint.assignedAgentId, endpoint.companyId), - enabled: endpoint.provider === "slack", - }); - const mentionMessage = `@${(endpoint.botUsername ?? endpoint.botLabel ?? endpoint.assignedAgentName).replace(/^@/, "")} you there?`; const resourcesQuery = useQuery({ queryKey: queryKeys.chatEndpoints.resources(endpointId), queryFn: () => chatEndpointsApi.listResources(endpointId), @@ -512,7 +506,7 @@ function Settings({ No destinations yet.

) : ( -
+
{destinationResources.map((resource) => (
@@ -595,27 +589,7 @@ function Settings({ queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpointId), next); }} />} - {endpoint.provider === "slack" && ( -
-

Invite the bot to a channel, then mention it to start a conversation.

-
- {mentionMessage} - -
-
- )} - {endpoint.provider === "slack" && - {avatarAgent.isPending ?

Loading agent avatar…

- : avatarAgent.isError ?

Couldn’t load the agent’s avatar.

- : } -
} - {endpoint.provider === "slack" && } + {endpoint.provider === "slack" && } ); } @@ -634,7 +608,7 @@ function SettingToggle({ onChange: (value: boolean) => void; }) { return ( -
+

{label}

{detail &&

{detail}

} diff --git a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx index fae0c99bb7f..a8d45b008ac 100644 --- a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx @@ -1,6 +1,7 @@ import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipai/shared"; import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails"; -import { SlackAutomaticSetup } from "./SlackAutomaticSetup"; +import { SlackSetup } from "./slack/SlackSetup"; +import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state"; import { defaultSlackAppName } from "./slack-app-name"; import { GitHubChatSetup } from "./GitHubChatSetup"; import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning"; @@ -105,10 +106,11 @@ export function isChatEndpointRepairing( ); } -export function ChatConnectionPurpose({ provider, onChat, onTools }: { +export function ChatConnectionPurpose({ provider, onChat, onTools, slackLabels }: { provider: ChatProvider; onChat: () => void; onTools: () => void; + slackLabels?: string[]; }) { const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { @@ -117,7 +119,7 @@ export function ChatConnectionPurpose({ provider, onChat, onTools }: { }, [setBreadcrumbs]); return (
- +

Choose how to connect

@@ -192,6 +194,7 @@ function ChatSdkEndpointSetup() { const [viewedStep, setViewedStep] = useState(null); const [slackIdentityReady, setSlackIdentityReady] = useState(false); const [automaticBusy, setAutomaticBusy] = useState(false); + const [managedAutoStart, setManagedAutoStart] = useState(Boolean(resumeEndpointId)); const [slackDraft, setSlackDraft] = useState<{ agentId: string; app: SlackAppConfiguration } | null>(null); const [endpoint, setEndpoint] = useState(null); const [credentials, setCredentials] = useState>({}); @@ -220,7 +223,7 @@ function ChatSdkEndpointSetup() { useEffect(() => { if (!resumeQuery.data) return; setEndpoint(current => { - if (current?.setup?.slackSetupMethod === "automatic" && !current.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); + if (isProvisionedSlackSetup(current?.setup?.slackSetupMethod) && !current?.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); return resumeQuery.data; }); setAgentId(resumeQuery.data.assignedAgentId); @@ -273,7 +276,8 @@ function ChatSdkEndpointSetup() { next: ChatEndpoint, onlyIfStillVisible = false, ) => { - if (next.setup?.slackSetupMethod === "automatic" && next.setup.slackRegistration?.status === "configured" + if (next.setup?.slackSetupMethod === "managed" && endpoint && endpoint.setup?.slackSetupMethod !== "managed") setManagedAutoStart(true); + if (isProvisionedSlackSetup(next.setup?.slackSetupMethod) && next.setup?.slackRegistration?.status === "configured" && endpoint?.setup?.slackRegistration?.status !== "configured") setViewedStep(null); setEndpoint((visible) => onlyIfStillVisible && visible?.id !== next.id ? visible : next, @@ -287,21 +291,31 @@ function ChatSdkEndpointSetup() { ); } }; + const slackChoices = useQuery({ + queryKey: ["slack-setup-options", selectedCompanyId], + queryFn: () => chatEndpointsApi.slackSetupOptions(selectedCompanyId!), + enabled: provider === "slack" && Boolean(selectedCompanyId), + retry: false, + }); const createEndpoint = useMutation({ - mutationFn: () => - chatEndpointsApi.create(selectedCompanyId!, { + mutationFn: ({ ownApp = false }: { ownApp?: boolean; exit?: boolean }) => { + if (provider === "slack" && !slackChoices.data) throw new Error("Slack setup is still loading. Try again."); + return chatEndpointsApi.create(selectedCompanyId!, { provider: provider!, assignedAgentId: agentId, - ...(provider === "slack" ? { slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), - }), - onSuccess: (next) => { - setViewedStep(null); + ...(provider === "slack" ? { slackSetupMethod: ownApp ? "automatic" : slackChoices.data!.defaultMethod, slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), + }); + }, + onSuccess: (next, { exit }) => { + setManagedAutoStart(!exit); + setViewedStep(exit ? 0 : null); syncEndpointSnapshot(next); if (next.provider === "imessage-photon" || next.provider === "slack") { const resumed = new URLSearchParams(params); resumed.set("resume", next.id); setParams(resumed, { replace: true }); } + if (exit) navigate("/apps"); }, onError: (error) => pushToast({ @@ -324,13 +338,22 @@ function ChatSdkEndpointSetup() { if (exit && provider !== "slack") { navigate("/apps"); return; } if (!endpoint) { if (!agentId) { if (exit) navigate("/apps"); return; } - createEndpoint.mutate(undefined, { onSuccess: () => { if (exit) navigate("/apps"); } }); + createEndpoint.mutate({ exit }); return; } try { if (provider === "slack") await persistSlackDetails(); } catch { return; } - if (exit) navigate("/apps"); else setViewedStep(1); + if (exit) navigate("/apps"); else { setManagedAutoStart(true); setViewedStep(null); } }; + const useOwnSlackApp = useMutation({ + mutationFn: async () => { + if (!endpoint) return createEndpoint.mutateAsync({ ownApp: true }); + await persistSlackDetails(); + return chatEndpointsApi.update(endpoint.id, { slackSetupMethod: "automatic" }); + }, + onSuccess: next => { setManagedAutoStart(false); syncEndpointSnapshot(next); setViewedStep(1); }, + onError: () => pushToast({ title: "Couldn't switch Slack setup", body: "Try again.", tone: "error" }), + }); const setupAction = useMutation({ mutationFn: async ({ action, @@ -454,21 +477,14 @@ function ChatSdkEndpointSetup() { reconnectRequested, ); const isSlack = provider === "slack"; - const automaticSlack = isSlack && (!endpoint || endpoint.setup?.slackSetupMethod === "automatic"); - const automaticSlackComplete = automaticSlack && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); - const tryStep = isSlack ? automaticSlack ? 3 : 5 : 2; - const availableStep = endpoint - ? automaticSlack && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 - : !repairing && - (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") - ? isSlack && endpoint.setup?.step !== "complete" - ? !automaticSlack && !slackIdentityReady ? 4 : tryStep - : tryStep - : automaticSlack && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 - : isSlack && endpoint.providerAccountId && !repairing ? 3 - : isSlack && (slackCredentialsReady || repairing || endpoint.setup?.slackSetupMethod === "existing" || automaticSlack && endpoint.setup?.slackRegistration?.appId) ? 2 : 1 - : 0; + const slackNavigation = slackSetupState({ endpoint, repairing, credentialsReady: slackCredentialsReady, identityReady: slackIdentityReady, defaultMethod: slackChoices.data?.defaultMethod }); + const automaticSlack = isSlack && slackNavigation.provisioned; + const automaticSlackComplete = automaticSlack && slackNavigation.complete; + const tryStep = isSlack ? slackNavigation.finalStage : 2; + const availableStep = isSlack ? slackNavigation.availableStage : !endpoint ? 0 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") ? tryStep : 1; const step = Math.min(viewedStep ?? availableStep, availableStep); + const agentStepBusy = automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || useOwnSlackApp.isPending; const slackVerificationQuery = useQuery({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id], queryFn: () => chatEndpointsApi.get(endpoint!.id), @@ -507,17 +523,17 @@ function ChatSdkEndpointSetup() { ); if (purpose === "choice") { - return setPurpose("chat")} onTools={() => navigate(toolHref)} />; + return setPurpose("chat")} onTools={() => navigate(toolHref)} />; } return (

setViewedStep(isSlack ? slackNavigation.toStage(value) : value)} />
{step === 0 ? ( @@ -541,15 +557,23 @@ function ChatSdkEndpointSetup() { emptyMessage="No active agents are available." />} {isSlack && (selectedAgent || endpoint) && - setSlackDraft({ agentId, app })} onBlur={() => void persistSlackDetails().catch(() => {})} /> {saveSlackDetails.isError &&

Couldn't save the Slack app details. Try again before continuing.

} + {slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && }
} + {isSlack && !endpoint && !slackChoices.data && (slackChoices.isError + ?
Couldn't load Slack setup.
+ :

Loading Slack…

)} {provider === "github" && } - void continueFromAgent(true)}> + void continueFromAgent(true)}>
)}
-

{slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

+

{endpoint.setup?.slackSetupMethod === "managed" ? "Connect to Slack" : slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

{repairing && !automaticSlack && (

Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership. Leave credentials blank to reuse the saved values. @@ -1628,8 +1655,10 @@ function ProviderConnectStep({

}
- {automaticSlack && {}} onBusy={onAutomaticBusy} onSaved={onEndpointSaved} onContinue={onSlackAppCreated} diff --git a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx index 3fe861aaedb..ff342c014a4 100644 --- a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx +++ b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx @@ -1,183 +1 @@ -import { useEffect, useRef, useState } from "react"; -import { ExternalLink, Loader2 } from "lucide-react"; -import { slackRegistrationErrorMessage } from "@paperclipai/shared"; -import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { SetupWizardFooter } from "@/components/SetupWizard"; -import { sanitizedSetupErrorMessage } from "./chat-setup-error"; -import { SlackSetupAdvanced } from "./SlackAppDetails"; - -/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ -export function SlackAutomaticSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, - onManual, onContinue, onSaveExit }: { - endpoint: ChatEndpoint; - stage: "app" | "credentials"; - disabled: boolean; - saveDetails: () => Promise; - onSaved: (endpoint: ChatEndpoint) => void; - onBusy: (busy: boolean) => void; - onManual: (existing: boolean) => Promise; - onContinue: () => void; - onSaveExit: () => void; -}) { - const [configurationToken, setConfigurationToken] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [checkedNoApp, setCheckedNoApp] = useState(false); - const requestId = useRef(crypto.randomUUID()); - const inFlight = useRef(false); - const pendingWindow = useRef(null); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - pendingWindow.current?.close(); - pendingWindow.current = null; - }; - }, []); - const registration = endpoint.setup?.slackRegistration; - const uncertain = registration?.status === "uncertain"; - const creating = registration?.status === "creating"; - const created = Boolean(registration?.appId); - const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; - - async function run(action: () => Promise) { - if (inFlight.current) return; - inFlight.current = true; - setBusy(true); - onBusy(true); - setError(null); - try { await action(); } - catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } - finally { - setConfigurationToken(""); - setBusy(false); - onBusy(false); - inFlight.current = false; - } - } - - async function create() { - // Reserve the window during the click so async app creation cannot trigger a popup blocker. - const installWindow = window.open("about:blank", "_blank"); - if (installWindow) { - installWindow.opener = null; - installWindow.document.title = "Preparing Slack installation"; - installWindow.document.body.textContent = "Preparing your Slack installation…"; - pendingWindow.current = installWindow; - } - let token = configurationToken.trim(); - setConfigurationToken(""); - try { - let next: ChatEndpoint; - try { - if (!created) await saveDetails(); - if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); - next = await chatEndpointsApi.createSlackApp(endpoint.id, { - requestId: requestId.current, - credentials: { configurationToken: token }, - ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), - }); - } catch (failure) { - // A response can be lost after Slack creates the app. Only saved state can advance. - const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); - if (current && mounted.current) onSaved(current); - if (!current?.setup?.slackRegistration?.appId) { - throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); - } - next = current; - } - token = ""; - if (!mounted.current) return; - setCheckedNoApp(false); - onSaved(next); - const saved = next.setup?.slackRegistration; - if (!saved?.appId) return; - onContinue(); - // Pending/uncertain configuration and existing installations must not start OAuth. - if (saved.status !== "install" || saved.errorCode) return; - if (!installWindow || installWindow.closed) { - setError("Select Install in Slack to open the installation page."); - return; - } - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - if (!mounted.current) return; - if (installWindow.closed) { - setError("Select Install in Slack to reopen the installation page."); - return; - } - installWindow.location.replace(result.authorizationUrl); - pendingWindow.current = null; - } finally { - token = ""; - pendingWindow.current?.close(); - pendingWindow.current = null; - } - } - - async function authorize() { - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. - window.location.assign(result.authorizationUrl); - } - - return
- {error || registration?.errorCode ?

- {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} -

: null} - {(stage === "app" && !created && !creating || configurationPending) && <> - {uncertain &&
- Open Slack app settings - -
} - {(!uncertain || checkedNoApp) &&
- -
-
    -
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. -
  3. Select the workspace where you want to install your bot.
  4. -
  5. Copy the Access Token and paste it below.
  6. -
-

Paperclip uses this token to create your app

-
- - setConfigurationToken(event.target.value)} - aria-describedby="slack-configuration-token-help" /> -
} - } - {creating &&

Creating your Slack app. You can return to this saved setup.

} - {created &&

- {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. - {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} -

} - {stage === "credentials" && registration?.status === "credentials_saved" &&
-

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

- -
} - {stage === "app" &&
- - -
} - - {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } - -
; -} +export { OwnSlackAppSetup as SlackAutomaticSetup } from "./slack/OwnSlackAppSetup"; diff --git a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx index b9256c27d59..2cc0568ea77 100644 --- a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx +++ b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx @@ -12,19 +12,21 @@ Ask me to take over for login, two-factor authentication, CAPTCHA, workspace-adm 2. Check public HTTPS before creating the app. Slack must be able to reach Paperclip's generated webhook URL over publicly reachable HTTPS with a valid certificate. Paperclip Cloud includes HTTPS. A private Tailscale Serve address alone is not reachable by Slack; self-hosting needs an authorized public ingress, such as Tailscale Funnel or a reverse proxy with a trusted certificate. Follow the warning's Learn more link if needed. Use the callback URL generated by this connection, including its full path. Check that it uses the intended public instance hostname or an explicitly configured webhook ingress, not localhost, an internal service address, or an old pool hostname. If it is wrong or unreachable, resolve the instance configuration before registration; do not invent a callback path, disable signature verification, or bypass Cloud authentication protections. -3. Create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Settings > Agent avatar for manual upload later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. +3. Use the saved Slack setup method. For a new connection, keep managed setup when it is the instance default. Continue on Choose agent opens Slack authorization or starts installation automatically; there is no separate Add to Slack screen. To explicitly use your own app, open Advanced on Choose agent and choose Use your own app below Slash command. Select only a workspace authorized by my own Paperclip account, or let me complete the manager authorization in Slack; pause for login and administrator consent. Reuse my saved workspace authorization for additional agents. Paperclip creates and installs the child app, uploads its avatar, and links my verified Slack identity. Continue within the same two-stop wizard: Choose agent, Connect. If Slack requires child-app consent or approval, complete that for the saved app and resume. Never switch methods after creation dispatch, migrate an existing bot, read manager credentials, or reuse a teammate's grant. If managed setup becomes unavailable, preserve the draft and report its recovery action. Use your own app only when selected or when managed setup is unavailable before creation. + +For the own-app path, create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Slack app settings > Basic Information to set its app icon later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. 4. Install Slack app. Successful creation and configuration advance Paperclip to Install Slack app and open Slack consent in a new window automatically. Use Install in Slack if that window was blocked or closed. Check the workspace and approve installation within the agreed scope. Pause for required login, ownership decisions, or administrator approval. Return to the saved wizard through Slack's OAuth redirect. Paperclip obtains and saves the bot token itself; do not locate, copy, or request the bot token or signing secret on the automatic path. Wait for Paperclip to report saved credentials. The fourth screen, Send a message to your agent, becomes Success automatically after Paperclip receives qualifying connection evidence. There is no separate Try it screen. If approval is declined or pending, preserve the app and resume its installation later without another configuration token. If authorization expires, start a fresh installation of this same app. If credentials are saved but connecting fails, use Retry connecting. If Slack access changed or the token was revoked, use Authorize in Slack again for this same app. Do not recreate the app. Installation automatically links the Slack account returned by OAuth to my signed-in Paperclip account and sends a welcome DM. Have me approve with my own Slack account; do not authorize with your account or a teammate’s account. There is no separate personal-linking step on the automatic path. Existing/conflicting/revoked links are managed in the connection’s Access settings later. Never overwrite them as a shortcut. Manual fallback only when selected or needed for recovery: choose Create manually or Use an existing app on the creation step. For manual creation, use Paperclip's generated manifest, select the agreed workspace in Slack, and install that exact app. Preserve all scopes, bot events, slash command, interactivity, and signed HTTP callbacks. For an existing app, reconcile its configuration with this connection's manifest and generated URLs. Have me enter the Bot User OAuth Token (OAuth & Permissions, xoxb- prefix) and Signing Secret (Basic Information > App Credentials) directly into the matching Paperclip fields, or use safe clipboard transfer only as described above. The Signing Secret is not the Client Secret, Verification Token, or app-level token. Click Connect Slack app. On a resumed manual setup, leave credential fields blank to keep saved values. Never use Socket Mode as a shortcut. -5. On the automatic path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. +5. On either provisioned path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. 6. Manual/existing-app paths only: connect your Slack account. Copy the exact command shown by Paperclip, consisting of this app's saved slash command followed by connect. In the correct Slack workspace, send it as my signed-in Slack user. This discovers identity without starting agent work. Return to Paperclip and wait for that identity to appear. Compare the Slack account/workspace and the target Paperclip user. Only select the ownership confirmation for that identity when they are verified as my accounts; its visible label is This is my Slack account and browser tools may identify it as Link to my Paperclip account. Ask me if ownership is ambiguous. Never link a teammate's account to mine. Confirm Linked to you and use Continue to message test. Keep unlinked-person access off unless I explicitly requested a different policy. On these manual paths installing the bot and linking a person are separate steps; future work uses that linked person's Paperclip permissions. 7. Prepare the agreed destination. If using a channel, invite/add this bot to that channel through Slack's UI if authorized. In the connection's Settings, inspect Where this agent can work. Use its Refresh action if available, or reload the page after the bot invitation if the channel is missing. Inspect Allowed Channels and make sure the intended test channel is enabled; newly invited channels start enabled, while an explicitly disabled channel stays off until a person re-enables it. Do not invite the bot to unrelated channels, enable every channel, or broaden anyone's access just to get a test to pass. For a DM, check the connection permits direct messages and use my conversation with this bot. Return to the saved wizard if you visited Settings. Reading access and allowed response destinations are distinct; seeing a channel in Slack does not prove the bot is allowed to reply there. -8. Optional live qualification when explicitly requested: on the automatic path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. +8. Optional live qualification when explicitly requested: on either provisioned path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. 9. Finish and review. Use Done to finish setup. The message test is optional and does not waive required credentials, callback verification, or identity linking. If I declined testing, finish without claiming it passed. Confirm the connection is saved for the correct agent/workspace, inspect its permitted destinations and linked identity, and leave existing access restrictions and per-action approvals intact. Slack tools for this agent's eligible tasks and routines use this bot connection and the responsible user's linked Slack account; do not create a separate personal Slack MCP connection or promise all methods work regardless of scopes or plan. Additional communication instructions are optional and apply to new tasks; add them only if I supplied preferences. Other people link their own accounts using the Access page's connect instructions; they do not share the bot secrets or my identity. diff --git a/ui/src/pages/apps/chat/SlackTestMessageButton.tsx b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx new file mode 100644 index 00000000000..10cea6448d2 --- /dev/null +++ b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Loader2, Send } from "lucide-react"; +import type { SlackTestMessageResult } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { queryKeys } from "@/lib/queryKeys"; + +export function SlackTestMessageButton({ endpointId }: { endpointId: string }) { + const queryClient = useQueryClient(); + const [requestId, setRequestId] = useState(() => crypto.randomUUID()); + const [accounts, setAccounts] = useState["accounts"] | null>(null); + const [nextCursor, setNextCursor] = useState(null); + const [selected, setSelected] = useState(""); + const [invitationExpired, setInvitationExpired] = useState(false); + const [search, setSearch] = useState(""); + const send = useMutation({ + mutationFn: (input: { slackUserId?: string; accountCursor?: string; requestId?: string }) => chatEndpointsApi.sendSlackTestMessage(endpointId, { requestId, ...input }), + onSuccess: (result, input) => { + if (result.status === "connection_required") { + setAccounts(previous => input.accountCursor ? [...new Map([...(previous ?? []), ...result.accounts].map(account => [account.id, account])).values()] : result.accounts); + setNextCursor(result.nextCursor); + return; + } + setAccounts(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.principals(endpointId) }); + }, + }); + const sent = send.data?.status === "sent"; + const pendingConnection = send.data?.status === "connection_pending" ? send.data : null; + useEffect(() => { + setInvitationExpired(false); + if (!pendingConnection) return; + const timer = setTimeout(() => setInvitationExpired(true), Math.max(0, new Date(pendingConnection.expiresAt).getTime() - Date.now())); + return () => clearTimeout(timer); + }, [pendingConnection?.expiresAt]); + const principals = useQuery({ + queryKey: queryKeys.chatEndpoints.principals(endpointId), + queryFn: () => chatEndpointsApi.listPrincipals(endpointId), + enabled: Boolean(pendingConnection) && !invitationExpired, refetchInterval: pendingConnection && !invitationExpired ? 3000 : false, + }); + const confirmed = Boolean(pendingConnection && principals.data?.some(link => link.principalId === pendingConnection.principalId && link.status === "linked")); + useEffect(() => { + if (confirmed && !send.isPending && !send.isError) send.mutate({ slackUserId: selected }); + }, [confirmed, send.isPending, send.isError]); + const reset = () => { setRequestId(crypto.randomUUID()); send.reset(); }; + const startTest = () => { + if (invitationExpired) { + const next = crypto.randomUUID(); setRequestId(next); + send.mutate({ requestId: next, slackUserId: selected }); + } else send.mutate({}); + }; + return
+ + {pendingConnection &&

{invitationExpired ? "The Slack invitation expired." : "Open the invitation in Slack to connect your account."}

} + {send.isError &&
+ {send.error.message} +
} + { if (!open && !send.isPending) setAccounts(null); }}> + + + Connect your Slack account + Choose your account. Messages to this agent will use your Paperclip permissions. + + setSearch(event.target.value)} /> +
+ {accounts?.filter(account => `${account.name} ${account.id}`.toLowerCase().includes(search.toLowerCase())).map(account => + )} + {accounts?.length === 0 &&

No available accounts found in this Slack workspace.

} +
+ {nextCursor && } + {send.isError &&

{send.error.message}

} +
+ + +
+
+
+
; +} diff --git a/ui/src/pages/apps/chat/SlackToolSettings.tsx b/ui/src/pages/apps/chat/SlackToolSettings.tsx index 8e2fb86df9b..772eb0af66c 100644 --- a/ui/src/pages/apps/chat/SlackToolSettings.tsx +++ b/ui/src/pages/apps/chat/SlackToolSettings.tsx @@ -1,4 +1,3 @@ -import { Link } from "react-router-dom"; import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { @@ -200,11 +199,9 @@ export function SlackSearchView({ export function SlackToolsSettings({ companyId, endpointId, - connectionId, }: { companyId: string; endpointId: string; - connectionId?: string | null; }) { const query = useQuery({ queryKey: ["slack-capabilities", companyId, endpointId], @@ -217,14 +214,7 @@ export function SlackToolsSettings({ capabilities={query.data} error={query.error?.message} /> - {connectionId && ( - - Manage action permissions - - )} +
); } diff --git a/ui/src/pages/apps/chat/chat-ui-contract.test.ts b/ui/src/pages/apps/chat/chat-ui-contract.test.ts index f67ccd5be70..34b79e374d4 100644 --- a/ui/src/pages/apps/chat/chat-ui-contract.test.ts +++ b/ui/src/pages/apps/chat/chat-ui-contract.test.ts @@ -242,7 +242,8 @@ describe("chat connector UI contract", () => { expect(setup).toContain("defaultSlackAppConfiguration"); expect(setup).not.toContain("No credentials"); expect(setup).not.toContain("managed Microsoft app"); - expect(setup).toContain("endpoint.providerAccountId && !repairing"); + expect(setup).toContain("slackSetupState({ endpoint, repairing"); + expect(source("./slack/setup-state.ts")).toContain("endpoint.providerAccountId && !repairing"); expect(setup).not.toContain('field("webhookSecret"'); expect(setup).not.toContain("@paperclipai/teams-connect"); expect(setup).not.toContain("Copy setup command"); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx new file mode 100644 index 00000000000..245773a6a70 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { StrictMode, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, afterEach, expect, it, vi } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; + +const api = vi.hoisted(() => ({ slackSetupOptions: vi.fn(), provisionManagedSlack: vi.fn(), authorizeSlackManager: vi.fn(), get: vi.fn() })); +vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: api })); +// Stop at the navigation boundary without leaving jsdom. +const navigation = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/oauthHandoff", () => ({ prepareOAuthNavigation: navigation, savePendingCloudHandoff: vi.fn() })); +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +let endpoint: ChatEndpoint; +const props = () => ({ endpoint, stage: "app" as const, disabled: false, autoStart: true, saveDetails: vi.fn().mockResolvedValue(undefined), onSaved: vi.fn(), onBusy: vi.fn(), onSaveExit: vi.fn(), onManual: vi.fn(), onContinue: vi.fn() }); +beforeEach(() => { + vi.resetAllMocks(); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + endpoint = { id: "endpoint", companyId: "company", assignedAgentName: "Maya", setup: { slackSetupMethod: "managed" } } as ChatEndpoint; + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "grant", workspaceName: "Paperclip" }] }); + api.provisionManagedSlack.mockResolvedValue({}); api.get.mockResolvedValue(endpoint); + api.authorizeSlackManager.mockResolvedValue({ authorizationUrl: "https://slack.com/oauth" }); + navigation.mockImplementation(() => new Promise(() => {})); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); }); +async function render(overrides = {}) { + await act(async () => { root.render(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); +} +it("starts a single managed install after Continue even across rerenders", async () => { + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant" })); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("requests explicit reinstall when reconnecting a configured app", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + await render({ autoStart: false, reconnect: true }); + const button = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack"); + expect(button).toBeDefined(); + await act(async () => button!.click()); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant", reconnect: true, requestId: expect.any(String) })); +}); +it("reuses the reconnect receipt after a lost response and failed recovery read", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + api.provisionManagedSlack.mockRejectedValue(new Error("Response lost")); + api.get.mockRejectedValue(new Error("Recovery read unavailable")); + await render({ autoStart: false, reconnect: true }); + const button = () => Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack")!; + await act(async () => button().click()); + await act(async () => button().click()); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(2); + expect(api.provisionManagedSlack.mock.calls[1]![1]).toEqual(api.provisionManagedSlack.mock.calls[0]![1]); +}); +it("opens manager authorization directly when there is no saved workspace", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [] }); + await render(); + expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1); + expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not start while Choose agent is visible or after Save & exit", async () => { + await render({ autoStart: false }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("waits for an explicit workspace choice when there are several", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "one", workspaceName: "One" }, { grantId: "two", workspaceName: "Two" }] }); + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Choose a Slack workspace"); +}); +it.each(["uncertain", "failed", "creating", "install", "credentials_saved"])("does not automatically retry a %s registration", async status => { + endpoint.setup!.slackRegistration = { status } as NonNullable["slackRegistration"]; + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("does not loop authorization after denial", async () => { + endpoint.setup!.slackManagerError = "slack_install_declined"; + await render(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not silently retry an ambiguous request after refresh of setup options", async () => { + api.provisionManagedSlack.mockRejectedValue(new Error("Request failed")); + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); +}); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx new file mode 100644 index 00000000000..d9023162f02 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState, type ComponentProps } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import type { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; + +export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, onBusy, onSaveExit, autoStart = false, reconnect = false }: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(endpoint.companyId), retry: false }); + const [selectedGrant, setSelectedGrant] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const inFlight = useRef(false); + const requestId = useRef(crypto.randomUUID()); + const registration = endpoint.setup?.slackRegistration; + const started = Boolean(registration); + const workspaces = choices.data?.workspaces ?? []; + const grantId = registration?.managerGrantId ?? selectedGrant ?? (workspaces.length === 1 ? workspaces[0]!.grantId : null); + const needsAuthorization = !grantId || registration?.errorCode === "slack_manager_reauthorize" || Boolean(grantId && !workspaces.some(workspace => workspace.grantId === grantId)); + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); onBusy(true); setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); } + finally { inFlight.current = false; setBusy(false); onBusy(false); } + } + async function authorize() { + const authorization = await chatEndpointsApi.authorizeSlackManager(endpoint.id); + const target = await prepareOAuthNavigation(authorization); + if (target.kind === "reauthentication" && authorization.handoff) savePendingCloudHandoff(authorization.handoff.session); + window.location.assign(target.url); + } + async function connect() { + if (needsAuthorization || !grantId) { + await authorize(); return; + } + if (!started) await saveDetails(); + if (confirmed || registration?.status === "failed") requestId.current = crypto.randomUUID(); + const input = { requestId: requestId.current, grantId, ...(reconnect ? { reconnect: true } : {}), ...(confirmed ? { confirmedNoAppCreated: true } : {}) }; + setConfirmed(false); + const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, input); + await choices.refetch(); + onSaved(await chatEndpointsApi.get(endpoint.id)); + if (result.authorization) window.location.assign(result.authorization.authorizationUrl); + } + const autoStarted = useRef(false); + useEffect(() => { + // Only fresh setup can start itself. Declines, uncertain creation, and saved + // registration recovery require an explicit action and never recreate an app. + if (!autoStart || autoStarted.current || disabled || !choices.data?.managedAvailable + || choices.isFetching || choices.isError || started || endpoint.setup?.slackManagerError + || workspaces.length > 1) return; + autoStarted.current = true; + void run(connect); + }); + const setupError = (code: string) => code === "slack_manifest_update_pending" + ? "Your app was created. Retry to finish its configuration." + : code === "slack_creation_uncertain" ? "Slack may have created your app. Check Slack app settings before trying again." + : slackRegistrationErrorMessage(code); + const message = error ?? (endpoint.setup?.slackManagerError ? setupError(endpoint.setup.slackManagerError) : null) ?? (choices.isError ? "Couldn't load your Slack workspaces. Try again." : registration?.errorCode ? setupError(registration.errorCode) : null); + return
+ {message &&

{message}

} + {busy || choices.isPending || creating ?

{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}

+ : !choices.data?.managedAvailable ?

Managed Slack setup is unavailable. Try again later.

+ : <> + {workspaces.length === 1 &&

Add {endpoint.assignedAgentName} to {workspaces[0]!.workspaceName}.

} + {workspaces.length > 1 && } + {(!started || registration?.errorCode === "slack_manager_reauthorize") && workspaces.length > 0 &&
} + } + {uncertain &&
+ Open Slack app settings + +
} + + {choices.isError || !choices.isPending && !choices.data?.managedAvailable ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx new file mode 100644 index 00000000000..a08c09376b7 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from "react"; +import { ExternalLink, Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; +import { SlackSetupAdvanced } from "../SlackAppDetails"; + +/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ +export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, + onManual, onManaged, onContinue, onSaveExit }: { + endpoint: ChatEndpoint; + stage: "app" | "credentials"; + disabled: boolean; + saveDetails: () => Promise; + onSaved: (endpoint: ChatEndpoint) => void; + onBusy: (busy: boolean) => void; + onManual: (existing: boolean) => Promise; + onManaged?: () => Promise; + onContinue: () => void; + onSaveExit: () => void; +}) { + const [configurationToken, setConfigurationToken] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [checkedNoApp, setCheckedNoApp] = useState(false); + const requestId = useRef(crypto.randomUUID()); + const inFlight = useRef(false); + const pendingWindow = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + pendingWindow.current?.close(); + pendingWindow.current = null; + }; + }, []); + const registration = endpoint.setup?.slackRegistration; + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + const created = Boolean(registration?.appId); + const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; + + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + onBusy(true); + setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } + finally { + setConfigurationToken(""); + setBusy(false); + onBusy(false); + inFlight.current = false; + } + } + + async function create() { + // Reserve the window during the click so async app creation cannot trigger a popup blocker. + const installWindow = window.open("about:blank", "_blank"); + if (installWindow) { + installWindow.opener = null; + installWindow.document.title = "Preparing Slack installation"; + installWindow.document.body.textContent = "Preparing your Slack installation…"; + pendingWindow.current = installWindow; + } + let token = configurationToken.trim(); + setConfigurationToken(""); + try { + let next: ChatEndpoint; + try { + if (!created) await saveDetails(); + if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); + setCheckedNoApp(false); + next = await chatEndpointsApi.createSlackApp(endpoint.id, { + requestId: requestId.current, + credentials: { configurationToken: token }, + ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), + }); + } catch (failure) { + // A response can be lost after Slack creates the app. Only saved state can advance. + const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); + if (current && mounted.current) onSaved(current); + if (!current?.setup?.slackRegistration?.appId) { + throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); + } + next = current; + } + token = ""; + if (!mounted.current) return; + onSaved(next); + const saved = next.setup?.slackRegistration; + if (!saved?.appId) return; + onContinue(); + // Pending/uncertain configuration and existing installations must not start OAuth. + if (saved.status !== "install" || saved.errorCode) return; + if (!installWindow || installWindow.closed) { + setError("Select Install in Slack to open the installation page."); + return; + } + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + if (!mounted.current) return; + if (installWindow.closed) { + setError("Select Install in Slack to reopen the installation page."); + return; + } + installWindow.location.replace(result.authorizationUrl); + pendingWindow.current = null; + } finally { + token = ""; + pendingWindow.current?.close(); + pendingWindow.current = null; + } + } + + async function authorize() { + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. + window.location.assign(result.authorizationUrl); + } + + return
+ {error || registration?.errorCode ?

+ {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} +

: null} + {(stage === "app" && !created && !creating || configurationPending) && <> + {uncertain &&
+ Open Slack app settings + +
} + {(!uncertain || checkedNoApp) &&
+ +
+
    +
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. +
  3. Select the workspace where you want to install your bot.
  4. +
  5. Copy the Access Token and paste it below.
  6. +
+

Paperclip uses this token to create your app

+
+ + setConfigurationToken(event.target.value)} + aria-describedby="slack-configuration-token-help" /> +
} + } + {creating &&

Creating your Slack app. You can return to this saved setup.

} + {created &&

+ {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. + {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} +

} + {stage === "credentials" && registration?.status === "credentials_saved" &&
+

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

+ +
} + {stage === "app" &&
+ {onManaged && !registration && } + + +
} + + {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/SlackSetup.tsx b/ui/src/pages/apps/chat/slack/SlackSetup.tsx new file mode 100644 index 00000000000..e962d6d7cde --- /dev/null +++ b/ui/src/pages/apps/chat/slack/SlackSetup.tsx @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ComponentProps } from "react"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; +export function SlackSetup(props: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", props.endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(props.endpoint.companyId), retry: false }); + return props.endpoint.setup?.slackSetupMethod === "managed" + ? + : props.onSaved(await chatEndpointsApi.update(props.endpoint.id, { slackSetupMethod: "managed" })) : undefined} />; +} diff --git a/ui/src/pages/apps/chat/slack/setup-state.test.ts b/ui/src/pages/apps/chat/slack/setup-state.test.ts new file mode 100644 index 00000000000..b21830596b1 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { slackSetupState } from "./setup-state"; +const draft = (method?: "managed" | "automatic" | "manual" | "existing") => ({ id: "endpoint", setup: { step: "provider_setup", slackSetupMethod: method } }) as ChatEndpoint; +const state = (endpoint: ChatEndpoint | null, extra = {}) => slackSetupState({ endpoint, repairing: false, credentialsReady: false, identityReady: false, ...extra }); +describe("Slack setup capabilities and progress", () => { + it("does not flash token instructions while discovery is pending", () => { + expect(state(null).labels).toEqual(["Choose agent", "Connect"]); + }); + it("defaults only new drafts and preserves legacy manual drafts", () => { + expect(state(null, { defaultMethod: "managed" }).labels).toEqual(["Choose agent", "Connect"]); + expect(state(draft(), { defaultMethod: "managed" }).provisioned).toBe(false); + expect(state(draft("automatic"), { defaultMethod: "managed" }).labels).toHaveLength(4); + }); + it("keeps managed provider operations within the two visible stops", () => { + const endpoint = draft("managed"); + endpoint.setup!.slackRegistration = { status: "install", appId: "AAPP", managementUrl: "https://api.slack.com/apps/AAPP" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.slackRegistration.status = "credentials_saved"; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.providerAccountId = "TWORKSPACE"; + endpoint.setup!.slackRegistration.status = "configured"; + endpoint.setup!.slackAccount = { status: "linked", externalUserId: "UPERSON", paperclipUserId: "person", welcomeStatus: "sent" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.step = "test"; + expect(state(endpoint).complete).toBe(true); + expect(state(endpoint).toStage(1)).toBe(3); + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + }); +}); diff --git a/ui/src/pages/apps/chat/slack/setup-state.ts b/ui/src/pages/apps/chat/slack/setup-state.ts new file mode 100644 index 00000000000..9e25bc230db --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.ts @@ -0,0 +1,30 @@ +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +export { isProvisionedSlackSetup }; +export function slackSetupNavigation(method: string | undefined, availableStage = 1) { + const managed = method === "managed"; + return { + labels: managed ? ["Choose agent", "Connect"] : isProvisionedSlackSetup(method) + ? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"] + : ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"], + toVisible: (step: number) => managed ? Math.min(step, 1) : step, + toStage: (step: number) => managed && step === 1 ? availableStage : step, + }; +} + +/** Logical stages stay compatible with existing draft URLs; navigation is derived here. */ +export function slackSetupState(input: { endpoint: ChatEndpoint | null; repairing: boolean; credentialsReady: boolean; identityReady: boolean; defaultMethod?: string }) { + const { endpoint, repairing, credentialsReady, identityReady } = input; + const method = endpoint ? endpoint.setup?.slackSetupMethod : input.defaultMethod ?? "managed"; + const provisioned = isProvisionedSlackSetup(method); + const complete = provisioned && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); + const finalStage = provisioned ? 3 : 5; + const availableStage = !endpoint ? 0 + : provisioned && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") + ? endpoint.setup.step !== "complete" && !provisioned && !identityReady ? 4 : finalStage + : provisioned && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 + : endpoint.providerAccountId && !repairing ? 3 + : credentialsReady || repairing || method === "existing" || provisioned && endpoint.setup?.slackRegistration?.appId ? 2 : 1; + return { ...slackSetupNavigation(method, availableStage), provisioned, complete, finalStage, availableStage }; +} diff --git a/ui/storybook/fixtures/SlackSetupFixture.tsx b/ui/storybook/fixtures/SlackSetupFixture.tsx index be7b266de05..dc1d26a3991 100644 --- a/ui/storybook/fixtures/SlackSetupFixture.tsx +++ b/ui/storybook/fixtures/SlackSetupFixture.tsx @@ -7,10 +7,10 @@ import { type ChatEndpoint } from "@/api/chatEndpoints"; import { storybookAgents, storybookAuthSession } from "./paperclipData"; import { defaultSlackAppConfiguration } from "@paperclipai/shared"; -export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; +export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "approval_denied" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; /** Production wizard over a local provider fixture. No requests go to Slack. */ -export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSetupScenario }) { +export function SlackSetupFixture({ scenario = "create", managed = false, managedAvailable = true, workspaceCount = 1, optionsDelayMs = 0, optionsError = false }: { scenario?: SlackSetupScenario; managed?: boolean; managedAvailable?: boolean; workspaceCount?: number; optionsDelayMs?: number; optionsError?: boolean }) { const [ready, setReady] = useState(false); const [signalVerification, setSignalVerification] = useState<() => void>(() => () => {}); useLayoutEffect(() => { @@ -20,14 +20,16 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet const endpoint: ChatEndpoint = { id: "slack-story", companyId: "company-storybook", provider: "slack", status: "draft", assignedAgentId: agent.id, assignedAgentName: agent.name, allowUnlinkedPeople: false, - setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : "automatic", + setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : managed ? "managed" : "automatic", slackApp: defaultSlackAppConfiguration(agent.name), webhookUrl: "https://ingress.example/api/chat-webhooks/slack-story/slack", slackOAuthCallbackUri: "https://board.example/api/chat-slack/oauth/callback", }, }; + const grantId = "11111111-1111-4111-8111-111111111111"; + let authorized = workspaceCount > 0 || new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1"; const created = () => { - endpoint.setup!.slackRegistration = { status: "install", appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; + endpoint.setup!.slackRegistration = { status: "install", ...(managed ? { managerGrantId: grantId } : {}), appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; endpoint.setup!.slackAvatar = scenario === "avatar_failed" ? { status: "failed", errorCode: "slack_avatar_upload_failed" } : { status: "uploaded", uploadedAt: new Date().toISOString() }; }; let linked = false; @@ -37,11 +39,12 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet endpoint.setup!.slackAccount = { externalUserId: "UPERSON", paperclipUserId: storybookAuthSession.user.id, status: "linked", welcomeStatus: scenario === "welcome_failed" ? "failed" : "sent", ...(scenario === "welcome_failed" ? {} : { dmChannelId: "DSTORY" }) }; endpoint.status = "verifying"; endpoint.providerAccountId = "TSTORY"; endpoint.botExternalId = "USTORY"; endpoint.botUsername = "maya"; }; - if (["install", "declined", "recovery", "manifest_pending"].includes(scenario)) created(); + if (["install", "declined", "approval_denied", "recovery", "manifest_pending"].includes(scenario)) created(); if (scenario === "manifest_pending") endpoint.setup!.slackRegistration!.errorCode = "slack_manifest_update_pending"; - if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = "slack_install_declined"; + if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = managed ? "slack_approval_pending" : "slack_install_declined"; + if (scenario === "approval_denied") endpoint.setup!.slackRegistration!.errorCode = "slack_approval_denied"; if (scenario === "recovery") endpoint.setup!.slackRegistration = { ...endpoint.setup!.slackRegistration!, status: "credentials_saved", errorCode: "slack_configuration_incomplete" }; - if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; + if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", ...(managed ? { managerGrantId: grantId } : {}), errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; if (["verify", "avatar_failed", "welcome_failed", "success"].includes(scenario)) installed(); if (["avatar_failed", "welcome_failed", "success"].includes(scenario)) { endpoint.setup!.webhookVerifiedAt = new Date().toISOString(); @@ -52,12 +55,26 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); const path = url.pathname; + if (path === "/api/companies/company-storybook/chat-slack/setup-options") { + if (optionsDelayMs) await new Promise(resolve => setTimeout(resolve, optionsDelayMs)); + if (optionsError) return Response.json({ error: "Slack setup unavailable" }, { status: 503 }); + return Response.json({ managedAvailable: managed && managedAvailable, defaultMethod: managed ? "managed" : "automatic", workspaces: managed && authorized ? Array.from({ length: Math.max(1, workspaceCount) }, (_, i) => ({ grantId: i ? "22222222-2222-4222-8222-222222222222" : grantId, workspaceId: `TSTORY${i}`, workspaceName: i ? "Research workspace" : "Paperclip", userId: "UPERSON" })) : [] }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/authorize") { + authorized = true; + const returnUrl = new URL(window.location.href); + returnUrl.hash = ""; + returnUrl.searchParams.set("slackFixtureAuthorized", "1"); + return Response.json({ authorizationUrl: returnUrl.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/provision") { installed(); return Response.json({}); } if (path === "/api/companies/company-storybook/agents") return Response.json(agents); if (path === `/api/agents/${agent.id}`) return Response.json(agent); if (path === "/api/companies/company-storybook/chat-endpoints") { const input = JSON.parse(String(init?.body ?? "{}")); agent = agents.find(value => value.id === input.assignedAgentId) ?? agent; endpoint.assignedAgentId = agent.id; endpoint.assignedAgentName = agent.name; + endpoint.setup!.slackSetupMethod = input.slackSetupMethod ?? (managed ? "managed" : "automatic"); endpoint.setup!.slackApp = input.slackApp ?? defaultSlackAppConfiguration(agent.name); return Response.json(endpoint); } @@ -94,7 +111,7 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet }; setReady(true); return () => { window.fetch = original; }; - }, [scenario]); + }, [scenario, managed, managedAvailable, workspaceCount, optionsDelayMs, optionsError]); return