diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index e53dcafe773..27b2b1accd6 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -67,6 +67,11 @@ Tasks have parentage. Every task exists in service of a parent task, all the way The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks. +When a task is assigned to the current human, the message composer defaults to +the latest agent that assigned it to them. If no assignment is available, it uses +the latest other commenter, or leaves the recipient empty. A manual recipient +choice stays selected while task data refreshes. + ### Company Skills and Policy Company skills are shared operating capabilities, not privileged objects by default. Every authenticated agent in a company can create, import, install, edit, update, test, reset, and remove that company's skills unless the company has configured an explicit restriction. diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index f5632ecb178..238bf4b8b8a 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -29,6 +29,17 @@ An issue has at most one assignee. This is a hard invariant. Paperclip is single-assignee by design. +A legacy agent run can hand its own task to a human without cancelling itself. +The REST request must use the run's signed token and match the current agent, +company, task, and active run. The assignment and requested status persist while +the current turn finishes normally. This avoids losing the handoff when workspace +restoration takes longer than the stop timeout. The former agent no longer owns +the task. Its later task mutations are rejected, including under the update +transaction's task and run locks. Read and comment access follows normal policy. + +Board-initiated reassignments, agent API keys, native executions, and handoffs to +another agent still require the outgoing execution to stop before ownership changes. + ## 3. Status Semantics Paperclip issue statuses are not just UI labels. They imply different expectations about ownership and execution. diff --git a/doc/plans/2026-10-12-chat-channel-provider-ownership-refactor.md b/doc/plans/2026-10-12-chat-channel-provider-ownership-refactor.md new file mode 100644 index 00000000000..1818ac829ae --- /dev/null +++ b/doc/plans/2026-10-12-chat-channel-provider-ownership-refactor.md @@ -0,0 +1,95 @@ +# Chat channel provider ownership refactor + +Date: 2026-10-12. Batch five starts at `4b958ecbd8f33a7782fe6f29ec825f80f636b833`. + +The user requested another stack of about ten PRs. Put provider-specific code in +the provider's existing folder. Preserve behavior and build each PR on the previous +verified extraction. This batch does not authorize merging the new stack. + +The starting service is 21,460 lines and 862,284 bytes. The previous ten PRs are +merged. Common admission, runtime ownership, and publication scheduling stay in +the service. In particular, the receiver lease currently serves both Discord and +Photon; its Discord names do not make it a Discord-only boundary. + +| Order | Responsibility and destination under `server/src/services/chat-channels/` | Approximate source lines | Contract and state | Risks and verification | +| --- | --- | ---: | --- | --- | +| 1 | Credential verification: provider `credentials.ts` files | 250 | Credentials, fetch, timeout; return verified bot identity. Photon inspection retains its error translation. | Preserve scope/event checks and immutable identity. Existing configuration, credential, and Photon tests. | +| 2 | Inventory discovery: `slack/inventory.ts`, `github/inventory.ts`, `discord/inventory.ts` | 120 | Credential input and prepared inventory result. Slack label hydration uses the same endpoint reader. | Preserve GitHub installation discovery and canonical repository IDs; same public resource tests. | +| 3 | GitHub secret rotation: `github/setup-secret.ts` | 278 | Endpoint reader, credential lease, credential storage, runtime invalidation, and audit. | Keep lease checks, commit recovery, and audit ordering intact; real database rotation/race tests. | +| 4 | Teams file delivery: `microsoft-teams/file-delivery.ts` | 360 | Transfer protocol and consent callback; shared runtime readers and retry cooldown. Maintenance state remains per service. | Preserve exact transfer authority, unknown outcomes, consent races, and live shutdown state; assembled file-delivery tests. | +| 5 | Native slash callbacks: `slack/commands.ts`, `telegram/commands.ts` | 340 | Provider command callbacks use shared admission and durable effects. Dispatcher retains callback admission. | Preserve synthetic Telegram media/time, Slack acknowledgment, identity linking, and duplicate receipts. | +| 6 | Telegram attachment failure: `telegram/attachment-failure.ts` | 106 | Accepted inbound delivery and attachment result; return whether it settled. | Keep comment/action/delivery writes atomic and avoid waking an agent with no usable content. | +| 7 | GitHub publication preparation and settlement: `github/publication-policy.ts` | 235 | Current source proof, explicit operator classifier, receipt cleanup, and task URL getter. | Preserve prepared-text receipts and governed-tool fallback suppression; publication/review integration tests. | +| 8 | Slack task tool authority: `slack/task-authority.ts` | 170 | Register resolver with current endpoint/credential and inbound authorization readers; return unregister callback. | Preserve lock order, responsible-user authority, company scope, and shutdown cleanup; existing Slack tool tests. | +| 9 | GitHub App setup: `github/app-setup.ts` | 180 | App persistence, repository refresh, registration/wizard assembly, and cloud ingress registration. | Preserve live origins, credential leases, ingress fences, and unregister ownership; setup and wizard integration tests. | +| 10 | GitHub installation lifecycle: `github/installation-lifecycle.ts` | 430 | Admitted lifecycle delivery, locked credential lease, identity/inventory readers; explicit runtime outcomes and failure notification. | Keep claim/retry ownership in the shared coordinator. Preserve failure notification before throwing; deletion/reinstall/race tests. | + +These estimates include code that stays behind as dispatch and typed wiring. Each +slice must be reassessed before implementation. Do not introduce an untyped context +bag, copy mutable live values, or change algorithms to meet a line-count target. + +Run existing public-path tests before production edits and after each extraction. +Compare moved declarations and retained statements with normalized TypeScript +syntax; review every deliberate dispatch substitution. Run server typecheck and +module boundaries for each slice, and repository typecheck, full tests, and build +once at the final handoff. Record failures and omitted checks without claiming they +passed. Local fixtures prove assembled application behavior, not live third-party +execution. Use the same managed checkout for dependency preflight and builds. + +Every PR uses the repository template and points to its preceding stack branch. +Check current-head Greptile feedback and CI before completing each slice. Update +this plan with evidence and a fresh map after the batch. + +## Implemented boundaries and evidence + +All ten local slices are committed. The service is now 19,153 lines and 762,442 +bytes: 2,307 lines and 99,842 bytes removed from the entry point. The service body +is 17,787 lines, including 138 nested functions (17,270 lines) and 71 state/wiring +statements (231 lines). Imports occupy 399 lines. The aggregate AST audit checks +38 moved bodies/declarations and the syntax and order of 263 retained statements. + +The first baseline passed 96 selected assembled-service cases and 33 credential, +inventory, and Photon cases. Additional command, setup, and publication baselines +passed before their moves. Each slice passed server typecheck, module boundaries, +and its public-path checks. Post-move integration case counts were 6, 8, 10, 50, +7, 1, 21, 3, 34, and 12 respectively; these selections overlap and are not a count +of unique tests. The first slice also reran all 33 credential/inventory/Photon +cases. Repository-wide typecheck passes on the complete stack. Full tests and build +are recorded below when complete. + +Review found that the inventory dispatcher needed `return await` to retain its +existing catch boundary. Three new real-database HTTP tests passed against the +pre-extraction service, failed with 500 instead of 422 after the initial move, and +passed after the correction. They also check the public error code/message and +unchanged credential refs. The repair is included in slice two and replayed through +all subsequent local branches. Moved provider bodies remain unchanged. + +The Teams activity protocol callback is deferred until use because its new factory +is initialized after file-authority construction. Shutdown is read through a live +callback. GitHub lifecycle claims and credential leasing remain in the coordinator; +the provider workflow returns runtime outcomes and notifies its cleanup owner +immediately after persisting a failure, before throwing. + +## Remaining map + +The largest shared functions are `processMessage` (2,166 lines), `handleAction` +(1,074), `configureWithCredentialLease` (868), `processSelectedPublication` (729), +`handleModalSubmit` (708), `handleWebhook` (685), and `processLifecycleDelivery` +(657). These include shared admission and transaction ownership; do not relocate +them wholesale into a provider folder. + +The next provider candidates to inspect are: + +- Provider setup-state and runtime-config projections. Extend provider modules + while retaining live origin reads and the shared dispatcher. +- Photon outbound publication (a 227-line provider branch), including interaction + prompts and exact retry evidence. Keep current-source checks and partial receipt + ownership explicit. +- GitHub authenticated webhook handling (a 209-line provider branch). Preserve + durable ingress admission, signed ping verification, and receiver fencing. +- Discord durable Markdown transport (96 lines) and its modal wire handling + (85-line branch plus smaller branches). Preserve shared question resolution and + publication ownership. + +This remains a candidate map, not authorization for another batch or merging this +stack. All provider calls in local integration evidence use fixtures. diff --git a/doc/screenshots/app-access-avatar/README.md b/doc/screenshots/app-access-avatar/README.md new file mode 100644 index 00000000000..cce5d0be7a1 --- /dev/null +++ b/doc/screenshots/app-access-avatar/README.md @@ -0,0 +1,19 @@ +# App access user avatars + +Storybook: **Connections / Chat management / 2 · People and access** +(`connections-chat-management--access`). + +These screenshots show the production access component with synthetic fixture +accounts: a linked account with an image, a linked account without an image, +and an account waiting for confirmation. The image fixture is an existing +Cliptoon asset used as a deterministic profile image; it is not a real person's +photo. + +The avatar is centered beside both the Paperclip name and the Slack name, at +the same vertical center as Disconnect. Missing or failed images retain initials. +Truncated Paperclip names retain their full text in the native hover tooltip. + +- [Desktop, 1280 × 900](desktop.png) +- [Mobile, 390 × 844](mobile.png) + +The Access story checks image loading, initials, hover text, and vertical alignment. diff --git a/doc/screenshots/app-access-avatar/desktop.png b/doc/screenshots/app-access-avatar/desktop.png new file mode 100644 index 00000000000..9d56372f29a Binary files /dev/null and b/doc/screenshots/app-access-avatar/desktop.png differ diff --git a/doc/screenshots/app-access-avatar/mobile.png b/doc/screenshots/app-access-avatar/mobile.png new file mode 100644 index 00000000000..4b5e25c33e3 Binary files /dev/null and b/doc/screenshots/app-access-avatar/mobile.png differ diff --git a/maps/agent-access-map/tasks.md b/maps/agent-access-map/tasks.md index d0ccdbc978c..b3272d30a74 100644 --- a/maps/agent-access-map/tasks.md +++ b/maps/agent-access-map/tasks.md @@ -71,7 +71,12 @@ Agent access: `partial` — Native set_task_title/reassign_task/set_dependencies for permitted non-lifecycle fields; REST. Policy: Company/task visibility, assignment/change policy, active-run checkout ownership -and lifecycle rules. +and lifecycle rules. A legacy run can assign its own task to a human without +cancelling itself only with its signed run token and matching active run, agent, +company and task. The outgoing run cannot mutate its human-owned task afterward, +including through a late update already admitted before the handoff. Agent keys, +native runs, board reassignment and transfers to +another agent retain verified-stop requirements. Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the reviewed commit above. @@ -79,7 +84,10 @@ 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. +remaining operations before qualifying the whole capability. Focused route +regressions in [ownership tests](../../server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts) +cover a human handoff and stop enforcement for other actors, runtimes and targets; +they use mocked services and do not claim live execution proof. Gaps: Native fallback blocks status/assignment/dependency policy fields. Dedicated tools differ in scope and supported fields. diff --git a/maps/feature-map/tasks.md b/maps/feature-map/tasks.md index 19eb631af3c..6ed9fd7f353 100644 --- a/maps/feature-map/tasks.md +++ b/maps/feature-map/tasks.md @@ -55,6 +55,13 @@ Manual: Filter by status and assignee, change grouping/sort, open a task, and re 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: For a disposable task owned by a legacy agent, have its current run +assign the task to a human with status In review. Verify that assignment and +status persist, that the current run finishes without `issue_reassigned`, and +that slow workspace restoration does not turn the task Blocked. Try a later edit +from the outgoing run and verify rejection. Repeat a board +reassignment and confirm that the outgoing run still stops. + 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. ## Gotchas diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index 42cdb367382..e83627fbadc 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -341,6 +341,7 @@ export interface ChatIdentityLink { externalDetail?: string | null; paperclipUserId?: string | null; paperclipUserLabel?: string | null; + paperclipUserImage?: string | null; status: ChatIdentityLinkStatus; expiresAt?: string | null; confirmedAt?: string | null; diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index d8c86374466..142707f41d7 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -6930,7 +6930,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(saved.setup.webhookVerifiedAt).toBeNull(); expect(saved.providerAccountId).toBe("TAUTO"); expect(saved.setup.slackAccount).toEqual({ externalUserId: f.installerId, paperclipUserId: actor.userId, status: "linked", welcomeStatus: "sent", dmChannelId: "DWELCOME" }); - expect(await f.service.listPrincipals(f.endpoint.id)).toEqual([expect.objectContaining({ status: "linked", paperclipUserId: actor.userId })]); + expect(await f.service.listPrincipals(f.endpoint.id)).toEqual([expect.objectContaining({ status: "linked", paperclipUserId: actor.userId, paperclipUserImage: null })]); + await db.update(authUsers).set({ image: "https://example.test/installer.png" }).where(eq(authUsers.id, actor.userId)); + expect(await f.service.listPrincipals(f.endpoint.id)).toEqual([expect.objectContaining({ paperclipUserImage: "https://example.test/installer.png" })]); + await db.update(authUsers).set({ image: null }).where(eq(authUsers.id, actor.userId)); const [, welcome] = f.provider.mock.calls.find(([url]) => String(url).endsWith("chat.postMessage"))!; expect(new URLSearchParams(String(welcome!.body)).get("channel")).toBe(f.installerId); expect(new URLSearchParams(String(welcome!.body)).get("text")).toMatch(/^Hi, I’m .+\. Your Slack account is connected to Paperclip\.$/); @@ -10117,6 +10120,60 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ]); }); + it.each(["slack", "discord", "github"] as const)("preserves the public inventory error response for %s", async (provider) => { + const fixture = await seedCompany(); + let failInventory = false; + let service: ChatChannelService; + let endpoint: Awaited>; + if (provider === "slack") { + const successfulFetch = fakeSlackFetch(); + const context = await configuredSlackEndpoint(fixture, { + fetch: (async (input, init) => { + if (failInventory && String(input).includes("/conversations.list")) + throw new Error("inventory fixture outage"); + return successfulFetch(input, init); + }) as typeof fetch, + }); + ({ service, endpoint } = context); + } else if (provider === "discord") { + const applicationId = uniqueDiscordApplicationId(); + const successfulFetch = fakeDiscordFetch(applicationId); + const context = createService(new FakeChatSdkRuntime(), (async (input) => { + if (failInventory && new URL(String(input)).pathname.endsWith("/channels")) + throw new Error("inventory fixture outage"); + return successfulFetch(input); + }) as typeof fetch); + service = context.service; + endpoint = await service.create(fixture.companyId, { + provider, assignedAgentId: fixture.assignedAgentId, + }, "owner-user"); + await service.configure(endpoint.id, { + action: "configure", + credentials: { applicationId, botToken: "discord-secret", guildId: "1457808928258658549" }, + }, "owner-user"); + } else { + const context = await configuredGitHubEndpoint(fixture); + ({ service, endpoint } = context); + context.setSupplementalProviderFetch(async (input) => { + if (failInventory && String(input).includes("/app/installations?")) + throw new Error("inventory fixture outage"); + return undefined; + }); + } + const [before] = await readToolConnectionsById(endpoint.connectionId); + failInventory = true; + const response = await request(routesApp(db, fixture.companyId, service)) + .post(`/api/chat-endpoints/${endpoint.id}/setup`) + .send({ action: "reconnect" }); + expect(response.status).toBe(422); + expect(response.body).toMatchObject({ + error: expect.stringContaining("inventory fixture outage"), + details: { code: "chat_provider_inventory_failed" }, + }); + const [after] = await readToolConnectionsById(endpoint.connectionId); + expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs); + }); + it("rejects under-scoped Slack and GitHub apps before saving provider credentials", async () => { const fixture = await seedCompany(); const slack = createService(new FakeChatSdkRuntime(), (async ( diff --git a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts index 5054ca63d49..6aa18723cba 100644 --- a/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts +++ b/server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts @@ -325,6 +325,7 @@ function createRunContextDb( runAgentOrRows: string | Record[] = ownerAgentId, runId: string = ownerRunId, chatBindings: Array<{ id: string; companyId: string; issueId: string; state: string }> = [], + emptyWakeQueue = false, ) { const chatBindingQueries: ReturnType[] = []; const runRows = Array.isArray(runAgentOrRows) @@ -382,6 +383,9 @@ function createRunContextDb( transaction: async (callback: (tx: typeof dbStub) => Promise) => callback(dbStub), select: vi.fn((selection: Record = {}) => ({ from: vi.fn((table: Parameters[0]) => { + if (emptyWakeQueue && getTableName(table) === "agent_wakeup_requests") { + return buildQuery({}, false, true); + } if (getTableName(table) === "issue_thread_interactions") { return { where: vi.fn(() => ({ limit: vi.fn(async () => []) })) }; } @@ -2688,6 +2692,149 @@ describe("agent issue mutation checkout ownership", () => { expect(mockIssueService.update).not.toHaveBeenCalled(); }); + describe("human self-handoff", () => { + const runningRun = { + id: ownerRunId, + companyId, + agentId: ownerAgentId, + runtimeMode: "legacy", + status: "running", + contextSnapshot: { issueId }, + }; + const handoff = { + assigneeAgentId: null, + assigneeUserId: "board-user", + status: "in_review", + }; + const handoffApp = (actor: Record) => createApp(actor, + createRunContextDb({}, ownerAgentId, ownerRunId, [], true)); + + beforeEach(() => { + mockIssueService.getById.mockResolvedValue(makeIssue({ + status: "in_review", + executionRunId: ownerRunId, + })); + mockHeartbeatService.getRun.mockResolvedValue(runningRun); + mockHeartbeatService.cancelRun.mockResolvedValue({ ...runningRun, status: "cancelled" }); + mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, id: string) => ({ + ambiguous: false, + agent: await mockAgentService.getById(id), + })); + }); + + it.each(["in_progress", "in_review"])("persists a human handoff from %s without waiting for its own slow shutdown", async status => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status, executionRunId: ownerRunId })); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified.")); + + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send(handoff); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject(handoff); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({ + ...handoff, + actorRunId: ownerRunId, + actorRunStopId: null, + }); + expect(mockLogActivity.mock.calls.find(call => call[1].action === "issue.updated")?.[1]).toMatchObject({ + action: "issue.updated", + runId: ownerRunId, + details: handoff, + }); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("recognizes the caller when the active run is resolved without an issue execution lock", async () => { + mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review" })); + mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(runningRun); + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send(handoff); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(res.body).toMatchObject(handoff); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it.each(["edit", "complete", "reclaim"])("rejects the outgoing run's later %s after handing the task to a human", async action => { + const app = handoffApp({ ...ownerActor(), source: "agent_jwt" }); + const handedOff = await request(app).patch(`/api/issues/${issueId}`).send(handoff); + expect(handedOff.status, JSON.stringify(handedOff.body)).toBe(200); + mockIssueService.getById.mockResolvedValue(makeIssue(handoff)); + mockIssueService.update.mockClear(); + + const res = await request(app).patch(`/api/issues/${issueId}`).send( + action === "edit" ? { title: "Late outgoing edit" } : + action === "complete" ? { status: "done" } : + { assigneeAgentId: ownerAgentId, assigneeUserId: null }, + ); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.details).toMatchObject({ code: "issue_handed_off" }); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it("keeps a queued human response with its current agent", async () => { + const res = await request(createApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send(handoff); + expect(res.status, JSON.stringify(res.body)).toBe(409); + expect(res.body.code).toBe("interaction_response_queued"); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it("retains assignment permission checks for a different human recipient", async () => { + mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({ + allowed: input.action !== "tasks:assign", + action: input.action, + reason: "deny_policy_restricted", + explanation: "Assignment requires approval.", + })); + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send({ ...handoff, assigneeUserId: "another-user" }); + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); + + it.each(["board", "agent_key", "other_run", "native", "other_issue", "other_agent", "other_company"])( + "still requires a verified stop for %s reassignment", async scenario => { + let actor: Record = { ...ownerActor(), source: "agent_jwt" }; + if (scenario === "board") actor = boardActor(); + if (scenario === "agent_key") actor = ownerActor(); + const run = { + ...runningRun, + ...(scenario === "other_run" ? { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}), + ...(scenario === "native" ? { runtimeMode: "native" } : {}), + ...(scenario === "other_issue" ? { contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } } : {}), + ...(scenario === "other_agent" ? { agentId: peerAgentId } : {}), + ...(scenario === "other_company" ? { companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}), + }; + mockHeartbeatService.getRun.mockResolvedValue(run); + mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified.")); + + const res = await request(handoffApp(actor)).patch(`/api/issues/${issueId}`).send(handoff); + + expect(res.status, JSON.stringify(res.body)).toBe(500); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(run.id, "Cancelled before issue reassignment", + expect.objectContaining({ errorCode: "issue_reassigned" })); + expect(mockIssueService.update).not.toHaveBeenCalled(); + }, + ); + + it.each(["agent", "unassigned"])("still stops the outgoing run before an %s handoff", async target => { + const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" })) + .patch(`/api/issues/${issueId}`).send({ + assigneeAgentId: target === "agent" ? peerAgentId : null, + assigneeUserId: null, + }); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledTimes(1); + expect(mockHeartbeatService.cancelRun.mock.invocationCallOrder[0]) + .toBeLessThan(mockIssueService.update.mock.invocationCallOrder[0]!); + }); + }); + describe("task watchdog scope grants", () => { const watchdogRunId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab"; const watchdogReportIssueId = "cccccccc-cccc-4ccc-8ccc-cccccccccccd"; diff --git a/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts b/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts index 36d17da5c5f..860af32ab45 100644 --- a/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts +++ b/server/src/__tests__/issue-comment-attribution-audit-routes.test.ts @@ -78,6 +78,7 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", () async function seed() { const company = await db.insert(companies).values({ name: "Attribution Company", + issueCounter: 2, issuePrefix: `AT${randomUUID().replaceAll("-", "").slice(0, 6).toUpperCase()}`, }).returning().then((rows) => rows[0]!); const [actorAgent, targetAgent] = await db.insert(agents).values(["Actor", "Target"].map((name) => ({ @@ -162,6 +163,27 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", () }; } + it("records the human recipient for REST task and child creation", async () => { + const fixture = await seed(); + const app = await createApp(db, agentActor(fixture)); + for (const [path, action] of [ + [`/api/companies/${fixture.company.id}/issues`, "issue.created"], + [`/api/issues/${fixture.issue.id}/children`, "issue.child_created"], + ]) { + const response = await request(app).post(path!).send({ + title: "Task for the human", status: "todo", assigneeUserId: fixture.responsibleUserId, + }); + expect(response.status, JSON.stringify(response.body)).toBe(201); + const events = await db.select().from(activityLog).where(and( + eq(activityLog.entityId, response.body.id), eq(activityLog.action, action!), + )); + expect(events).toEqual([expect.objectContaining({ + actorType: "agent", actorId: fixture.actorAgent.id, + details: expect.objectContaining({ assigneeAgentId: null, assigneeUserId: fixture.responsibleUserId }), + })]); + } + }); + it("attributes foreign-issue agent comments and emits complete agent and human PATCH receipts", async () => { const fixture = await seed(); const agentApp = await createApp(db, agentActor(fixture)); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 4475e075122..5f3f8c58ae7 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -21,6 +21,7 @@ import { issueAccessGrants, issuePlanDecompositions, issueReadStates, + issueRecoveryActions, issueRelations, issueThreadInteractions, issueWorkProducts, @@ -46,6 +47,8 @@ import { readIssueCommentRunLogText, } from "../services/issues.ts"; import { getRunLogStore } from "../services/run-log-store.js"; +import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js"; +import { settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js"; import { WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE, WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE, @@ -7048,6 +7051,7 @@ describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adopti await db.delete(goals); await db.delete(agents); await db.delete(instanceSettings); + await db.delete(companyMemberships); await db.delete(companies); }); @@ -7136,6 +7140,67 @@ describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adopti return { issueId, assigneeAgentId, actorAgentId, staleRunId, actorRunId }; } + it.each(["succeeded", "failed"])("preserves a human handoff when the outgoing legacy run later %s", async status => { + const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" }); + const original = await svc.getById(seeded.issueId); + await db.insert(companyMemberships).values({ + companyId: original!.companyId, + principalType: "user", + principalId: "handoff-reviewer", + status: "active", + }); + await db.update(heartbeatRuns).set({ + contextSnapshot: { issueId: seeded.issueId }, + }).where(eq(heartbeatRuns.id, seeded.actorRunId)); + await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId); + + const handoff = await svc.update(seeded.issueId, { + assigneeAgentId: null, + assigneeUserId: "handoff-reviewer", + status: "in_review", + actorAgentId: seeded.actorAgentId, + actorRunId: seeded.actorRunId, + }); + expect(handoff).toMatchObject({ + assigneeAgentId: null, + assigneeUserId: "handoff-reviewer", + status: "in_review", + checkoutRunId: null, + executionRunId: null, + }); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.actorRunId)); + expect(run!.status).toBe("running"); + await expect(svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId)) + .rejects.toMatchObject({ status: 409 }); + await expect(svc.update(seeded.issueId, { + title: "Late outgoing edit", + actorAgentId: seeded.actorAgentId, + actorRunId: seeded.actorRunId, + })).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } }); + + await terminalizeLegacyExecution({ + db, + run: run!, + status, + fromStatuses: ["running"], + reconcileIfNeeded: true, + patch: { finishedAt: new Date(), ...(status === "failed" ? { errorCode: "process_lost" } : {}) }, + }); + await settleUnrecoverableExecutions(db); + await expect(svc.update(seeded.issueId, { + status: "done", + actorAgentId: seeded.actorAgentId, + actorRunId: seeded.actorRunId, + })).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } }); + expect(await svc.getById(seeded.issueId)).toMatchObject({ + status: "in_review", + assigneeAgentId: null, + assigneeUserId: "handoff-reviewer", + }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId))) + .toHaveLength(0); + }); + it("lets the current assignee adopt a stale terminal checkout owner", async () => { const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" }); diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index bafcdfce5ac..8c952f63708 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -977,14 +977,19 @@ describeEmbeddedPostgres( const handoff = await request(directedAgent).patch(`/api/issues/${created.body.id}`) .send({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user", comment: "Ready for human review" }); expect(handoff.status, JSON.stringify(handoff.body)).toBe(200); - const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, directRun.id)); - expect(stopped).toMatchObject({ status: "cancelled", errorCode: "issue_reassigned" }); + expect(handoff.body).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user" }); + const [outgoing] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, directRun.id)); + expect(outgoing).toMatchObject({ status: "running", errorCode: null }); + const lateEdit = await request(directedAgent).patch(`/api/issues/${created.body.id}`) + .send({ description: "Late outgoing edit" }); + expect([403, 404]).toContain(lateEdit.status); const returned = await request(board).patch(`/api/issues/${created.body.id}`) .send({ status: "backlog", assigneeAgentId: fixture.agents.lowTrust.id, assigneeUserId: null }); expect(returned.status, JSON.stringify(returned.body)).toBe(200); - // Reassignment back does not revive the cancelled run's human request. - const staleRead = await request(directedAgent).get(`/api/issues/${created.body.id}`); - expect([403, 404]).toContain(staleRead.status); + const [humanRequest] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id)); + expect(humanRequest.status).toBe("cancelled"); + const reassignedRead = await request(directedAgent).get(`/api/issues/${created.body.id}`); + expect(reassignedRead.status, JSON.stringify(reassignedRead.body)).toBe(200); }); diff --git a/server/src/agent-run-cancellation.ts b/server/src/agent-run-cancellation.ts index ea38644de28..7ae25021fa5 100644 --- a/server/src/agent-run-cancellation.ts +++ b/server/src/agent-run-cancellation.ts @@ -19,7 +19,9 @@ export async function assertAgentRunWriteAllowed(tx: Db, companyId: string, acto stopId?: string | null; }) { if (!actor.agentId || !actor.runId) return; - const [run] = await tx.select({ status: heartbeatRuns.status, resultJson: heartbeatRuns.resultJson }) + const [run] = await tx.select({ status: heartbeatRuns.status, resultJson: heartbeatRuns.resultJson, + runtimeMode: heartbeatRuns.runtimeMode, companyId: heartbeatRuns.companyId, + contextSnapshot: heartbeatRuns.contextSnapshot }) .from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actor.runId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, actor.agentId))) .for("share"); @@ -28,4 +30,5 @@ export async function assertAgentRunWriteAllowed(tx: Db, companyId: string, acto if (agentRunWritesRevoked(run) && !stoppedForThisMutation) { throw forbidden("This run was cancelled", { code: "agent_run_cancelled" }); } + return run; } diff --git a/server/src/legacy-run-task-ownership.ts b/server/src/legacy-run-task-ownership.ts new file mode 100644 index 00000000000..2c9bfbf5b6a --- /dev/null +++ b/server/src/legacy-run-task-ownership.ts @@ -0,0 +1,24 @@ +import { forbidden } from "./errors.js"; + +export function assertLegacyRunTaskOwnership( + run: { + companyId: string; + runtimeMode: string; + contextSnapshot?: Record | null; + } | null | undefined, + issue: { + id: string; + companyId: string; + assigneeAgentId: string | null; + assigneeUserId: string | null; + }, +) { + if (run?.runtimeMode === "legacy" && run.companyId === issue.companyId && + run.contextSnapshot?.issueId === issue.id && + issue.assigneeAgentId === null && issue.assigneeUserId) { + throw forbidden("This task is assigned to a human; the outgoing run can no longer change it", { + code: "issue_handed_off", + issueId: issue.id, + }); + } +} diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3ecbbdab36f..66b7d0460b8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,4 +1,5 @@ import { parseObject } from "../adapters/utils.js"; +import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; import { enqueueFastResponse } from "../services/fast-responses.js"; import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js"; import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../services/native-runtime/native-workspace-finalization-recovery-state.js"; @@ -661,7 +662,7 @@ const attachmentArtifactMetadataInputSchema = z .passthrough(); function buildCreateIssueActivityStatusDetails( - issue: { assigneeAgentId: string | null; status: string }, + issue: { assigneeAgentId: string | null; assigneeUserId: string | null; status: string }, res: Response, ) { const statusDefault = res.locals.createIssueStatusDefault as @@ -669,6 +670,8 @@ function buildCreateIssueActivityStatusDetails( const assignmentWakeSkipped = !issue.assigneeAgentId || issue.status === "backlog"; return { + assigneeAgentId: issue.assigneeAgentId, + assigneeUserId: issue.assigneeUserId, status: issue.status, statusDefaulted: statusDefault?.defaulted ?? false, statusDefaultReason: statusDefault?.reason ?? "explicit", @@ -5632,6 +5635,11 @@ export function issueRoutes( res.status(403).json({ error: "Agent authentication required" }); return false; } + if (req.actor.source === "agent_jwt" && req.actor.runId && + issue.assigneeAgentId === null && issue.assigneeUserId) { + const run = await heartbeat.getRun(req.actor.runId); + if (run?.agentId === actorAgentId) assertLegacyRunTaskOwnership(run, issue); + } // Task-watchdog runs receive a scoped *grant* to mutate issues inside the // watched subtree. This must be evaluated before the base assignee-ownership // boundary below: that boundary denies an agent mutating an issue owned by a @@ -14241,7 +14249,18 @@ export function issueRoutes( agentId: existing.assigneeAgentId, }); const runToStopForReassignment = await resolveActiveIssueRun(existing); - if (runToStopForReassignment) { + const isHumanSelfHandoff = + req.actor.type === "agent" && + req.actor.source === "agent_jwt" && + nextAssigneeAgentId === null && + typeof nextAssigneeUserId === "string" && + req.actor.agentId === existing.assigneeAgentId && + runToStopForReassignment?.runtimeMode === "legacy" && + runToStopForReassignment.id === req.actor.runId && + runToStopForReassignment.agentId === req.actor.agentId && + runToStopForReassignment.companyId === existing.companyId && + runToStopForReassignment.contextSnapshot?.issueId === existing.id; + if (runToStopForReassignment && !isHumanSelfHandoff) { const cancelled = await heartbeat.cancelRun( runToStopForReassignment.id, "Cancelled before issue reassignment", diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 6efc1b198cd..25b6c7278f0 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -910,6 +910,7 @@ const chatPrincipalLinkResponseSchema = z externalDetail: z.string(), paperclipUserId: z.string().nullable(), paperclipUserLabel: z.string().nullable(), + paperclipUserImage: z.string().nullable(), status: chatIdentityLinkStatusSchema, }) .strict(); diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 7716efe06aa..ea9742c0bcf 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,23 @@ +import { createGitHubInstallationLifecycle } from "./chat-channels/github/installation-lifecycle.js"; +import { createGitHubAppSetup } from "./chat-channels/github/app-setup.js"; +import { createSlackTaskAuthorityRegistration } from "./chat-channels/slack/task-authority.js"; +import { createGitHubPublicationPolicy } from "./chat-channels/github/publication-policy.js"; +import { createTelegramAttachmentFailure } from "./chat-channels/telegram/attachment-failure.js"; +import { createSlackCommands, slackCommandForAgent } from "./chat-channels/slack/commands.js"; +import { createTelegramCommands, telegramGuidanceCommand } from "./chat-channels/telegram/commands.js"; +import { createTeamsFileDelivery } from "./chat-channels/microsoft-teams/file-delivery.js"; +import { createGitHubSetupSecret } from "./chat-channels/github/setup-secret.js"; +import { prepareSlackInventory, createSlackInventory } from "./chat-channels/slack/inventory.js"; +import { prepareGitHubInventory } from "./chat-channels/github/inventory.js"; +import { prepareDiscordInventory } from "./chat-channels/discord/inventory.js"; +import type { VerifiedProviderIdentity } from "./chat-channels/runtime-types.js"; +import { verifySlackCredentials } from "./chat-channels/slack/credentials.js"; +import { verifyDiscordCredentials } from "./chat-channels/discord/credentials.js"; +import { verifyTelegramCredentials } from "./chat-channels/telegram/credentials.js"; +import { verifyGitHubCredentials, REQUIRED_GITHUB_EVENTS, UNAVOIDABLE_GITHUB_EVENTS } from "./chat-channels/github/credentials.js"; +import { verifySpekoCredentials } from "./chat-channels/speko/credentials.js"; +import { verifyTeamsCredentials } from "./chat-channels/microsoft-teams/credentials.js"; +import { createPhotonCredentialChecks } from "./chat-channels/imessage-photon/credentials.js"; import { slackTestMessageService } from "./connectors/slack/test-message.js"; import type { SlackSetupOptions } from "./connectors/slack/setup/registration.js"; import { isProvisionedSlackSetup } from "@paperclipai/shared"; @@ -47,7 +67,6 @@ import { isTelegramEditedMessageRaw, isTelegramMigrationRaw, telegramMessageSequence, - telegramMessageId, telegramZeroMessageId, telegramDeliveryHasZeroMessageId, telegramMessageSentAt, @@ -88,18 +107,18 @@ import { chatCredentialMutationLease, CREDENTIAL_MUTATION_LEASE_TTL_MS, type Cre import type { AgentAvatarRequest } from "./agent-avatars.js"; import { slackChatRegistrationService, slackRegistrationProjection } from "./chat-slack-registration.js"; import { chatSlackRegistrations } from "@paperclipai/db"; -import { SLACK_CHAT_BOT_SCOPES } from "@paperclipai/shared"; + import { canActorReadIssuePrivacy, canPublishIssueToChatAudience } from "./authorization.js"; import { withSlackBoardLease } from "./slack-board-lease.js"; import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js"; import { authorizeSlackBoardPublication } from "./slack-board-authority.js"; import { assertSlackBoardWorkAllowed } from "./slack-board-resume.js"; import { slackExplicitPublicationDuplicate, unresolvedSlackPublicationCondition } from "./connectors/slack-publication.js"; -import { rememberVerifiedSlackSearchEvent, slackSearchActionToken } from "./connectors/slack-search-context.js"; -import { slackAuthorizationRevision } from "./connectors/slack-revision.js"; +import { rememberVerifiedSlackSearchEvent } from "./connectors/slack-search-context.js"; + import { slackPublicationAllowed } from "./connectors/slack-access.js"; -import { instanceSettingsService } from "./instance-settings.js"; -import { registerSlackTaskAuthority, slackRunOrigin } from "./connectors/slack-authority.js"; + + import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; import { assertChatExecutionDefaultsAccess, assertChatTaskCreationAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; @@ -115,13 +134,13 @@ import { githubMessageAddressesAnotherBot, } from "./chat-github-events.js"; import { githubReviewPrompt, githubManualMessagePrompt, GITHUB_CONFIGURED_SKILL_GUIDANCE } from "./chat-github-review-policy.js"; -import { chatGitHubConfigurations, chatGitHubRegistrations, chatGitHubReviews } from "@paperclipai/db"; +import { chatGitHubConfigurations, chatGitHubReviews } from "@paperclipai/db"; import type { GitHubReviewEventContext, GitHubIssueEventContext, GitHubAutomaticEventContext, GitHubReviewPolicy } from "@paperclipai/shared"; import { githubChatReviewService } from "./chat-github-reviews.js"; import { githubResponseCommentService } from "./chat-github-response-comments.js"; -import { githubChatWizardService } from "./chat-github-wizard.js"; -import { registerGitHubBotCloudIngress } from "./chat-github-cloud-ingress.js"; -import { githubChatRegistrationService } from "./chat-github-registration.js"; + + + import { githubChatPrincipalAccess } from "./chat-github-access.js"; import { githubAppJwt } from "./chat-github-client.js"; import { resumeExternalConversation } from "./slack-conversation-state.js"; @@ -136,7 +155,7 @@ import { PhotonState } from "./photon/state.js"; import { nativePhotonInteraction, publishPhotonPrompt, type PhotonPromptReceipt, type PhotonInteractionBinding } from "./photon/interactions.js"; -import { PhotonCloudClient, PhotonError, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; +import { PhotonError, photonSharedIdentity, photonSharedScope } from "./photon/cloud.js"; import { PhotonChatAdapter, photonReplyReference } from "./photon/adapter.js"; import { photonChannelConfigurationSchema } from "@paperclipai/shared"; @@ -203,7 +222,6 @@ import { companyMemberships, companySecretBindings, heartbeatRuns, - nativeRunFinalizations, issueComments, issueAttachments, issueThreadInteractions, @@ -234,7 +252,6 @@ import { LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION, } from "@paperclipai/shared"; import { - formatAttachmentSize, MAX_ATTACHMENT_BYTES, normalizeContentType, } from "../attachment-types.js"; @@ -243,12 +260,10 @@ import { deriveTeamsPersonalRecipient, } from "./chat-teams-personal-recipient.js"; import { - teamsFileTransferService, - type TeamsFileTransferAuthority, type TeamsFileTransferOptions, } from "./chat-teams-file-transfers.js"; -import { projectTeamsFilePublication } from "./chat-teams-file-publication.js"; -import type { TeamsFileConsentEvent } from "./chat-teams-file-consent.js"; + + import { projectChatFileTransfer, projectChatPublicationBatch, @@ -317,19 +332,15 @@ import { projectSafeChatPublication } from "./chat-publication-projection.js"; import { safeChatTaskUrl } from "./chat-task-url.js"; import { resyncGitHubAppWebhook, - listGitHubAppWebhookDeliveries, - requestGitHubAppWebhookRedelivery, } from "./chat-github-webhook-config.js"; import { githubAttachmentLimitOmissions, restoreGitHubAttachmentLimitOmissions, } from "./chat-github-attachments.js"; import { getExternalChannelBindingSummary } from "./chat-channel-binding.js"; -import { discoverDedicatedGitHubAppInstallation, getSlackBotChannel, listGitHubInstallationRepositories, listSlackBotChannels, type ChatProviderInventoryResult } from "./chat-provider-inventory.js"; +import { type ChatProviderInventoryResult } from "./chat-provider-inventory.js"; import { discordMarkdownRequiresAttachment, - listDiscordBotChannels, - verifyDiscordBot, } from "./chat-discord.js"; import { renderPublicationTransportText, @@ -366,9 +377,7 @@ import { } from "./chat-interaction-publications.js"; import { chatProviderConversationUrl } from "./chat-provider-links.js"; import { classifyChatPublicationError } from "./chat-publication-errors.js"; -import { - githubRunReplyState, -} from "./chat-run-publications.js"; + import { inboundWakePublicationKey, inboundWakePublicationText, @@ -560,25 +569,6 @@ const REQUIRED_CREDENTIALS: Record< telegram: ["botToken"], }; -const REQUIRED_SLACK_BOT_SCOPES = SLACK_CHAT_BOT_SCOPES; - -const REQUIRED_GITHUB_EVENTS = [ - "issue_comment", - "pull_request_review_comment", -] as const; - -const REQUIRED_GITHUB_PERMISSIONS = { - issues: "write", - metadata: "read", - pull_requests: "write", -} as const; - -const UNAVOIDABLE_GITHUB_EVENTS = [ - "github_app_authorization", - "installation", - "installation_repositories", -] as const; - const SUPPORTED_GITHUB_WEBHOOK_EVENTS = new Set([ "issues", "pull_request", @@ -640,15 +630,6 @@ const INGRESS_REORDER_WINDOW_MS: Partial> = { telegram: 750, }; type ResourceRow = typeof chatEndpointResources.$inferSelect; -type VerifiedProviderIdentity = { - githubOwnerType?: "personal" | "organization"; - githubOwnerLogin?: string; - providerAccountId?: string | null; - providerAccountLabel?: string | null; - botExternalId?: string | null; - botUsername?: string | null; - botLabel?: string | null; -}; function runtimeGeneration(setup: ChatEndpointSetupState): number { const value = Number((setup as InternalSetupState).runtimeGeneration ?? 0); @@ -1081,23 +1062,6 @@ function linearControlCommand(text: string): "new" | "close" | "status" | null { ); } -function telegramGuidanceCommand( - text: string, -): "start" | "task" | "unknown" | null { - const match = /^\/([a-z][\w-]*)(?:@[\w.-]+)?(?:\s|$)/i.exec(text.trim()); - const command = match?.[1]?.toLowerCase(); - if ( - !command || - command === "new" || - command === "close" || - command === "status" - ) - return null; - if (command === "start") return "start"; - if (command === "task") return "task"; - return "unknown"; -} - function safeTitle(text: string, fallback: string): string { const line = text .replace(/<@[A-Z0-9]+>/gi, "") @@ -1328,19 +1292,6 @@ function providerSetupState( } } -function slackCommandForAgent(agentName: string, publicId: string): string { - const slug = agentName - .toLowerCase() - .replace(/[^a-z0-9-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 20); - const suffix = publicId - .toLowerCase() - .replace(/[^a-z0-9]/g, "") - .slice(0, 6); - return `/${slug || "paperclip"}-${suffix || "agent"}`; -} - export function createChatSdkStatePersistence(db: Db): ChatSdkStatePersistence { return { async read(scope: ChatSdkStateScope, key: string) { @@ -1436,6 +1387,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { >(); const persistence = createChatSdkStatePersistence(db); const fetchImpl = options.fetch ?? globalThis.fetch; + const { inspectPhotonCredentials, verifyPhotonCredentials } = createPhotonCredentialChecks(fetchImpl); + const { hydrateSlackResourceLabel } = createSlackInventory(db, { endpointRecord, resolveCredentials, fetchImpl }); const configuredPublicBaseUrl = absoluteBaseUrl(options.publicBaseUrl); const configuredWebhookPublicBaseUrl = parseChatWebhookPublicBaseUrl(options.webhookPublicBaseUrl); // A warm Cloud instance is constructed before it receives its signed claim. @@ -2518,229 +2471,13 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { provider: ChatProvider, credentials: Record, ): Promise { - if (provider === "imessage-photon") { - const inspection = await inspectPhotonCredentials(credentials.projectId, credentials.projectSecret); - if (inspection.allocation !== (credentials.allocation ?? "dedicated")) throw unprocessable("Photon allocation changed; inspect the project again"); - if (inspection.allocation === "shared") { - if (!inspection.eligible) throw unprocessable("Photon shared project is unavailable"); - return { providerAccountId: inspection.projectId, providerAccountLabel: inspection.projectName, botExternalId: photonSharedIdentity(inspection.projectId), botUsername: null, botLabel: `${inspection.projectName} (DM only)` }; - } - const line = inspection.lines.find((candidate) => candidate.lineId === credentials.lineId && candidate.eligible); - if (!line || !inspection.eligible) throw unprocessable("Select an eligible dedicated Photon line"); - return { providerAccountId: inspection.projectId, providerAccountLabel: inspection.projectName, botExternalId: line.phoneNumber, botUsername: line.phoneNumber, botLabel: line.phoneNumber }; - } - if (provider === "slack") { - const response = await fetchImpl("https://slack.com/api/auth.test", { - method: "POST", - signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), - headers: { - authorization: `Bearer ${credentials.botToken}`, - "content-type": "application/x-www-form-urlencoded", - }, - body: "", - }); - const result = (await response.json()) as { - ok?: boolean; - error?: string; - team_id?: string; - team?: string; - user_id?: string; - user?: string; - }; - if (!response.ok || !result.ok) - throw unprocessable( - `Slack rejected the bot token: ${result.error ?? response.status}`, - ); - const grantedScopes = new Set( - (response.headers.get("x-oauth-scopes") ?? "") - .split(",") - .map((scope) => scope.trim()) - .filter(Boolean), - ); - const missingScopes = REQUIRED_SLACK_BOT_SCOPES.filter( - (scope) => !grantedScopes.has(scope), - ); - if (missingScopes.length > 0) { - throw unprocessable( - `Slack app is missing required bot scopes: ${missingScopes.join(", ")}`, - { - code: "chat_provider_permissions_missing", - provider: "slack", - missingScopes, - }, - ); - } - return { - providerAccountId: result.team_id, - providerAccountLabel: result.team, - botExternalId: result.user_id, - botUsername: result.user, - botLabel: result.user, - }; - } - if (provider === "discord") { - const identity = await verifyDiscordBot({ - applicationId: credentials.applicationId, - botToken: credentials.botToken, - fetch: fetchImpl, - guildId: credentials.guildId, - }).catch((error) => { - throw unprocessable(redactError(error), { - code: "chat_provider_permissions_missing", - provider: "discord", - }); - }); - return identity; - } - if (provider === "telegram") { - const response = await fetchImpl( - `https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/getMe`, - { signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS) }, - ); - const result = (await response.json()) as { - ok?: boolean; - description?: string; - result?: { id?: number; username?: string; first_name?: string }; - }; - if (!response.ok || !result.ok) - throw unprocessable( - `Telegram rejected the bot token: ${result.description ?? response.status}`, - ); - return { - providerAccountId: String(result.result?.id ?? ""), - botExternalId: String(result.result?.id ?? ""), - botUsername: result.result?.username, - botLabel: result.result?.first_name, - }; - } - if (provider === "github") { - const token = githubAppJwt(credentials.appId, credentials.privateKey); - const response = await fetchImpl("https://api.github.com/app", { - signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), - headers: { - accept: "application/vnd.github+json", - authorization: `Bearer ${token}`, - "x-github-api-version": "2022-11-28", - }, - }); - const result = (await response.json()) as { - id?: number; - login?: string; - slug?: string; - name?: string; - message?: string; - owner?: { login?: string; type?: string }; - permissions?: Record; - events?: string[]; - }; - if (!response.ok) - throw unprocessable( - `GitHub rejected the app credentials: ${result.message ?? response.status}`, - ); - const missingPermissions = Object.entries(REQUIRED_GITHUB_PERMISSIONS) - .filter( - ([permission, access]) => result.permissions?.[permission] !== access, - ) - .map(([permission]) => permission); - const excessivePermissions = Object.keys(result.permissions ?? {}).filter( - (permission) => !(permission in REQUIRED_GITHUB_PERMISSIONS) && !((permission === "contents" && result.permissions?.contents === "read") || (permission === "checks" && result.permissions?.checks === "write")), - ); - const configuredEvents = new Set(result.events ?? []); - const missingEvents = REQUIRED_GITHUB_EVENTS.filter( - (event) => !configuredEvents.has(event), - ); - const allowedEvents = new Set([ - ...REQUIRED_GITHUB_EVENTS, - ...UNAVOIDABLE_GITHUB_EVENTS, - "pull_request", - "pull_request_review", - "issues", - ]); - const excessiveEvents = [...configuredEvents].filter( - (event) => !allowedEvents.has(event), - ); - if ( - missingPermissions.length > 0 || - excessivePermissions.length > 0 || - missingEvents.length > 0 || - excessiveEvents.length > 0 - ) { - throw unprocessable( - [ - missingPermissions.length > 0 - ? `GitHub App needs the documented minimum access for: ${missingPermissions.join(", ")}` - : null, - excessivePermissions.length > 0 - ? `GitHub App has broader permissions than Paperclip needs: ${excessivePermissions.join(", ")}` - : null, - missingEvents.length > 0 - ? `GitHub App must subscribe to: ${missingEvents.join(", ")}` - : null, - excessiveEvents.length > 0 - ? `GitHub App subscribes to broader events than Paperclip needs: ${excessiveEvents.join(", ")}` - : null, - ] - .filter(Boolean) - .join(". "), - { - code: "chat_provider_permissions_missing", - provider: "github", - missingPermissions, - excessivePermissions, - missingEvents, - excessiveEvents, - }, - ); - } - return { - githubOwnerType: result.owner?.type === "Organization" ? "organization" : result.owner?.type === "User" ? "personal" : undefined, - githubOwnerLogin: result.owner?.login, - providerAccountId: result.owner?.login, - providerAccountLabel: result.owner?.login ?? result.name, - // The App registration id is the stable, immutable endpoint identity. - // The adapter discovers the separate bot-user id while initializing. - botExternalId: - typeof result.id === "number" && Number.isFinite(result.id) - ? String(result.id) - : undefined, - botUsername: result.slug ? `${result.slug}[bot]` : undefined, - botLabel: result.name ?? result.slug, - }; - } - if (provider === "speko") { - if (!/^whsec_[A-Za-z0-9+/]{43}=$/.test(credentials.signingSecret ?? "")) throw unprocessable("Speko requires a Standard Webhooks signing secret"); - const identity = await createSpekoProvider(credentials.apiKey, fetchImpl).verifyAgent(credentials.agentId); - return { providerAccountId: identity.organizationId, providerAccountLabel: "Speko workspace", botExternalId: identity.id, botLabel: identity.name }; - } - const teamsCredentials = normalizeMicrosoftTeamsCredentialIds(credentials); - const body = new URLSearchParams({ - client_id: teamsCredentials.clientId, - client_secret: teamsCredentials.clientSecret, - grant_type: "client_credentials", - scope: "https://api.botframework.com/.default", - }); - const response = await fetchImpl( - `https://login.microsoftonline.com/${encodeURIComponent(teamsCredentials.tenantId)}/oauth2/v2.0/token`, - { - method: "POST", - signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), - headers: { "content-type": "application/x-www-form-urlencoded" }, - body, - }, - ); - const result = (await response.json()) as { - access_token?: string; - error_description?: string; - }; - if (!response.ok || !result.access_token) - throw unprocessable( - `Microsoft rejected the app credentials: ${result.error_description ?? response.status}`, - ); - return { - providerAccountId: teamsCredentials.tenantId, - providerAccountLabel: teamsCredentials.tenantId, - botExternalId: teamsCredentials.clientId, - }; + if (provider === "imessage-photon") return verifyPhotonCredentials(credentials); + if (provider === "slack") return verifySlackCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); + if (provider === "discord") return verifyDiscordCredentials(credentials, fetchImpl, redactError); + if (provider === "telegram") return verifyTelegramCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); + if (provider === "github") return verifyGitHubCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); + if (provider === "speko") return verifySpekoCredentials(credentials, fetchImpl); + return verifyTeamsCredentials(credentials, fetchImpl, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS); } function nativeBotIdentityKey( @@ -3114,12 +2851,22 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } const withCredentialMutationLease = chatCredentialMutationLease(db, options); + const { generateSetupSecret } = createGitHubSetupSecret(db, { + endpointRecord, withCredentialMutationLease, resolveCredentials, persistCredentials, + runtimeGeneration, credentialFingerprint, invalidateRuntime, redactError, + get setupSecretActivityLogger() { return options.setupSecretActivityLogger; }, + get setupSecretCredentialPersistBarrier() { return options.setupSecretCredentialPersistBarrier; }, + get setupSecretFinalOwnershipBarrier() { return options.setupSecretFinalOwnershipBarrier; }, + }); const { setupTestStatus, test } = createSetupProbes(db, { endpointRecord, withCredentialMutationLease, fetchImpl, findAuthorizedIdentityLink, runtimeGeneration, lockCurrentPrincipalAuthorization, get, setupTestActivationBarrier: () => options.setupTestActivationBarrier?.() ?? Promise.resolve() }); - const { listConversations, listActivity, listActivityPage } = createChatActivity(db, { endpointRecord, teamsTransferProtocol }); + const { listConversations, listActivity, listActivityPage } = createChatActivity(db, { endpointRecord, teamsTransferProtocol: (...args) => teamsTransferProtocol(...args) }); const { lockTelegramCallbackNoticeAuthority } = createTelegramCallbackNoticeAuthority({ nonDirectDestinationAllowed, lockCurrentPrincipalAuthorization }); const { stageProviderEffect, processProviderEffect, processPendingProviderEffects } = createProviderEffects(db, { endpointRecord, runtimeCallbackEndpoint, withCredentialMutationLease, runtimeFor, invalidateRuntime, lockCurrentPrincipalAuthorization, redactError, MAX_ERROR_TEXT, nonDirectDestinationAllowed, lockTelegramCallbackNoticeAuthority }); const { publicationFiles } = createOutboundAttachments(db, { get storage() { return options.storage; } }); const { ingestAttachments, attachmentOmissionDetail } = createInboundAttachments(db, { authorizeInboundWakeup, lifecycleRuntimeFence, sanitizeFilename, issuesSvc, isExternalActionAuthorizationChange, redactError, get storage() { return options.storage; } }); + const { settleTelegramAttachmentOnlyFailure } = createTelegramAttachmentFailure(db, { + attachmentOmissionDetail, runtimeContextForRecord, endpointRecord, stageProviderEffect, scheduleProviderEffect, + }); const { handleReaction, processPendingReactionDeliveries } = createInboundReactions(db, { lockCurrentPrincipalAuthorization, runtimeCallbackRecord, runtimeCallbackEndpoint, ensurePrincipal, durableExternalThreadIdentity, recordCurrentMicrosoftTeamsRoute, lifecycleRuntimeFence, deliveryReady, redactError, nonDirectDestinationAllowed, reactionLinkPreflightBarrier: () => options.reactionLinkPreflightBarrier?.() ?? Promise.resolve(), reactionReplayEndpointLockBarrier: () => options.reactionReplayEndpointLockBarrier?.() ?? Promise.resolve(), reactionReplayConversationLockBarrier: () => options.reactionReplayConversationLockBarrier?.() ?? Promise.resolve() }); const { addReceiptReaction, processReceiptReaction, processPendingReceiptReactions } = createReceiptReactions(db, { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, runtimeFor, fetchImpl, runtime, runtimeGeneration, credentialFingerprint, redactError, MAX_ERROR_TEXT, receiptReactionTransportBarrier: () => options.receiptReactionTransportBarrier?.() ?? Promise.resolve() }); const { admitDiscordRootMention, discordRootThreadPending, reconcileDiscordRootThread } = createDiscordRootThreads(db, { ensureDiscordGatewayRuntimeIsCurrent, withCredentialMutationLease, runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, processMessage, redactError, scheduleConversationDrain, lifecycleRuntimeFence, runtimeFor, DISCORD_ROOT_THREAD_CONFIRMATION_DELAY_MS, discordRootThreadTransportBarrier: () => options.discordRootThreadTransportBarrier?.() ?? Promise.resolve() }); @@ -3130,327 +2877,23 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { 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 { handleSlackSlashCommand } = createSlackCommands(db, { + runtime, runtimeFor, handleSdkMessage, stageProviderEffect, scheduleProviderEffect, ensurePrincipal, + runtimeCallbackEndpoint, getPublicBaseUrl, canonicalProviderResourceId, nonDirectDestinationAllowed, + sponsorAllowsGuest, latestSlackDmControlThreadId, scheduleMessageProcessing, processSlackTaskStart, + get deferWebhookProcessing() { return options.deferWebhookProcessing; }, + }); + const { handleTelegramSlashCommand } = createTelegramCommands({ runtime, runtimeFor, handleSdkMessage }); 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 }); const { stageSlackSessionStop, processSlackSessionStop, processPendingSlackSessionStops } = createSlackSessionStop(db, { runtimeCallbackEndpoint, lockCurrentPrincipalAuthorization, nonDirectDestinationAllowed, stageAuthorizedTaskControlPublication, getPublicBaseUrl, get heartbeat() { return options.heartbeat; }, redactError }); const { stageTelegramMaintenance, processTelegramMaintenance, processPendingTelegramMaintenance, hasTelegramStopSubscription, recordTelegramStopSubscription } = createTelegramMaintenance(db, { endpointRecord, runtimeContextForRecord, withCredentialMutationLease, resolveCredentials, clearCredentials, runtimeCallbackEndpoint, getWebhookPublicBaseUrl, fetchImpl, runtimeGeneration, credentialFingerprint, redactError, credentialCheckTimeoutMs: PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS }); const { reconcileGitHubWebhookRepository, reconcileProviderResourceRows, listResources, listGitHubRepositories, replaceResources, toggleAllGitHubRepositories, hasNewerProcessedProviderLifecycleEffect, applyProviderAvailability } = createChatResourceReconciliation(db, { endpointRecord, withCredentialMutationLease, runtimeGeneration, maxErrorText: MAX_ERROR_TEXT }); - - async function generateSetupSecret( - endpointId: string, - actorUserId?: string | null, - ) { - const initial = await endpointRecord(endpointId); - if (!initial) throw notFound("Chat endpoint not found"); - if (initial.endpoint.provider !== "github") { - throw unprocessable( - "Setup secret generation is only available for GitHub", - ); - } - let committedWebhookSecretRef: ToolCredentialSecretRef | null = null; - return withCredentialMutationLease( - initial.endpoint, - async (credentialLease) => { - // A competing request may have completed while this request waited for - // the durable lease. Reload both endpoint state and credential refs only - // after ownership so each successful rotation starts from its immediate - // predecessor instead of replacing the credential set from a stale read. - const record = await endpointRecord(endpointId); - if (!record) throw notFound("Chat endpoint not found"); - const endpoint = record.endpoint; - if ( - ![ - "draft", - "verifying", - "active", - "attention", - "revoked", - "paused", - ].includes(endpoint.status) - ) { - throw conflict( - "The GitHub webhook secret cannot be rotated in this connection state", - { - code: "chat_endpoint_setup_secret_unavailable", - }, - ); - } - const webhookSecret = randomBytes(32).toString("hex"); - // Rotation must fail closed if any existing credential cannot be resolved. - // Falling back to an empty object here would replace the full credential - // set with only the new webhook secret and strand an otherwise-live App. - const existing = await resolveCredentials(endpoint); - const replacedExistingSecret = record.credentialSecretRefs.some( - (ref) => ref.configPath === "credentials.webhookSecret", - ); - const rotatingConfiguredApp = - Boolean(endpoint.providerAccountId || endpoint.botExternalId) || - record.credentialSecretRefs.some((ref) => - ["credentials.appId", "credentials.privateKey"].includes( - ref.configPath, - ), - ); - const rotationId = randomUUID(); - const auditDetails = { - endpointId: endpoint.id, - provider: endpoint.provider, - rotationId, - rotated: rotatingConfiguredApp, - replacedPrevious: replacedExistingSecret, - }; - const writeSetupSecretActivity = - options.setupSecretActivityLogger ?? logActivity; - // This durable intent is the audit boundary. If it cannot be written, - // fail before changing state or refs so retry is safe. Endpoint/tool - // state moves fail-closed before ref replacement. Once refs change, only - // best-effort work remains and the plaintext must still be returned. - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await writeSetupSecretActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: "chat_endpoint.setup_secret_rotation_started", - entityType: "tool_connection", - entityId: endpoint.connectionId, - details: auditDetails, - }); - await credentialLease.assertOwned(tx); - }); - try { - if (rotatingConfiguredApp) { - const updatedAt = new Date(); - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await tx - .select({ setup: chatEndpoints.setup }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) throw notFound("Chat endpoint not found"); - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: - "Update the GitHub webhook secret, then reconnect this App", - lastError: null, - setup: { - ...current.setup, - step: "provider_setup", - webhookVerifiedAt: null, - runtimeGeneration: runtimeGeneration(current.setup) + 1, - } as InternalSetupState, - updatedAt, - }) - .where(eq(chatEndpoints.id, endpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "disabled", - enabled: false, - healthStatus: "degraded", - healthMessage: - "GitHub webhook secret rotation needs reconnect", - lastError: null, - healthCheckedAt: updatedAt, - updatedAt, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await credentialLease.assertOwned(tx); - }); - await invalidateRuntime(endpoint.id).catch(() => undefined); - } else { - // Replacing a secret while the operator is still creating the GitHub - // App is not a live credential rotation. Keep first-time setup in its - // draft state, clear any ping for the superseded value, and also heal - // endpoints affected by the earlier pre-connect rotation trap. - const updatedAt = new Date(); - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await tx - .select({ setup: chatEndpoints.setup }) - .from(chatEndpoints) - .where(eq(chatEndpoints.id, endpoint.id)) - .for("update") - .then((rows) => rows[0] ?? null); - if (!current) throw notFound("Chat endpoint not found"); - await tx - .update(chatEndpoints) - .set({ - status: "draft", - healthMessage: null, - lastError: null, - setup: { - ...current.setup, - step: "provider_setup", - webhookVerifiedAt: null, - }, - updatedAt, - }) - .where(eq(chatEndpoints.id, endpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "draft", - enabled: false, - healthStatus: "unchecked", - healthMessage: null, - lastError: null, - healthCheckedAt: null, - updatedAt, - }) - .where(eq(toolConnections.id, endpoint.connectionId)); - await credentialLease.assertOwned(tx); - }); - } - await options.setupSecretCredentialPersistBarrier?.(); - // Make credential refs the final required mutation. Once this returns, - // only the best-effort completion audit remains, so the request cannot - // fail after making the newly generated secret authoritative. - const persistedRefs = await persistCredentials( - endpoint, - { ...existing, webhookSecret }, - credentialLease, - actorUserId, - ); - committedWebhookSecretRef = - persistedRefs.find( - (ref) => ref.configPath === "credentials.webhookSecret", - ) ?? null; - if (!committedWebhookSecretRef) { - throw new Error( - "GitHub webhook secret was not present after credential persistence", - ); - } - } catch (error) { - await writeSetupSecretActivity(db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: "chat_endpoint.setup_secret_rotation_failed", - entityType: "tool_connection", - entityId: endpoint.connectionId, - details: auditDetails, - }).catch((auditError) => { - logger.warn( - { - endpointId: endpoint.id, - error: redactError(auditError), - rotationId, - }, - "could not audit failed GitHub setup-secret rotation", - ); - }); - throw error; - } - await db - .transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await writeSetupSecretActivity(tx as unknown as Db, { - companyId: endpoint.companyId, - actorType: "user", - actorId: actorUserId ?? "board", - action: "chat_endpoint.setup_secret_generated", - entityType: "tool_connection", - entityId: endpoint.connectionId, - details: auditDetails, - }); - await credentialLease.assertOwned(tx); - }) - .catch((error) => { - logger.warn( - { - endpointId: endpoint.id, - error: redactError(error), - rotationId, - }, - "GitHub setup secret rotated but completion audit failed", - ); - }); - return { webhookSecret }; - }, - { - beforeFinalOwnershipCheck: options.setupSecretFinalOwnershipBarrier, - recoverCommittedResultAfterLeaseLoss: async () => { - const committedRef = committedWebhookSecretRef; - if (!committedRef) return false; - const authoritative = await db - .select({ refs: toolConnections.credentialSecretRefs }) - .from(toolConnections) - .innerJoin( - companySecretBindings, - and( - eq(companySecretBindings.companyId, initial.endpoint.companyId), - eq(companySecretBindings.targetType, "tool_connection"), - eq( - companySecretBindings.targetId, - initial.endpoint.connectionId, - ), - eq(companySecretBindings.configPath, committedRef.configPath), - eq(companySecretBindings.secretId, committedRef.secretId), - eq( - companySecretBindings.versionSelector, - String(committedRef.versionSelector ?? "latest"), - ), - ), - ) - .where( - and( - eq(toolConnections.companyId, initial.endpoint.companyId), - eq(toolConnections.id, initial.endpoint.connectionId), - ), - ) - .then((rows) => rows[0] ?? null); - return ( - authoritative?.refs.some( - (ref) => - credentialFingerprint([ref]) === - credentialFingerprint([committedRef]), - ) === true - ); - }, - }, - ); - } - - async function hydrateSlackResourceLabel( - endpointId: string, - providerResourceId: string, - ): Promise { - const record = await endpointRecord(endpointId); - if ( - !record || - record.endpoint.provider !== "slack" || - record.endpoint.status === "archived" || - record.endpoint.status === "revoked" - ) { - return; - } - const credentials = await resolveCredentials(record.endpoint); - const resource = await getSlackBotChannel({ - botToken: credentials.botToken, - channelId: providerResourceId, - fetch: fetchImpl, - }); - if ( - !resource || - slackResourceLabelIsFallback(providerResourceId, resource.label) - ) - return; - await db - .update(chatEndpointResources) - .set({ label: resource.label, updatedAt: new Date() }) - .where( - and( - eq(chatEndpointResources.companyId, record.endpoint.companyId), - eq(chatEndpointResources.endpointId, endpointId), - eq(chatEndpointResources.type, "channel"), - eq(chatEndpointResources.providerResourceId, providerResourceId), - eq(chatEndpointResources.availability, "available"), - ), - ); - } + const { applyGitHubInstallationLifecycle } = createGitHubInstallationLifecycle(db, { + endpointRecord, resolveCredentials, runtimeCallbackEndpoint, runtimeGeneration, credentialFingerprint, + verifyCredentials, legacyGitHubLabelsMatchVerifiedCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, + prepareProviderInventory, persistCredentials, reconcileProviderResourceRows, redactError, + normalizedLifecycleWithRuntimeFence, MAX_ERROR_TEXT, + }); async function prepareProviderInventory( endpoint: EndpointRow, @@ -3461,70 +2904,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { managementUrl?: string; }> { try { - if (endpoint.provider === "slack") { - return { - credentials, - inventory: await listSlackBotChannels({ - botToken: credentials.botToken, - fetch: fetchImpl, - }), - }; - } - if (endpoint.provider === "github") { - const appJwt = githubAppJwt(credentials.appId, credentials.privateKey); - const installation = await discoverDedicatedGitHubAppInstallation({ - appJwt, - fetch: fetchImpl, - }); - const preparedCredentials = { - ...credentials, - installationId: installation.installationId, - }; - const inventory = await listGitHubInstallationRepositories({ - appJwt, - installationId: installation.installationId, - fetch: fetchImpl, - }); - return { - credentials: preparedCredentials, - managementUrl: installation.accountType === "Organization" && installation.accountLabel - ? `https://github.com/organizations/${encodeURIComponent(installation.accountLabel)}/settings/installations/${installation.installationId}` - : `https://github.com/settings/installations/${installation.installationId}`, - inventory: { - ...inventory, - resources: inventory.resources.map((resource) => { - const fullName = - typeof resource.metadata?.fullName === "string" - ? resource.metadata.fullName - : resource.label.includes("/") - ? resource.label - : null; - return fullName - ? { - ...resource, - providerResourceId: fullName.toLowerCase(), - metadata: { - ...resource.metadata, - providerRepositoryId: resource.providerResourceId, - fullName, - }, - } - : resource; - }), - }, - }; - } - if (endpoint.provider === "discord") { - return { - credentials, - inventory: await listDiscordBotChannels({ - botUserId: credentials.applicationId, - botToken: credentials.botToken, - fetch: fetchImpl, - guildId: credentials.guildId, - }), - }; - } + if (endpoint.provider === "slack") return await prepareSlackInventory(credentials, fetchImpl); + if (endpoint.provider === "github") return await prepareGitHubInventory(credentials, fetchImpl); + if (endpoint.provider === "discord") return await prepareDiscordInventory(credentials, fetchImpl); return { credentials, inventory: null }; } catch (error) { throw unprocessable(redactError(error), { @@ -4021,27 +3403,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }; } - async function inspectPhotonCredentials(projectId: string, projectSecret: string) { - try { - return await new PhotonCloudClient(fetchImpl).inspect(projectId, projectSecret); - } catch (error) { - if (!(error instanceof PhotonError)) throw error; - // Only credentials/allocation failures mean the setup input needs repair. - // Preserve safe provider messages while distinguishing outages and bad - // upstream responses from validation errors. Never return response bodies. - const status = error.code === "credentials" || error.code === "line_unavailable" - ? 422 - : error.code === "quota" - ? 429 - : error.code === "network" - ? 503 - : 502; - throw new HttpError(status, error.message, { - code: `photon_${error.code}`, - }); - } - } - async function inspectPhoton( endpointId: string, input: { projectId: string; projectSecret: string }, @@ -5367,113 +4728,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return resource; } - async function settleTelegramAttachmentOnlyFailure(input: { - activeDelivery: DeliveryRow; - attachmentResult: { - storedIds: string[]; - omissionReasons: Record; - }; - commentId: string; - conversationId: string; - endpoint: EndpointRow; - message: Message; - principalId: string | null; - resourceId: string | null; - runtimeContext?: InboundRuntimeContext; - thread: Thread; - }): Promise { - if ( - input.endpoint.provider !== "telegram" || - input.message.text.trim().length > 0 || - input.message.attachments.length === 0 || - input.attachmentResult.storedIds.length > 0 - ) { - return false; - } - const diagnostic = - attachmentOmissionDetail(input.attachmentResult) ?? - "The Telegram attachment could not be imported"; - const visibleFailure = `Paperclip could not safely import the attached Telegram file. Please resend it as a supported file under ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} or include text describing the request.`; - const effectContext = - input.runtimeContext ?? - runtimeContextForRecord( - (await endpointRecord(input.endpoint.id)) ?? - (() => { - throw new Error("Chat endpoint is unavailable"); - })(), - ); - const effect = await db.transaction(async (tx) => { - // Replace the optimistic attachment placeholder with an explicit safe - // Paperclip-visible failure. The message link stays authoritative for - // exact provider redelivery, while no agent wakeup is enqueued for an - // empty turn that contains no usable bytes. - await tx - .update(issueComments) - .set({ body: visibleFailure, updatedAt: new Date() }) - .where( - and( - eq(issueComments.id, input.commentId), - eq(issueComments.companyId, input.endpoint.companyId), - ), - ); - await tx - .update(chatActions) - .set({ - status: "failed", - result: { code: "inbound_wakeup_attachment_unavailable" }, - updatedAt: new Date(), - }) - .where( - and( - eq(chatActions.deliveryId, input.activeDelivery.id), - eq(chatActions.kind, "inbound_wakeup"), - eq(chatActions.status, "preparing"), - ), - ); - const staged = await stageProviderEffect(tx, { - endpoint: input.endpoint, - deliveryId: input.activeDelivery.id, - conversationId: input.conversationId, - principalId: input.principalId, - providerActionId: `provider_effect:attachment-ingest-failure:${input.activeDelivery.id}`, - payload: { - version: 1, - authorizationMode: "safe_notice", - effect: "thread_message", - threadId: input.thread.id, - text: visibleFailure, - settleDelivery: false, - ...(input.resourceId ? { resourceId: input.resourceId } : {}), - }, - runtimeContext: effectContext, - }); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - conversationId: input.conversationId, - state: "processed", - processedAt: new Date(), - nextAttemptAt: null, - redactedError: diagnostic, - updatedAt: new Date(), - }) - .where( - and( - eq(chatDeliveries.id, input.activeDelivery.id), - eq(chatDeliveries.state, "processing"), - ), - ); - return staged; - }); - if (!effect) throw new Error("Attachment failure notice was not persisted"); - // Posting the safe notice is its own durable outbox operation. A provider - // outage must not turn the already-settled, deliberately unwoken inbound - // delivery back into a replayable attachment message. - scheduleProviderEffect(effect.id, input.thread); - return true; - } - async function stageInboundWakeup( tx: DbOrTransaction, input: { @@ -5730,7 +4984,18 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { isShuttingDown: () => shuttingDown, wakeup: (...args) => options.heartbeat.wakeup(...args), }); + const { githubCurrentOmissionPreparedText, settleGitHubAutomaticPublication } = createGitHubPublicationPolicy(db, { + isExplicitOperatorPublication, getTaskBaseUrl, failedChatRetrySource, isExternalActionAuthorizationChange, + FailedChatRetryPublicationReadError, endpointRecord, runIdFromMilestonePublication, fetchImpl, + stageTerminalReceiptReactionRemovals, runtimeContextForRecord, scheduleMessageProcessing, processReceiptReaction, + }); const { stageTeamsBoardFileIntent, deriveTeamsFileTransferAuthority } = createTeamsFileAuthority({ runtimeGeneration, credentialFingerprint, runtime, runtimeContexts, localRuntimeEpoch, runtimeVersions, lockCurrentPrincipalAuthorization, lifecycleRuntimeFence, assertFailedRetryReceipt, inboundWakeScope, failedChatRetrySource, isExplicitOperatorPublication, retryCommentIds }); + const { teamsTransferProtocol, handleTeamsFileConsent, tryProcessTeamsFilePublication, scheduleTeamsFileMaintenance } = createTeamsFileDelivery(db, { + endpointRecord, runtimeContextForRecord, runtimeFor, runtimeContexts, localRuntimeEpoch, runtime, + deriveTeamsFileTransferAuthority, publicationFiles, scheduleMessageProcessing, processPendingPublications, + withCredentialMutationLease, teamsFileRetryAfter, isShuttingDown: () => shuttingDown, + get teamsFileUploadRequest() { return options.teamsFileUploadRequest; }, + }); async function authorizeRetainedChatSourcePublication( tx: DbOrTransaction, publication: typeof chatPublications.$inferSelect, @@ -13624,347 +12889,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if (!record) return; const command = event.event.command.toLowerCase().split("@")[0]; const text = event.event.text.trim(); - if (event.provider === "telegram") { - const endpointRuntime = - runtime.get(event.endpointId) ?? (await runtimeFor(record.endpoint)); - const thread = endpointRuntime.thread(event.event.channel.id); - const parsedCommandMessage = - endpointRuntime.parseTelegramCommandMessage?.(event.event.raw) ?? null; - const synthetic = { - id: - telegramMessageId(event.event.raw) ?? - event.event.triggerId ?? - createHash("sha256") - .update( - JSON.stringify({ - command, - raw: event.event.raw, - userId: event.event.user.userId, - }), - ) - .digest("hex"), - threadId: thread.id, - // Telegram privacy mode does not deliver an ordinary @mention in a - // group. /task@bot is therefore the provider-native root - // activation path; direct replies and later /task commands can - // continue the same active group/topic task. - text: command === "/task" && text ? text : command, - formatted: { type: "root", children: [] }, - raw: event.event.raw, - author: event.event.user, - metadata: { - // Telegram slash commands carry the original message as `raw`. - // Preserve its whole-second provider clock so the durable drain can - // use message_id to order a command and the next rapid DM correctly. - dateSent: telegramMessageSentAt(event.event.raw) ?? new Date(), - edited: false, - }, - // Telegram routes a captioned file command through onSlashCommand - // instead of the ordinary message callback. Preserve the provider's - // parsed attachment descriptors so the durable delivery worker can - // rehydrate and ingest them exactly like non-command media. - attachments: parsedCommandMessage?.attachments ?? [], - links: [], - isMention: true, - } as unknown as Message; - await handleSdkMessage( - { - endpointId: event.endpointId, - provider: event.provider, - thread, - message: synthetic, - trigger: thread.isDM ? "direct_message" : "mention", - }, - runtimeContext, - ); - return; - } - const providerCommandId = - event.event.triggerId ?? - createHash("sha256") - .update(JSON.stringify(event.event.raw)) - .digest("hex"); - const stageSlackNotice = (tx: DbTransaction, notice: string, principalId?: string | null) => - stageProviderEffect(tx, { - endpoint: record.endpoint, - principalId: principalId ?? null, - providerActionId: `provider_effect:slash_notice:${createHash("sha256").update(JSON.stringify([ - event.event.command, event.event.user.userId, providerCommandId, event.event.text, - ])).digest("hex")}`, - payload: { - version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice", - threadId: event.event.channel.id, userId: event.event.user.userId, - text: notice, settleDelivery: false, - }, - runtimeContext, - }); - const queueSlackNotice = async (notice: string, principalId?: string | null) => { - const effect = await db.transaction((tx) => stageSlackNotice(tx, notice, principalId)); - if (!effect) throw new Error("Slack notice was not persisted"); - scheduleProviderEffect(effect.id, event.event.channel); - }; - if (!["verifying", "active"].includes(record.endpoint.status)) { - await queueSlackNotice("This Paperclip connection is not active."); - return; - } - const expectedCommand = - typeof record.endpoint.setup.command === "string" - ? record.endpoint.setup.command - : slackCommandForAgent( - record.assignedAgentName, - record.endpoint.publicId, - ); - if (event.provider !== "slack" || command !== expectedCommand) { - await queueSlackNotice( - `This connection only accepts ${expectedCommand}.`, - ); - return; - } - const principal = await ensurePrincipal( - record.endpoint, - event.event.user, - event.event.raw, - ); - if (text.toLowerCase() === "connect") { - // Discovery must work before identity and channel access are granted. - // It records no task and grants no permissions; linking still requires - // explicit confirmation by a company member in the board. - if (principal.principal.kind !== "user" || principal.principal.isBot) return; - const effect = await db.transaction(async (tx) => { - if (!(await runtimeCallbackEndpoint(tx, event.endpointId, runtimeContext, ["verifying", "active"]))) { - throw conflict("This Slack connection changed; send the connect command again"); - } - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.principal.id}`}, 0))`); - const inserted = await tx.insert(chatActions).values({ - companyId: record.endpoint.companyId, endpointId: event.endpointId, - principalId: principal.principal.id, kind: "slack_connect", - providerActionId: `slack_connect:${providerCommandId}`, - payload: { version: 1, channelId: event.event.channel.id, userId: event.event.user.userId }, - status: "processed", result: { code: "slack_identity_discovered" }, - }).onConflictDoNothing().returning({ id: chatActions.id }); - if (!inserted.length) return null; - const currentLink = await tx.select().from(chatIdentityLinks).where(and( - eq(chatIdentityLinks.endpointId, event.endpointId), eq(chatIdentityLinks.principalId, principal.principal.id), - )).then((rows) => rows[0]); - let notice = "Your Slack account is already connected to Paperclip. You can return to Slack and message the agent."; - if (currentLink?.status !== "linked") { - const token = randomBytes(32).toString("base64url"); - const tokenHash = createHash("sha256").update(token).digest("hex"); - const expiresAt = new Date(Date.now() + 15 * 60 * 1000); - await tx.insert(chatIdentityLinks).values({ - companyId: record.endpoint.companyId, endpointId: event.endpointId, principalId: principal.principal.id, - status: "pending", confirmationTokenHash: tokenHash, expiresAt, - }).onConflictDoUpdate({ - target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], - set: { paperclipUserId: null, status: "pending", confirmationTokenHash: tokenHash, expiresAt, confirmedAt: null, revokedAt: null, updatedAt: new Date() }, - }); - await tx.update(chatActions).set({ payload: { - version: 1, channelId: event.event.channel.id, userId: event.event.user.userId, identityLinkHash: tokenHash, - } }).where(eq(chatActions.id, inserted[0].id)); - const url = `${getPublicBaseUrl()}/chat-identity/confirm?token=${encodeURIComponent(token)}`; - notice = getPublicBaseUrl() - ? `[Connect your Paperclip account](${url}) — sign in and confirm this Slack identity. This private link expires in 15 minutes and works once. You can also confirm in the setup wizard. No agent work has started.` - : "Return to the Paperclip setup wizard to confirm your Slack account. No agent work has started."; - } - await logActivity(tx as unknown as Db, { - companyId: record.endpoint.companyId, actorType: "system", actorId: `chat:${principal.principal.id}`, - action: "chat.slack_identity_discovered", entityType: "chat_endpoint", entityId: event.endpointId, - details: { principalId: principal.principal.id }, - }); - const staged = await stageSlackNotice(tx, notice, principal.principal.id); - if (!staged) throw new Error("Slack identity link notice was not persisted"); - return staged; - }); - if (effect) scheduleProviderEffect(effect.id, event.event.channel); - return; - } - const resource = await db - .select() - .from(chatEndpointResources) - .where( - and( - eq(chatEndpointResources.endpointId, event.endpointId), - eq( - chatEndpointResources.providerResourceId, - canonicalProviderResourceId(event.provider, { - channelId: event.event.channel.id, - id: event.event.channel.id, - }), - ), - ), - ) - .then((rows) => rows[0] ?? null); - // Slack's SDK channel wrapper has reported false for isDM on real slash - // callbacks even though the signed payload contains a D-prefixed channel - // id. The provider id is authoritative for this routing decision. - const rawSlackChannelId = event.event.channel.id.replace(/^slack:/, ""); - const slashIsDirectMessage = /^D[A-Z0-9]+$/i.test(rawSlackChannelId) - ? true - : event.event.channel.isDM; - const destinationAllowed = slashIsDirectMessage - ? record.endpoint.allowDirectMessages - : nonDirectDestinationAllowed(record.endpoint, resource); - const authorized = - destinationAllowed && - !principal.linkedDenied && - (principal.userId !== null || - (record.endpoint.allowUnlinkedPeople && - (await sponsorAllowsGuest(record.endpoint)))); - if (!authorized) { - await queueSlackNotice( - "This channel or account is not allowed to start Paperclip work.", - principal.principal.id, - ); - return; - } - const slackControl = /^(status|new|close)$/i - .exec(text)?.[1] - ?.toLowerCase() as "status" | "new" | "close" | undefined; - if (slackControl) { - if (!slashIsDirectMessage) { - // Slack slash-command payloads are channel-scoped and do not carry a - // thread timestamp, so they cannot safely identify one of several - // Paperclip tasks in the channel. Native @mention threads remain the - // task-management surface there; the control vocabulary is exact only - // in a DM, whose provider channel identity is stable. - await queueSlackNotice( - "Use status, new, and close in a direct message with this agent. In a channel, open the Paperclip task from its Slack thread.", - principal.principal.id, - ); - return; - } - const endpointRuntime = - runtime.get(event.endpointId) ?? (await runtimeFor(record.endpoint)); - // A slash-command task starts a real Slack root and binds Paperclip to - // that returned thread id. Later slash controls carry only the DM channel - // id, so target the most recently active task instead of synthesizing an - // unrelated base-DM thread that cannot find the binding. - const activeThreadId = await latestSlackDmControlThreadId( - event.endpointId, - rawSlackChannelId, - ); - const thread = endpointRuntime.thread( - activeThreadId ?? `slack:${rawSlackChannelId}:`, - ); - const synthetic = { - id: createHash("sha256") - .update( - `${event.event.command}:${event.event.user.userId}:${providerCommandId}:${slackControl}`, - ) - .digest("hex"), - threadId: thread.id, - text: `/${slackControl}`, - formatted: { type: "root", children: [] }, - raw: event.event.raw, - author: event.event.user, - metadata: { dateSent: new Date(), edited: false }, - attachments: [], - links: [], - isMention: true, - } as unknown as Message; - await handleSdkMessage( - { - endpointId: event.endpointId, - provider: event.provider, - thread, - message: synthetic, - trigger: "direct_message", - }, - runtimeContext, - { receiptReactionSupported: false, slackSlashControl: true }, - ); - return; - } - if (!text) { - await queueSlackNotice( - `Use ${expectedCommand} followed by a task, or ${expectedCommand} status, new, or close in a direct message.`, - principal.principal.id, - ); - return; - } - const providerActionId = `slash_task:${createHash("sha256") - .update( - `${event.event.command}:${event.event.user.userId}:${providerCommandId}`, - ) - .digest("hex")}`; - const syntheticMessageId = createHash("sha256") - .update( - `${event.event.command}:${event.event.user.userId}:${providerCommandId}`, - ) - .digest("hex"); - // A duplicate Slack retry must acknowledge from the already-durable row - // without waiting for the first worker's endpoint/identity locks or Web - // API request. Slack otherwise treats a healthy in-flight command as an - // acknowledgement timeout and retries it again. - const existingAction = await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, record.endpoint.id), - eq(chatActions.providerActionId, providerActionId), - ), - ) - .then((rows) => rows[0] ?? null); - const insertedAction = existingAction - ? null - : await db.transaction(async (tx) => { - if ( - !(await runtimeCallbackEndpoint( - tx, - event.endpointId, - runtimeContext, - ["verifying", "active"], - )) - ) - return null; - await notifyChatActionWork(tx, "slash_task_start"); - return tx - .insert(chatActions) - .values({ - companyId: record.endpoint.companyId, - endpointId: record.endpoint.id, - principalId: principal.principal.id, - kind: "slash_task_start", - providerActionId, - payload: { - version: 1, - channelId: event.event.channel.id, - command, - syntheticMessageId, - taskText: text, - }, - status: "queued", - }) - .onConflictDoNothing() - .returning() - .then((rows) => rows[0] ?? null); - }); - const action = - existingAction ?? - insertedAction ?? - (await db - .select() - .from(chatActions) - .where( - and( - eq(chatActions.endpointId, record.endpoint.id), - eq(chatActions.providerActionId, providerActionId), - ), - ) - .then((rows) => rows[0] ?? null)); - if (!action) throw new Error("Slack command admission was not persisted"); - // Slack has a tight acknowledgement budget. The unique action row is the - // durable receipt; provider publication and Paperclip admission run behind - // the acknowledgement under an atomic `received -> resolving` claim. - // Duplicate callbacks schedule the same id and converge without waiting - // on Slack's Web API or creating a second root. - scheduleMessageProcessing(async () => { - await processSlackTaskStart( - action.id, - options.deferWebhookProcessing === true, - ); - }); + if (event.provider === "telegram") return handleTelegramSlashCommand(event, runtimeContext, record, command, text); + return handleSlackSlashCommand(event, runtimeContext, record, command, text); } async function applyProviderLifecycleEffect( @@ -14092,402 +13018,15 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } if (currentEndpoint.provider === "github") { - try { - const currentCredentials = - await resolveCredentials(currentEndpoint); - const lifecycleInstallationId = - effect.kind === "endpoint" && - typeof effect.metadata?.installationId === "string" - ? effect.metadata.installationId - : null; - const removesCurrentInstallation = - effect.kind === "endpoint" && - effect.availability === "revoked" && - lifecycleInstallationId !== null && - lifecycleInstallationId === currentCredentials.installationId; - const currentInstallationRemovalReason = - removesCurrentInstallation && effect.kind === "endpoint" - ? effect.reason - : null; - if (removesCurrentInstallation) { - // A signed deletion for the installation currently bound to - // this endpoint is authoritative provider truth. Inventory now - // correctly contains zero active installations, so treating its - // empty result as a transient discovery failure would strand the - // endpoint in attention and ask GitHub to redeliver forever. - // Keep the App key and webhook secret so a later installation - // event can recover this same immutable bot identity. - const revoked = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const current = await runtimeCallbackEndpoint( - tx, - currentEndpoint.id, - admittedRuntimeContext, - ["verifying", "active", "attention", "revoked"], - ); - const revokedAt = new Date(); - if (!current) { - await notifyChatDeliveryWork(tx); - const [filtered] = await tx - .update(chatDeliveries) - .set({ - state: "filtered", - processedAt: revokedAt, - nextAttemptAt: null, - redactedError: - "GitHub installation deletion belonged to a superseded runtime", - updatedAt: revokedAt, - }) - .where( - and( - eq(chatDeliveries.id, claimed.id), - eq(chatDeliveries.state, "processing"), - eq(chatDeliveries.attempts, candidate.attempts + 1), - ), - ) - .returning({ id: chatDeliveries.id }); - if (!filtered) { - throw new Error( - "GitHub installation deletion ownership changed before stale settlement", - ); - } - await credentialLease.assertOwned(tx); - return false; - } - const reason = redactSensitiveText( - currentInstallationRemovalReason ?? - "GitHub App installation was removed", - ).slice(0, MAX_ERROR_TEXT); - await tx - .update(chatEndpoints) - .set({ - status: "revoked", - healthMessage: reason, - lastError: reason, - lastEventAt: revokedAt, - setup: { - ...current.setup, - runtimeGeneration: runtimeGeneration(current.setup) + 1, - } as InternalSetupState, - updatedAt: revokedAt, - }) - .where(eq(chatEndpoints.id, current.id)); - await tx - .update(toolConnections) - .set({ - status: "disabled", - enabled: false, - healthStatus: "failed", - healthMessage: reason, - lastError: reason, - healthCheckedAt: revokedAt, - updatedAt: revokedAt, - }) - .where(eq(toolConnections.id, current.connectionId)); - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: revokedAt }) - .where( - and( - eq(chatEndpointResources.companyId, current.companyId), - eq(chatEndpointResources.endpointId, current.id), - ), - ); - await tx - .update(chatConversations) - .set({ state: "unavailable", updatedAt: revokedAt }) - .where( - and( - eq(chatConversations.companyId, current.companyId), - eq(chatConversations.endpointId, current.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - await notifyChatDeliveryWork(tx); - const [processed] = await tx - .update(chatDeliveries) - .set({ - state: "processed", - processedAt: revokedAt, - redactedError: null, - nextAttemptAt: null, - updatedAt: revokedAt, - }) - .where( - and( - eq(chatDeliveries.id, claimed.id), - eq(chatDeliveries.state, "processing"), - eq(chatDeliveries.attempts, candidate.attempts + 1), - ), - ) - .returning({ id: chatDeliveries.id }); - if (!processed) { - throw new Error( - "GitHub installation deletion ownership changed before revocation settlement", - ); - } - await credentialLease.assertOwned(tx); - return true; - }); - stopRuntimeAfterLifecycle = revoked; - return; - } - const identity = await verifyCredentials( - "github", - currentCredentials, - ); - const upgradingLegacyIdentity = - !currentEndpoint.botExternalId && - legacyGitHubLabelsMatchVerifiedCredentials( - currentEndpoint, - identity, - ); - if ( - !upgradingLegacyIdentity && - !nativeBotIdentityMatches("github", currentEndpoint, identity) - ) { - throw conflict( - "The GitHub App identity changed while reconciling this connection", - { - code: "chat_bot_identity_changed", - }, - ); - } - await assertNativeBotIdentityAvailable(currentEndpoint, identity); - if (upgradingLegacyIdentity) { - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await tx - .update(chatEndpoints) - .set({ - botExternalId: identity.botExternalId, - updatedAt: new Date(), - }) - .where(eq(chatEndpoints.id, currentEndpoint.id)); - await credentialLease.assertOwned(tx); - }); - } - - // GitHub lifecycle payloads are wake-up signals, not provider - // truth. Re-read the one active App installation and its complete - // repository inventory while the credential lease excludes secret - // rotation and every other lifecycle callback for this endpoint. - const prepared = await prepareProviderInventory( - currentEndpoint, - currentCredentials, - ); - if ( - prepared.credentials.installationId !== - currentCredentials.installationId - ) { - await persistCredentials( - currentEndpoint, - prepared.credentials, - credentialLease, - ); - refreshRuntimeAfterLifecycle = true; - } - if (prepared.inventory) - await reconcileProviderResourceRows( - currentEndpoint, - prepared.inventory, - credentialLease, - ); - - const canonical = await endpointRecord(currentEndpoint.id); - if (!canonical) throw notFound("Chat endpoint not found"); - const now = new Date(); - const intentionallyUnavailable = - canonical.endpoint.status === "paused" || - canonical.endpoint.status === "archived" || - !canonical.endpoint.setup.webhookVerifiedAt; - const advanceRuntimeGeneration = - refreshRuntimeAfterLifecycle || - !canonical.endpoint.setup.webhookVerifiedAt; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - if (intentionallyUnavailable) { - await tx - .update(chatEndpoints) - .set({ - ...(advanceRuntimeGeneration - ? { - setup: { - ...canonical.endpoint.setup, - runtimeGeneration: - runtimeGeneration(canonical.endpoint.setup) + 1, - } as InternalSetupState, - } - : {}), - lastEventAt: now, - updatedAt: now, - }) - .where(eq(chatEndpoints.id, canonical.endpoint.id)); - } else { - const status = - canonical.endpoint.setup.step === "complete" - ? "active" - : "verifying"; - await tx - .update(chatEndpoints) - .set({ - status, - healthMessage: - status === "active" - ? "Connected" - : "Waiting for a test conversation", - lastError: null, - lastEventAt: now, - ...(advanceRuntimeGeneration - ? { - setup: { - ...canonical.endpoint.setup, - runtimeGeneration: - runtimeGeneration(canonical.endpoint.setup) + 1, - } as InternalSetupState, - } - : {}), - updatedAt: now, - }) - .where(eq(chatEndpoints.id, canonical.endpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "active", - enabled: true, - healthStatus: "healthy", - healthMessage: "Connected", - lastError: null, - healthCheckedAt: now, - updatedAt: now, - }) - .where( - eq(toolConnections.id, canonical.endpoint.connectionId), - ); - } - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: "processed", - processedAt: now, - redactedError: null, - nextAttemptAt: null, - updatedAt: now, - }) - .where( - and( - eq(chatDeliveries.id, claimed.id), - eq(chatDeliveries.state, "processing"), - eq(chatDeliveries.attempts, candidate.attempts + 1), - ), - ); - await credentialLease.assertOwned(tx); - }); - if (!canonical.endpoint.setup.webhookVerifiedAt) { - stopRuntimeAfterLifecycle = true; - } - } catch (error) { - const latest = await endpointRecord(currentEndpoint.id); - const preserveIntentionalState = - !latest || - latest.endpoint.status === "paused" || - latest.endpoint.status === "archived" || - !latest.endpoint.setup.webhookVerifiedAt; - if (!preserveIntentionalState) { - const failedAt = new Date(); - const failure = redactError(error); - const nextGeneration = - runtimeGeneration(latest.endpoint.setup) + 1; - const retryRuntimeContext: LifecycleRuntimeFence = { - credentialFingerprint: credentialFingerprint( - latest.credentialSecretRefs, - ), - generation: nextGeneration, - }; - const terminal = candidate.attempts + 1 >= 5; - await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - await tx - .update(chatEndpoints) - .set({ - status: "attention", - healthMessage: - "GitHub App credentials, permissions, events, or identity need attention", - lastError: failure, - lastEventAt: failedAt, - setup: { - ...latest.endpoint.setup, - runtimeGeneration: nextGeneration, - } as InternalSetupState, - updatedAt: failedAt, - }) - .where(eq(chatEndpoints.id, currentEndpoint.id)); - await tx - .update(toolConnections) - .set({ - status: "disabled", - enabled: false, - healthStatus: "degraded", - healthMessage: - "GitHub App credentials, permissions, events, or identity need attention", - lastError: failure, - healthCheckedAt: failedAt, - updatedAt: failedAt, - }) - .where(eq(toolConnections.id, currentEndpoint.connectionId)); - await tx - .update(chatEndpointResources) - .set({ availability: "unavailable", updatedAt: failedAt }) - .where( - and( - eq( - chatEndpointResources.companyId, - currentEndpoint.companyId, - ), - eq(chatEndpointResources.endpointId, currentEndpoint.id), - ), - ); - await tx - .update(chatConversations) - .set({ state: "unavailable", updatedAt: failedAt }) - .where( - and( - eq( - chatConversations.companyId, - currentEndpoint.companyId, - ), - eq(chatConversations.endpointId, currentEndpoint.id), - inArray(chatConversations.state, ["active", "waiting"]), - ), - ); - await notifyChatDeliveryWork(tx); - await tx - .update(chatDeliveries) - .set({ - state: terminal ? "failed" : "retry", - normalizedEvent: normalizedLifecycleWithRuntimeFence( - candidate, - retryRuntimeContext, - ), - redactedError: failure, - nextAttemptAt: terminal ? null : failedAt, - updatedAt: failedAt, - }) - .where( - and( - eq(chatDeliveries.id, claimed.id), - eq(chatDeliveries.state, "processing"), - eq(chatDeliveries.attempts, candidate.attempts + 1), - ), - ); - await credentialLease.assertOwned(tx); - }); + const outcome = await applyGitHubInstallationLifecycle({ + currentEndpoint, effect, credentialLease, admittedRuntimeContext, claimed, candidate, + onFailurePersisted: () => { stopRuntimeAfterFailure = true; lifecycleFailurePersisted = true; - } - throw error; - } + }, + }); + refreshRuntimeAfterLifecycle = outcome.refreshRuntimeAfterLifecycle; + stopRuntimeAfterLifecycle = outcome.stopRuntimeAfterLifecycle; return; } @@ -17426,371 +15965,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - function teamsTransferProtocol( - input: { - credentialLease?: CredentialMutationLeaseGuard; - callbackContext?: RuntimeContext; - } = {}, - ) { - const denied = () => - conflict("Teams file delivery authority changed", { - code: "chat_file_transfer_authority_changed", - }); - const currentRuntime = async (authority: TeamsFileTransferAuthority) => { - if (!input.credentialLease) throw denied(); - await input.credentialLease.assertOwned(); - const record = await endpointRecord(authority.endpointId); - if ( - !record || - record.endpoint.companyId !== authority.companyId || - record.endpoint.provider !== "microsoft-teams" || - record.endpoint.status !== "active" - ) - throw denied(); - const context = runtimeContextForRecord(record); - if ( - context.generation !== authority.runtimeGeneration || - context.credentialFingerprint !== authority.credentialFingerprint - ) - throw denied(); - const instance = await runtimeFor(record.endpoint); - const registered = runtimeContexts.get(instance as object); - if ( - !registered || - registered.version !== context.version || - registered.localEpoch !== localRuntimeEpoch(authority.endpointId) || - runtime.get(authority.endpointId) !== instance - ) - throw denied(); - await input.credentialLease.assertOwned(); - return instance; - }; - const personalThread = (authority: TeamsFileTransferAuthority) => - `teams:${Buffer.from(authority.providerConversationId).toString("base64url")}:personal`; - return teamsFileTransferService(db, { - project: projectTeamsFilePublication, - authorize: async (tx, expected, stage) => { - await input.credentialLease?.assertOwned(tx); - if ( - input.callbackContext && - (input.callbackContext.generation !== expected.runtimeGeneration || - input.callbackContext.credentialFingerprint !== - expected.credentialFingerprint || - input.callbackContext.localEpoch !== - localRuntimeEpoch(expected.endpointId) || - input.callbackContext.endpointRuntime !== - runtime.get(expected.endpointId)) - ) - throw denied(); - const [publication] = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, expected.companyId), - eq(chatPublications.endpointId, expected.endpointId), - eq(chatPublications.id, expected.publicationId), - ), - ) - .for("update"); - // This selector is derived only inside the staged protocol's locked - // authority check, never from a retry request or a model instruction. - const [continuing] = - publication?.state === "delivery_unknown" && - (stage === "response" || stage === "file_info") - ? await tx - .select({ - transferId: chatTeamsFileTransfers.id, - publicationId: chatTeamsFileTransfers.publicationId, - version: chatTeamsFileTransfers.version, - phase: chatTeamsFileTransfers.phase, - }) - .from(chatTeamsFileTransfers) - .where( - and( - eq(chatTeamsFileTransfers.companyId, expected.companyId), - eq(chatTeamsFileTransfers.endpointId, expected.endpointId), - eq( - chatTeamsFileTransfers.conversationId, - expected.conversationId, - ), - eq( - chatTeamsFileTransfers.publicationId, - expected.publicationId, - ), - eq( - chatTeamsFileTransfers.phase, - stage === "response" - ? "consent_unknown" - : "file_info_unknown", - ), - ), - ) - .for("update") - : []; - const current = - publication && - (await deriveTeamsFileTransferAuthority( - tx, - publication, - { - generation: expected.runtimeGeneration, - credentialFingerprint: expected.credentialFingerprint, - }, - continuing && { - ...continuing, - phase: continuing.phase as - | "consent_unknown" - | "file_info_unknown", - }, - )); - if (!current) throw denied(); - await input.credentialLease?.assertOwned(tx); - return current; - }, - loadBytes: async (authority) => { - const [publication] = await db - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, authority.companyId), - eq(chatPublications.endpointId, authority.endpointId), - eq(chatPublications.id, authority.publicationId), - ), - ); - if (!publication) throw denied(); - const files = await publicationFiles(publication, publication.payload); - if (files.length !== 1 || !Buffer.isBuffer(files[0]?.data)) - throw denied(); - return files[0].data; - }, - postConsent: async ({ authority, card, signal }) => { - const instance = await currentRuntime(authority); - signal.throwIfAborted(); - return instance.sendTeamsFileConsentCard( - personalThread(authority), - card, - ); - }, - postFileInfo: async ({ authority, card, signal }) => { - const instance = await currentRuntime(authority); - signal.throwIfAborted(); - return instance.sendTeamsUploadedFileCard( - personalThread(authority), - card, - ); - }, - uploadRequest: options.teamsFileUploadRequest, - }); - } - - async function handleTeamsFileConsent( - callback: ChatSdkCallbackEvent, - context: RuntimeContext, - ): Promise<"recorded" | "ignored" | "denied"> { - if ( - callback.provider !== "microsoft-teams" || - callback.endpointId !== callback.event.endpointId || - context.endpointRuntime !== runtime.get(callback.endpointId) || - context.localEpoch !== localRuntimeEpoch(callback.endpointId) - ) - return "denied"; - // Do not wait for the sender's credential lease: an authentic acceptance - // can arrive while its consent-card POST is still awaiting the receipt. - let result: "recorded" | "ignored" | "denied"; - try { - result = await teamsTransferProtocol({ - callbackContext: context, - }).recordConsent(callback.event); - } catch (error) { - // A known current-policy denial is not a temporary provider outage. - // Contention and unproven persistence still propagate as retryable 503. - if ( - error instanceof HttpError && - (error.details as { code?: string } | undefined)?.code === - "chat_file_transfer_authority_changed" - ) - return "denied"; - throw error; - } - if (result === "recorded") - scheduleMessageProcessing(async () => { - await processPendingPublications(); - }); - return result; - } - - async function tryProcessTeamsFilePublication( - selected: typeof chatPublications.$inferSelect, - ): Promise { - const [existing] = await db - .select({ - id: chatTeamsFileTransfers.id, - endpointId: chatTeamsFileTransfers.endpointId, - conversationId: chatTeamsFileTransfers.conversationId, - }) - .from(chatTeamsFileTransfers) - .where( - and( - eq(chatTeamsFileTransfers.companyId, selected.companyId), - eq(chatTeamsFileTransfers.publicationId, selected.id), - ), - ); - if (!existing && !selected.payload.attachmentIds?.length) return false; - const record = await endpointRecord(selected.endpointId); - if (!record || record.endpoint.provider !== "microsoft-teams") - return Boolean(existing); - if ( - existing && - (existing.endpointId !== selected.endpointId || - existing.conversationId !== selected.conversationId) - ) - return true; - if (record.endpoint.status !== "active") return Boolean(existing); - try { - return await withCredentialMutationLease( - record.endpoint, - async (credentialLease) => { - await runtimeFor(record.endpoint); - const protocol = teamsTransferProtocol({ credentialLease }); - let transferId = existing?.id; - if (!transferId) { - const authority = await db.transaction(async (tx) => { - await credentialLease.assertOwned(tx); - const [current] = await tx - .select() - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, selected.companyId), - eq(chatPublications.id, selected.id), - ), - ) - .for("update"); - if ( - !current || - !["pending", "retry"].includes(current.state) || - (current.nextAttemptAt && current.nextAttemptAt > new Date()) - ) - return null; - return deriveTeamsFileTransferAuthority( - tx, - current, - runtimeContextForRecord(record), - ); - }); - // Missing personal-recipient proof is not permission to guess a DM. - // The ordinary path sends the truthful task-link fallback instead. - if (!authority) return false; - transferId = ( - await protocol.issue( - authority, - new Date(Date.now() + 60 * 60 * 1000), - ) - ).id; - } - // A successful consent/upload transition can immediately progress to - // its next effect, but no user wait, unknown outcome or backoff spins. - for (let stage = 0; stage < 3; stage++) { - const [current] = await db - .select({ nextAttemptAt: chatPublications.nextAttemptAt }) - .from(chatPublications) - .where( - and( - eq(chatPublications.companyId, selected.companyId), - eq(chatPublications.id, selected.id), - ), - ); - if ( - !current || - (current.nextAttemptAt && current.nextAttemptAt > new Date()) - ) - break; - const result = await protocol.process( - selected.companyId, - transferId, - ); - if ( - ![ - "consent_pending", - "upload_pending", - "file_info_pending", - ].includes(result.phase) - ) - break; - } - teamsFileRetryAfter.delete(selected.id); - return true; - }, - ); - } catch { - // A failed authorization/lock/receipt write must never fall through to a - // generic resend. Preserve exact durable evidence; this cooldown grants - // no authority and safely resets when the server restarts. - if (teamsFileRetryAfter.size >= 1000) - teamsFileRetryAfter.delete(teamsFileRetryAfter.keys().next().value!); - teamsFileRetryAfter.set(selected.id, Date.now() + 30_000); - return true; - } - } - - let teamsMaintenancePending = false; - let teamsMaintenanceAfter = 0; - function scheduleTeamsFileMaintenance() { - if ( - teamsMaintenancePending || - Date.now() < teamsMaintenanceAfter || - shuttingDown - ) - return; - teamsMaintenancePending = true; - teamsMaintenanceAfter = Date.now() + 30_000; - scheduleMessageProcessing(async () => { - try { - const scopes = await db - .selectDistinct({ companyId: chatTeamsFileTransfers.companyId }) - .from(chatTeamsFileTransfers) - .where( - or( - and( - inArray(chatTeamsFileTransfers.phase, [ - "consent_pending", - "awaiting_consent", - "upload_pending", - ]), - lte(chatTeamsFileTransfers.expiresAt, new Date()), - ), - and( - inArray(chatTeamsFileTransfers.phase, [ - "consent_sending", - "uploading", - "file_info_sending", - "conflict", - ]), - lte(chatTeamsFileTransfers.attemptExpiresAt, new Date()), - ), - ), - ); - const protocol = teamsTransferProtocol(); - // Separate from the ordinary message queue. Each company sweep bounds - // its row count and lock waits and performs no provider I/O. - for (const scope of scopes) { - if (shuttingDown) break; - await protocol.expireAndRecover(scope.companyId, 100); - } - } catch { - // Selection errors are closed here too; private transfer envelopes and - // SQL parameters must not escape through the generic background logger. - logger.warn( - "Teams file maintenance will retry after a temporary failure", - ); - } finally { - teamsMaintenancePending = false; - } - }); - } - async function recordExactOutboundPublicationLink( tx: DbOrTransaction, input: { @@ -18315,169 +16489,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ); } - /** Add navigation only from the exact accepted native input batch. The - * omission text/model response is not authority, nor is an older task file. */ - async function githubCurrentOmissionPreparedText( - tx: DbOrTransaction, - publication: typeof chatPublications.$inferSelect, - endpoint: EndpointRow, - ): Promise { - if (endpoint.provider !== "github") return null; - const preparationKey = `github-omission-navigation:${publication.id}`; - const [prepared] = await tx - .select() - .from(chatActions) - .where( - and( - eq(chatActions.companyId, publication.companyId), - eq(chatActions.endpointId, publication.endpointId), - eq(chatActions.conversationId, publication.conversationId), - eq(chatActions.providerActionId, preparationKey), - ), - ) - .limit(1); - // A prepared final cannot become a different kind of publication while - // retaining the decorated body. Check its receipt before these exclusions. - if ( - !publication.commentId || - publication.payload.progressState !== undefined || - publication.payload.interactionId || - publication.payload.card || - publication.payload.attachmentIds?.length || - publication.payload.transportPart || - publication.idempotencyKey.startsWith("control:") || - isExplicitOperatorPublication(publication) - ) - return prepared ? false : null; - const taskUrl = prepared - ? typeof prepared.payload.taskUrl === "string" - ? safeChatTaskUrl(prepared.payload.taskUrl, publication.issueId) - : null - : safeChatTaskUrl(getTaskBaseUrl(), publication.issueId); - if ( - prepared && - (prepared.kind !== "github_omission_navigation" || - prepared.status !== "processed" || - prepared.payload.version !== 1 || - prepared.payload.publicationId !== publication.id || - taskUrl !== prepared.payload.taskUrl || - prepared.payload.preparedTextSha256 !== - createHash("sha256").update(publication.payload.text).digest("hex")) - ) - return false; - if (!taskUrl) return prepared ? false : null; - const [origin] = await tx - .select({ run: heartbeatRuns, resultId: nativeRunFinalizations.resultId }) - .from(issueComments) - .innerJoin( - heartbeatRuns, - and( - eq(heartbeatRuns.id, issueComments.createdByRunId), - eq(heartbeatRuns.companyId, publication.companyId), - eq(heartbeatRuns.nativeIssueId, publication.issueId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), - eq(heartbeatRuns.runtimeMode, "native"), - eq(heartbeatRuns.status, "succeeded"), - isNotNull(heartbeatRuns.finishedAt), - ), - ) - .innerJoin( - nativeRunFinalizations, - and( - eq(nativeRunFinalizations.companyId, publication.companyId), - eq(nativeRunFinalizations.issueId, publication.issueId), - eq(nativeRunFinalizations.runId, heartbeatRuns.id), - eq(nativeRunFinalizations.phase, "committed"), - ), - ) - .where( - and( - eq(issueComments.id, publication.commentId), - eq(issueComments.companyId, publication.companyId), - eq(issueComments.issueId, publication.issueId), - eq(issueComments.authorAgentId, endpoint.assignedAgentId), - isNull(issueComments.deletedAt), - ), - ) - .limit(1); - const presentation = origin?.run.resultJson?.presentationDecision as - Record | undefined; - if (!origin?.resultId || presentation?.commentId !== publication.commentId) - return prepared ? false : null; - if ( - prepared && - (prepared.payload.runId !== origin.run.id || - prepared.payload.resultId !== origin.resultId) - ) - return false; - try { - // This existing publication proof includes the accepted response_wake - // result, immutable owner/coalesced receipts, every current source, - // runtime/generation, source edits/deletes and current principal access. - const source = await failedChatRetrySource(tx, { - companyId: publication.companyId, - issueId: publication.issueId, - agentId: origin.run.agentId, - failedRunId: origin.run.id, - publication: true, - committedResponse: { resultId: origin.resultId }, - }); - if ( - source.endpointId !== endpoint.id || - source.conversationId !== publication.conversationId - ) - return prepared ? false : null; - const omitted = source.sources.some((entry) => { - const count = ( - entry.omissionReasons as Record | undefined - )?.download_unavailable; - return ( - typeof count === "number" && Number.isSafeInteger(count) && count > 0 - ); - }); - if (!omitted) return prepared ? false : null; - if (prepared) return publication.payload.text; - const text = publication.payload.text.includes(taskUrl) - ? publication.payload.text - : `${publication.payload.text}\n\n[Open this Paperclip task](${taskUrl})`; - await tx.insert(chatActions).values({ - companyId: publication.companyId, - endpointId: publication.endpointId, - conversationId: publication.conversationId, - kind: "github_omission_navigation", - providerActionId: preparationKey, - status: "processed", - payload: { - version: 1, - publicationId: publication.id, - runId: origin.run.id, - resultId: origin.resultId, - taskUrl, - preparedTextSha256: createHash("sha256").update(text).digest("hex"), - }, - }); - return text; - } catch (error) { - if ( - isExternalChatWaitAuthorizationContention(error) || - error instanceof NativeChatReviewPresentationContentionError - ) - throw new NativeChatReviewPresentationContentionError(); - if ( - isExternalActionAuthorizationChange(error) || - (error && - typeof error === "object" && - "details" in error && - error.details && - typeof error.details === "object" && - "code" in error.details && - error.details.code === "chat_failed_run_retry_not_authorized") - ) - return prepared ? false : null; - throw new FailedChatRetryPublicationReadError(); - } - } - async function claimPublicationTransportAuthorization(input: { credentialLease: CredentialMutationLeaseGuard; endpoint: EndpointRow; @@ -19769,82 +17780,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } } - async function settleGitHubAutomaticPublication( - publication: typeof chatPublications.$inferSelect, - guard?: CredentialMutationLeaseGuard, - ): Promise { - if (isExplicitOperatorPublication(publication) || publication.payload.interactionId) return false; - const record = await endpointRecord(publication.endpointId); - if (!record || record.endpoint.provider !== "github" || record.endpoint.companyId !== publication.companyId) return false; - // Legacy connections cannot use the replacement task-scoped tools yet. - const [configuration] = await db.select({ endpointId: chatGitHubConfigurations.endpointId }) - .from(chatGitHubConfigurations).where(and( - eq(chatGitHubConfigurations.companyId, publication.companyId), - eq(chatGitHubConfigurations.endpointId, publication.endpointId), - )).limit(1); - if (!configuration) return false; - let runId = runIdFromMilestonePublication(publication); - if (!publication.payload.progressState) { - // Also settle automatic agent comments retained from an older instance - // version, and per-destination finals from a mixed-provider origin. - if (!publication.commentId || !publication.idempotencyKey.startsWith("comment:")) return false; - const [comment] = await db.select({ runId: issueComments.createdByRunId }).from(issueComments) - .where(and(eq(issueComments.id, publication.commentId), eq(issueComments.companyId, publication.companyId), - eq(issueComments.issueId, publication.issueId), eq(issueComments.authorType, "agent"))); - if (!comment) return false; - runId = comment.runId; - } - if (["failed", "completed"].includes(publication.payload.progressState ?? "") && runId) { - const reply = await githubRunReplyState(db, { - companyId: publication.companyId, endpointId: publication.endpointId, issueId: publication.issueId, runId, - }); - // A pending or ambiguous write may already have arrived. Suppress the - // fallback without changing the governed outbox that owns that effect. - if (reply === "unsettled") { - // Quarantine only the automatic fallback. The governed tool outbox - // retains the uncertain write; ordinary board messages and question - // cards must not wait behind this notice in the conversation FIFO. - await db.transaction(async tx => { - await guard?.assertOwned(tx); - await notifyChatPublicationWork(tx); - await tx.update(chatPublications).set({ state: "cancelled", nextAttemptAt: null, - redactedError: "Automatic fallback suppressed while the GitHub tool reply remains unresolved", updatedAt: new Date() }) - .where(and(eq(chatPublications.id, publication.id), guard - ? and(eq(chatPublications.state, "streaming"), eq(chatPublications.attempts, publication.attempts + 1)) - : inArray(chatPublications.state, ["pending", "retry"]))); - }); - return true; - } - if (reply === "none" && (publication.payload.progressState === "failed" || - await githubResponseCommentService(db, fetchImpl).hasWorkingResponseForRun({ - companyId: publication.companyId, endpointId: publication.endpointId, issueId: publication.issueId, runId, - }))) return false; - } else if (publication.payload.progressState === "failed") return false; - const actionIds = await db.transaction(async tx => { - await guard?.assertOwned(tx); - await notifyChatPublicationWork(tx); - const [cancelled] = await tx.update(chatPublications).set({ state: "cancelled", nextAttemptAt: null, - redactedError: "GitHub replies are sent through task-scoped tools", updatedAt: new Date() }) - .where(and(eq(chatPublications.id, publication.id), eq(chatPublications.companyId, publication.companyId), - guard ? and(eq(chatPublications.state, "streaming"), eq(chatPublications.attempts, publication.attempts + 1)) - : inArray(chatPublications.state, ["pending", "retry"]))) - .returning({ id: chatPublications.id }); - if (!cancelled || !runId) return []; - const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( - eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, publication.companyId), - eq(heartbeatRuns.agentId, record.endpoint.assignedAgentId), - sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`, - inArray(heartbeatRuns.status, ["succeeded", "failed", "interrupted", "timed_out", "cancelled"]))); - if (!run) return []; - return stageTerminalReceiptReactionRemovals(tx as unknown as Db, { - endpoint: record.endpoint, publication, payload: publication.payload, - runtimeContext: runtimeContextForRecord(record), closedProgressRunId: run.id, - }); - }); - for (const id of actionIds) scheduleMessageProcessing(() => processReceiptReaction(id)); - return true; - } - async function processSelectedPublication( selectedPublication: typeof chatPublications.$inferSelect, ): Promise { @@ -21031,83 +18966,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { : null; } - async function storeGitHubApp(endpointId: string, userId: string, credentials: Record, source: "manifest" | "existing_app" = "existing_app") { - const initial = await endpointRecord(endpointId); - if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found"); - await withCredentialMutationLease(initial.endpoint, async credentialLease => { - const record = await endpointRecord(endpointId); - if (!record || ["archived", "active", "paused"].includes(record.endpoint.status)) throw conflict("Use the reconnect flow for an existing active bot"); - const normalized = await normalizedCredentials(record.endpoint, credentials); - const identity = await verifyCredentials("github", normalized); - if (record.endpoint.botExternalId && !nativeBotIdentityMatches("github", record.endpoint, identity)) throw conflict("This App belongs to a different bot. Create a new connection instead."); - await assertNativeBotIdentityAvailable(record.endpoint, identity); - // Claim the globally unique App identity before writing credentials. - // A losing concurrent registration must never persist usable secrets. - if (!record.endpoint.botExternalId) { - try { - await db.transaction(async tx => { - await credentialLease.assertOwned(tx); - const [current] = await tx.select({ setup: chatEndpoints.setup }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update"); - const [resource] = await tx.select({ id: chatEndpointResources.id }).from(chatEndpointResources).where(eq(chatEndpointResources.endpointId, endpointId)).limit(1); - // A fresh manual App has no registration session. Record its first - // import before vault writes so interrupted credential storage can resume. - const initialImport = !current?.setup.github?.repositorySelectionSaved && !resource; - const claimed = await tx.update(chatEndpoints).set({ status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel, - setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || ${JSON.stringify({ initialSetupPending: true, ...(initialImport ? { initialRepositoryImportPending: true } : {}) })}::jsonb)`, - healthMessage: "Complete GitHub App setup", updatedAt: new Date() }).where(and(eq(chatEndpoints.id, endpointId), isNull(chatEndpoints.botExternalId))).returning({ id: chatEndpoints.id }); - if (claimed.length !== 1) throw conflict("The bot changed during registration"); - }); - } catch (error) { - if (isNativeBotIdentityUniqueViolation(error)) throw nativeBotIdentityConflict("github"); - throw error; - } - } - await persistCredentials(record.endpoint, normalized, credentialLease, userId); - const slug = identity.botUsername?.replace(/\[bot\]$/, ""); - if (!slug || !/^[a-z0-9-]+$/.test(slug)) throw unprocessable("GitHub did not identify this App"); - await db.transaction(async tx => { - await credentialLease.assertOwned(tx); - const [current] = await tx.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update"); - if (!current || ["archived", "active", "paused"].includes(current.status)) throw conflict("Use the reconnect flow for an existing active bot"); - await tx.update(chatEndpoints).set({ - status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername, - botDisplayName: identity.botLabel, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel, - setup: { ...current.setup, github: { ...current.setup.github, appOwnerType: identity.githubOwnerType, appOwnerLogin: identity.githubOwnerLogin, stage: "install", appSlug: slug, installationUrl: `https://github.com/apps/${slug}/installations/new`, registrationStatus: "completed" } }, - healthMessage: "Install the App on GitHub to continue", updatedAt: new Date(), - }).where(eq(chatEndpoints.id, endpointId)); - if (source === "existing_app") { - // Explicit recovery uses the existing-App identity path. Only drop the - // manifest requirement after the same App's credentials are vaulted; - // leave registration incomplete so advance repairs and verifies delivery. - await tx.update(chatGitHubRegistrations).set({ - handoff: sql`${chatGitHubRegistrations.handoff} - 'manifestClaimId'`, - }).where(and( - eq(chatGitHubRegistrations.companyId, record.endpoint.companyId), - eq(chatGitHubRegistrations.endpointId, endpointId), - eq(chatGitHubRegistrations.userId, userId), - inArray(chatGitHubRegistrations.status, ["pending", "exchanging", "failed"]), - sql`${chatGitHubRegistrations.handoff} ? 'manifestClaimId'`, - )); - } - await logActivity(tx as unknown as Db, { companyId: record.endpoint.companyId, actorType: "user", actorId: userId, action: "chat_github.app_connected", entityType: "tool_connection", entityId: record.endpoint.connectionId, details: { endpointId, appId: identity.botExternalId } }); - }); - }); - // Manifest registration can send its ping before the credential exchange - // returns. Request a provider-signed replay after vaulting the secret. - const stored = await endpointRecord(endpointId); - const credentialsNow = await resolveCredentials(stored!.endpoint); - try { - const appToken = githubAppJwt(credentialsNow.appId, credentialsNow.privateKey); - const history = await listGitHubAppWebhookDeliveries({ fetch: fetchImpl, appToken }); - const ping = history.deliveries.find(delivery => delivery.event === "ping"); - if (ping) await requestGitHubAppWebhookRedelivery({ fetch: fetchImpl, appToken, deliveryId: ping.id }); - } catch { - // Registration succeeded. The verify screen exposes signed-delivery - // recovery rather than reporting credentials as failed or exchanging again. - } - 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"); @@ -21193,179 +19051,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, 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"); }, - }); - - async function refreshGitHubRepositories(endpointId: string, userId: string) { - const initial = await endpointRecord(endpointId); - if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found"); - await withCredentialMutationLease(initial.endpoint, async lease => { - const record = await endpointRecord(endpointId); - if (!record) throw notFound("GitHub bot not found"); - const credentials = await resolveCredentials(record.endpoint); - const prepared = await prepareProviderInventory(record.endpoint, credentials); - if (prepared.credentials.installationId !== credentials.installationId) await persistCredentials(record.endpoint, prepared.credentials, lease, userId); - if (prepared.inventory) await reconcileProviderResourceRows(record.endpoint, prepared.inventory, lease); - await lease.assertOwned(); - await db.update(chatEndpoints).set({ setup: { ...record.endpoint.setup, github: { ...record.endpoint.setup.github, stage: "repositories", managementUrl: prepared.managementUrl } }, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId)); - }); - return listResources(endpointId); - } - - const githubWizard = githubChatWizardService(db, { - origin: () => getPublicBaseUrl() ?? options.githubWizardOrigin ?? null, fetch: fetchImpl, startDirect: githubRegistration.start, completeDirect: githubRegistration.complete, resumeDirect: githubRegistration.resumeRegistration, - storeApp: (id, userId, credentials) => storeGitHubApp(id, userId, credentials, "manifest"), - storeCredentials: async (id, userId, additional) => { - const record = await endpointRecord(id); - if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); - await withCredentialMutationLease(record.endpoint, async lease => { - const current = await endpointRecord(id); - if (!current || ["archived", "revoked", "paused"].includes(current.endpoint.status)) throw conflict("GitHub App is unavailable"); - const credentials = await resolveCredentials(current.endpoint); - await persistCredentials(current.endpoint, { ...credentials, ...additional }, lease, userId); - }); - }, - refreshRepositories: refreshGitHubRepositories, resources: listResources, replaceResources, - configure: async (id, userId) => { - await update(id, { allowGroupChats: true }, userId); - return configure(id, { action: "configure" }, userId); - }, - finish: (id, userId) => test(id, { optionalSlackTestForUser: userId }), - }); - const unregisterGitHubCloudIngress = registerGitHubBotCloudIngress( - db, - async (endpoint, request) => { - const record = await endpointRecord(endpoint.id); - if ( - !record || - record.endpoint.companyId !== endpoint.companyId || - record.endpoint.connectionId !== endpoint.connectionId || - record.endpoint.assignedAgentId !== endpoint.assignedAgentId || - record.endpoint.setup.github?.cloudRegistrationId !== - endpoint.setup.github?.cloudRegistrationId - ) - throw forbidden("GitHub Cloud event binding changed"); - // A manifest ping may arrive before its webhook verifier reaches the vault. - // Keep the sealed delivery queued until the stack can authenticate it. - if (!record.credentialSecretRefs.some((ref) => ref.configPath === "credentials.webhookSecret")) - throw conflict("GitHub webhook credentials are not ready"); - // Preserve the exact provider payload and signature. Authentication, lifecycle - // checks and durable admission all run in the stack's canonical ingress. - const response = await handleWebhook( - record.endpoint.publicId, - "github", - request, - ); - if ([400, 401, 403, 404, 413, 422].includes(response.status)) return; // Discard hostile transport without retrying it indefinitely. - if (!response.ok) - throw conflict("GitHub Cloud ingress could not be durably accepted"); - }, - ); - - const unregisterSlackTaskAuthority = registerSlackTaskAuthority(db, async (binding) => { - if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) - throw forbidden("Chat connectors are disabled"); - const identity = await slackRunOrigin(db, binding); - const run = identity.run; - if ((run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId) !== binding.issueId || !run.responsibleUserId) - throw forbidden("Slack tools require the bound task and an accepted linked user"); - const resolved = await db.transaction(async (tx) => { - const [action] = await tx.select().from(chatActions).where(and( - eq(chatActions.companyId, binding.companyId), - eq(chatActions.kind, "inbound_wakeup"), - sql`${chatActions.payload}->>'issueId' = ${binding.issueId}`, - sql`${chatActions.payload}->>'agentId' = ${binding.agentId}`, - identity.sourceMessageId - ? sql`${chatActions.payload}->>'commentId' = ${identity.sourceMessageId}` - : eq(chatActions.id, run.wakeupRequestId ?? "00000000-0000-0000-0000-000000000000"), - )).limit(1); - if (!action) { - // Ordinary tasks and routines use their accepted responsible user, never - // an earlier Slack sender or the connection owner's credentials. - const [message] = identity.sourceMessageId ? await tx.select().from(issueComments) - .where(and(eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId), - eq(issueComments.id, identity.sourceMessageId), isNull(issueComments.deletedAt))) : []; - if (identity.sourceMessageId && (!message || message.authorType !== "user" || message.authorUserId !== run.responsibleUserId)) - throw forbidden("Slack tools require the current task requester's identity"); - // An external participant may not acquire unrelated bot connections by - // falling back to ordinary-task access after source admission fails. - if (identity.sourceMessageId) { - const [external] = await tx.select({ id: chatMessageLinks.id }).from(chatMessageLinks) - .where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.commentId, identity.sourceMessageId), eq(chatMessageLinks.direction, "inbound"))).limit(1); - if (external) throw forbidden("External messages require their originating Slack connection"); - } - const endpoints = await tx.select().from(chatEndpoints).where(and( - eq(chatEndpoints.companyId, binding.companyId), eq(chatEndpoints.provider, "slack"), - eq(chatEndpoints.assignedAgentId, binding.agentId), eq(chatEndpoints.status, "active"), - ...(binding.endpointId ? [eq(chatEndpoints.id, binding.endpointId)] : []), - )).for("no key update"); - if (endpoints.length !== 1) throw forbidden("Select an active Slack connection assigned to this agent"); - const endpoint = endpoints[0]!; - const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, binding.companyId), eq(issues.id, binding.issueId), eq(issues.assigneeAgentId, binding.agentId))); - if (!issue) throw forbidden("Slack tools require a task assigned to this agent"); - const [member] = await tx.select().from(companyMemberships).where(and( - eq(companyMemberships.companyId, binding.companyId), eq(companyMemberships.principalType, "user"), - eq(companyMemberships.principalId, run.responsibleUserId!), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), - )); - if (!member) throw forbidden("The responsible user must be an active company operator"); - const links = await tx.select({ principal: chatExternalPrincipals }).from(chatIdentityLinks) - .innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, binding.companyId))) - .where(and(eq(chatIdentityLinks.companyId, binding.companyId), eq(chatIdentityLinks.endpointId, endpoint.id), - eq(chatIdentityLinks.paperclipUserId, run.responsibleUserId!), eq(chatIdentityLinks.status, "linked"), - eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId!), eq(chatExternalPrincipals.isBot, false))); - if (links.length !== 1) throw forbidden("Link your Slack account to this connection before using it from tasks or routines"); - // A Board message changes who is directing the work, not the privacy - // of context already present in its linked Slack conversation. - const conversations = await tx.select().from(chatConversations).where(and( - eq(chatConversations.companyId, binding.companyId), eq(chatConversations.endpointId, endpoint.id), - eq(chatConversations.issueId, binding.issueId), - )).limit(2); - if (conversations.length > 1) throw forbidden("The task's Slack conversation boundary is ambiguous"); - return { endpoint, issue, conversation: conversations[0] ?? null, delivery: null, principalId: links[0]!.principal.id, slackUserId: links[0]!.principal.externalId }; - } - if (action.payload.requestedByActorType !== "user" || action.payload.requestedByActorId !== run.responsibleUserId || - (binding.endpointId && action.endpointId !== binding.endpointId)) - throw forbidden("Slack tools require a verified linked Slack request"); - // This standalone resolver does not own the scheduler's issue lock. Use - // the ingress lock order (endpoint, then issue) so a normal webhook or - // endpoint update cannot turn tool setup into a NOWAIT failure. The - // scheduler retains its nonblocking check when it owns the issue first. - await tx.select({ id: chatEndpoints.id }).from(chatEndpoints).where(and( - eq(chatEndpoints.companyId, binding.companyId), - eq(chatEndpoints.id, action.endpointId), - )).for("no key update"); - const source = await authorizeInboundWakeup(tx, action); - if (source.endpoint.provider !== "slack") throw forbidden("This is not a Slack task"); - const [principal] = await tx.select().from(chatExternalPrincipals).where(and( - eq(chatExternalPrincipals.companyId, binding.companyId), - eq(chatExternalPrincipals.id, action.principalId!), - eq(chatExternalPrincipals.provider, "slack"), - eq(chatExternalPrincipals.providerAccountId, source.endpoint.providerAccountId!), - eq(chatExternalPrincipals.isBot, false), - )); - if (!principal) throw forbidden("Slack sender is no longer available"); - return { ...source, principalId: principal.id, slackUserId: principal.externalId }; - }); - const credentials = await resolveCredentials(resolved.endpoint); - if (!credentials.botToken) throw forbidden("Slack bot credential is unavailable"); - return { - endpoint: resolved.endpoint, issueId: binding.issueId, conversation: resolved.conversation, - principalId: resolved.principalId, slackUserId: resolved.slackUserId, - userId: run.responsibleUserId, deliveryId: resolved.delivery?.id ?? null, - identityContextId: identity.context?.id ?? null, - workMode: resolved.issue.workMode, - revision: createHash("sha256").update(JSON.stringify([ - resolved.endpoint.status, resolved.endpoint.allowDirectMessages, resolved.conversation?.sessionGeneration ?? null, - run.responsibleUserId, credentials.botToken, - await slackAuthorizationRevision(db, binding.companyId, resolved.endpoint.id, resolved.endpoint.connectionId, binding.agentId, run.responsibleUserId), - ])).digest("hex"), - botToken: credentials.botToken, - searchActionToken: resolved.delivery ? slackSearchActionToken(db, resolved.endpoint.id, resolved.endpoint.providerAccountId!, resolved.slackUserId, String((resolved.delivery.normalizedEvent.message as Record | undefined)?.providerMessageId ?? "")) : null, - }; + const { storeGitHubApp, githubRegistration, refreshGitHubRepositories, githubWizard, unregisterGitHubCloudIngress } = createGitHubAppSetup(db, { + endpointRecord, withCredentialMutationLease, normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, + assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, + persistCredentials, resolveCredentials, fetchImpl, get, getPublicBaseUrl, getWebhookPublicBaseUrl, + prepareProviderInventory, reconcileProviderResourceRows, listResources, replaceResources, update, configure, test, handleWebhook, + get githubWizardOrigin() { return options.githubWizardOrigin; }, }); + const { unregisterSlackTaskAuthority } = createSlackTaskAuthorityRegistration(db, { authorizeInboundWakeup, resolveCredentials }); return { voice, diff --git a/server/src/services/chat-channels/discord/credentials.ts b/server/src/services/chat-channels/discord/credentials.ts new file mode 100644 index 00000000000..404e8bb2c64 --- /dev/null +++ b/server/src/services/chat-channels/discord/credentials.ts @@ -0,0 +1,28 @@ +import { + unprocessable, +} from "../../../errors.js"; + +import { + verifyDiscordBot, +} from "../../chat-discord.js"; + +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +export async function verifyDiscordCredentials( + credentials: Record, + fetchImpl: typeof fetch, + redactError: (error: unknown) => string, +): Promise { + const identity = await verifyDiscordBot({ + applicationId: credentials.applicationId, + botToken: credentials.botToken, + fetch: fetchImpl, + guildId: credentials.guildId, + }).catch((error) => { + throw unprocessable(redactError(error), { + code: "chat_provider_permissions_missing", + provider: "discord", + }); + }); + return identity; +} diff --git a/server/src/services/chat-channels/discord/inventory.ts b/server/src/services/chat-channels/discord/inventory.ts new file mode 100644 index 00000000000..84a0962fb7a --- /dev/null +++ b/server/src/services/chat-channels/discord/inventory.ts @@ -0,0 +1,17 @@ +import { + listDiscordBotChannels, +} from "../../chat-discord.js"; + +import type { PreparedProviderInventory } from "../runtime-types.js"; + +export async function prepareDiscordInventory(credentials: Record, fetchImpl: typeof fetch): Promise { + return { + credentials, + inventory: await listDiscordBotChannels({ + botUserId: credentials.applicationId, + botToken: credentials.botToken, + fetch: fetchImpl, + guildId: credentials.guildId, + }), + }; +} diff --git a/server/src/services/chat-channels/github/app-setup.ts b/server/src/services/chat-channels/github/app-setup.ts new file mode 100644 index 00000000000..07abbbdc7a9 --- /dev/null +++ b/server/src/services/chat-channels/github/app-setup.ts @@ -0,0 +1,238 @@ +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +import { createSetupProbes } from "../setup-probes.js"; + +import type { EndpointRow } from "../runtime-types.js"; + +import { createChatResourceReconciliation } from "../resource-reconciliation.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { chatGitHubRegistrations } from "@paperclipai/db"; + +import { githubChatWizardService } from "../../chat-github-wizard.js"; +import { registerGitHubBotCloudIngress } from "../../chat-github-cloud-ingress.js"; +import { githubChatRegistrationService } from "../../chat-github-registration.js"; + +import { githubAppJwt } from "../../chat-github-client.js"; + +import { + and, + eq, + inArray, + isNull, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpointResources, + chatEndpoints, +} from "@paperclipai/db"; +import type { + ChatEndpoint, + ChatProvider, + ConfigureChatEndpointInput, + ToolCredentialSecretRef, + UpdateChatEndpointInput, +} from "@paperclipai/shared"; + +import { + HttpError, + conflict, + forbidden, + notFound, + unprocessable, +} from "../../../errors.js"; + +import { + logActivity, +} from "../../activity-log.js"; + +import { + listGitHubAppWebhookDeliveries, + requestGitHubAppWebhookRedelivery, +} from "../../chat-github-webhook-config.js"; + +import type { EndpointRecord, PreparedProviderInventory } from "../runtime-types.js"; + +type GitHubAppSetupOptions = { + endpointRecord: (endpointId: string) => Promise; + withCredentialMutationLease: ReturnType; + normalizedCredentials: (endpoint: EndpointRow, supplied: Record | undefined) => Promise>; + verifyCredentials: (provider: ChatProvider, credentials: Record) => Promise; + nativeBotIdentityMatches: (provider: ChatProvider, current: VerifiedProviderIdentity, incoming: VerifiedProviderIdentity) => boolean; + assertNativeBotIdentityAvailable: (endpoint: EndpointRow, identity: VerifiedProviderIdentity) => Promise; + isNativeBotIdentityUniqueViolation: (error: unknown) => boolean; + nativeBotIdentityConflict: (provider: ChatProvider) => HttpError; + persistCredentials: (endpoint: EndpointRow, credentials: Record, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise; + resolveCredentials: (endpoint: EndpointRow) => Promise>; + fetchImpl: typeof fetch; + get: (endpointId: string) => Promise; + getPublicBaseUrl: () => string | null; + getWebhookPublicBaseUrl: () => string | null; + prepareProviderInventory: (endpoint: EndpointRow, credentials: Record) => Promise; + reconcileProviderResourceRows: ReturnType["reconcileProviderResourceRows"]; + listResources: ReturnType["listResources"]; + replaceResources: ReturnType["replaceResources"]; + update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null) => Promise; + configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null) => Promise; + test: ReturnType["test"]; + githubWizardOrigin?: string | null; + handleWebhook: (publicId: string, provider: "github", request: Request) => Promise; +}; + +export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) { + const { endpointRecord, withCredentialMutationLease, normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, resolveCredentials, fetchImpl, get, getPublicBaseUrl, getWebhookPublicBaseUrl, prepareProviderInventory, reconcileProviderResourceRows, listResources, replaceResources, update, configure, test, handleWebhook } = options; + + + async function storeGitHubApp(endpointId: string, userId: string, credentials: Record, source: "manifest" | "existing_app" = "existing_app") { + const initial = await endpointRecord(endpointId); + if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found"); + await withCredentialMutationLease(initial.endpoint, async credentialLease => { + const record = await endpointRecord(endpointId); + if (!record || ["archived", "active", "paused"].includes(record.endpoint.status)) throw conflict("Use the reconnect flow for an existing active bot"); + const normalized = await normalizedCredentials(record.endpoint, credentials); + const identity = await verifyCredentials("github", normalized); + if (record.endpoint.botExternalId && !nativeBotIdentityMatches("github", record.endpoint, identity)) throw conflict("This App belongs to a different bot. Create a new connection instead."); + await assertNativeBotIdentityAvailable(record.endpoint, identity); + // Claim the globally unique App identity before writing credentials. + // A losing concurrent registration must never persist usable secrets. + if (!record.endpoint.botExternalId) { + try { + await db.transaction(async tx => { + await credentialLease.assertOwned(tx); + const [current] = await tx.select({ setup: chatEndpoints.setup }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update"); + const [resource] = await tx.select({ id: chatEndpointResources.id }).from(chatEndpointResources).where(eq(chatEndpointResources.endpointId, endpointId)).limit(1); + // A fresh manual App has no registration session. Record its first + // import before vault writes so interrupted credential storage can resume. + const initialImport = !current?.setup.github?.repositorySelectionSaved && !resource; + const claimed = await tx.update(chatEndpoints).set({ status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel, + setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || ${JSON.stringify({ initialSetupPending: true, ...(initialImport ? { initialRepositoryImportPending: true } : {}) })}::jsonb)`, + healthMessage: "Complete GitHub App setup", updatedAt: new Date() }).where(and(eq(chatEndpoints.id, endpointId), isNull(chatEndpoints.botExternalId))).returning({ id: chatEndpoints.id }); + if (claimed.length !== 1) throw conflict("The bot changed during registration"); + }); + } catch (error) { + if (isNativeBotIdentityUniqueViolation(error)) throw nativeBotIdentityConflict("github"); + throw error; + } + } + await persistCredentials(record.endpoint, normalized, credentialLease, userId); + const slug = identity.botUsername?.replace(/\[bot\]$/, ""); + if (!slug || !/^[a-z0-9-]+$/.test(slug)) throw unprocessable("GitHub did not identify this App"); + await db.transaction(async tx => { + await credentialLease.assertOwned(tx); + const [current] = await tx.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update"); + if (!current || ["archived", "active", "paused"].includes(current.status)) throw conflict("Use the reconnect flow for an existing active bot"); + await tx.update(chatEndpoints).set({ + status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername, + botDisplayName: identity.botLabel, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel, + setup: { ...current.setup, github: { ...current.setup.github, appOwnerType: identity.githubOwnerType, appOwnerLogin: identity.githubOwnerLogin, stage: "install", appSlug: slug, installationUrl: `https://github.com/apps/${slug}/installations/new`, registrationStatus: "completed" } }, + healthMessage: "Install the App on GitHub to continue", updatedAt: new Date(), + }).where(eq(chatEndpoints.id, endpointId)); + if (source === "existing_app") { + // Explicit recovery uses the existing-App identity path. Only drop the + // manifest requirement after the same App's credentials are vaulted; + // leave registration incomplete so advance repairs and verifies delivery. + await tx.update(chatGitHubRegistrations).set({ + handoff: sql`${chatGitHubRegistrations.handoff} - 'manifestClaimId'`, + }).where(and( + eq(chatGitHubRegistrations.companyId, record.endpoint.companyId), + eq(chatGitHubRegistrations.endpointId, endpointId), + eq(chatGitHubRegistrations.userId, userId), + inArray(chatGitHubRegistrations.status, ["pending", "exchanging", "failed"]), + sql`${chatGitHubRegistrations.handoff} ? 'manifestClaimId'`, + )); + } + await logActivity(tx as unknown as Db, { companyId: record.endpoint.companyId, actorType: "user", actorId: userId, action: "chat_github.app_connected", entityType: "tool_connection", entityId: record.endpoint.connectionId, details: { endpointId, appId: identity.botExternalId } }); + }); + }); + // Manifest registration can send its ping before the credential exchange + // returns. Request a provider-signed replay after vaulting the secret. + const stored = await endpointRecord(endpointId); + const credentialsNow = await resolveCredentials(stored!.endpoint); + try { + const appToken = githubAppJwt(credentialsNow.appId, credentialsNow.privateKey); + const history = await listGitHubAppWebhookDeliveries({ fetch: fetchImpl, appToken }); + const ping = history.deliveries.find(delivery => delivery.event === "ping"); + if (ping) await requestGitHubAppWebhookRedelivery({ fetch: fetchImpl, appToken, deliveryId: ping.id }); + } catch { + // Registration succeeded. The verify screen exposes signed-delivery + // recovery rather than reporting credentials as failed or exchanging again. + } + return get(endpointId); + } + + 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"); }, + }); + + async function refreshGitHubRepositories(endpointId: string, userId: string) { + const initial = await endpointRecord(endpointId); + if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found"); + await withCredentialMutationLease(initial.endpoint, async lease => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("GitHub bot not found"); + const credentials = await resolveCredentials(record.endpoint); + const prepared = await prepareProviderInventory(record.endpoint, credentials); + if (prepared.credentials.installationId !== credentials.installationId) await persistCredentials(record.endpoint, prepared.credentials, lease, userId); + if (prepared.inventory) await reconcileProviderResourceRows(record.endpoint, prepared.inventory, lease); + await lease.assertOwned(); + await db.update(chatEndpoints).set({ setup: { ...record.endpoint.setup, github: { ...record.endpoint.setup.github, stage: "repositories", managementUrl: prepared.managementUrl } }, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId)); + }); + return listResources(endpointId); + } + + const githubWizard = githubChatWizardService(db, { + origin: () => getPublicBaseUrl() ?? options.githubWizardOrigin ?? null, fetch: fetchImpl, startDirect: githubRegistration.start, completeDirect: githubRegistration.complete, resumeDirect: githubRegistration.resumeRegistration, + storeApp: (id, userId, credentials) => storeGitHubApp(id, userId, credentials, "manifest"), + storeCredentials: async (id, userId, additional) => { + const record = await endpointRecord(id); + if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); + await withCredentialMutationLease(record.endpoint, async lease => { + const current = await endpointRecord(id); + if (!current || ["archived", "revoked", "paused"].includes(current.endpoint.status)) throw conflict("GitHub App is unavailable"); + const credentials = await resolveCredentials(current.endpoint); + await persistCredentials(current.endpoint, { ...credentials, ...additional }, lease, userId); + }); + }, + refreshRepositories: refreshGitHubRepositories, resources: listResources, replaceResources, + configure: async (id, userId) => { + await update(id, { allowGroupChats: true }, userId); + return configure(id, { action: "configure" }, userId); + }, + finish: (id, userId) => test(id, { optionalSlackTestForUser: userId }), + }); + const unregisterGitHubCloudIngress = registerGitHubBotCloudIngress( + db, + async (endpoint, request) => { + const record = await endpointRecord(endpoint.id); + if ( + !record || + record.endpoint.companyId !== endpoint.companyId || + record.endpoint.connectionId !== endpoint.connectionId || + record.endpoint.assignedAgentId !== endpoint.assignedAgentId || + record.endpoint.setup.github?.cloudRegistrationId !== + endpoint.setup.github?.cloudRegistrationId + ) + throw forbidden("GitHub Cloud event binding changed"); + // A manifest ping may arrive before its webhook verifier reaches the vault. + // Keep the sealed delivery queued until the stack can authenticate it. + if (!record.credentialSecretRefs.some((ref) => ref.configPath === "credentials.webhookSecret")) + throw conflict("GitHub webhook credentials are not ready"); + // Preserve the exact provider payload and signature. Authentication, lifecycle + // checks and durable admission all run in the stack's canonical ingress. + const response = await handleWebhook( + record.endpoint.publicId, + "github", + request, + ); + if ([400, 401, 403, 404, 413, 422].includes(response.status)) return; // Discard hostile transport without retrying it indefinitely. + if (!response.ok) + throw conflict("GitHub Cloud ingress could not be durably accepted"); + }, + ); + return { storeGitHubApp, githubRegistration, refreshGitHubRepositories, githubWizard, unregisterGitHubCloudIngress }; +} diff --git a/server/src/services/chat-channels/github/credentials.ts b/server/src/services/chat-channels/github/credentials.ts new file mode 100644 index 00000000000..2dd9b77179d --- /dev/null +++ b/server/src/services/chat-channels/github/credentials.ts @@ -0,0 +1,123 @@ +import { githubAppJwt } from "../../chat-github-client.js"; + +import { + unprocessable, +} from "../../../errors.js"; + +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +export const REQUIRED_GITHUB_EVENTS = [ + "issue_comment", + "pull_request_review_comment", +] as const; + +export const REQUIRED_GITHUB_PERMISSIONS = { + issues: "write", + metadata: "read", + pull_requests: "write", +} as const; + +export const UNAVOIDABLE_GITHUB_EVENTS = [ + "github_app_authorization", + "installation", + "installation_repositories", +] as const; + +export async function verifyGitHubCredentials( + credentials: Record, + fetchImpl: typeof fetch, + PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number, +): Promise { + const token = githubAppJwt(credentials.appId, credentials.privateKey); + const response = await fetchImpl("https://api.github.com/app", { + signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "x-github-api-version": "2022-11-28", + }, + }); + const result = (await response.json()) as { + id?: number; + login?: string; + slug?: string; + name?: string; + message?: string; + owner?: { login?: string; type?: string }; + permissions?: Record; + events?: string[]; + }; + if (!response.ok) + throw unprocessable( + `GitHub rejected the app credentials: ${result.message ?? response.status}`, + ); + const missingPermissions = Object.entries(REQUIRED_GITHUB_PERMISSIONS) + .filter( + ([permission, access]) => result.permissions?.[permission] !== access, + ) + .map(([permission]) => permission); + const excessivePermissions = Object.keys(result.permissions ?? {}).filter( + (permission) => !(permission in REQUIRED_GITHUB_PERMISSIONS) && !((permission === "contents" && result.permissions?.contents === "read") || (permission === "checks" && result.permissions?.checks === "write")), + ); + const configuredEvents = new Set(result.events ?? []); + const missingEvents = REQUIRED_GITHUB_EVENTS.filter( + (event) => !configuredEvents.has(event), + ); + const allowedEvents = new Set([ + ...REQUIRED_GITHUB_EVENTS, + ...UNAVOIDABLE_GITHUB_EVENTS, + "pull_request", + "pull_request_review", + "issues", + ]); + const excessiveEvents = [...configuredEvents].filter( + (event) => !allowedEvents.has(event), + ); + if ( + missingPermissions.length > 0 || + excessivePermissions.length > 0 || + missingEvents.length > 0 || + excessiveEvents.length > 0 + ) { + throw unprocessable( + [ + missingPermissions.length > 0 + ? `GitHub App needs the documented minimum access for: ${missingPermissions.join(", ")}` + : null, + excessivePermissions.length > 0 + ? `GitHub App has broader permissions than Paperclip needs: ${excessivePermissions.join(", ")}` + : null, + missingEvents.length > 0 + ? `GitHub App must subscribe to: ${missingEvents.join(", ")}` + : null, + excessiveEvents.length > 0 + ? `GitHub App subscribes to broader events than Paperclip needs: ${excessiveEvents.join(", ")}` + : null, + ] + .filter(Boolean) + .join(". "), + { + code: "chat_provider_permissions_missing", + provider: "github", + missingPermissions, + excessivePermissions, + missingEvents, + excessiveEvents, + }, + ); + } + return { + githubOwnerType: result.owner?.type === "Organization" ? "organization" : result.owner?.type === "User" ? "personal" : undefined, + githubOwnerLogin: result.owner?.login, + providerAccountId: result.owner?.login, + providerAccountLabel: result.owner?.login ?? result.name, + // The App registration id is the stable, immutable endpoint identity. + // The adapter discovers the separate bot-user id while initializing. + botExternalId: + typeof result.id === "number" && Number.isFinite(result.id) + ? String(result.id) + : undefined, + botUsername: result.slug ? `${result.slug}[bot]` : undefined, + botLabel: result.name ?? result.slug, + }; +} diff --git a/server/src/services/chat-channels/github/installation-lifecycle.ts b/server/src/services/chat-channels/github/installation-lifecycle.ts new file mode 100644 index 00000000000..e0b2d9efda9 --- /dev/null +++ b/server/src/services/chat-channels/github/installation-lifecycle.ts @@ -0,0 +1,483 @@ +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +import type { EndpointRow, DbTransaction, LifecycleRuntimeFence, InternalSetupState } from "../runtime-types.js"; + +import { createChatResourceReconciliation } from "../resource-reconciliation.js"; + +import { notifyChatDeliveryWork } from "../../chat-work-notifications.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + and, + eq, + inArray, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatConversations, + chatDeliveries, + chatEndpointResources, + chatEndpoints, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ChatProvider, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + conflict, + notFound, +} from "../../../errors.js"; + +import { redactSensitiveText } from "../../../redaction.js"; + +import { + type ChatProviderLifecycleEffect, +} from "../../chat-provider-lifecycle.js"; + +import type { EndpointRecord, PreparedProviderInventory } from "../runtime-types.js"; + +type GitHubInstallationLifecycleOptions = { + endpointRecord: (endpointId: string) => Promise; + resolveCredentials: (endpoint: EndpointRow) => Promise>; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + verifyCredentials: (provider: ChatProvider, credentials: Record) => Promise; + legacyGitHubLabelsMatchVerifiedCredentials: (current: VerifiedProviderIdentity, verified: VerifiedProviderIdentity) => boolean; + nativeBotIdentityMatches: (provider: ChatProvider, current: VerifiedProviderIdentity, incoming: VerifiedProviderIdentity) => boolean; + assertNativeBotIdentityAvailable: (endpoint: EndpointRow, identity: VerifiedProviderIdentity) => Promise; + prepareProviderInventory: (endpoint: EndpointRow, credentials: Record) => Promise; + persistCredentials: (endpoint: EndpointRow, credentials: Record, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise; + reconcileProviderResourceRows: ReturnType["reconcileProviderResourceRows"]; + redactError: (error: unknown) => string; + normalizedLifecycleWithRuntimeFence: (delivery: typeof chatDeliveries.$inferSelect, fence: LifecycleRuntimeFence) => Record; + MAX_ERROR_TEXT: number; +}; + +type GitHubInstallationLifecycleInput = { + currentEndpoint: EndpointRow; + effect: ChatProviderLifecycleEffect; + credentialLease: CredentialMutationLeaseGuard; + admittedRuntimeContext: LifecycleRuntimeFence; + claimed: { id: string }; + candidate: typeof chatDeliveries.$inferSelect; + onFailurePersisted: () => void; +}; + +type GitHubInstallationLifecycleOutcome = { + refreshRuntimeAfterLifecycle: boolean; + stopRuntimeAfterLifecycle: boolean; +}; + +export function createGitHubInstallationLifecycle(db: Db, options: GitHubInstallationLifecycleOptions) { + const { endpointRecord, resolveCredentials, runtimeCallbackEndpoint, runtimeGeneration, credentialFingerprint, verifyCredentials, legacyGitHubLabelsMatchVerifiedCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, prepareProviderInventory, persistCredentials, reconcileProviderResourceRows, redactError, normalizedLifecycleWithRuntimeFence, MAX_ERROR_TEXT } = options; + + async function applyGitHubInstallationLifecycle(input: GitHubInstallationLifecycleInput): Promise { + const { currentEndpoint, effect, credentialLease, admittedRuntimeContext, claimed, candidate } = input; + let refreshRuntimeAfterLifecycle = false; + let stopRuntimeAfterLifecycle = false; + try { + const currentCredentials = + await resolveCredentials(currentEndpoint); + const lifecycleInstallationId = + effect.kind === "endpoint" && + typeof effect.metadata?.installationId === "string" + ? effect.metadata.installationId + : null; + const removesCurrentInstallation = + effect.kind === "endpoint" && + effect.availability === "revoked" && + lifecycleInstallationId !== null && + lifecycleInstallationId === currentCredentials.installationId; + const currentInstallationRemovalReason = + removesCurrentInstallation && effect.kind === "endpoint" + ? effect.reason + : null; + if (removesCurrentInstallation) { + // A signed deletion for the installation currently bound to + // this endpoint is authoritative provider truth. Inventory now + // correctly contains zero active installations, so treating its + // empty result as a transient discovery failure would strand the + // endpoint in attention and ask GitHub to redeliver forever. + // Keep the App key and webhook secret so a later installation + // event can recover this same immutable bot identity. + const revoked = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await runtimeCallbackEndpoint( + tx, + currentEndpoint.id, + admittedRuntimeContext, + ["verifying", "active", "attention", "revoked"], + ); + const revokedAt = new Date(); + if (!current) { + await notifyChatDeliveryWork(tx); + const [filtered] = await tx + .update(chatDeliveries) + .set({ + state: "filtered", + processedAt: revokedAt, + nextAttemptAt: null, + redactedError: + "GitHub installation deletion belonged to a superseded runtime", + updatedAt: revokedAt, + }) + .where( + and( + eq(chatDeliveries.id, claimed.id), + eq(chatDeliveries.state, "processing"), + eq(chatDeliveries.attempts, candidate.attempts + 1), + ), + ) + .returning({ id: chatDeliveries.id }); + if (!filtered) { + throw new Error( + "GitHub installation deletion ownership changed before stale settlement", + ); + } + await credentialLease.assertOwned(tx); + return false; + } + const reason = redactSensitiveText( + currentInstallationRemovalReason ?? + "GitHub App installation was removed", + ).slice(0, MAX_ERROR_TEXT); + await tx + .update(chatEndpoints) + .set({ + status: "revoked", + healthMessage: reason, + lastError: reason, + lastEventAt: revokedAt, + setup: { + ...current.setup, + runtimeGeneration: runtimeGeneration(current.setup) + 1, + } as InternalSetupState, + updatedAt: revokedAt, + }) + .where(eq(chatEndpoints.id, current.id)); + await tx + .update(toolConnections) + .set({ + status: "disabled", + enabled: false, + healthStatus: "failed", + healthMessage: reason, + lastError: reason, + healthCheckedAt: revokedAt, + updatedAt: revokedAt, + }) + .where(eq(toolConnections.id, current.connectionId)); + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: revokedAt }) + .where( + and( + eq(chatEndpointResources.companyId, current.companyId), + eq(chatEndpointResources.endpointId, current.id), + ), + ); + await tx + .update(chatConversations) + .set({ state: "unavailable", updatedAt: revokedAt }) + .where( + and( + eq(chatConversations.companyId, current.companyId), + eq(chatConversations.endpointId, current.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + await notifyChatDeliveryWork(tx); + const [processed] = await tx + .update(chatDeliveries) + .set({ + state: "processed", + processedAt: revokedAt, + redactedError: null, + nextAttemptAt: null, + updatedAt: revokedAt, + }) + .where( + and( + eq(chatDeliveries.id, claimed.id), + eq(chatDeliveries.state, "processing"), + eq(chatDeliveries.attempts, candidate.attempts + 1), + ), + ) + .returning({ id: chatDeliveries.id }); + if (!processed) { + throw new Error( + "GitHub installation deletion ownership changed before revocation settlement", + ); + } + await credentialLease.assertOwned(tx); + return true; + }); + stopRuntimeAfterLifecycle = revoked; + return { refreshRuntimeAfterLifecycle, stopRuntimeAfterLifecycle }; + } + const identity = await verifyCredentials( + "github", + currentCredentials, + ); + const upgradingLegacyIdentity = + !currentEndpoint.botExternalId && + legacyGitHubLabelsMatchVerifiedCredentials( + currentEndpoint, + identity, + ); + if ( + !upgradingLegacyIdentity && + !nativeBotIdentityMatches("github", currentEndpoint, identity) + ) { + throw conflict( + "The GitHub App identity changed while reconciling this connection", + { + code: "chat_bot_identity_changed", + }, + ); + } + await assertNativeBotIdentityAvailable(currentEndpoint, identity); + if (upgradingLegacyIdentity) { + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await tx + .update(chatEndpoints) + .set({ + botExternalId: identity.botExternalId, + updatedAt: new Date(), + }) + .where(eq(chatEndpoints.id, currentEndpoint.id)); + await credentialLease.assertOwned(tx); + }); + } + + // GitHub lifecycle payloads are wake-up signals, not provider + // truth. Re-read the one active App installation and its complete + // repository inventory while the credential lease excludes secret + // rotation and every other lifecycle callback for this endpoint. + const prepared = await prepareProviderInventory( + currentEndpoint, + currentCredentials, + ); + if ( + prepared.credentials.installationId !== + currentCredentials.installationId + ) { + await persistCredentials( + currentEndpoint, + prepared.credentials, + credentialLease, + ); + refreshRuntimeAfterLifecycle = true; + } + if (prepared.inventory) + await reconcileProviderResourceRows( + currentEndpoint, + prepared.inventory, + credentialLease, + ); + + const canonical = await endpointRecord(currentEndpoint.id); + if (!canonical) throw notFound("Chat endpoint not found"); + const now = new Date(); + const intentionallyUnavailable = + canonical.endpoint.status === "paused" || + canonical.endpoint.status === "archived" || + !canonical.endpoint.setup.webhookVerifiedAt; + const advanceRuntimeGeneration = + refreshRuntimeAfterLifecycle || + !canonical.endpoint.setup.webhookVerifiedAt; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + if (intentionallyUnavailable) { + await tx + .update(chatEndpoints) + .set({ + ...(advanceRuntimeGeneration + ? { + setup: { + ...canonical.endpoint.setup, + runtimeGeneration: + runtimeGeneration(canonical.endpoint.setup) + 1, + } as InternalSetupState, + } + : {}), + lastEventAt: now, + updatedAt: now, + }) + .where(eq(chatEndpoints.id, canonical.endpoint.id)); + } else { + const status = + canonical.endpoint.setup.step === "complete" + ? "active" + : "verifying"; + await tx + .update(chatEndpoints) + .set({ + status, + healthMessage: + status === "active" + ? "Connected" + : "Waiting for a test conversation", + lastError: null, + lastEventAt: now, + ...(advanceRuntimeGeneration + ? { + setup: { + ...canonical.endpoint.setup, + runtimeGeneration: + runtimeGeneration(canonical.endpoint.setup) + 1, + } as InternalSetupState, + } + : {}), + updatedAt: now, + }) + .where(eq(chatEndpoints.id, canonical.endpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "active", + enabled: true, + healthStatus: "healthy", + healthMessage: "Connected", + lastError: null, + healthCheckedAt: now, + updatedAt: now, + }) + .where( + eq(toolConnections.id, canonical.endpoint.connectionId), + ); + } + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: "processed", + processedAt: now, + redactedError: null, + nextAttemptAt: null, + updatedAt: now, + }) + .where( + and( + eq(chatDeliveries.id, claimed.id), + eq(chatDeliveries.state, "processing"), + eq(chatDeliveries.attempts, candidate.attempts + 1), + ), + ); + await credentialLease.assertOwned(tx); + }); + if (!canonical.endpoint.setup.webhookVerifiedAt) { + stopRuntimeAfterLifecycle = true; + } + } catch (error) { + const latest = await endpointRecord(currentEndpoint.id); + const preserveIntentionalState = + !latest || + latest.endpoint.status === "paused" || + latest.endpoint.status === "archived" || + !latest.endpoint.setup.webhookVerifiedAt; + if (!preserveIntentionalState) { + const failedAt = new Date(); + const failure = redactError(error); + const nextGeneration = + runtimeGeneration(latest.endpoint.setup) + 1; + const retryRuntimeContext: LifecycleRuntimeFence = { + credentialFingerprint: credentialFingerprint( + latest.credentialSecretRefs, + ), + generation: nextGeneration, + }; + const terminal = candidate.attempts + 1 >= 5; + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: + "GitHub App credentials, permissions, events, or identity need attention", + lastError: failure, + lastEventAt: failedAt, + setup: { + ...latest.endpoint.setup, + runtimeGeneration: nextGeneration, + } as InternalSetupState, + updatedAt: failedAt, + }) + .where(eq(chatEndpoints.id, currentEndpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "disabled", + enabled: false, + healthStatus: "degraded", + healthMessage: + "GitHub App credentials, permissions, events, or identity need attention", + lastError: failure, + healthCheckedAt: failedAt, + updatedAt: failedAt, + }) + .where(eq(toolConnections.id, currentEndpoint.connectionId)); + await tx + .update(chatEndpointResources) + .set({ availability: "unavailable", updatedAt: failedAt }) + .where( + and( + eq( + chatEndpointResources.companyId, + currentEndpoint.companyId, + ), + eq(chatEndpointResources.endpointId, currentEndpoint.id), + ), + ); + await tx + .update(chatConversations) + .set({ state: "unavailable", updatedAt: failedAt }) + .where( + and( + eq( + chatConversations.companyId, + currentEndpoint.companyId, + ), + eq(chatConversations.endpointId, currentEndpoint.id), + inArray(chatConversations.state, ["active", "waiting"]), + ), + ); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + state: terminal ? "failed" : "retry", + normalizedEvent: normalizedLifecycleWithRuntimeFence( + candidate, + retryRuntimeContext, + ), + redactedError: failure, + nextAttemptAt: terminal ? null : failedAt, + updatedAt: failedAt, + }) + .where( + and( + eq(chatDeliveries.id, claimed.id), + eq(chatDeliveries.state, "processing"), + eq(chatDeliveries.attempts, candidate.attempts + 1), + ), + ); + await credentialLease.assertOwned(tx); + }); + input.onFailurePersisted(); + } + throw error; + } + return { refreshRuntimeAfterLifecycle, stopRuntimeAfterLifecycle }; + } + return { applyGitHubInstallationLifecycle }; +} diff --git a/server/src/services/chat-channels/github/inventory.ts b/server/src/services/chat-channels/github/inventory.ts new file mode 100644 index 00000000000..ff7301eb8c2 --- /dev/null +++ b/server/src/services/chat-channels/github/inventory.ts @@ -0,0 +1,50 @@ +import { githubAppJwt } from "../../chat-github-client.js"; + +import { discoverDedicatedGitHubAppInstallation, listGitHubInstallationRepositories } from "../../chat-provider-inventory.js"; + +import type { PreparedProviderInventory } from "../runtime-types.js"; + +export async function prepareGitHubInventory(credentials: Record, fetchImpl: typeof fetch): Promise { + const appJwt = githubAppJwt(credentials.appId, credentials.privateKey); + const installation = await discoverDedicatedGitHubAppInstallation({ + appJwt, + fetch: fetchImpl, + }); + const preparedCredentials = { + ...credentials, + installationId: installation.installationId, + }; + const inventory = await listGitHubInstallationRepositories({ + appJwt, + installationId: installation.installationId, + fetch: fetchImpl, + }); + return { + credentials: preparedCredentials, + managementUrl: installation.accountType === "Organization" && installation.accountLabel + ? `https://github.com/organizations/${encodeURIComponent(installation.accountLabel)}/settings/installations/${installation.installationId}` + : `https://github.com/settings/installations/${installation.installationId}`, + inventory: { + ...inventory, + resources: inventory.resources.map((resource) => { + const fullName = + typeof resource.metadata?.fullName === "string" + ? resource.metadata.fullName + : resource.label.includes("/") + ? resource.label + : null; + return fullName + ? { + ...resource, + providerResourceId: fullName.toLowerCase(), + metadata: { + ...resource.metadata, + providerRepositoryId: resource.providerResourceId, + fullName, + }, + } + : resource; + }), + }, + }; +} diff --git a/server/src/services/chat-channels/github/publication-policy.ts b/server/src/services/chat-channels/github/publication-policy.ts new file mode 100644 index 00000000000..73569feb890 --- /dev/null +++ b/server/src/services/chat-channels/github/publication-policy.ts @@ -0,0 +1,311 @@ +import { createReceiptReactions } from "../receipt-reactions.js"; + +import type { EndpointRow, DbOrTransaction, RuntimeContext } from "../runtime-types.js"; +import { createChatFailedRunRetry } from "../failed-run-retry.js"; +import { createChatPublicationReplacement } from "../publication-replacement.js"; + +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; + +import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { chatGitHubConfigurations } from "@paperclipai/db"; + +import { githubResponseCommentService } from "../../chat-github-response-comments.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + inArray, + isNotNull, + isNull, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatPublications, + heartbeatRuns, + nativeRunFinalizations, + issueComments, +} from "@paperclipai/db"; +import type { + SafeChatPublicationPayload, +} from "@paperclipai/shared"; + +import { + NativeChatReviewPresentationContentionError, +} from "../../native-runtime/native-chat-review-presentation.js"; +import { isExternalChatWaitAuthorizationContention } from "../../native-runtime/chat-attachment-reuse.js"; + +import { safeChatTaskUrl } from "../../chat-task-url.js"; + +import { + githubRunReplyState, +} from "../../chat-run-publications.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type GitHubPublicationPolicyOptions = { + isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean; + getTaskBaseUrl: () => string | null | undefined; + failedChatRetrySource: ReturnType["failedChatRetrySource"]; + isExternalActionAuthorizationChange: (error: unknown) => boolean; + FailedChatRetryPublicationReadError: ReturnType["FailedChatRetryPublicationReadError"]; + endpointRecord: (endpointId: string) => Promise; + runIdFromMilestonePublication: ReturnType["runIdFromMilestonePublication"]; + fetchImpl: typeof fetch; + stageTerminalReceiptReactionRemovals: (tx: Db, input: { endpoint: EndpointRow; publication: typeof chatPublications.$inferSelect; payload: SafeChatPublicationPayload; runtimeContext: RuntimeContext; closedProgressRunId?: string }) => Promise; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + scheduleMessageProcessing: (task: () => Promise) => void; + processReceiptReaction: ReturnType["processReceiptReaction"]; +}; + +export function createGitHubPublicationPolicy(db: Db, options: GitHubPublicationPolicyOptions) { + const { isExplicitOperatorPublication, getTaskBaseUrl, failedChatRetrySource, isExternalActionAuthorizationChange, FailedChatRetryPublicationReadError, endpointRecord, runIdFromMilestonePublication, fetchImpl, stageTerminalReceiptReactionRemovals, runtimeContextForRecord, scheduleMessageProcessing, processReceiptReaction } = options; + + + /** Add navigation only from the exact accepted native input batch. The + * omission text/model response is not authority, nor is an older task file. */ + async function githubCurrentOmissionPreparedText( + tx: DbOrTransaction, + publication: typeof chatPublications.$inferSelect, + endpoint: EndpointRow, + ): Promise { + if (endpoint.provider !== "github") return null; + const preparationKey = `github-omission-navigation:${publication.id}`; + const [prepared] = await tx + .select() + .from(chatActions) + .where( + and( + eq(chatActions.companyId, publication.companyId), + eq(chatActions.endpointId, publication.endpointId), + eq(chatActions.conversationId, publication.conversationId), + eq(chatActions.providerActionId, preparationKey), + ), + ) + .limit(1); + // A prepared final cannot become a different kind of publication while + // retaining the decorated body. Check its receipt before these exclusions. + if ( + !publication.commentId || + publication.payload.progressState !== undefined || + publication.payload.interactionId || + publication.payload.card || + publication.payload.attachmentIds?.length || + publication.payload.transportPart || + publication.idempotencyKey.startsWith("control:") || + isExplicitOperatorPublication(publication) + ) + return prepared ? false : null; + const taskUrl = prepared + ? typeof prepared.payload.taskUrl === "string" + ? safeChatTaskUrl(prepared.payload.taskUrl, publication.issueId) + : null + : safeChatTaskUrl(getTaskBaseUrl(), publication.issueId); + if ( + prepared && + (prepared.kind !== "github_omission_navigation" || + prepared.status !== "processed" || + prepared.payload.version !== 1 || + prepared.payload.publicationId !== publication.id || + taskUrl !== prepared.payload.taskUrl || + prepared.payload.preparedTextSha256 !== + createHash("sha256").update(publication.payload.text).digest("hex")) + ) + return false; + if (!taskUrl) return prepared ? false : null; + const [origin] = await tx + .select({ run: heartbeatRuns, resultId: nativeRunFinalizations.resultId }) + .from(issueComments) + .innerJoin( + heartbeatRuns, + and( + eq(heartbeatRuns.id, issueComments.createdByRunId), + eq(heartbeatRuns.companyId, publication.companyId), + eq(heartbeatRuns.nativeIssueId, publication.issueId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), + eq(heartbeatRuns.runtimeMode, "native"), + eq(heartbeatRuns.status, "succeeded"), + isNotNull(heartbeatRuns.finishedAt), + ), + ) + .innerJoin( + nativeRunFinalizations, + and( + eq(nativeRunFinalizations.companyId, publication.companyId), + eq(nativeRunFinalizations.issueId, publication.issueId), + eq(nativeRunFinalizations.runId, heartbeatRuns.id), + eq(nativeRunFinalizations.phase, "committed"), + ), + ) + .where( + and( + eq(issueComments.id, publication.commentId), + eq(issueComments.companyId, publication.companyId), + eq(issueComments.issueId, publication.issueId), + eq(issueComments.authorAgentId, endpoint.assignedAgentId), + isNull(issueComments.deletedAt), + ), + ) + .limit(1); + const presentation = origin?.run.resultJson?.presentationDecision as + Record | undefined; + if (!origin?.resultId || presentation?.commentId !== publication.commentId) + return prepared ? false : null; + if ( + prepared && + (prepared.payload.runId !== origin.run.id || + prepared.payload.resultId !== origin.resultId) + ) + return false; + try { + // This existing publication proof includes the accepted response_wake + // result, immutable owner/coalesced receipts, every current source, + // runtime/generation, source edits/deletes and current principal access. + const source = await failedChatRetrySource(tx, { + companyId: publication.companyId, + issueId: publication.issueId, + agentId: origin.run.agentId, + failedRunId: origin.run.id, + publication: true, + committedResponse: { resultId: origin.resultId }, + }); + if ( + source.endpointId !== endpoint.id || + source.conversationId !== publication.conversationId + ) + return prepared ? false : null; + const omitted = source.sources.some((entry) => { + const count = ( + entry.omissionReasons as Record | undefined + )?.download_unavailable; + return ( + typeof count === "number" && Number.isSafeInteger(count) && count > 0 + ); + }); + if (!omitted) return prepared ? false : null; + if (prepared) return publication.payload.text; + const text = publication.payload.text.includes(taskUrl) + ? publication.payload.text + : `${publication.payload.text}\n\n[Open this Paperclip task](${taskUrl})`; + await tx.insert(chatActions).values({ + companyId: publication.companyId, + endpointId: publication.endpointId, + conversationId: publication.conversationId, + kind: "github_omission_navigation", + providerActionId: preparationKey, + status: "processed", + payload: { + version: 1, + publicationId: publication.id, + runId: origin.run.id, + resultId: origin.resultId, + taskUrl, + preparedTextSha256: createHash("sha256").update(text).digest("hex"), + }, + }); + return text; + } catch (error) { + if ( + isExternalChatWaitAuthorizationContention(error) || + error instanceof NativeChatReviewPresentationContentionError + ) + throw new NativeChatReviewPresentationContentionError(); + if ( + isExternalActionAuthorizationChange(error) || + (error && + typeof error === "object" && + "details" in error && + error.details && + typeof error.details === "object" && + "code" in error.details && + error.details.code === "chat_failed_run_retry_not_authorized") + ) + return prepared ? false : null; + throw new FailedChatRetryPublicationReadError(); + } + } + + async function settleGitHubAutomaticPublication( + publication: typeof chatPublications.$inferSelect, + guard?: CredentialMutationLeaseGuard, + ): Promise { + if (isExplicitOperatorPublication(publication) || publication.payload.interactionId) return false; + const record = await endpointRecord(publication.endpointId); + if (!record || record.endpoint.provider !== "github" || record.endpoint.companyId !== publication.companyId) return false; + // Legacy connections cannot use the replacement task-scoped tools yet. + const [configuration] = await db.select({ endpointId: chatGitHubConfigurations.endpointId }) + .from(chatGitHubConfigurations).where(and( + eq(chatGitHubConfigurations.companyId, publication.companyId), + eq(chatGitHubConfigurations.endpointId, publication.endpointId), + )).limit(1); + if (!configuration) return false; + let runId = runIdFromMilestonePublication(publication); + if (!publication.payload.progressState) { + // Also settle automatic agent comments retained from an older instance + // version, and per-destination finals from a mixed-provider origin. + if (!publication.commentId || !publication.idempotencyKey.startsWith("comment:")) return false; + const [comment] = await db.select({ runId: issueComments.createdByRunId }).from(issueComments) + .where(and(eq(issueComments.id, publication.commentId), eq(issueComments.companyId, publication.companyId), + eq(issueComments.issueId, publication.issueId), eq(issueComments.authorType, "agent"))); + if (!comment) return false; + runId = comment.runId; + } + if (["failed", "completed"].includes(publication.payload.progressState ?? "") && runId) { + const reply = await githubRunReplyState(db, { + companyId: publication.companyId, endpointId: publication.endpointId, issueId: publication.issueId, runId, + }); + // A pending or ambiguous write may already have arrived. Suppress the + // fallback without changing the governed outbox that owns that effect. + if (reply === "unsettled") { + // Quarantine only the automatic fallback. The governed tool outbox + // retains the uncertain write; ordinary board messages and question + // cards must not wait behind this notice in the conversation FIFO. + await db.transaction(async tx => { + await guard?.assertOwned(tx); + await notifyChatPublicationWork(tx); + await tx.update(chatPublications).set({ state: "cancelled", nextAttemptAt: null, + redactedError: "Automatic fallback suppressed while the GitHub tool reply remains unresolved", updatedAt: new Date() }) + .where(and(eq(chatPublications.id, publication.id), guard + ? and(eq(chatPublications.state, "streaming"), eq(chatPublications.attempts, publication.attempts + 1)) + : inArray(chatPublications.state, ["pending", "retry"]))); + }); + return true; + } + if (reply === "none" && (publication.payload.progressState === "failed" || + await githubResponseCommentService(db, fetchImpl).hasWorkingResponseForRun({ + companyId: publication.companyId, endpointId: publication.endpointId, issueId: publication.issueId, runId, + }))) return false; + } else if (publication.payload.progressState === "failed") return false; + const actionIds = await db.transaction(async tx => { + await guard?.assertOwned(tx); + await notifyChatPublicationWork(tx); + const [cancelled] = await tx.update(chatPublications).set({ state: "cancelled", nextAttemptAt: null, + redactedError: "GitHub replies are sent through task-scoped tools", updatedAt: new Date() }) + .where(and(eq(chatPublications.id, publication.id), eq(chatPublications.companyId, publication.companyId), + guard ? and(eq(chatPublications.state, "streaming"), eq(chatPublications.attempts, publication.attempts + 1)) + : inArray(chatPublications.state, ["pending", "retry"]))) + .returning({ id: chatPublications.id }); + if (!cancelled || !runId) return []; + const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, publication.companyId), + eq(heartbeatRuns.agentId, record.endpoint.assignedAgentId), + sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`, + inArray(heartbeatRuns.status, ["succeeded", "failed", "interrupted", "timed_out", "cancelled"]))); + if (!run) return []; + return stageTerminalReceiptReactionRemovals(tx as unknown as Db, { + endpoint: record.endpoint, publication, payload: publication.payload, + runtimeContext: runtimeContextForRecord(record), closedProgressRunId: run.id, + }); + }); + for (const id of actionIds) scheduleMessageProcessing(() => processReceiptReaction(id)); + return true; + } + return { githubCurrentOmissionPreparedText, settleGitHubAutomaticPublication }; +} diff --git a/server/src/services/chat-channels/github/setup-secret.ts b/server/src/services/chat-channels/github/setup-secret.ts new file mode 100644 index 00000000000..4fb2cb43fe3 --- /dev/null +++ b/server/src/services/chat-channels/github/setup-secret.ts @@ -0,0 +1,336 @@ +import type { EndpointRow, InternalSetupState } from "../runtime-types.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + randomBytes, + randomUUID, +} from "node:crypto"; +import { + and, + eq, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpoints, + companySecretBindings, + toolConnections, +} from "@paperclipai/db"; +import type { + ChatEndpointSetupState, + ToolCredentialSecretRef, +} from "@paperclipai/shared"; + +import { + conflict, + notFound, + unprocessable, +} from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; + +import { + logActivity, +} from "../../activity-log.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type GitHubSetupSecretOptions = { + endpointRecord: (endpointId: string) => Promise; + withCredentialMutationLease: ReturnType; + resolveCredentials: (endpoint: EndpointRow) => Promise>; + persistCredentials: (endpoint: EndpointRow, credentials: Record, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise; + runtimeGeneration: (setup: ChatEndpointSetupState) => number; + credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string; + invalidateRuntime: (endpointId: string) => Promise; + redactError: (error: unknown) => string; + setupSecretActivityLogger?: typeof logActivity; + setupSecretCredentialPersistBarrier?: () => Promise; + setupSecretFinalOwnershipBarrier?: () => Promise; +}; + +export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOptions) { + const { endpointRecord, withCredentialMutationLease, resolveCredentials, persistCredentials, runtimeGeneration, credentialFingerprint, invalidateRuntime, redactError } = options; + + + async function generateSetupSecret( + endpointId: string, + actorUserId?: string | null, + ) { + const initial = await endpointRecord(endpointId); + if (!initial) throw notFound("Chat endpoint not found"); + if (initial.endpoint.provider !== "github") { + throw unprocessable( + "Setup secret generation is only available for GitHub", + ); + } + let committedWebhookSecretRef: ToolCredentialSecretRef | null = null; + return withCredentialMutationLease( + initial.endpoint, + async (credentialLease) => { + // A competing request may have completed while this request waited for + // the durable lease. Reload both endpoint state and credential refs only + // after ownership so each successful rotation starts from its immediate + // predecessor instead of replacing the credential set from a stale read. + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const endpoint = record.endpoint; + if ( + ![ + "draft", + "verifying", + "active", + "attention", + "revoked", + "paused", + ].includes(endpoint.status) + ) { + throw conflict( + "The GitHub webhook secret cannot be rotated in this connection state", + { + code: "chat_endpoint_setup_secret_unavailable", + }, + ); + } + const webhookSecret = randomBytes(32).toString("hex"); + // Rotation must fail closed if any existing credential cannot be resolved. + // Falling back to an empty object here would replace the full credential + // set with only the new webhook secret and strand an otherwise-live App. + const existing = await resolveCredentials(endpoint); + const replacedExistingSecret = record.credentialSecretRefs.some( + (ref) => ref.configPath === "credentials.webhookSecret", + ); + const rotatingConfiguredApp = + Boolean(endpoint.providerAccountId || endpoint.botExternalId) || + record.credentialSecretRefs.some((ref) => + ["credentials.appId", "credentials.privateKey"].includes( + ref.configPath, + ), + ); + const rotationId = randomUUID(); + const auditDetails = { + endpointId: endpoint.id, + provider: endpoint.provider, + rotationId, + rotated: rotatingConfiguredApp, + replacedPrevious: replacedExistingSecret, + }; + const writeSetupSecretActivity = + options.setupSecretActivityLogger ?? logActivity; + // This durable intent is the audit boundary. If it cannot be written, + // fail before changing state or refs so retry is safe. Endpoint/tool + // state moves fail-closed before ref replacement. Once refs change, only + // best-effort work remains and the plaintext must still be returned. + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await writeSetupSecretActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: "chat_endpoint.setup_secret_rotation_started", + entityType: "tool_connection", + entityId: endpoint.connectionId, + details: auditDetails, + }); + await credentialLease.assertOwned(tx); + }); + try { + if (rotatingConfiguredApp) { + const updatedAt = new Date(); + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await tx + .select({ setup: chatEndpoints.setup }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) throw notFound("Chat endpoint not found"); + await tx + .update(chatEndpoints) + .set({ + status: "attention", + healthMessage: + "Update the GitHub webhook secret, then reconnect this App", + lastError: null, + setup: { + ...current.setup, + step: "provider_setup", + webhookVerifiedAt: null, + runtimeGeneration: runtimeGeneration(current.setup) + 1, + } as InternalSetupState, + updatedAt, + }) + .where(eq(chatEndpoints.id, endpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "disabled", + enabled: false, + healthStatus: "degraded", + healthMessage: + "GitHub webhook secret rotation needs reconnect", + lastError: null, + healthCheckedAt: updatedAt, + updatedAt, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await credentialLease.assertOwned(tx); + }); + await invalidateRuntime(endpoint.id).catch(() => undefined); + } else { + // Replacing a secret while the operator is still creating the GitHub + // App is not a live credential rotation. Keep first-time setup in its + // draft state, clear any ping for the superseded value, and also heal + // endpoints affected by the earlier pre-connect rotation trap. + const updatedAt = new Date(); + await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const current = await tx + .select({ setup: chatEndpoints.setup }) + .from(chatEndpoints) + .where(eq(chatEndpoints.id, endpoint.id)) + .for("update") + .then((rows) => rows[0] ?? null); + if (!current) throw notFound("Chat endpoint not found"); + await tx + .update(chatEndpoints) + .set({ + status: "draft", + healthMessage: null, + lastError: null, + setup: { + ...current.setup, + step: "provider_setup", + webhookVerifiedAt: null, + }, + updatedAt, + }) + .where(eq(chatEndpoints.id, endpoint.id)); + await tx + .update(toolConnections) + .set({ + status: "draft", + enabled: false, + healthStatus: "unchecked", + healthMessage: null, + lastError: null, + healthCheckedAt: null, + updatedAt, + }) + .where(eq(toolConnections.id, endpoint.connectionId)); + await credentialLease.assertOwned(tx); + }); + } + await options.setupSecretCredentialPersistBarrier?.(); + // Make credential refs the final required mutation. Once this returns, + // only the best-effort completion audit remains, so the request cannot + // fail after making the newly generated secret authoritative. + const persistedRefs = await persistCredentials( + endpoint, + { ...existing, webhookSecret }, + credentialLease, + actorUserId, + ); + committedWebhookSecretRef = + persistedRefs.find( + (ref) => ref.configPath === "credentials.webhookSecret", + ) ?? null; + if (!committedWebhookSecretRef) { + throw new Error( + "GitHub webhook secret was not present after credential persistence", + ); + } + } catch (error) { + await writeSetupSecretActivity(db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: "chat_endpoint.setup_secret_rotation_failed", + entityType: "tool_connection", + entityId: endpoint.connectionId, + details: auditDetails, + }).catch((auditError) => { + logger.warn( + { + endpointId: endpoint.id, + error: redactError(auditError), + rotationId, + }, + "could not audit failed GitHub setup-secret rotation", + ); + }); + throw error; + } + await db + .transaction(async (tx) => { + await credentialLease.assertOwned(tx); + await writeSetupSecretActivity(tx as unknown as Db, { + companyId: endpoint.companyId, + actorType: "user", + actorId: actorUserId ?? "board", + action: "chat_endpoint.setup_secret_generated", + entityType: "tool_connection", + entityId: endpoint.connectionId, + details: auditDetails, + }); + await credentialLease.assertOwned(tx); + }) + .catch((error) => { + logger.warn( + { + endpointId: endpoint.id, + error: redactError(error), + rotationId, + }, + "GitHub setup secret rotated but completion audit failed", + ); + }); + return { webhookSecret }; + }, + { + beforeFinalOwnershipCheck: options.setupSecretFinalOwnershipBarrier, + recoverCommittedResultAfterLeaseLoss: async () => { + const committedRef = committedWebhookSecretRef; + if (!committedRef) return false; + const authoritative = await db + .select({ refs: toolConnections.credentialSecretRefs }) + .from(toolConnections) + .innerJoin( + companySecretBindings, + and( + eq(companySecretBindings.companyId, initial.endpoint.companyId), + eq(companySecretBindings.targetType, "tool_connection"), + eq( + companySecretBindings.targetId, + initial.endpoint.connectionId, + ), + eq(companySecretBindings.configPath, committedRef.configPath), + eq(companySecretBindings.secretId, committedRef.secretId), + eq( + companySecretBindings.versionSelector, + String(committedRef.versionSelector ?? "latest"), + ), + ), + ) + .where( + and( + eq(toolConnections.companyId, initial.endpoint.companyId), + eq(toolConnections.id, initial.endpoint.connectionId), + ), + ) + .then((rows) => rows[0] ?? null); + return ( + authoritative?.refs.some( + (ref) => + credentialFingerprint([ref]) === + credentialFingerprint([committedRef]), + ) === true + ); + }, + }, + ); + } + return { generateSetupSecret }; +} diff --git a/server/src/services/chat-channels/identity-links.ts b/server/src/services/chat-channels/identity-links.ts index 47596405f3a..b45ee22db66 100644 --- a/server/src/services/chat-channels/identity-links.ts +++ b/server/src/services/chat-channels/identity-links.ts @@ -56,6 +56,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions id: authUsers.id, name: authUsers.name, email: authUsers.email, + image: authUsers.image, }) .from(authUsers) .where(inArray(authUsers.id, userIds)) @@ -80,6 +81,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions : principal.externalId, paperclipUserId: link?.paperclipUserId ?? null, paperclipUserLabel: user?.name ?? user?.email ?? null, + paperclipUserImage: user?.image ?? null, status: link?.status ?? "pending", lastConnectAt: connectByPrincipal.get(principal.id) ?? null, }; diff --git a/server/src/services/chat-channels/imessage-photon/credentials.ts b/server/src/services/chat-channels/imessage-photon/credentials.ts new file mode 100644 index 00000000000..4392fe205da --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/credentials.ts @@ -0,0 +1,45 @@ +import { PhotonCloudClient, PhotonError, photonSharedIdentity } from "../../photon/cloud.js"; + +import { + HttpError, + unprocessable, +} from "../../../errors.js"; + +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +export function createPhotonCredentialChecks(fetchImpl: typeof fetch) { + + + async function inspectPhotonCredentials(projectId: string, projectSecret: string) { + try { + return await new PhotonCloudClient(fetchImpl).inspect(projectId, projectSecret); + } catch (error) { + if (!(error instanceof PhotonError)) throw error; + // Only credentials/allocation failures mean the setup input needs repair. + // Preserve safe provider messages while distinguishing outages and bad + // upstream responses from validation errors. Never return response bodies. + const status = error.code === "credentials" || error.code === "line_unavailable" + ? 422 + : error.code === "quota" + ? 429 + : error.code === "network" + ? 503 + : 502; + throw new HttpError(status, error.message, { + code: `photon_${error.code}`, + }); + } + } + async function verifyPhotonCredentials(credentials: Record): Promise { + const inspection = await inspectPhotonCredentials(credentials.projectId, credentials.projectSecret); + if (inspection.allocation !== (credentials.allocation ?? "dedicated")) throw unprocessable("Photon allocation changed; inspect the project again"); + if (inspection.allocation === "shared") { + if (!inspection.eligible) throw unprocessable("Photon shared project is unavailable"); + return { providerAccountId: inspection.projectId, providerAccountLabel: inspection.projectName, botExternalId: photonSharedIdentity(inspection.projectId), botUsername: null, botLabel: `${inspection.projectName} (DM only)` }; + } + const line = inspection.lines.find((candidate) => candidate.lineId === credentials.lineId && candidate.eligible); + if (!line || !inspection.eligible) throw unprocessable("Select an eligible dedicated Photon line"); + return { providerAccountId: inspection.projectId, providerAccountLabel: inspection.projectName, botExternalId: line.phoneNumber, botUsername: line.phoneNumber, botLabel: line.phoneNumber }; + } + return { inspectPhotonCredentials, verifyPhotonCredentials }; +} diff --git a/server/src/services/chat-channels/microsoft-teams/credentials.ts b/server/src/services/chat-channels/microsoft-teams/credentials.ts new file mode 100644 index 00000000000..a54fed5ebc3 --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/credentials.ts @@ -0,0 +1,43 @@ +import { + unprocessable, +} from "../../../errors.js"; + +import { + normalizeMicrosoftTeamsCredentialIds, +} from "../../chat-teams-credentials.js"; + +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +export async function verifyTeamsCredentials(credentials: Record, fetchImpl: typeof fetch, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number): Promise { + + const teamsCredentials = normalizeMicrosoftTeamsCredentialIds(credentials); + const body = new URLSearchParams({ + client_id: teamsCredentials.clientId, + client_secret: teamsCredentials.clientSecret, + grant_type: "client_credentials", + scope: "https://api.botframework.com/.default", + }); + const response = await fetchImpl( + `https://login.microsoftonline.com/${encodeURIComponent(teamsCredentials.tenantId)}/oauth2/v2.0/token`, + { + method: "POST", + signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), + headers: { "content-type": "application/x-www-form-urlencoded" }, + body, + }, + ); + const result = (await response.json()) as { + access_token?: string; + error_description?: string; + }; + if (!response.ok || !result.access_token) + throw unprocessable( + `Microsoft rejected the app credentials: ${result.error_description ?? response.status}`, + ); + return { + providerAccountId: teamsCredentials.tenantId, + providerAccountLabel: teamsCredentials.tenantId, + botExternalId: teamsCredentials.clientId, + }; + +} diff --git a/server/src/services/chat-channels/microsoft-teams/file-delivery.ts b/server/src/services/chat-channels/microsoft-teams/file-delivery.ts new file mode 100644 index 00000000000..9848222c9b9 --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/file-delivery.ts @@ -0,0 +1,434 @@ +import { createOutboundAttachments } from "../outbound-attachments.js"; + +import { createTeamsFileAuthority } from "./file-transfer-authority.js"; + +import type { EndpointRow, RuntimeContext } from "../runtime-types.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js"; + +import { + and, + eq, + inArray, + lte, + or, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatPublications, + chatTeamsFileTransfers, +} from "@paperclipai/db"; + +import { + teamsFileTransferService, + type TeamsFileTransferAuthority, + type TeamsFileTransferOptions, +} from "../../chat-teams-file-transfers.js"; +import { projectTeamsFilePublication } from "../../chat-teams-file-publication.js"; +import type { TeamsFileConsentEvent } from "../../chat-teams-file-consent.js"; + +import { + HttpError, + conflict, +} from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; + +import { + type ChatSdkCallbackEvent, + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type TeamsFileDeliveryOptions = { + endpointRecord: (endpointId: string) => Promise; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + runtimeFor: (endpoint: EndpointRow) => Promise; + runtimeContexts: Pick, "get">; + localRuntimeEpoch: (endpointId: string) => number; + runtime: Pick; + deriveTeamsFileTransferAuthority: ReturnType["deriveTeamsFileTransferAuthority"]; + publicationFiles: ReturnType["publicationFiles"]; + teamsFileUploadRequest?: TeamsFileTransferOptions["uploadRequest"]; + scheduleMessageProcessing: (task: () => Promise) => void; + processPendingPublications: () => Promise; + withCredentialMutationLease: ReturnType; + teamsFileRetryAfter: Map; + isShuttingDown: () => boolean; +}; + +export function createTeamsFileDelivery(db: Db, options: TeamsFileDeliveryOptions) { + const { endpointRecord, runtimeContextForRecord, runtimeFor, runtimeContexts, localRuntimeEpoch, runtime, deriveTeamsFileTransferAuthority, publicationFiles, scheduleMessageProcessing, processPendingPublications, withCredentialMutationLease, teamsFileRetryAfter, isShuttingDown } = options; + + + function teamsTransferProtocol( + input: { + credentialLease?: CredentialMutationLeaseGuard; + callbackContext?: RuntimeContext; + } = {}, + ) { + const denied = () => + conflict("Teams file delivery authority changed", { + code: "chat_file_transfer_authority_changed", + }); + const currentRuntime = async (authority: TeamsFileTransferAuthority) => { + if (!input.credentialLease) throw denied(); + await input.credentialLease.assertOwned(); + const record = await endpointRecord(authority.endpointId); + if ( + !record || + record.endpoint.companyId !== authority.companyId || + record.endpoint.provider !== "microsoft-teams" || + record.endpoint.status !== "active" + ) + throw denied(); + const context = runtimeContextForRecord(record); + if ( + context.generation !== authority.runtimeGeneration || + context.credentialFingerprint !== authority.credentialFingerprint + ) + throw denied(); + const instance = await runtimeFor(record.endpoint); + const registered = runtimeContexts.get(instance as object); + if ( + !registered || + registered.version !== context.version || + registered.localEpoch !== localRuntimeEpoch(authority.endpointId) || + runtime.get(authority.endpointId) !== instance + ) + throw denied(); + await input.credentialLease.assertOwned(); + return instance; + }; + const personalThread = (authority: TeamsFileTransferAuthority) => + `teams:${Buffer.from(authority.providerConversationId).toString("base64url")}:personal`; + return teamsFileTransferService(db, { + project: projectTeamsFilePublication, + authorize: async (tx, expected, stage) => { + await input.credentialLease?.assertOwned(tx); + if ( + input.callbackContext && + (input.callbackContext.generation !== expected.runtimeGeneration || + input.callbackContext.credentialFingerprint !== + expected.credentialFingerprint || + input.callbackContext.localEpoch !== + localRuntimeEpoch(expected.endpointId) || + input.callbackContext.endpointRuntime !== + runtime.get(expected.endpointId)) + ) + throw denied(); + const [publication] = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, expected.companyId), + eq(chatPublications.endpointId, expected.endpointId), + eq(chatPublications.id, expected.publicationId), + ), + ) + .for("update"); + // This selector is derived only inside the staged protocol's locked + // authority check, never from a retry request or a model instruction. + const [continuing] = + publication?.state === "delivery_unknown" && + (stage === "response" || stage === "file_info") + ? await tx + .select({ + transferId: chatTeamsFileTransfers.id, + publicationId: chatTeamsFileTransfers.publicationId, + version: chatTeamsFileTransfers.version, + phase: chatTeamsFileTransfers.phase, + }) + .from(chatTeamsFileTransfers) + .where( + and( + eq(chatTeamsFileTransfers.companyId, expected.companyId), + eq(chatTeamsFileTransfers.endpointId, expected.endpointId), + eq( + chatTeamsFileTransfers.conversationId, + expected.conversationId, + ), + eq( + chatTeamsFileTransfers.publicationId, + expected.publicationId, + ), + eq( + chatTeamsFileTransfers.phase, + stage === "response" + ? "consent_unknown" + : "file_info_unknown", + ), + ), + ) + .for("update") + : []; + const current = + publication && + (await deriveTeamsFileTransferAuthority( + tx, + publication, + { + generation: expected.runtimeGeneration, + credentialFingerprint: expected.credentialFingerprint, + }, + continuing && { + ...continuing, + phase: continuing.phase as + | "consent_unknown" + | "file_info_unknown", + }, + )); + if (!current) throw denied(); + await input.credentialLease?.assertOwned(tx); + return current; + }, + loadBytes: async (authority) => { + const [publication] = await db + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, authority.companyId), + eq(chatPublications.endpointId, authority.endpointId), + eq(chatPublications.id, authority.publicationId), + ), + ); + if (!publication) throw denied(); + const files = await publicationFiles(publication, publication.payload); + if (files.length !== 1 || !Buffer.isBuffer(files[0]?.data)) + throw denied(); + return files[0].data; + }, + postConsent: async ({ authority, card, signal }) => { + const instance = await currentRuntime(authority); + signal.throwIfAborted(); + return instance.sendTeamsFileConsentCard( + personalThread(authority), + card, + ); + }, + postFileInfo: async ({ authority, card, signal }) => { + const instance = await currentRuntime(authority); + signal.throwIfAborted(); + return instance.sendTeamsUploadedFileCard( + personalThread(authority), + card, + ); + }, + uploadRequest: options.teamsFileUploadRequest, + }); + } + + async function handleTeamsFileConsent( + callback: ChatSdkCallbackEvent, + context: RuntimeContext, + ): Promise<"recorded" | "ignored" | "denied"> { + if ( + callback.provider !== "microsoft-teams" || + callback.endpointId !== callback.event.endpointId || + context.endpointRuntime !== runtime.get(callback.endpointId) || + context.localEpoch !== localRuntimeEpoch(callback.endpointId) + ) + return "denied"; + // Do not wait for the sender's credential lease: an authentic acceptance + // can arrive while its consent-card POST is still awaiting the receipt. + let result: "recorded" | "ignored" | "denied"; + try { + result = await teamsTransferProtocol({ + callbackContext: context, + }).recordConsent(callback.event); + } catch (error) { + // A known current-policy denial is not a temporary provider outage. + // Contention and unproven persistence still propagate as retryable 503. + if ( + error instanceof HttpError && + (error.details as { code?: string } | undefined)?.code === + "chat_file_transfer_authority_changed" + ) + return "denied"; + throw error; + } + if (result === "recorded") + scheduleMessageProcessing(async () => { + await processPendingPublications(); + }); + return result; + } + + async function tryProcessTeamsFilePublication( + selected: typeof chatPublications.$inferSelect, + ): Promise { + const [existing] = await db + .select({ + id: chatTeamsFileTransfers.id, + endpointId: chatTeamsFileTransfers.endpointId, + conversationId: chatTeamsFileTransfers.conversationId, + }) + .from(chatTeamsFileTransfers) + .where( + and( + eq(chatTeamsFileTransfers.companyId, selected.companyId), + eq(chatTeamsFileTransfers.publicationId, selected.id), + ), + ); + if (!existing && !selected.payload.attachmentIds?.length) return false; + const record = await endpointRecord(selected.endpointId); + if (!record || record.endpoint.provider !== "microsoft-teams") + return Boolean(existing); + if ( + existing && + (existing.endpointId !== selected.endpointId || + existing.conversationId !== selected.conversationId) + ) + return true; + if (record.endpoint.status !== "active") return Boolean(existing); + try { + return await withCredentialMutationLease( + record.endpoint, + async (credentialLease) => { + await runtimeFor(record.endpoint); + const protocol = teamsTransferProtocol({ credentialLease }); + let transferId = existing?.id; + if (!transferId) { + const authority = await db.transaction(async (tx) => { + await credentialLease.assertOwned(tx); + const [current] = await tx + .select() + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, selected.companyId), + eq(chatPublications.id, selected.id), + ), + ) + .for("update"); + if ( + !current || + !["pending", "retry"].includes(current.state) || + (current.nextAttemptAt && current.nextAttemptAt > new Date()) + ) + return null; + return deriveTeamsFileTransferAuthority( + tx, + current, + runtimeContextForRecord(record), + ); + }); + // Missing personal-recipient proof is not permission to guess a DM. + // The ordinary path sends the truthful task-link fallback instead. + if (!authority) return false; + transferId = ( + await protocol.issue( + authority, + new Date(Date.now() + 60 * 60 * 1000), + ) + ).id; + } + // A successful consent/upload transition can immediately progress to + // its next effect, but no user wait, unknown outcome or backoff spins. + for (let stage = 0; stage < 3; stage++) { + const [current] = await db + .select({ nextAttemptAt: chatPublications.nextAttemptAt }) + .from(chatPublications) + .where( + and( + eq(chatPublications.companyId, selected.companyId), + eq(chatPublications.id, selected.id), + ), + ); + if ( + !current || + (current.nextAttemptAt && current.nextAttemptAt > new Date()) + ) + break; + const result = await protocol.process( + selected.companyId, + transferId, + ); + if ( + ![ + "consent_pending", + "upload_pending", + "file_info_pending", + ].includes(result.phase) + ) + break; + } + teamsFileRetryAfter.delete(selected.id); + return true; + }, + ); + } catch { + // A failed authorization/lock/receipt write must never fall through to a + // generic resend. Preserve exact durable evidence; this cooldown grants + // no authority and safely resets when the server restarts. + if (teamsFileRetryAfter.size >= 1000) + teamsFileRetryAfter.delete(teamsFileRetryAfter.keys().next().value!); + teamsFileRetryAfter.set(selected.id, Date.now() + 30_000); + return true; + } + } + + let teamsMaintenancePending = false; + let teamsMaintenanceAfter = 0; + function scheduleTeamsFileMaintenance() { + if ( + teamsMaintenancePending || + Date.now() < teamsMaintenanceAfter || + isShuttingDown() + ) + return; + teamsMaintenancePending = true; + teamsMaintenanceAfter = Date.now() + 30_000; + scheduleMessageProcessing(async () => { + try { + const scopes = await db + .selectDistinct({ companyId: chatTeamsFileTransfers.companyId }) + .from(chatTeamsFileTransfers) + .where( + or( + and( + inArray(chatTeamsFileTransfers.phase, [ + "consent_pending", + "awaiting_consent", + "upload_pending", + ]), + lte(chatTeamsFileTransfers.expiresAt, new Date()), + ), + and( + inArray(chatTeamsFileTransfers.phase, [ + "consent_sending", + "uploading", + "file_info_sending", + "conflict", + ]), + lte(chatTeamsFileTransfers.attemptExpiresAt, new Date()), + ), + ), + ); + const protocol = teamsTransferProtocol(); + // Separate from the ordinary message queue. Each company sweep bounds + // its row count and lock waits and performs no provider I/O. + for (const scope of scopes) { + if (isShuttingDown()) break; + await protocol.expireAndRecover(scope.companyId, 100); + } + } catch { + // Selection errors are closed here too; private transfer envelopes and + // SQL parameters must not escape through the generic background logger. + logger.warn( + "Teams file maintenance will retry after a temporary failure", + ); + } finally { + teamsMaintenancePending = false; + } + }); + } + return { teamsTransferProtocol, handleTeamsFileConsent, tryProcessTeamsFilePublication, scheduleTeamsFileMaintenance }; +} diff --git a/server/src/services/chat-channels/runtime-types.ts b/server/src/services/chat-channels/runtime-types.ts index cd2f6881e9b..809d5050e0c 100644 --- a/server/src/services/chat-channels/runtime-types.ts +++ b/server/src/services/chat-channels/runtime-types.ts @@ -50,3 +50,19 @@ export type InternalSetupState = ChatEndpointSetupState & { Record >; }; + +export type VerifiedProviderIdentity = { + githubOwnerType?: "personal" | "organization"; + githubOwnerLogin?: string; + providerAccountId?: string | null; + providerAccountLabel?: string | null; + botExternalId?: string | null; + botUsername?: string | null; + botLabel?: string | null; +}; + +export type PreparedProviderInventory = { + credentials: Record; + inventory: import("../chat-provider-inventory.js").ChatProviderInventoryResult | null; + managementUrl?: string; +}; diff --git a/server/src/services/chat-channels/slack/commands.ts b/server/src/services/chat-channels/slack/commands.ts new file mode 100644 index 00000000000..cc2cb592061 --- /dev/null +++ b/server/src/services/chat-channels/slack/commands.ts @@ -0,0 +1,380 @@ +import { createProviderEffects, type ProviderEffectTarget } from "../provider-effects.js"; + +import { createSlackTaskStart } from "./task-start.js"; + +import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js"; + +import { notifyChatActionWork } from "../../chat-work-notifications.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { + createHash, + randomBytes, +} from "node:crypto"; +import { + and, + eq, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatEndpointResources, + chatExternalPrincipals, + chatIdentityLinks, +} from "@paperclipai/db"; +import type { + ChatProvider, +} from "@paperclipai/shared"; + +import { + conflict, +} from "../../../errors.js"; + +import { + type ChatSdkCallbackEvent, + type ChatSdkMessageCallbackEvent, + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import { + logActivity, +} from "../../activity-log.js"; + +import type { + Author, + Message, + SlashCommandEvent, + Thread, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; + +export function slackCommandForAgent(agentName: string, publicId: string): string { + const slug = agentName + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 20); + const suffix = publicId + .toLowerCase() + .replace(/[^a-z0-9]/g, "") + .slice(0, 6); + return `/${slug || "paperclip"}-${suffix || "agent"}`; +} + +type SlackCommandsOptions = { + runtime: Pick; + runtimeFor: (endpoint: EndpointRow) => Promise; + handleSdkMessage: (event: ChatSdkMessageCallbackEvent, context: RuntimeContext, input?: { receiptReactionSupported?: boolean; slackSlashControl?: boolean }) => Promise; + stageProviderEffect: ReturnType["stageProviderEffect"]; + scheduleProviderEffect: (actionId: string, liveTarget?: ProviderEffectTarget) => void; + ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: typeof chatExternalPrincipals.$inferSelect; userId: string | null; linkedDenied: boolean }>; + runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise; + getPublicBaseUrl: () => string | null; + canonicalProviderResourceId: (provider: ChatProvider, thread: Pick) => string; + nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean; + sponsorAllowsGuest: (endpoint: EndpointRow) => Promise; + latestSlackDmControlThreadId: (endpointId: string, channelId: string) => Promise; + scheduleMessageProcessing: (task: () => Promise) => void; + processSlackTaskStart: ReturnType["processSlackTaskStart"]; + deferWebhookProcessing?: boolean; +}; + +export function createSlackCommands(db: Db, options: SlackCommandsOptions) { + const { runtime, runtimeFor, handleSdkMessage, stageProviderEffect, scheduleProviderEffect, ensurePrincipal, runtimeCallbackEndpoint, getPublicBaseUrl, canonicalProviderResourceId, nonDirectDestinationAllowed, sponsorAllowsGuest, latestSlackDmControlThreadId, scheduleMessageProcessing, processSlackTaskStart } = options; + async function handleSlackSlashCommand(event: ChatSdkCallbackEvent, runtimeContext: RuntimeContext, record: EndpointRecord, command: string, text: string) { + const providerCommandId = + event.event.triggerId ?? + createHash("sha256") + .update(JSON.stringify(event.event.raw)) + .digest("hex"); + const stageSlackNotice = (tx: DbTransaction, notice: string, principalId?: string | null) => + stageProviderEffect(tx, { + endpoint: record.endpoint, + principalId: principalId ?? null, + providerActionId: `provider_effect:slash_notice:${createHash("sha256").update(JSON.stringify([ + event.event.command, event.event.user.userId, providerCommandId, event.event.text, + ])).digest("hex")}`, + payload: { + version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice", + threadId: event.event.channel.id, userId: event.event.user.userId, + text: notice, settleDelivery: false, + }, + runtimeContext, + }); + const queueSlackNotice = async (notice: string, principalId?: string | null) => { + const effect = await db.transaction((tx) => stageSlackNotice(tx, notice, principalId)); + if (!effect) throw new Error("Slack notice was not persisted"); + scheduleProviderEffect(effect.id, event.event.channel); + }; + if (!["verifying", "active"].includes(record.endpoint.status)) { + await queueSlackNotice("This Paperclip connection is not active."); + return; + } + const expectedCommand = + typeof record.endpoint.setup.command === "string" + ? record.endpoint.setup.command + : slackCommandForAgent( + record.assignedAgentName, + record.endpoint.publicId, + ); + if (event.provider !== "slack" || command !== expectedCommand) { + await queueSlackNotice( + `This connection only accepts ${expectedCommand}.`, + ); + return; + } + const principal = await ensurePrincipal( + record.endpoint, + event.event.user, + event.event.raw, + ); + if (text.toLowerCase() === "connect") { + // Discovery must work before identity and channel access are granted. + // It records no task and grants no permissions; linking still requires + // explicit confirmation by a company member in the board. + if (principal.principal.kind !== "user" || principal.principal.isBot) return; + const effect = await db.transaction(async (tx) => { + if (!(await runtimeCallbackEndpoint(tx, event.endpointId, runtimeContext, ["verifying", "active"]))) { + throw conflict("This Slack connection changed; send the connect command again"); + } + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.principal.id}`}, 0))`); + const inserted = await tx.insert(chatActions).values({ + companyId: record.endpoint.companyId, endpointId: event.endpointId, + principalId: principal.principal.id, kind: "slack_connect", + providerActionId: `slack_connect:${providerCommandId}`, + payload: { version: 1, channelId: event.event.channel.id, userId: event.event.user.userId }, + status: "processed", result: { code: "slack_identity_discovered" }, + }).onConflictDoNothing().returning({ id: chatActions.id }); + if (!inserted.length) return null; + const currentLink = await tx.select().from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.endpointId, event.endpointId), eq(chatIdentityLinks.principalId, principal.principal.id), + )).then((rows) => rows[0]); + let notice = "Your Slack account is already connected to Paperclip. You can return to Slack and message the agent."; + if (currentLink?.status !== "linked") { + const token = randomBytes(32).toString("base64url"); + const tokenHash = createHash("sha256").update(token).digest("hex"); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + await tx.insert(chatIdentityLinks).values({ + companyId: record.endpoint.companyId, endpointId: event.endpointId, principalId: principal.principal.id, + status: "pending", confirmationTokenHash: tokenHash, expiresAt, + }).onConflictDoUpdate({ + target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], + set: { paperclipUserId: null, status: "pending", confirmationTokenHash: tokenHash, expiresAt, confirmedAt: null, revokedAt: null, updatedAt: new Date() }, + }); + await tx.update(chatActions).set({ payload: { + version: 1, channelId: event.event.channel.id, userId: event.event.user.userId, identityLinkHash: tokenHash, + } }).where(eq(chatActions.id, inserted[0].id)); + const url = `${getPublicBaseUrl()}/chat-identity/confirm?token=${encodeURIComponent(token)}`; + notice = getPublicBaseUrl() + ? `[Connect your Paperclip account](${url}) — sign in and confirm this Slack identity. This private link expires in 15 minutes and works once. You can also confirm in the setup wizard. No agent work has started.` + : "Return to the Paperclip setup wizard to confirm your Slack account. No agent work has started."; + } + await logActivity(tx as unknown as Db, { + companyId: record.endpoint.companyId, actorType: "system", actorId: `chat:${principal.principal.id}`, + action: "chat.slack_identity_discovered", entityType: "chat_endpoint", entityId: event.endpointId, + details: { principalId: principal.principal.id }, + }); + const staged = await stageSlackNotice(tx, notice, principal.principal.id); + if (!staged) throw new Error("Slack identity link notice was not persisted"); + return staged; + }); + if (effect) scheduleProviderEffect(effect.id, event.event.channel); + return; + } + const resource = await db + .select() + .from(chatEndpointResources) + .where( + and( + eq(chatEndpointResources.endpointId, event.endpointId), + eq( + chatEndpointResources.providerResourceId, + canonicalProviderResourceId(event.provider, { + channelId: event.event.channel.id, + id: event.event.channel.id, + }), + ), + ), + ) + .then((rows) => rows[0] ?? null); + // Slack's SDK channel wrapper has reported false for isDM on real slash + // callbacks even though the signed payload contains a D-prefixed channel + // id. The provider id is authoritative for this routing decision. + const rawSlackChannelId = event.event.channel.id.replace(/^slack:/, ""); + const slashIsDirectMessage = /^D[A-Z0-9]+$/i.test(rawSlackChannelId) + ? true + : event.event.channel.isDM; + const destinationAllowed = slashIsDirectMessage + ? record.endpoint.allowDirectMessages + : nonDirectDestinationAllowed(record.endpoint, resource); + const authorized = + destinationAllowed && + !principal.linkedDenied && + (principal.userId !== null || + (record.endpoint.allowUnlinkedPeople && + (await sponsorAllowsGuest(record.endpoint)))); + if (!authorized) { + await queueSlackNotice( + "This channel or account is not allowed to start Paperclip work.", + principal.principal.id, + ); + return; + } + const slackControl = /^(status|new|close)$/i + .exec(text)?.[1] + ?.toLowerCase() as "status" | "new" | "close" | undefined; + if (slackControl) { + if (!slashIsDirectMessage) { + // Slack slash-command payloads are channel-scoped and do not carry a + // thread timestamp, so they cannot safely identify one of several + // Paperclip tasks in the channel. Native @mention threads remain the + // task-management surface there; the control vocabulary is exact only + // in a DM, whose provider channel identity is stable. + await queueSlackNotice( + "Use status, new, and close in a direct message with this agent. In a channel, open the Paperclip task from its Slack thread.", + principal.principal.id, + ); + return; + } + const endpointRuntime = + runtime.get(event.endpointId) ?? (await runtimeFor(record.endpoint)); + // A slash-command task starts a real Slack root and binds Paperclip to + // that returned thread id. Later slash controls carry only the DM channel + // id, so target the most recently active task instead of synthesizing an + // unrelated base-DM thread that cannot find the binding. + const activeThreadId = await latestSlackDmControlThreadId( + event.endpointId, + rawSlackChannelId, + ); + const thread = endpointRuntime.thread( + activeThreadId ?? `slack:${rawSlackChannelId}:`, + ); + const synthetic = { + id: createHash("sha256") + .update( + `${event.event.command}:${event.event.user.userId}:${providerCommandId}:${slackControl}`, + ) + .digest("hex"), + threadId: thread.id, + text: `/${slackControl}`, + formatted: { type: "root", children: [] }, + raw: event.event.raw, + author: event.event.user, + metadata: { dateSent: new Date(), edited: false }, + attachments: [], + links: [], + isMention: true, + } as unknown as Message; + await handleSdkMessage( + { + endpointId: event.endpointId, + provider: event.provider, + thread, + message: synthetic, + trigger: "direct_message", + }, + runtimeContext, + { receiptReactionSupported: false, slackSlashControl: true }, + ); + return; + } + if (!text) { + await queueSlackNotice( + `Use ${expectedCommand} followed by a task, or ${expectedCommand} status, new, or close in a direct message.`, + principal.principal.id, + ); + return; + } + const providerActionId = `slash_task:${createHash("sha256") + .update( + `${event.event.command}:${event.event.user.userId}:${providerCommandId}`, + ) + .digest("hex")}`; + const syntheticMessageId = createHash("sha256") + .update( + `${event.event.command}:${event.event.user.userId}:${providerCommandId}`, + ) + .digest("hex"); + // A duplicate Slack retry must acknowledge from the already-durable row + // without waiting for the first worker's endpoint/identity locks or Web + // API request. Slack otherwise treats a healthy in-flight command as an + // acknowledgement timeout and retries it again. + const existingAction = await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, record.endpoint.id), + eq(chatActions.providerActionId, providerActionId), + ), + ) + .then((rows) => rows[0] ?? null); + const insertedAction = existingAction + ? null + : await db.transaction(async (tx) => { + if ( + !(await runtimeCallbackEndpoint( + tx, + event.endpointId, + runtimeContext, + ["verifying", "active"], + )) + ) + return null; + await notifyChatActionWork(tx, "slash_task_start"); + return tx + .insert(chatActions) + .values({ + companyId: record.endpoint.companyId, + endpointId: record.endpoint.id, + principalId: principal.principal.id, + kind: "slash_task_start", + providerActionId, + payload: { + version: 1, + channelId: event.event.channel.id, + command, + syntheticMessageId, + taskText: text, + }, + status: "queued", + }) + .onConflictDoNothing() + .returning() + .then((rows) => rows[0] ?? null); + }); + const action = + existingAction ?? + insertedAction ?? + (await db + .select() + .from(chatActions) + .where( + and( + eq(chatActions.endpointId, record.endpoint.id), + eq(chatActions.providerActionId, providerActionId), + ), + ) + .then((rows) => rows[0] ?? null)); + if (!action) throw new Error("Slack command admission was not persisted"); + // Slack has a tight acknowledgement budget. The unique action row is the + // durable receipt; provider publication and Paperclip admission run behind + // the acknowledgement under an atomic `received -> resolving` claim. + // Duplicate callbacks schedule the same id and converge without waiting + // on Slack's Web API or creating a second root. + scheduleMessageProcessing(async () => { + await processSlackTaskStart( + action.id, + options.deferWebhookProcessing === true, + ); + }); + + } + return { handleSlackSlashCommand }; +} diff --git a/server/src/services/chat-channels/slack/credentials.ts b/server/src/services/chat-channels/slack/credentials.ts new file mode 100644 index 00000000000..d5c9f036d3d --- /dev/null +++ b/server/src/services/chat-channels/slack/credentials.ts @@ -0,0 +1,63 @@ +import { SLACK_CHAT_BOT_SCOPES } from "@paperclipai/shared"; + +import { + unprocessable, +} from "../../../errors.js"; + +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +const REQUIRED_SLACK_BOT_SCOPES = SLACK_CHAT_BOT_SCOPES; + +export async function verifySlackCredentials( + credentials: Record, + fetchImpl: typeof fetch, + PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number, +): Promise { + const response = await fetchImpl("https://slack.com/api/auth.test", { + method: "POST", + signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS), + headers: { + authorization: `Bearer ${credentials.botToken}`, + "content-type": "application/x-www-form-urlencoded", + }, + body: "", + }); + const result = (await response.json()) as { + ok?: boolean; + error?: string; + team_id?: string; + team?: string; + user_id?: string; + user?: string; + }; + if (!response.ok || !result.ok) + throw unprocessable( + `Slack rejected the bot token: ${result.error ?? response.status}`, + ); + const grantedScopes = new Set( + (response.headers.get("x-oauth-scopes") ?? "") + .split(",") + .map((scope) => scope.trim()) + .filter(Boolean), + ); + const missingScopes = REQUIRED_SLACK_BOT_SCOPES.filter( + (scope) => !grantedScopes.has(scope), + ); + if (missingScopes.length > 0) { + throw unprocessable( + `Slack app is missing required bot scopes: ${missingScopes.join(", ")}`, + { + code: "chat_provider_permissions_missing", + provider: "slack", + missingScopes, + }, + ); + } + return { + providerAccountId: result.team_id, + providerAccountLabel: result.team, + botExternalId: result.user_id, + botUsername: result.user, + botLabel: result.user, + }; +} diff --git a/server/src/services/chat-channels/slack/inventory.ts b/server/src/services/chat-channels/slack/inventory.ts new file mode 100644 index 00000000000..50509311755 --- /dev/null +++ b/server/src/services/chat-channels/slack/inventory.ts @@ -0,0 +1,79 @@ +import type { EndpointRow, EndpointRecord } from "../runtime-types.js"; + +import { slackResourceLabelIsFallback } from "./resources.js"; + +import { + and, + eq, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatEndpointResources, +} from "@paperclipai/db"; + +import { getSlackBotChannel, listSlackBotChannels } from "../../chat-provider-inventory.js"; + +import type { PreparedProviderInventory } from "../runtime-types.js"; + +export async function prepareSlackInventory(credentials: Record, fetchImpl: typeof fetch): Promise { + return { + credentials, + inventory: await listSlackBotChannels({ + botToken: credentials.botToken, + fetch: fetchImpl, + }), + }; +} + + +type SlackInventoryOptions = { + endpointRecord: (endpointId: string) => Promise; + resolveCredentials: (endpoint: EndpointRow) => Promise>; + fetchImpl: typeof fetch; +}; + +export function createSlackInventory(db: Db, options: SlackInventoryOptions) { + const { endpointRecord, resolveCredentials, fetchImpl } = options; + + + async function hydrateSlackResourceLabel( + endpointId: string, + providerResourceId: string, + ): Promise { + const record = await endpointRecord(endpointId); + if ( + !record || + record.endpoint.provider !== "slack" || + record.endpoint.status === "archived" || + record.endpoint.status === "revoked" + ) { + return; + } + const credentials = await resolveCredentials(record.endpoint); + const resource = await getSlackBotChannel({ + botToken: credentials.botToken, + channelId: providerResourceId, + fetch: fetchImpl, + }); + if ( + !resource || + slackResourceLabelIsFallback(providerResourceId, resource.label) + ) + return; + await db + .update(chatEndpointResources) + .set({ label: resource.label, updatedAt: new Date() }) + .where( + and( + eq(chatEndpointResources.companyId, record.endpoint.companyId), + eq(chatEndpointResources.endpointId, endpointId), + eq(chatEndpointResources.type, "channel"), + eq(chatEndpointResources.providerResourceId, providerResourceId), + eq(chatEndpointResources.availability, "available"), + ), + ); + } + return { hydrateSlackResourceLabel }; +} diff --git a/server/src/services/chat-channels/slack/task-authority.ts b/server/src/services/chat-channels/slack/task-authority.ts new file mode 100644 index 00000000000..43b5673126d --- /dev/null +++ b/server/src/services/chat-channels/slack/task-authority.ts @@ -0,0 +1,155 @@ +import type { EndpointRow, DbOrTransaction } from "../runtime-types.js"; + +import { slackSearchActionToken } from "../../connectors/slack-search-context.js"; +import { slackAuthorizationRevision } from "../../connectors/slack-revision.js"; + +import { instanceSettingsService } from "../../instance-settings.js"; +import { registerSlackTaskAuthority, slackRunOrigin } from "../../connectors/slack-authority.js"; + +import { + createHash, +} from "node:crypto"; +import { + and, + eq, + isNull, + ne, + sql, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatConversations, + chatDeliveries, + chatEndpoints, + chatExternalPrincipals, + chatIdentityLinks, + chatMessageLinks, + companyMemberships, + issueComments, + issues, +} from "@paperclipai/db"; + +import { + forbidden, +} from "../../../errors.js"; + +type SlackTaskAuthorityRegistrationOptions = { + authorizeInboundWakeup: (tx: DbOrTransaction, action: typeof chatActions.$inferSelect) => Promise<{ + issue: typeof issues.$inferSelect; + delivery: typeof chatDeliveries.$inferSelect; + endpoint: EndpointRow; + conversation: typeof chatConversations.$inferSelect; + }>; + resolveCredentials: (endpoint: EndpointRow) => Promise>; +}; + +export function createSlackTaskAuthorityRegistration(db: Db, options: SlackTaskAuthorityRegistrationOptions) { + const { authorizeInboundWakeup, resolveCredentials } = options; + + + const unregisterSlackTaskAuthority = registerSlackTaskAuthority(db, async (binding) => { + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) + throw forbidden("Chat connectors are disabled"); + const identity = await slackRunOrigin(db, binding); + const run = identity.run; + if ((run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId) !== binding.issueId || !run.responsibleUserId) + throw forbidden("Slack tools require the bound task and an accepted linked user"); + const resolved = await db.transaction(async (tx) => { + const [action] = await tx.select().from(chatActions).where(and( + eq(chatActions.companyId, binding.companyId), + eq(chatActions.kind, "inbound_wakeup"), + sql`${chatActions.payload}->>'issueId' = ${binding.issueId}`, + sql`${chatActions.payload}->>'agentId' = ${binding.agentId}`, + identity.sourceMessageId + ? sql`${chatActions.payload}->>'commentId' = ${identity.sourceMessageId}` + : eq(chatActions.id, run.wakeupRequestId ?? "00000000-0000-0000-0000-000000000000"), + )).limit(1); + if (!action) { + // Ordinary tasks and routines use their accepted responsible user, never + // an earlier Slack sender or the connection owner's credentials. + const [message] = identity.sourceMessageId ? await tx.select().from(issueComments) + .where(and(eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId), + eq(issueComments.id, identity.sourceMessageId), isNull(issueComments.deletedAt))) : []; + if (identity.sourceMessageId && (!message || message.authorType !== "user" || message.authorUserId !== run.responsibleUserId)) + throw forbidden("Slack tools require the current task requester's identity"); + // An external participant may not acquire unrelated bot connections by + // falling back to ordinary-task access after source admission fails. + if (identity.sourceMessageId) { + const [external] = await tx.select({ id: chatMessageLinks.id }).from(chatMessageLinks) + .where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.commentId, identity.sourceMessageId), eq(chatMessageLinks.direction, "inbound"))).limit(1); + if (external) throw forbidden("External messages require their originating Slack connection"); + } + const endpoints = await tx.select().from(chatEndpoints).where(and( + eq(chatEndpoints.companyId, binding.companyId), eq(chatEndpoints.provider, "slack"), + eq(chatEndpoints.assignedAgentId, binding.agentId), eq(chatEndpoints.status, "active"), + ...(binding.endpointId ? [eq(chatEndpoints.id, binding.endpointId)] : []), + )).for("no key update"); + if (endpoints.length !== 1) throw forbidden("Select an active Slack connection assigned to this agent"); + const endpoint = endpoints[0]!; + const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, binding.companyId), eq(issues.id, binding.issueId), eq(issues.assigneeAgentId, binding.agentId))); + if (!issue) throw forbidden("Slack tools require a task assigned to this agent"); + const [member] = await tx.select().from(companyMemberships).where(and( + eq(companyMemberships.companyId, binding.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, run.responsibleUserId!), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), + )); + if (!member) throw forbidden("The responsible user must be an active company operator"); + const links = await tx.select({ principal: chatExternalPrincipals }).from(chatIdentityLinks) + .innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, binding.companyId))) + .where(and(eq(chatIdentityLinks.companyId, binding.companyId), eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.paperclipUserId, run.responsibleUserId!), eq(chatIdentityLinks.status, "linked"), + eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId!), eq(chatExternalPrincipals.isBot, false))); + if (links.length !== 1) throw forbidden("Link your Slack account to this connection before using it from tasks or routines"); + // A Board message changes who is directing the work, not the privacy + // of context already present in its linked Slack conversation. + const conversations = await tx.select().from(chatConversations).where(and( + eq(chatConversations.companyId, binding.companyId), eq(chatConversations.endpointId, endpoint.id), + eq(chatConversations.issueId, binding.issueId), + )).limit(2); + if (conversations.length > 1) throw forbidden("The task's Slack conversation boundary is ambiguous"); + return { endpoint, issue, conversation: conversations[0] ?? null, delivery: null, principalId: links[0]!.principal.id, slackUserId: links[0]!.principal.externalId }; + } + if (action.payload.requestedByActorType !== "user" || action.payload.requestedByActorId !== run.responsibleUserId || + (binding.endpointId && action.endpointId !== binding.endpointId)) + throw forbidden("Slack tools require a verified linked Slack request"); + // This standalone resolver does not own the scheduler's issue lock. Use + // the ingress lock order (endpoint, then issue) so a normal webhook or + // endpoint update cannot turn tool setup into a NOWAIT failure. The + // scheduler retains its nonblocking check when it owns the issue first. + await tx.select({ id: chatEndpoints.id }).from(chatEndpoints).where(and( + eq(chatEndpoints.companyId, binding.companyId), + eq(chatEndpoints.id, action.endpointId), + )).for("no key update"); + const source = await authorizeInboundWakeup(tx, action); + if (source.endpoint.provider !== "slack") throw forbidden("This is not a Slack task"); + const [principal] = await tx.select().from(chatExternalPrincipals).where(and( + eq(chatExternalPrincipals.companyId, binding.companyId), + eq(chatExternalPrincipals.id, action.principalId!), + eq(chatExternalPrincipals.provider, "slack"), + eq(chatExternalPrincipals.providerAccountId, source.endpoint.providerAccountId!), + eq(chatExternalPrincipals.isBot, false), + )); + if (!principal) throw forbidden("Slack sender is no longer available"); + return { ...source, principalId: principal.id, slackUserId: principal.externalId }; + }); + const credentials = await resolveCredentials(resolved.endpoint); + if (!credentials.botToken) throw forbidden("Slack bot credential is unavailable"); + return { + endpoint: resolved.endpoint, issueId: binding.issueId, conversation: resolved.conversation, + principalId: resolved.principalId, slackUserId: resolved.slackUserId, + userId: run.responsibleUserId, deliveryId: resolved.delivery?.id ?? null, + identityContextId: identity.context?.id ?? null, + workMode: resolved.issue.workMode, + revision: createHash("sha256").update(JSON.stringify([ + resolved.endpoint.status, resolved.endpoint.allowDirectMessages, resolved.conversation?.sessionGeneration ?? null, + run.responsibleUserId, credentials.botToken, + await slackAuthorizationRevision(db, binding.companyId, resolved.endpoint.id, resolved.endpoint.connectionId, binding.agentId, run.responsibleUserId), + ])).digest("hex"), + botToken: credentials.botToken, + searchActionToken: resolved.delivery ? slackSearchActionToken(db, resolved.endpoint.id, resolved.endpoint.providerAccountId!, resolved.slackUserId, String((resolved.delivery.normalizedEvent.message as Record | undefined)?.providerMessageId ?? "")) : null, + }; + }); + return { unregisterSlackTaskAuthority }; +} diff --git a/server/src/services/chat-channels/speko/credentials.ts b/server/src/services/chat-channels/speko/credentials.ts new file mode 100644 index 00000000000..1aefca553df --- /dev/null +++ b/server/src/services/chat-channels/speko/credentials.ts @@ -0,0 +1,16 @@ +import { createSpekoProvider } from "../../voice/speko-provider.js"; + +import { + unprocessable, +} from "../../../errors.js"; + +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +export async function verifySpekoCredentials( + credentials: Record, + fetchImpl: typeof fetch, +): Promise { + if (!/^whsec_[A-Za-z0-9+/]{43}=$/.test(credentials.signingSecret ?? "")) throw unprocessable("Speko requires a Standard Webhooks signing secret"); + const identity = await createSpekoProvider(credentials.apiKey, fetchImpl).verifyAgent(credentials.agentId); + return { providerAccountId: identity.organizationId, providerAccountLabel: "Speko workspace", botExternalId: identity.id, botLabel: identity.name }; +} diff --git a/server/src/services/chat-channels/telegram/attachment-failure.ts b/server/src/services/chat-channels/telegram/attachment-failure.ts new file mode 100644 index 00000000000..58d5d4a2677 --- /dev/null +++ b/server/src/services/chat-channels/telegram/attachment-failure.ts @@ -0,0 +1,152 @@ +import { createProviderEffects, type ProviderEffectTarget } from "../provider-effects.js"; + +import type { EndpointRow, RuntimeContext, InboundRuntimeContext } from "../runtime-types.js"; + +import { notifyChatDeliveryWork } from "../../chat-work-notifications.js"; + +import { + and, + eq, +} from "drizzle-orm"; + +import type { Db } from "@paperclipai/db"; + +import { + chatActions, + chatDeliveries, + issueComments, +} from "@paperclipai/db"; + +import { + formatAttachmentSize, + MAX_ATTACHMENT_BYTES, +} from "../../../attachment-types.js"; + +import type { + Message, + Thread, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; + +type DeliveryRow = typeof chatDeliveries.$inferSelect; +type TelegramAttachmentFailureOptions = { + attachmentOmissionDetail: (result: { omissionReasons: Record }) => string | null; + runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext; + endpointRecord: (endpointId: string) => Promise; + stageProviderEffect: ReturnType["stageProviderEffect"]; + scheduleProviderEffect: (actionId: string, liveTarget?: ProviderEffectTarget) => void; +}; + +export function createTelegramAttachmentFailure(db: Db, options: TelegramAttachmentFailureOptions) { + const { attachmentOmissionDetail, runtimeContextForRecord, endpointRecord, stageProviderEffect, scheduleProviderEffect } = options; + + + async function settleTelegramAttachmentOnlyFailure(input: { + activeDelivery: DeliveryRow; + attachmentResult: { + storedIds: string[]; + omissionReasons: Record; + }; + commentId: string; + conversationId: string; + endpoint: EndpointRow; + message: Message; + principalId: string | null; + resourceId: string | null; + runtimeContext?: InboundRuntimeContext; + thread: Thread; + }): Promise { + if ( + input.endpoint.provider !== "telegram" || + input.message.text.trim().length > 0 || + input.message.attachments.length === 0 || + input.attachmentResult.storedIds.length > 0 + ) { + return false; + } + const diagnostic = + attachmentOmissionDetail(input.attachmentResult) ?? + "The Telegram attachment could not be imported"; + const visibleFailure = `Paperclip could not safely import the attached Telegram file. Please resend it as a supported file under ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} or include text describing the request.`; + const effectContext = + input.runtimeContext ?? + runtimeContextForRecord( + (await endpointRecord(input.endpoint.id)) ?? + (() => { + throw new Error("Chat endpoint is unavailable"); + })(), + ); + const effect = await db.transaction(async (tx) => { + // Replace the optimistic attachment placeholder with an explicit safe + // Paperclip-visible failure. The message link stays authoritative for + // exact provider redelivery, while no agent wakeup is enqueued for an + // empty turn that contains no usable bytes. + await tx + .update(issueComments) + .set({ body: visibleFailure, updatedAt: new Date() }) + .where( + and( + eq(issueComments.id, input.commentId), + eq(issueComments.companyId, input.endpoint.companyId), + ), + ); + await tx + .update(chatActions) + .set({ + status: "failed", + result: { code: "inbound_wakeup_attachment_unavailable" }, + updatedAt: new Date(), + }) + .where( + and( + eq(chatActions.deliveryId, input.activeDelivery.id), + eq(chatActions.kind, "inbound_wakeup"), + eq(chatActions.status, "preparing"), + ), + ); + const staged = await stageProviderEffect(tx, { + endpoint: input.endpoint, + deliveryId: input.activeDelivery.id, + conversationId: input.conversationId, + principalId: input.principalId, + providerActionId: `provider_effect:attachment-ingest-failure:${input.activeDelivery.id}`, + payload: { + version: 1, + authorizationMode: "safe_notice", + effect: "thread_message", + threadId: input.thread.id, + text: visibleFailure, + settleDelivery: false, + ...(input.resourceId ? { resourceId: input.resourceId } : {}), + }, + runtimeContext: effectContext, + }); + await notifyChatDeliveryWork(tx); + await tx + .update(chatDeliveries) + .set({ + conversationId: input.conversationId, + state: "processed", + processedAt: new Date(), + nextAttemptAt: null, + redactedError: diagnostic, + updatedAt: new Date(), + }) + .where( + and( + eq(chatDeliveries.id, input.activeDelivery.id), + eq(chatDeliveries.state, "processing"), + ), + ); + return staged; + }); + if (!effect) throw new Error("Attachment failure notice was not persisted"); + // Posting the safe notice is its own durable outbox operation. A provider + // outage must not turn the already-settled, deliberately unwoken inbound + // delivery back into a replayable attachment message. + scheduleProviderEffect(effect.id, input.thread); + return true; + } + return { settleTelegramAttachmentOnlyFailure }; +} diff --git a/server/src/services/chat-channels/telegram/commands.ts b/server/src/services/chat-channels/telegram/commands.ts new file mode 100644 index 00000000000..70ce398dfa0 --- /dev/null +++ b/server/src/services/chat-channels/telegram/commands.ts @@ -0,0 +1,109 @@ +import type { EndpointRow, RuntimeContext } from "../runtime-types.js"; + +import { + telegramMessageId, + telegramMessageSentAt, +} from "./messages.js"; + +import { type ChatRuntime } from "../../chat-runtime.js"; +import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js"; + +import { + createHash, +} from "node:crypto"; + +import { + type ChatSdkCallbackEvent, + type ChatSdkMessageCallbackEvent, + type ChatSdkRuntime, +} from "../../chat-sdk-runtime.js"; + +import type { + Message, + SlashCommandEvent, +} from "chat"; + +import type { EndpointRecord } from "../runtime-types.js"; + +export function telegramGuidanceCommand( + text: string, +): "start" | "task" | "unknown" | null { + const match = /^\/([a-z][\w-]*)(?:@[\w.-]+)?(?:\s|$)/i.exec(text.trim()); + const command = match?.[1]?.toLowerCase(); + if ( + !command || + command === "new" || + command === "close" || + command === "status" + ) + return null; + if (command === "start") return "start"; + if (command === "task") return "task"; + return "unknown"; +} + +type TelegramCommandsOptions = { + runtime: Pick; + runtimeFor: (endpoint: EndpointRow) => Promise; + handleSdkMessage: (event: ChatSdkMessageCallbackEvent, context: RuntimeContext) => Promise; +}; + +export function createTelegramCommands(options: TelegramCommandsOptions) { + const { runtime, runtimeFor, handleSdkMessage } = options; + async function handleTelegramSlashCommand(event: ChatSdkCallbackEvent, runtimeContext: RuntimeContext, record: EndpointRecord, command: string, text: string) { + const endpointRuntime = + runtime.get(event.endpointId) ?? (await runtimeFor(record.endpoint)); + const thread = endpointRuntime.thread(event.event.channel.id); + const parsedCommandMessage = + endpointRuntime.parseTelegramCommandMessage?.(event.event.raw) ?? null; + const synthetic = { + id: + telegramMessageId(event.event.raw) ?? + event.event.triggerId ?? + createHash("sha256") + .update( + JSON.stringify({ + command, + raw: event.event.raw, + userId: event.event.user.userId, + }), + ) + .digest("hex"), + threadId: thread.id, + // Telegram privacy mode does not deliver an ordinary @mention in a + // group. /task@bot is therefore the provider-native root + // activation path; direct replies and later /task commands can + // continue the same active group/topic task. + text: command === "/task" && text ? text : command, + formatted: { type: "root", children: [] }, + raw: event.event.raw, + author: event.event.user, + metadata: { + // Telegram slash commands carry the original message as `raw`. + // Preserve its whole-second provider clock so the durable drain can + // use message_id to order a command and the next rapid DM correctly. + dateSent: telegramMessageSentAt(event.event.raw) ?? new Date(), + edited: false, + }, + // Telegram routes a captioned file command through onSlashCommand + // instead of the ordinary message callback. Preserve the provider's + // parsed attachment descriptors so the durable delivery worker can + // rehydrate and ingest them exactly like non-command media. + attachments: parsedCommandMessage?.attachments ?? [], + links: [], + isMention: true, + } as unknown as Message; + await handleSdkMessage( + { + endpointId: event.endpointId, + provider: event.provider, + thread, + message: synthetic, + trigger: thread.isDM ? "direct_message" : "mention", + }, + runtimeContext, + ); + return; + } + return { handleTelegramSlashCommand }; +} diff --git a/server/src/services/chat-channels/telegram/credentials.ts b/server/src/services/chat-channels/telegram/credentials.ts new file mode 100644 index 00000000000..1b525dc2de1 --- /dev/null +++ b/server/src/services/chat-channels/telegram/credentials.ts @@ -0,0 +1,31 @@ +import { + unprocessable, +} from "../../../errors.js"; + +import type { VerifiedProviderIdentity } from "../runtime-types.js"; + +export async function verifyTelegramCredentials( + credentials: Record, + fetchImpl: typeof fetch, + PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number, +): Promise { + const response = await fetchImpl( + `https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/getMe`, + { signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS) }, + ); + const result = (await response.json()) as { + ok?: boolean; + description?: string; + result?: { id?: number; username?: string; first_name?: string }; + }; + if (!response.ok || !result.ok) + throw unprocessable( + `Telegram rejected the bot token: ${result.description ?? response.status}`, + ); + return { + providerAccountId: String(result.result?.id ?? ""), + botExternalId: String(result.result?.id ?? ""), + botUsername: result.result?.username, + botLabel: result.result?.first_name, + }; +} diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e34121a99fa..3149a777035 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -7,6 +7,7 @@ import { notifyChatPublicationWork } from "./chat-work-notifications.js"; import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js"; import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; +import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; import { retryIdempotentDatabaseOperation } from "../database-retry.js"; import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackConversation } from "./slack-conversation-state.js"; import { documentService } from "./documents.js"; @@ -11194,9 +11195,10 @@ export function issueService(db: Db) { if (actorAgentId && actorRunId) { // Recheck under a run lock: a request admitted before Stop must not // commit a late Done after cancellation revoked its credentials. - await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, { + const actorRun = await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, { agentId: actorAgentId, runId: actorRunId, stopId: actorRunStopId, }); + assertLegacyRunTaskOwnership(actorRun, receiptExisting); } if (actorAgentId && patch.status === "done") { const [review] = await tx.select({ id: toolActionRequests.id }).from(toolActionRequests).where(and(eq(toolActionRequests.companyId, existing.companyId), eq(toolActionRequests.issueId, id), inArray(toolActionRequests.status, ["pending", "approved", "executing"]))).limit(1); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index e8f2e574f42..058500e8d8a 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1286,7 +1286,7 @@ export class PaperclipRunnerToolAuthority { agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id, action: "issue.created", entityType: "issue", entityId: child.id, details: { identifier: child.identifier, title: child.title, parentId: child.parentId, - assigneeAgentId: child.assigneeAgentId, status: childStatus, source: "paperclip_runner_protocol" }, + assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol" }, }); publication = activity.publication; } @@ -1486,7 +1486,8 @@ export class PaperclipRunnerToolAuthority { agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId, action: "issue.reassigned", entityType: "issue", entityId: taskId, details: { source: "paperclip_runner_protocol", reassignmentKey: durableKey, inputFingerprint: fingerprint, - previousAssigneeAgentId: target.assigneeAgentId, assigneeAgentId, reason, changed, receipt }, + previousAssigneeAgentId: target.assigneeAgentId, previousAssigneeUserId: target.assigneeUserId, + assigneeAgentId, assigneeUserId, reason, changed, receipt }, }); publication = activity.publication; return receipt; diff --git a/server/src/services/native-runtime/task-reassignment.test.ts b/server/src/services/native-runtime/task-reassignment.test.ts index f28276130dd..8f3d86b0638 100644 --- a/server/src/services/native-runtime/task-reassignment.test.ts +++ b/server/src/services/native-runtime/task-reassignment.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { eq, and } from "drizzle-orm"; -import { activityLog, agents, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db"; +import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db"; import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; import { issueService } from "../issues.js"; import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; @@ -49,6 +49,24 @@ describe("runner task reassignment", () => { expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.targetId))).toHaveLength(1); }); + it("records human assignment fields for native creation and reassignment", async () => { + const f = await fixture(); + const person = randomUUID(); + await db.insert(authUsers).values({ id: person, name: "Reviewer", email: `${person}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: person, status: "active", membershipRole: "operator" }); + await f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, assigneeActorId: null, assigneeUserId: person } }); + const [reassigned] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.targetId), eq(activityLog.action, "issue.reassigned"))); + expect(reassigned).toMatchObject({ actorType: "agent", actorId: f.agentId, details: { + previousAssigneeAgentId: f.agentId, previousAssigneeUserId: null, + assigneeAgentId: null, assigneeUserId: person, changed: true, + } }); + const created = await f.authority.execute({ tool: "create_task", callId: "human-child", arguments: { + title: "Review result", assigneeUserId: person, idempotencyKey: "human-child", + } }) as { task: { id: string } }; + const [creation] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, created.task.id), eq(activityLog.action, "issue.created"))); + expect(creation).toMatchObject({ actorType: "agent", actorId: f.agentId, details: { assigneeAgentId: null, assigneeUserId: person } }); + }); + it.each(["blocked", "backlog"])("preserves %s and does not wake work prematurely", async status => { const f = await fixture(status); await f.authority.execute(f.call); diff --git a/tests/e2e/board-attachment-receipts.spec.ts b/tests/e2e/board-attachment-receipts.spec.ts index 649758e26e7..da87912cf87 100644 --- a/tests/e2e/board-attachment-receipts.spec.ts +++ b/tests/e2e/board-attachment-receipts.spec.ts @@ -9,7 +9,7 @@ import { // Real local Board UI, upload storage, comment HTTP routes, and disposable DB. // Only the interface flag and explicit transport/upload rejection faults are mocked. -// Tasks belong to the Board user: no agent, runner, or provider is contacted. +// Tasks have no recipient: no agent, runner, or provider is contacted. type Attachment = { id: string; issueCommentId: string | null; @@ -44,7 +44,9 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) { data: { title: "Inspect the newly uploaded Board files", status: "todo", - assigneeUserId: "local-board", + // Keep attachment sends on the comment route. A task assigned to the + // current human now suggests an empty recipient and sends a reassignment. + assigneeUserId: null, }, }), ); @@ -78,6 +80,14 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) { composer, editor, send, + sendComment: async () => { + await send.click(); + if (classic) { + const confirm = page.getByTestId("issue-chat-no-assignee-send-anyway"); + await expect(confirm).toBeVisible(); + await confirm.click(); + } + }, attachments: () => request .get(`/api/issues/${issue.id}/attachments`) @@ -183,7 +193,7 @@ for (const classic of [false, true]) { req.method() === "POST" && new URL(req.url()).pathname.endsWith("/comments"), ); - await fixture.send.click(); + await fixture.sendComment(); expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual( receipts.map((row) => row.id).sort(), ); @@ -244,7 +254,7 @@ for (const classic of [false, true]) { accepted = true; await route.abort("connectionreset"); }); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(() => accepted).toBe(true); expect(await fixture.comments()).toHaveLength(1); expect(acceptedRequestId).toEqual(expect.any(String)); @@ -276,7 +286,7 @@ for (const classic of [false, true]) { await fixture.editor.fill( "A deliberately new comment after the original receipt settled.", ); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(2); }); @@ -298,14 +308,14 @@ for (const classic of [false, true]) { body: JSON.stringify({ error: "Fixture policy rejected this attempt" }), }); }); - await fixture.send.click(); + await fixture.sendComment(); await expect(fixture.editor).toContainText("Known rejection,"); await expect(fixture.send).toBeEnabled(); expect(await fixture.comments()).toHaveLength(0); await page.reload(); await expect(fixture.editor).toContainText("Known rejection,"); await expect(fixture.composer.getByRole("alert")).toHaveCount(0); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect( (await fixture.attachments()).find((row) => row.id === receipt.id) @@ -339,7 +349,7 @@ for (const classic of [false, true]) { await route.fulfill({ response }).catch(() => {}); }); try { - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(() => accepted).toBe(true); expect(await fixture.comments()).toHaveLength(1); await fixture.editor.fill("A newer draft written while delivery was pending."); @@ -354,7 +364,7 @@ for (const classic of [false, true]) { expect(await fixture.comments()).toHaveLength(1); expect(acceptedRequestId).toEqual(expect.any(String)); expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(2); expect(attempts).toBe(2); expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([ @@ -412,7 +422,7 @@ for (const classic of [false, true]) req.method() === "POST" && new URL(req.url()).pathname.endsWith("/comments"), ); - await fixture.send.click(); + await fixture.sendComment(); expect((await outbound).postDataJSON().attachmentIds).toBeUndefined(); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect((await fixture.comments())[0]!.body).not.toContain( @@ -450,7 +460,7 @@ test("legacy failed upload can be removed before sending the retained text", asy }); await expect(remove).toBeVisible(); await remove.click(); - await fixture.send.click(); + await fixture.sendComment(); await expect.poll(async () => (await fixture.comments()).length).toBe(1); expect((await fixture.comments())[0]!.body).toBe( "Keep this text after removing the failed file.", diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index 79e0c43b15f..d50a6756c3b 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -59,6 +59,7 @@ export interface ChatIdentityLink { externalDetail?: string | null; paperclipUserId?: string | null; paperclipUserLabel?: string | null; + paperclipUserImage?: string | null; status: "linked" | "pending" | "revoked"; } diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 427d5f62ea6..a6f05717e4c 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -44,7 +44,14 @@ vi.mock("./MarkdownEditor", () => ({ })); vi.mock("./InlineEntitySelector", () => ({ - InlineEntitySelector: () => null, + InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( + + ), })); vi.mock("./ApprovalCard", () => ({ @@ -78,6 +85,34 @@ describe("CommentThread", () => { let writeTextMock: ReturnType Promise>>; let execCommandMock: ReturnType>; + it("updates automatic suggestions but preserves manual choices until the task changes", () => { + const root = createRoot(container); + const render = (suggestion: string, draftKey = "task-one") => act(() => root.render( + + {}} enableReassign + reassignOptions={[{ id: "agent:first", label: "First agent" }]} + currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} /> + , + )); + const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement; + render("agent:first"); + expect(selector().value).toBe("agent:first"); + render("agent:latest"); + expect(selector().value).toBe("agent:latest"); + for (const manual of ["user:me", ""]) { + act(() => { + selector().value = manual; + selector().dispatchEvent(new Event("change", { bubbles: true })); + }); + render("agent:first"); + render("agent:latest"); + expect(selector().value).toBe(manual); + } + render("agent:first", "task-two"); + expect(selector().value).toBe("agent:first"); + act(() => root.unmount()); + }); + beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 328bf906caa..287f350e173 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -752,6 +752,12 @@ export function CommentThread({ const [submitting, setSubmitting] = useState(false); const [attaching, setAttaching] = useState(false); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const manualAssigneeRef = useRef(false); + const assigneeDraftKeyRef = useRef(draftKey); + const selectAssignee = (value: string) => { + manualAssigneeRef.current = true; + setReassignTarget(value); + }; const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue); const [highlightCommentId, setHighlightCommentId] = useState(null); const [votingTargetId, setVotingTargetId] = useState(null); @@ -852,8 +858,12 @@ export function CommentThread({ }, []); useEffect(() => { - setReassignTarget(effectiveSuggestedAssigneeValue); - }, [effectiveSuggestedAssigneeValue]); + if (assigneeDraftKeyRef.current !== draftKey) { + assigneeDraftKeyRef.current = draftKey; + manualAssigneeRef.current = false; + } + if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue); + }, [draftKey, effectiveSuggestedAssigneeValue]); // Scroll to comment when URL hash matches #comment-{id} useEffect(() => { @@ -898,6 +908,7 @@ export function CommentThread({ try { await onAdd(submittedBody, reopen, reassignment ?? undefined); if (draftKey) clearDraft(draftKey); + manualAssigneeRef.current = false; setReassignTarget(effectiveSuggestedAssigneeValue); } catch { setBody((current) => @@ -1051,7 +1062,7 @@ export function CommentThread({ noneLabel="No responsible" searchPlaceholder="Search responsible..." emptyMessage="No responsible found." - onChange={setReassignTarget} + onChange={selectAssignee} className="text-xs h-8" renderTriggerValue={(option) => { if (!option) return Responsible; diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 5650ba0e7e8..6f6b2bc2eb6 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -167,7 +167,14 @@ vi.mock("./MarkdownEditor", () => ({ })); vi.mock("./InlineEntitySelector", () => ({ - InlineEntitySelector: () => null, + InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => ( + + ), })); vi.mock("./Identity", () => ({ @@ -375,6 +382,33 @@ function createFileDragEvent(type: string, files: File[]) { describe("IssueChatThread", () => { let container: HTMLDivElement; + it("updates automatic suggestions but preserves manual choices until the task changes", () => { + const root = createRoot(container); + const render = (suggestion: string, draftKey = "task-one") => act(() => root.render( + + {}} enableReassign + reassignOptions={[{ id: "agent:first", label: "First agent" }]} + currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} enableLiveTranscriptPolling={false} /> + , + )); + const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement; + render("agent:first"); + expect(selector().value).toBe("agent:first"); + render("agent:latest"); + expect(selector().value).toBe("agent:latest"); + for (const manual of ["user:me", ""]) { + act(() => { + selector().value = manual; + selector().dispatchEvent(new Event("change", { bubbles: true })); + }); + render("agent:first"); + render("agent:latest"); + expect(selector().value).toBe(manual); + } + render("agent:first", "task-two"); + expect(selector().value).toBe("agent:first"); + act(() => root.unmount()); + }); function renderIssueThread(root: ReturnType, props: Partial> = {}) { root.render( diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 2eb81a46ef1..695d9a168ed 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -4763,6 +4763,12 @@ const IssueChatComposer = forwardRef< const dragDepthRef = useRef(0); const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue; + const manualAssigneeRef = useRef(false); + const assigneeDraftKeyRef = useRef(draftKey); + const selectAssignee = (value: string) => { + manualAssigneeRef.current = true; + setReassignTarget(value); + }; const [reassignTarget, setReassignTarget] = useState( effectiveSuggestedAssigneeValue, ); @@ -4919,8 +4925,12 @@ const IssueChatComposer = forwardRef< }, [draftKey]); useEffect(() => { - setReassignTarget(effectiveSuggestedAssigneeValue); - }, [effectiveSuggestedAssigneeValue]); + if (assigneeDraftKeyRef.current !== draftKey) { + assigneeDraftKeyRef.current = draftKey; + manualAssigneeRef.current = false; + } + if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue); + }, [draftKey, effectiveSuggestedAssigneeValue]); useEffect(() => { setPendingWorkMode(resolvedIssueWorkMode); @@ -5065,6 +5075,7 @@ const IssueChatComposer = forwardRef< setComposerAttachments((current) => current.filter((item) => !submittedAttachmentKeys.has(item.id)), ); + manualAssigneeRef.current = false; setReassignTarget(effectiveSuggestedAssigneeValue); setRunSettings(null); } catch (error) { @@ -5606,7 +5617,7 @@ const IssueChatComposer = forwardRef< overrides={assigneeAdapterOverrides} settings={runSettings} onSettingsChange={setRunSettings} - onAssigneeChange={setReassignTarget} + onAssigneeChange={selectAssignee} triggerRef={reassignTriggerRef} renderAssigneeIdentity={(value) => { const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null; @@ -5622,7 +5633,7 @@ const IssueChatComposer = forwardRef< noneLabel="No responsible" searchPlaceholder="Search responsible..." emptyMessage="No responsible found." - onChange={setReassignTarget} + onChange={selectAssignee} className="h-8 text-xs" renderTriggerValue={(option) => { if (!option) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index c1425f56e79..de97aa93eb5 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -527,6 +527,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { conversationMode, reassignOptions, currentAssigneeValue, + suggestedAssigneeValue, assigneeAdapterOverrides, issueStatus, issueAssigneeAgentId = null, @@ -604,12 +605,15 @@ export function TaskChatThread(props: TaskChatThreadProps) { const [pendingComposerAssignee, setPendingComposerAssignee] = useState< string | null >(null); - const effectiveGoalAgentId = - pendingComposerAssignee === null - ? issueAssigneeAgentId - : pendingComposerAssignee.startsWith("agent:") - ? pendingComposerAssignee.slice("agent:".length) || null - : null; + const composerSuggestedAssignee = currentUserId && currentAssigneeValue === `user:${currentUserId}` + ? suggestedAssigneeValue + : undefined; + const effectiveComposerAssignee = pendingComposerAssignee ?? composerSuggestedAssignee; + const effectiveGoalAgentId = effectiveComposerAssignee === undefined + ? issueAssigneeAgentId + : effectiveComposerAssignee.startsWith("agent:") + ? effectiveComposerAssignee.slice("agent:".length) || null + : null; const runnerGoal = useRunnerGoalControl(issueId, effectiveGoalAgentId); useEffect(() => { @@ -3240,6 +3244,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { modelAgents={agentMap} userProfileMap={userProfileMap} currentAssigneeValue={currentAssigneeValue} + suggestedAssigneeValue={composerSuggestedAssignee} companyId={companyId} assigneeAdapterOverrides={assigneeAdapterOverrides} onPendingAssigneeChange={setPendingComposerAssignee} diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx index 769a4d4c500..be889270e03 100644 --- a/ui/src/components/task-chat/TaskChatComposer.test.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx @@ -1701,6 +1701,25 @@ describe("TaskChatComposer", () => { ).toBe("agent:a2"); }); + it("uses assignment suggestions and retains a manual human choice after refresh", async () => { + const view = (suggestion: string) => ; + render(view("agent:a1")); + const trigger = () => container.querySelector('[data-testid="task-chat-composer-assignee"]')!; + expect(trigger().textContent).toContain("Alpha"); + flushSync(() => root!.render(view("agent:a2"))); + expect(trigger().textContent).toContain("Beta"); + flushSync(() => trigger().click()); + await flushAsync(); + const me = document.querySelector('[data-assignee-identity="user:u1"]')!.closest("button")!; + flushSync(() => me.click()); + await flushAsync(); + flushSync(() => root!.render(view("agent:a1"))); + expect(trigger().textContent).toContain("Me"); + }); + it("shows human avatars in assignee options and after selection", async () => { render( | null; currentAssigneeValue?: string; + suggestedAssigneeValue?: string; onPendingAssigneeChange?: (value: string | null) => void; issueStatus?: string; /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ @@ -397,6 +398,7 @@ export function TaskChatComposer({ assigneeAdapterOverrides, userProfileMap, currentAssigneeValue = "", + suggestedAssigneeValue, onPendingAssigneeChange, issueStatus, mobile = false, @@ -638,7 +640,7 @@ export function TaskChatComposer({ const showAssignee = Boolean( enableReassign && reassignOptions && reassignOptions.length > 0, ); - const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? currentAssigneeValue; + const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue; const assigneeLabel = reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned"; const assigneeName = diff --git a/ui/src/lib/assignees.test.ts b/ui/src/lib/assignees.test.ts index 66b4f3f1bc3..36f912816e9 100644 --- a/ui/src/lib/assignees.test.ts +++ b/ui/src/lib/assignees.test.ts @@ -1,3 +1,4 @@ +import type { ActivityEvent } from "@paperclipai/shared"; import { describe, expect, it } from "vitest"; import { assigneeValueFromSelection, @@ -72,14 +73,14 @@ describe("assignee selection helpers", () => { ).toBe("agent:agent-123"); }); - it("falls back to the actual assignee when there is no better commenter hint", () => { + it("leaves the recipient empty instead of defaulting to the current human", () => { expect( suggestedCommentAssigneeValue( { assigneeUserId: "board-user" }, [{ authorUserId: "board-user" }], "board-user", ), - ).toBe("user:board-user"); + ).toBe(""); }); it("skips the current agent when choosing a suggested commenter assignee", () => { @@ -97,3 +98,71 @@ describe("assignee selection helpers", () => { ).toBe("agent:agent-123"); }); }); + + +describe("assignment history suggestions", () => { + const assignment = (actorId: string, date: string, details: Record = {}): ActivityEvent => ({ + companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null, + id: actorId, actorType: "agent", actorId, action: "issue.updated", + createdAt: new Date(date), details: { assigneeUserId: "me", _previous: { assigneeUserId: null }, ...details }, + }); + const suggest = (activity: ActivityEvent[]) => suggestedCommentAssigneeValue( + { assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, activity, + ); + + it("uses the latest assigning actor in either history order, ahead of commenters", () => { + const older = assignment("first", "2026-01-01"); + const newer = assignment("last", "2026-02-01"); + expect(suggest([older, newer])).toBe("agent:last"); + expect(suggest([newer, older])).toBe("agent:last"); + }); + + it("ignores other recipients, human actors, non-assignment events and unchanged fields", () => { + const valid = assignment("assigner", "2026-01-01"); + const invalid = assignment("other", "2026-02-01"); + expect(suggest([ + valid, + { ...invalid, actorType: "user" }, + { ...invalid, action: "issue.comment_added" }, + assignment("other-user", "2026-02-01", { assigneeUserId: "someone-else" }), + assignment("no-op", "2026-02-01", { _previous: { assigneeUserId: "me" } }), + assignment("resent", "2026-02-01", { _previous: { status: "todo" } }), + ])).toBe("agent:assigner"); + }); + + it("includes the agent that created a task assigned to the human", () => { + expect(suggest([{ ...assignment("creator", "2026-01-01"), action: "issue.created" }])).toBe("agent:creator"); + }); + + it("keeps the existing commenter fallback and other task defaults", () => { + expect(suggest([])).toBe("agent:commenter"); + expect(suggestedCommentAssigneeValue({ assigneeAgentId: "owner" }, [], "me")).toBe("agent:owner"); + expect(suggestedCommentAssigneeValue({ assigneeUserId: "someone-else" }, [], "me")).toBe("user:someone-else"); + }); +}); + + +it("prefers a newer native reassignment over a REST assignment and ignores native no-ops", () => { + const event = (actorId: string, action: string, createdAt: string, details: Record): ActivityEvent => ({ + id: actorId, companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null, + actorType: "agent", actorId, action, createdAt: new Date(createdAt), details, + }); + const history = [ + event("alpha", "issue.updated", "2026-01-01", { assigneeUserId: "me", _previous: { assigneeUserId: null } }), + event("beta", "issue.reassigned", "2026-02-01", { assigneeUserId: "me", previousAssigneeUserId: null, changed: true }), + event("no-op", "issue.reassigned", "2026-03-01", { assigneeUserId: "me", previousAssigneeUserId: "me", changed: false }), + ]; + expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [], "me", undefined, history)).toBe("agent:beta"); +}); + + +it.each(["issue.created", "issue.child_created"])("uses a REST %s receipt assigned to the current human", (action) => { + const event: ActivityEvent = { + id: "creation", companyId: "company", entityType: "issue", entityId: "task", + agentId: "creator", runId: null, actorType: "agent", actorId: "creator", action, + createdAt: new Date("2026-02-01"), + details: { title: "Task", identifier: "PAP-1", status: "todo", assigneeAgentId: null, assigneeUserId: "me" }, + }; + expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, [event])).toBe("agent:creator"); + expect(suggestedCommentAssigneeValue({ assigneeUserId: "other" }, [], "other", undefined, [event])).toBe(""); +}); diff --git a/ui/src/lib/assignees.ts b/ui/src/lib/assignees.ts index 2e45490cd98..1992442cc93 100644 --- a/ui/src/lib/assignees.ts +++ b/ui/src/lib/assignees.ts @@ -1,3 +1,5 @@ +import type { ActivityEvent } from "@paperclipai/shared"; + export interface AssigneeSelection { assigneeAgentId: string | null; assigneeUserId: string | null; @@ -30,7 +32,32 @@ export function suggestedCommentAssigneeValue( comments: CommentAssigneeSuggestionComment[] | null | undefined, currentUserId: string | null | undefined, currentAgentId?: string | null | undefined, + activity: readonly ActivityEvent[] = [], ): string { + const assignedToCurrentUser = Boolean(currentUserId && issue.assigneeUserId === currentUserId); + if (assignedToCurrentUser) { + const assignment = activity + .filter((event) => { + if (event.actorType !== "agent" || !event.actorId || event.actorId === currentAgentId) return false; + const details = event.details; + if (!details || details.assigneeUserId !== currentUserId) return false; + if (event.action === "issue.created" || event.action === "issue.child_created") return true; + if (event.action === "issue.reassigned") { + return details.changed === true + && Object.hasOwn(details, "previousAssigneeUserId") + && details.previousAssigneeUserId !== currentUserId; + } + if (event.action !== "issue.updated") return false; + const previous = details._previous; + // Updates can resend unchanged fields. Only a real assignment counts. + return previous !== null && typeof previous === "object" + && Object.hasOwn(previous, "assigneeUserId") + && (previous as Record).assigneeUserId !== currentUserId; + }) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]; + if (assignment) return `agent:${assignment.actorId}`; + } + if (comments && comments.length > 0 && (currentUserId || currentAgentId)) { for (let i = comments.length - 1; i >= 0; i--) { const comment = comments[i]; @@ -43,7 +70,7 @@ export function suggestedCommentAssigneeValue( } } - return assigneeValueFromSelection(issue); + return assignedToCurrentUser ? "" : assigneeValueFromSelection(issue); } export function parseAssigneeValue(value: string): AssigneeSelection { diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index b7ebb693a9a..af019ce5e2b 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -3850,14 +3850,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS [issue], ); + const { data: assignmentActivity } = useQuery({ + queryKey: queryKeys.issues.activity(issueId!), + queryFn: () => activityApi.forIssue(issueId!), + enabled: Boolean(issueId && currentUserId && issue?.assigneeUserId === currentUserId), + }); + const suggestedAssigneeValue = useMemo( () => suggestedCommentAssigneeValue( issue ?? {}, mergeIssueComments(comments ?? [], optimisticComments), currentUserId, + undefined, + assignmentActivity, ), - [issue, comments, optimisticComments, currentUserId], + [issue, comments, optimisticComments, currentUserId, assignmentActivity], ); const threadComments = useMemo( diff --git a/ui/src/pages/Pipelines.tsx b/ui/src/pages/Pipelines.tsx index 55b4b16271e..1aa8b6ea62a 100644 --- a/ui/src/pages/Pipelines.tsx +++ b/ui/src/pages/Pipelines.tsx @@ -2177,8 +2177,10 @@ export function PipelineItemDetailView({ pipelineId, caseId }: { pipelineId: str starterAssigneeValue ? parseAssigneeValue(starterAssigneeValue) : activeConversationIssue ?? {}, conversationComments, currentUserId, + undefined, + conversationActivity, ), - [activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue], + [activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue, conversationActivity], ); const conversationRunningRun = useMemo( () => resolveRunningPipelineConversationRun(resolvedConversationActiveRun, resolvedConversationLiveRuns), diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index 3544343a539..2c07fc4d894 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -1,8 +1,9 @@ +import { deriveInitials } from "@/components/Identity"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 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"; @@ -782,13 +783,19 @@ function Access({ role="listitem" className="flex flex-wrap items-center gap-3 py-3" > -
- - {(link.status !== "linked" || link.externalLabel !== link.paperclipUserLabel) &&

- {link.status === "revoked" ? "Disconnected" : link.status === "linked" - ? link.externalLabel - : "Waiting for account confirmation"} -

} +
+ +
+

{link.paperclipUserLabel ?? link.externalLabel}

+ {(link.status !== "linked" || link.externalLabel !== link.paperclipUserLabel) &&

+ {link.status === "revoked" ? "Disconnected" : link.status === "linked" + ? link.externalLabel + : "Waiting for account confirmation"} +

} +
{link.status === "linked" ? (